Added the option to select the stream resolution
This commit is contained in:
parent
16dd40d489
commit
0fb3402e39
9 changed files with 159 additions and 15 deletions
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,5 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<DropDownPreference
|
||||
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" />
|
||||
|
||||
<SwitchPreference
|
||||
app:key="pref_player_display_extended_title"
|
||||
app:summary="@string/display_extended_title_summary"
|
||||
app:title="@string/display_extended_title" />
|
||||
|
||||
<Preference
|
||||
app:key="pref_player_subtitles"
|
||||
app:summary="@string/subtitles_summary"
|
||||
|
@ -99,7 +112,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"
|
||||
|
@ -113,5 +126,5 @@
|
|||
app:title="@string/picture_in_picture_gesture"
|
||||
app:summary="@string/picture_in_picture_gesture_summary" />
|
||||
</PreferenceCategory>
|
||||
|
||||
|
||||
</PreferenceScreen>
|
|
@ -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?
|
||||
|
|
|
@ -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<UUID, String?>()
|
||||
|
||||
override suspend fun getUserViews(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty()
|
||||
}
|
||||
|
@ -350,6 +354,55 @@ 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()
|
||||
|
|
|
@ -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>,
|
||||
) {
|
||||
|
@ -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) })
|
||||
|
|
|
@ -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<PlayerItemState>(
|
||||
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue