diff --git a/core/src/main/res/values/string_arrays.xml b/core/src/main/res/values/string_arrays.xml
index bc768c8a..9fca4af5 100644
--- a/core/src/main/res/values/string_arrays.xml
+++ b/core/src/main/res/values/string_arrays.xml
@@ -37,4 +37,20 @@
- opengl
-
\ No newline at end of file
+
+ - Original
+ - 4K
+ - 1080p
+ - 720p
+ - 480p
+ - 360p
+
+
+ - @string/quality_original
+ - 4K
+ - 1080p
+ - 720p
+ - 480p
+ - 360p
+
+
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
index c78f6962..58d7dd8b 100644
--- a/core/src/main/res/values/strings.xml
+++ b/core/src/main/res/values/strings.xml
@@ -177,4 +177,7 @@
Are you sure you want to cancel the download?
Stop download
By using Findroid you agree with the Privacy Policy which states that we do not collect any data
+ Quality
+ %s\nAny setting other than Original might require transcoding.
+ Original
diff --git a/core/src/main/res/xml/fragment_settings_player.xml b/core/src/main/res/xml/fragment_settings_player.xml
index 9d47c844..3c5e3bec 100644
--- a/core/src/main/res/xml/fragment_settings_player.xml
+++ b/core/src/main/res/xml/fragment_settings_player.xml
@@ -1,5 +1,18 @@
+
+
+
+
-
+
-
+
\ No newline at end of file
diff --git a/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
index 8b902f55..d6c726d8 100644
--- a/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
+++ b/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
@@ -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?
diff --git a/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
index c01a3ca1..da3c0c45 100644
--- a/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
+++ b/data/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
@@ -31,6 +31,8 @@ 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.exception.InvalidStatusException
+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
@@ -61,6 +63,8 @@ class JellyfinRepositoryImpl(
jellyfinApi.systemApi.getPublicSystemInfo().content
}
+ private val playSessionIds = mutableMapOf()
+
override suspend fun getUserViews(): List = withContext(Dispatchers.IO) {
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty()
}
@@ -350,6 +354,55 @@ class JellyfinRepositoryImpl(
}
}
+ private fun getVideoTranscodeBitRate(transcodeResolution: Int?): Pair {
+ 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()
diff --git a/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt b/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
index a7a8cfa3..7785c900 100644
--- a/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
+++ b/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt
@@ -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,
) {
@@ -134,6 +146,14 @@ constructor(
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) })
diff --git a/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt b/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
index b877a5e6..5c445de7 100644
--- a/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
+++ b/player/video/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt
@@ -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
@@ -19,6 +20,7 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.MediaProtocol
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import javax.inject.Inject
@@ -26,6 +28,7 @@ import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject internal constructor(
private val repository: JellyfinRepository,
+ private val appPreferences: AppPreferences
) : ViewModel() {
private val playerItems = MutableSharedFlow(
@@ -144,7 +147,8 @@ class PlayerViewModel @Inject internal constructor(
}
val externalSubtitles = mediaSource.mediaStreams
.filter { mediaStream ->
- mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
+ (appPreferences.playerPreferredQuality != "Original" || mediaStream.isExternal)
+ && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
}
.map { mediaStream ->
// Temp fix for vtt
@@ -166,17 +170,41 @@ class PlayerViewModel @Inject internal constructor(
},
)
}
- return PlayerItem(
- name = name,
- itemId = id,
- mediaSourceId = mediaSource.id,
- mediaSourceUri = mediaSource.path,
- playbackPosition = playbackPosition,
- parentIndexNumber = if (this is FindroidEpisode) parentIndexNumber else null,
- indexNumber = if (this is FindroidEpisode) indexNumber else null,
- indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
- externalSubtitles = externalSubtitles,
- )
+ return when (mediaSource.protocol) {
+ MediaProtocol.FILE -> PlayerItem(
+ name = name,
+ itemId = id,
+ mediaSourceId = mediaSource.id,
+ mediaSourceUri = mediaSource.path,
+ playbackPosition = playbackPosition,
+ parentIndexNumber = if (this is FindroidEpisode) parentIndexNumber else null,
+ indexNumber = if (this is FindroidEpisode) indexNumber else null,
+ indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
+ externalSubtitles = externalSubtitles
+ )
+ MediaProtocol.HTTP -> PlayerItem(
+ name = name,
+ itemId = id,
+ mediaSourceId = mediaSource.id,
+ mediaSourceUri = mediaSource.path,
+ playbackPosition = playbackPosition,
+ parentIndexNumber = if (this is FindroidEpisode) parentIndexNumber else null,
+ indexNumber = if (this is FindroidEpisode) indexNumber else null,
+ indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
+ externalSubtitles = externalSubtitles
+ )
+ else -> PlayerItem(
+ name = name,
+ itemId = id,
+ mediaSourceId = mediaSource.id,
+ mediaSourceUri = mediaSource.path,
+ playbackPosition = playbackPosition,
+ parentIndexNumber = if (this is FindroidEpisode) parentIndexNumber else null,
+ indexNumber = if (this is FindroidEpisode) indexNumber else null,
+ indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
+ externalSubtitles = externalSubtitles,
+ )
+ }
}
sealed class PlayerItemState
diff --git a/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt b/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt
index 8879736d..99446baa 100644
--- a/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt
+++ b/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt
@@ -43,6 +43,13 @@ 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)
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
diff --git a/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt b/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt
index 5b1df5a7..0834dd6f 100644
--- a/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt
+++ b/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt
@@ -12,6 +12,8 @@ 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_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"
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"