Add support for external subtitles (#118)

* Add support for external subtitles in exoplayer

* Enable ASS/SSA external subtitles

* Enable VTT external subtitles

* Clean up

* Fix srt and vtt

Jellyfin currently converts vtt to srt without changing the codec tag. This makes the player unable to decode the subs because it thinks the file is vtt while in fact it is srt.

* Fix for vtt subs

Jellyfin return a srt stream when it should return a vtt stream
This commit is contained in:
Jarne Demeulemeester 2022-07-02 17:00:00 +02:00 committed by GitHub
parent 7dacb6e40d
commit 6f0d5a13a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 74 additions and 18 deletions

View file

@ -0,0 +1,13 @@
package dev.jdtech.jellyfin.models
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class ExternalSubtitle(
val title: String,
val language: String,
val uri: Uri,
val mimeType: String,
) : Parcelable

View file

@ -13,5 +13,6 @@ data class PlayerItem(
val mediaSourceUri: String = "",
val parentIndexNumber: Int? = null,
val indexNumber: Int? = null,
val item: DownloadItem? = null
val item: DownloadItem? = null,
val externalSubtitles: List<ExternalSubtitle> = emptyList()
) : Parcelable

View file

@ -154,7 +154,6 @@ class MPVPlayer(
// Internal state.
private var internalMediaItems: List<MediaItem>? = null
private var internalMediaItem: MediaItem? = null
@Player.State
private var playbackState: Int = Player.STATE_IDLE
@ -300,7 +299,6 @@ class MPVPlayer(
if (!isPlayerReady) {
isPlayerReady = true
listeners.sendEvent(Player.EVENT_TRACKS_CHANGED) { listener ->
//listener.onTracksChanged(currentTrackGroups, currentTrackSelections)
listener.onTracksInfoChanged(currentTracksInfo)
}
seekTo(C.TIME_UNSET)
@ -840,7 +838,6 @@ class MPVPlayer(
private fun prepareMediaItem(index: Int) {
internalMediaItems?.get(index)?.let { mediaItem ->
internalMediaItem = mediaItem
resetInternalState()
mediaItem.localConfiguration?.subtitleConfigurations?.forEach { subtitle ->
initialCommands.add(

View file

@ -74,4 +74,6 @@ interface JellyfinRepository {
suspend fun markAsUnplayed(itemId: UUID)
suspend fun getIntros(itemId: UUID): List<BaseItemDto>
fun getBaseUrl(): String
}

View file

@ -159,13 +159,16 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
codecProfiles = emptyList(),
containerProfiles = emptyList(),
directPlayProfiles = listOf(
DirectPlayProfile(
type = DlnaProfileType.VIDEO
), DirectPlayProfile(type = DlnaProfileType.AUDIO)
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO)
),
transcodingProfiles = emptyList(),
responseProfiles = emptyList(),
subtitleProfiles = emptyList(),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
),
xmlRootAttributes = emptyList(),
supportedMediaTypes = "",
enableAlbumArtInDidl = false,
@ -179,9 +182,6 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
requiresPlainVideoItems = false,
timelineOffsetSeconds = 0
),
startTimeTicks = null,
audioStreamIndex = null,
subtitleStreamIndex = null,
maxStreamingBitrate = 1_000_000_000,
)
).content.mediaSources
@ -286,4 +286,6 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
override suspend fun getIntros(itemId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items.orEmpty()
}
}
override fun getBaseUrl() = jellyfinApi.api.baseUrl.orEmpty()
}

View file

@ -108,6 +108,13 @@ constructor(
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
else -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
}
val mediaSubtitles = item.externalSubtitles.map { externalSubtitle ->
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
.setLabel(externalSubtitle.title)
.setMimeType(externalSubtitle.mimeType)
.setLanguage(externalSubtitle.language)
.build()
}
playFromDownloads = item.mediaSourceUri.isNotEmpty()
Timber.d("Stream url: $streamUrl")
@ -115,6 +122,7 @@ constructor(
MediaItem.Builder()
.setMediaId(item.itemId.toString())
.setUri(streamUrl)
.setSubtitleConfigurations(mediaSubtitles)
.build()
mediaItems.add(mediaItem)
}

View file

@ -1,10 +1,13 @@
package dev.jdtech.jellyfin.viewmodels
import android.net.Uri
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.util.MimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.ExternalSubtitle
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.getDownloadPlayerItem
@ -17,6 +20,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType.VIRTUAL
import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import javax.inject.Inject
@ -105,7 +109,7 @@ class PlayerViewModel @Inject internal constructor(
else -> emptyList()
}
private fun itemToMoviePlayerItems(
private suspend fun itemToMoviePlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
@ -160,11 +164,37 @@ class PlayerViewModel @Inject internal constructor(
.map { episode -> episode.toPlayerItem(mediaSourceIndex, playbackPosition) }
}
private fun BaseItemDto.toPlayerItem(
private suspend fun BaseItemDto.toPlayerItem(
mediaSourceIndex: Int,
playbackPosition: Long
): PlayerItem {
val mediaSource = mediaSources!![mediaSourceIndex]
val mediaSource = repository.getMediaSources(id)[mediaSourceIndex]
val externalSubtitles = mutableListOf<ExternalSubtitle>()
for (mediaStream in mediaSource.mediaStreams!!) {
if (mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.deliveryUrl.isNullOrBlank()) {
// Temp fix for vtt
// Jellyfin returns a srt stream when it should return vtt stream.
var deliveryUrl = mediaStream.deliveryUrl!!
if (mediaStream.codec == "webvtt") {
deliveryUrl = deliveryUrl.replace("Stream.srt", "Stream.vtt")
}
externalSubtitles.add(
ExternalSubtitle(
mediaStream.title.orEmpty(),
mediaStream.language.orEmpty(),
Uri.parse(repository.getBaseUrl() + deliveryUrl),
when (mediaStream.codec) {
"subrip" -> MimeTypes.APPLICATION_SUBRIP
"webvtt" -> MimeTypes.TEXT_VTT
"ass" -> MimeTypes.TEXT_SSA
else -> MimeTypes.TEXT_UNKNOWN
}
)
)
}
}
return when (mediaSource.protocol) {
MediaProtocol.FILE -> PlayerItem(
name = name,
@ -172,7 +202,8 @@ class PlayerViewModel @Inject internal constructor(
mediaSourceId = mediaSource.id!!,
playbackPosition = playbackPosition,
parentIndexNumber = parentIndexNumber,
indexNumber = indexNumber
indexNumber = indexNumber,
externalSubtitles = externalSubtitles
)
MediaProtocol.HTTP -> PlayerItem(
name = name,
@ -181,7 +212,8 @@ class PlayerViewModel @Inject internal constructor(
mediaSourceUri = mediaSource.path!!,
playbackPosition = playbackPosition,
parentIndexNumber = parentIndexNumber,
indexNumber = indexNumber
indexNumber = indexNumber,
externalSubtitles = externalSubtitles
)
else -> PlayerItem(
name = name,
@ -189,7 +221,8 @@ class PlayerViewModel @Inject internal constructor(
mediaSourceId = mediaSource.id!!,
playbackPosition = playbackPosition,
parentIndexNumber = parentIndexNumber,
indexNumber = indexNumber
indexNumber = indexNumber,
externalSubtitles = externalSubtitles
)
}
}