Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transcoding Support #555

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
18 changes: 17 additions & 1 deletion core/src/main/res/values/string_arrays.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,20 @@
<string-array name="mpv_gpu_api">
<item>opengl</item>
</string-array>
</resources>
<string-array name="video_quality">
<item>Original</item>
<item>4K</item>
<item>1080p</item>
<item>720p</item>
<item>480p</item>
<item>360p</item>
</string-array>
<string-array name="video_quality_labels">
<item>@string/quality_original</item>
<item>4K</item>
<item>1080p</item>
<item>720p</item>
<item>480p</item>
<item>360p</item>
</string-array>
</resources>
3 changes: 3 additions & 0 deletions core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,7 @@
<string name="cancel_download_message">Are you sure you want to cancel the download?</string>
<string name="stop_download">Stop download</string>
<string name="privacy_policy_notice">By using Findroid you agree with the <a href='https://raw.githubusercontent.com/jarnedemeulemeester/findroid/main/PRIVACY'>Privacy Policy</a> which states that we do not collect any data</string>
<string name="quality">Quality</string>
<string name="preferred_quality">%s\nAny setting other than Original might require transcoding.</string>
<string name="quality_original">Original</string>
</resources>
12 changes: 10 additions & 2 deletions core/src/main/res/xml/fragment_settings_player.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
app:defaultValue="Original"
app:entries="@array/video_quality_labels"
app:entryValues="@array/video_quality"
app:key="pref_player_preferred_quality"
app:summary="@string/preferred_quality"
app:title="@string/quality" />

<Preference
app:key="pref_player_subtitles"
app:summary="@string/subtitles_summary"
Expand Down Expand Up @@ -99,7 +107,7 @@
app:summary="@string/pref_player_intro_skipper_summary"
app:title="@string/pref_player_intro_skipper"
app:widgetLayout="@layout/preference_material3_switch" />

<SwitchPreferenceCompat
app:defaultValue="true"
app:key="pref_player_trick_play"
Expand All @@ -113,5 +121,5 @@
app:title="@string/picture_in_picture_gesture"
app:summary="@string/picture_in_picture_gesture_summary" />
</PreferenceCategory>

</PreferenceScreen>
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ interface JellyfinRepository {

suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String

suspend fun getHlsPlaylistUrl(itemId: UUID, mediaSourceId: String, transcodeResolution: Int?): String

suspend fun getIntroTimestamps(itemId: UUID): Intro?

suspend fun getTrickPlayManifest(itemId: UUID): TrickPlayManifest?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import io.ktor.utils.io.ByteReadChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
import org.jellyfin.sdk.api.client.extensions.get
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
Expand Down Expand Up @@ -61,6 +62,8 @@ class JellyfinRepositoryImpl(
jellyfinApi.systemApi.getPublicSystemInfo().content
}

private val playSessionIds = mutableMapOf<UUID, String?>()

override suspend fun getUserViews(): List<BaseItemDto> = withContext(Dispatchers.IO) {
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty()
}
Expand Down Expand Up @@ -285,44 +288,45 @@ class JellyfinRepositoryImpl(
override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List<FindroidSource> =
withContext(Dispatchers.IO) {
val sources = mutableListOf<FindroidSource>()
sources.addAll(
jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
deviceProfile = DeviceProfile(
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
codecProfiles = emptyList(),
containerProfiles = emptyList(),
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = emptyList(),
responseProfiles = emptyList(),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
),
xmlRootAttributes = emptyList(),
supportedMediaTypes = "",
enableAlbumArtInDidl = false,
enableMsMediaReceiverRegistrar = false,
enableSingleAlbumArtLimit = false,
enableSingleSubtitleLimit = false,
ignoreTranscodeByteRangeRequests = false,
maxAlbumArtHeight = 1_000_000_000,
maxAlbumArtWidth = 1_000_000_000,
requiresPlainFolders = false,
requiresPlainVideoItems = false,
timelineOffsetSeconds = 0,
),
val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
deviceProfile = DeviceProfile(
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
codecProfiles = emptyList(),
containerProfiles = emptyList(),
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = emptyList(),
responseProfiles = emptyList(),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
),
xmlRootAttributes = emptyList(),
supportedMediaTypes = "",
enableAlbumArtInDidl = false,
enableMsMediaReceiverRegistrar = false,
enableSingleAlbumArtLimit = false,
enableSingleSubtitleLimit = false,
ignoreTranscodeByteRangeRequests = false,
maxAlbumArtHeight = 1_000_000_000,
maxAlbumArtWidth = 1_000_000_000,
requiresPlainFolders = false,
requiresPlainVideoItems = false,
timelineOffsetSeconds = 0,
),
).content.mediaSources.map {
maxStreamingBitrate = 1_000_000_000,
),
).content
playSessionIds[itemId] = playbackInfo.playSessionId
sources.addAll(playbackInfo.mediaSources.map {
it.toFindroidSource(
this@JellyfinRepositoryImpl,
itemId,
Expand Down Expand Up @@ -350,6 +354,53 @@ class JellyfinRepositoryImpl(
}
}

private fun getVideoTranscodeBitRate(transcodeResolution: Int?): Pair<Int?, Int?> {
return when (transcodeResolution) {
2160 -> 59616000 to 384000
1080 -> 14616000 to 384000
720 -> 7616000 to 384000
480 -> 2616000 to 384000
360 -> 292000 to 128000
else -> null to null
}
}

override suspend fun getHlsPlaylistUrl(
itemId: UUID,
mediaSourceId: String,
transcodeResolution: Int?
): String =
withContext(Dispatchers.IO) {
try {
val (videoBitRate, audioBitRate) = getVideoTranscodeBitRate(transcodeResolution)
if (videoBitRate == null || audioBitRate == null) {
jellyfinApi.api.dynamicHlsApi.getVariantHlsVideoPlaylistUrl(
itemId,
static = true,
mediaSourceId = mediaSourceId,
playSessionId = playSessionIds[itemId] // playSessionId is required to update the transcoding resolution
)
} else {
jellyfinApi.api.dynamicHlsApi.getVariantHlsVideoPlaylistUrl(
itemId,
static = false,
mediaSourceId = mediaSourceId,
playSessionId = playSessionIds[itemId],
videoCodec = "h264",
audioCodec = "aac",
videoBitRate = videoBitRate,
audioBitRate = audioBitRate,
maxHeight = transcodeResolution,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
transcodeReasons = "ContainerBitrateExceedsLimit",
)
}
} catch (e: Exception) {
Timber.e(e)
""
}
}

override suspend fun getIntroTimestamps(itemId: UUID): Intro? =
withContext(Dispatchers.IO) {
val intro = database.getIntro(itemId)?.toIntro()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ class JellyfinRepositoryOfflineImpl(
TODO("Not yet implemented")
}

override suspend fun getHlsPlaylistUrl(
itemId: UUID,
mediaSourceId: String,
transcodeResolution: Int?
): String {
TODO("Not yet implemented")
}

override suspend fun getIntroTimestamps(itemId: UUID): Intro? =
withContext(Dispatchers.IO) {
database.getIntro(itemId)?.toIntro()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ constructor(
}
}

private fun getTranscodeResolution(preferredQuality: String): Int? {
return when (preferredQuality) {
"4K" -> 2160
"1080p" -> 1080
"720p" -> 720
"480p" -> 480
"360p" -> 360

else -> null
}
}

fun initializePlayer(
items: Array<PlayerItem>,
) {
Expand All @@ -133,7 +145,14 @@ constructor(
val mediaItems = mutableListOf<MediaItem>()
try {
for (item in items) {
val streamUrl = item.mediaSourceUri
val transcodeResolution = getTranscodeResolution(appPreferences.playerPreferredQuality)
val streamUrl = when {
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
else -> when (transcodeResolution) {
null -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
else -> jellyfinRepository.getHlsPlaylistUrl(item.itemId, item.mediaSourceId, transcodeResolution)
}
}
val mediaSubtitles = item.externalSubtitles.map { externalSubtitle ->
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.models.ExternalSubtitle
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidItem
Expand All @@ -26,6 +27,7 @@ import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject internal constructor(
private val repository: JellyfinRepository,
private val appPreferences: AppPreferences
) : ViewModel() {

private val playerItems = MutableSharedFlow<PlayerItemState>(
Expand Down Expand Up @@ -136,15 +138,19 @@ class PlayerViewModel @Inject internal constructor(
mediaSourceIndex: Int?,
playbackPosition: Long,
): PlayerItem {
val mediaSources = repository.getMediaSources(id, true)
val shouldTranscode = appPreferences.playerPreferredQuality != "Original"
val mediaSources = repository.getMediaSources(id, !shouldTranscode)
val mediaSource = if (mediaSourceIndex == null) {
mediaSources.firstOrNull { it.type == FindroidSourceType.LOCAL } ?: mediaSources[0]
} else {
mediaSources[mediaSourceIndex]
}
val externalSubtitles = mediaSource.mediaStreams
.filter { mediaStream ->
mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
// When transcoding, subtitles aren't embedded, so we add them externally
(appPreferences.playerPreferredQuality != "Original" || mediaStream.isExternal) &&
mediaStream.type == MediaStreamType.SUBTITLE &&
!mediaStream.path.isNullOrBlank()
}
.map { mediaStream ->
// Temp fix for vtt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ constructor(
}

// Player
val playerPreferredQuality: String get() = sharedPreferences.getString(
Constants.PREF_PLAYER_PREFERRED_QUALITY,
"Original"
)!!
val playerGestures get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES, true)
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
Expand Down
1 change: 1 addition & 0 deletions preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ object Constants {
// pref
const val PREF_CURRENT_SERVER = "pref_current_server"
const val PREF_OFFLINE_MODE = "pref_offline_mode"
const val PREF_PLAYER_PREFERRED_QUALITY = "pref_player_preferred_quality"
const val PREF_PLAYER_GESTURES = "pref_player_gestures"
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"
Expand Down
Loading