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

Added a preferred stream quality option #266

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 @@ -155,6 +155,9 @@
<string name="add_server_address">Add server address</string>
<string name="add">Add</string>
<string name="quick_connect">Quick Connect</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>
<string name="video">Video</string>
<string name="audio">Audio</string>
<string name="subtitle">Subtitles</string>
Expand Down
8 changes: 8 additions & 0 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" />

<SwitchPreferenceCompat
app:key="pref_player_display_extended_title"
app:summary="@string/display_extended_title_summary"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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 postCapabilities()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import java.util.UUID
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 All @@ -29,6 +30,9 @@ import org.jellyfin.sdk.model.api.UserConfiguration
import timber.log.Timber

class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRepository {

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 @@ -167,7 +171,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep

override suspend fun getMediaSources(itemId: UUID): List<MediaSourceInfo> =
withContext(Dispatchers.IO) {
jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
Expand Down Expand Up @@ -203,7 +207,9 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
),
maxStreamingBitrate = 1_000_000_000,
)
).content.mediaSources
).content
playSessionIds[itemId] = playbackInfo.playSessionId
playbackInfo.mediaSources
}

override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String =
Expand All @@ -220,6 +226,53 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
}
}

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) {
// https://github.com/ConfusedPolarBear/intro-skipper/blob/master/docs/api.md
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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 @@ -117,9 +129,13 @@ constructor(
val mediaItems: MutableList<MediaItem> = mutableListOf()
try {
for (item in items) {
val transcodeResolution = getTranscodeResolution(appPreferences.playerPreferredQuality)
val streamUrl = when {
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
else -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.ExternalSubtitle
import dev.jdtech.jellyfin.models.PlayerItem
Expand All @@ -30,7 +31,8 @@ import timber.log.Timber
class PlayerViewModel @Inject internal constructor(
private val application: Application,
private val repository: JellyfinRepository,
private val downloadDatabase: DownloadDatabaseDao
private val downloadDatabase: DownloadDatabaseDao,
private val appPreferences: AppPreferences
) : ViewModel() {

private val playerItems = MutableSharedFlow<PlayerItemState>(
Expand Down Expand Up @@ -177,7 +179,11 @@ class PlayerViewModel @Inject internal constructor(
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()) {
// When transcoding, subtitles aren't embedded, so we add them externally
if ((appPreferences.playerPreferredQuality != "Original" || mediaStream.isExternal) &&
mediaStream.type == MediaStreamType.SUBTITLE &&
!mediaStream.deliveryUrl.isNullOrBlank()
) {

// Temp fix for vtt
// Jellyfin returns a srt stream when it should return vtt stream.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ constructor(
}

// Player
val playerPreferredQuality: String get() = sharedPreferences.getString(
Constants.PREF_PLAYER_PREFERRED_QUALITY,
"Original"
)!!

val displayExtendedTitle get() = sharedPreferences.getBoolean(Constants.PREF_DISPLAY_EXTENDED_TITLE, false)

val playerGestures get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES, 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 @@ -10,6 +10,7 @@ object Constants {

// pref
const val PREF_CURRENT_SERVER = "pref_current_server"
const val PREF_PLAYER_PREFERRED_QUALITY = "pref_player_preferred_quality"
const val PREF_DISPLAY_EXTENDED_TITLE = "pref_player_display_extended_title"
const val PREF_PLAYER_GESTURES = "pref_player_gestures"
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
Expand Down