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">
|
<string-array name="mpv_gpu_api">
|
||||||
<item>opengl</item>
|
<item>opengl</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<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>
|
</resources>
|
|
@ -177,4 +177,7 @@
|
||||||
<string name="cancel_download_message">Are you sure you want to cancel the download?</string>
|
<string name="cancel_download_message">Are you sure you want to cancel the download?</string>
|
||||||
<string name="stop_download">Stop 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="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>
|
</resources>
|
||||||
|
|
|
@ -1,5 +1,18 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
<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
|
<Preference
|
||||||
app:key="pref_player_subtitles"
|
app:key="pref_player_subtitles"
|
||||||
app:summary="@string/subtitles_summary"
|
app:summary="@string/subtitles_summary"
|
||||||
|
|
|
@ -84,6 +84,8 @@ interface JellyfinRepository {
|
||||||
|
|
||||||
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String
|
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 getIntroTimestamps(itemId: UUID): Intro?
|
||||||
|
|
||||||
suspend fun getTrickPlayManifest(itemId: UUID): TrickPlayManifest?
|
suspend fun getTrickPlayManifest(itemId: UUID): TrickPlayManifest?
|
||||||
|
|
|
@ -31,6 +31,8 @@ import io.ktor.utils.io.ByteReadChannel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
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.api.client.extensions.get
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
@ -61,6 +63,8 @@ class JellyfinRepositoryImpl(
|
||||||
jellyfinApi.systemApi.getPublicSystemInfo().content
|
jellyfinApi.systemApi.getPublicSystemInfo().content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val playSessionIds = mutableMapOf<UUID, String?>()
|
||||||
|
|
||||||
override suspend fun getUserViews(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
override suspend fun getUserViews(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty()
|
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? =
|
override suspend fun getIntroTimestamps(itemId: UUID): Intro? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val intro = database.getIntro(itemId)?.toIntro()
|
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(
|
fun initializePlayer(
|
||||||
items: Array<PlayerItem>,
|
items: Array<PlayerItem>,
|
||||||
) {
|
) {
|
||||||
|
@ -134,6 +146,14 @@ constructor(
|
||||||
try {
|
try {
|
||||||
for (item in items) {
|
for (item in items) {
|
||||||
val streamUrl = item.mediaSourceUri
|
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 ->
|
val mediaSubtitles = item.externalSubtitles.map { externalSubtitle ->
|
||||||
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
|
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
|
||||||
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
|
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
|
||||||
|
|
|
@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
import dev.jdtech.jellyfin.models.ExternalSubtitle
|
import dev.jdtech.jellyfin.models.ExternalSubtitle
|
||||||
import dev.jdtech.jellyfin.models.FindroidEpisode
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
import dev.jdtech.jellyfin.models.FindroidItem
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
@ -19,6 +20,7 @@ import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -26,6 +28,7 @@ import javax.inject.Inject
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlayerViewModel @Inject internal constructor(
|
class PlayerViewModel @Inject internal constructor(
|
||||||
private val repository: JellyfinRepository,
|
private val repository: JellyfinRepository,
|
||||||
|
private val appPreferences: AppPreferences
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val playerItems = MutableSharedFlow<PlayerItemState>(
|
private val playerItems = MutableSharedFlow<PlayerItemState>(
|
||||||
|
@ -144,7 +147,8 @@ class PlayerViewModel @Inject internal constructor(
|
||||||
}
|
}
|
||||||
val externalSubtitles = mediaSource.mediaStreams
|
val externalSubtitles = mediaSource.mediaStreams
|
||||||
.filter { mediaStream ->
|
.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 ->
|
.map { mediaStream ->
|
||||||
// Temp fix for vtt
|
// Temp fix for vtt
|
||||||
|
@ -166,7 +170,30 @@ class PlayerViewModel @Inject internal constructor(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return PlayerItem(
|
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,
|
name = name,
|
||||||
itemId = id,
|
itemId = id,
|
||||||
mediaSourceId = mediaSource.id,
|
mediaSourceId = mediaSource.id,
|
||||||
|
@ -178,6 +205,7 @@ class PlayerViewModel @Inject internal constructor(
|
||||||
externalSubtitles = externalSubtitles,
|
externalSubtitles = externalSubtitles,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sealed class PlayerItemState
|
sealed class PlayerItemState
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,13 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player
|
// 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 playerGestures get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES, true)
|
||||||
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
|
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
|
||||||
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
|
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
|
||||||
|
|
|
@ -12,6 +12,8 @@ object Constants {
|
||||||
// pref
|
// pref
|
||||||
const val PREF_CURRENT_SERVER = "pref_current_server"
|
const val PREF_CURRENT_SERVER = "pref_current_server"
|
||||||
const val PREF_OFFLINE_MODE = "pref_offline_mode"
|
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 = "pref_player_gestures"
|
||||||
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
|
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
|
||||||
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"
|
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"
|
||||||
|
|
Loading…
Reference in a new issue