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"