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:
parent
7dacb6e40d
commit
6f0d5a13a8
7 changed files with 74 additions and 18 deletions
|
@ -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
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
@ -74,4 +74,6 @@ interface JellyfinRepository {
|
|||
suspend fun markAsUnplayed(itemId: UUID)
|
||||
|
||||
suspend fun getIntros(itemId: UUID): List<BaseItemDto>
|
||||
|
||||
fun getBaseUrl(): String
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue