feat: Quality change in player (transcoded stream)

This commit is contained in:
nomadics9 2024-07-14 21:54:21 +03:00
parent ab090a01d7
commit 8139119c35
20 changed files with 388 additions and 157 deletions

View file

@ -16,36 +16,10 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 8, "versionCode": 9,
"versionName": "0.14.2", "versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-armeabi-v7a.apk" "outputFile": "ananas-v0.14.2-libre-armeabi-v7a.apk"
}, },
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 8,
"versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-arm64-v8a.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "x86"
}
],
"attributes": [],
"versionCode": 8,
"versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-x86.apk"
},
{ {
"type": "ONE_OF_MANY", "type": "ONE_OF_MANY",
"filters": [ "filters": [
@ -55,9 +29,35 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 8, "versionCode": 9,
"versionName": "0.14.2", "versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-x86_64.apk" "outputFile": "ananas-v0.14.2-libre-x86_64.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "x86"
}
],
"attributes": [],
"versionCode": 9,
"versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-x86.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 9,
"versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-arm64-v8a.apk"
} }
], ],
"elementType": "File", "elementType": "File",
@ -67,9 +67,9 @@
"maxApi": 30, "maxApi": 30,
"baselineProfiles": [ "baselineProfiles": [
"baselineProfiles/1/ananas-v0.14.2-libre-armeabi-v7a.dm", "baselineProfiles/1/ananas-v0.14.2-libre-armeabi-v7a.dm",
"baselineProfiles/1/ananas-v0.14.2-libre-arm64-v8a.dm", "baselineProfiles/1/ananas-v0.14.2-libre-x86_64.dm",
"baselineProfiles/1/ananas-v0.14.2-libre-x86.dm", "baselineProfiles/1/ananas-v0.14.2-libre-x86.dm",
"baselineProfiles/1/ananas-v0.14.2-libre-x86_64.dm" "baselineProfiles/1/ananas-v0.14.2-libre-arm64-v8a.dm"
] ]
}, },
{ {
@ -77,9 +77,9 @@
"maxApi": 2147483647, "maxApi": 2147483647,
"baselineProfiles": [ "baselineProfiles": [
"baselineProfiles/0/ananas-v0.14.2-libre-armeabi-v7a.dm", "baselineProfiles/0/ananas-v0.14.2-libre-armeabi-v7a.dm",
"baselineProfiles/0/ananas-v0.14.2-libre-arm64-v8a.dm", "baselineProfiles/0/ananas-v0.14.2-libre-x86_64.dm",
"baselineProfiles/0/ananas-v0.14.2-libre-x86.dm", "baselineProfiles/0/ananas-v0.14.2-libre-x86.dm",
"baselineProfiles/0/ananas-v0.14.2-libre-x86_64.dm" "baselineProfiles/0/ananas-v0.14.2-libre-arm64-v8a.dm"
] ]
} }
], ],

View file

@ -1,6 +1,5 @@
package com.nomadics9.ananas package com.nomadics9.ananas
import android.app.AlertDialog
import android.app.AppOpsManager import android.app.AppOpsManager
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
@ -25,15 +24,18 @@ import android.widget.ImageView
import android.widget.Space import android.widget.Space
import android.widget.TextView import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs import androidx.navigation.navArgs
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import com.nomadics9.ananas.databinding.ActivityPlayerBinding import com.nomadics9.ananas.databinding.ActivityPlayerBinding
import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment
@ -88,7 +90,7 @@ class PlayerActivity : BasePlayerActivity() {
binding = ActivityPlayerBinding.inflate(layoutInflater) binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val changeQualityButton: Button = findViewById(R.id.btnChangeQuality) val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality)
changeQualityButton.setOnClickListener { changeQualityButton.setOnClickListener {
showQualitySelectionDialog() showQualitySelectionDialog()
} }
@ -426,9 +428,15 @@ class PlayerActivity : BasePlayerActivity() {
} }
private fun showQualitySelectionDialog() { private fun showQualitySelectionDialog() {
val qualities = arrayOf("1080p", "720p", "480p", "360p") val height = viewModel.getOriginalHeight()
AlertDialog.Builder(this) val qualities = when (height) {
0 -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
in 1001..1999 -> arrayOf("Auto", "Original (1080p) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
in 2000..3000 -> arrayOf("Auto", "Original (4K) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
else -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
}
MaterialAlertDialogBuilder(this)
.setTitle("Select Video Quality") .setTitle("Select Video Quality")
.setItems(qualities) { _, which -> .setItems(qualities) { _, which ->
val selectedQuality = qualities[which] val selectedQuality = qualities[which]
@ -438,6 +446,9 @@ class PlayerActivity : BasePlayerActivity() {
} }
override fun onPictureInPictureModeChanged( override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean, isInPictureInPictureMode: Boolean,
newConfig: Configuration, newConfig: Configuration,

View file

@ -8,29 +8,32 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Select Quality" /> android:text="Select Quality"
/>
<Button <Button
android:id="@+id/btnQuality1080" android:id="@+id/btnQuality1080"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="1080p" /> android:text="1080p" />
<Button <Button
android:id="@+id/btnQuality720" android:id="@+id/btnQuality720"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="720p" /> android:text="720p"
/>
<Button <Button
android:id="@+id/btnQuality480" android:id="@+id/btnQuality480"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="480p" /> android:text="480p"
/>
<Button <Button
android:id="@+id/btnQuality360" android:id="@+id/btnQuality360"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="360p" /> android:text="360p"
/>
</LinearLayout> </LinearLayout>

View file

@ -73,6 +73,23 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<ImageButton
android:id="@+id/btnChangeQuality"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@drawable/transparent_circle_background"
android:contentDescription="Quality"
android:padding="16dp"
android:src="@drawable/ic_quality"
android:layout_gravity="end"
app:tint="@android:color/white"
/>
<Space
android:layout_width="16dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton <ImageButton
android:id="@+id/btn_pip" android:id="@+id/btn_pip"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View file

@ -90,12 +90,6 @@
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
<Button
android:id="@+id/btnChangeQuality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Change Quality" />
<Button <Button
android:id="@+id/btn_skip_intro" android:id="@+id/btn_skip_intro"
style="@style/Widget.Material3.Button.Icon" style="@style/Widget.Material3.Button.Icon"

View file

@ -1,7 +1,7 @@
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
object Versions { object Versions {
const val appCode = 8 const val appCode = 9
const val appName = "0.14.2" const val appName = "0.14.2"
const val compileSdk = 34 const val compileSdk = 34

View file

@ -0,0 +1,35 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M10,7.75a0.75,0.75 0,0 1,1.142 -0.638l3.664,2.249a0.75,0.75 0,0 1,0 1.278l-3.664,2.25a0.75,0.75 0,0 1,-1.142 -0.64z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M12,17v4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M8,21h8"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M4,3L20,3A2,2 0,0 1,22 5L22,15A2,2 0,0 1,20 17L4,17A2,2 0,0 1,2 15L2,5A2,2 0,0 1,4 3z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -10,7 +10,7 @@
app:key="pref_player_mpv" app:key="pref_player_mpv"
app:summary="@string/mpv_player_summary" app:summary="@string/mpv_player_summary"
app:title="@string/mpv_player" app:title="@string/mpv_player"
app:defaultValue="true"/> app:defaultValue="false"/>
<ListPreference <ListPreference
app:defaultValue="mediacodec" app:defaultValue="mediacodec"
app:dependency="pref_player_mpv" app:dependency="pref_player_mpv"

View file

@ -565,12 +565,13 @@ class JellyfinRepositoryImpl(
return jellyfinApi.userId!! return jellyfinApi.userId!!
} }
override fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int?, Int?> { override fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int?, Int?> {
return when (transcodeResolution) { return when (transcodeResolution) {
1080 -> 14616000 to 384000 1080 -> 8000000 to 384000 // Adjusted for 1080p
720 -> 7616000 to 384000 720 -> 2000000 to 384000 // Adjusted for 720p
480 -> 2616000 to 384000 480 -> 1000000 to 384000 // Adjusted for 480p
360 -> 292000 to 128000 360 -> 800000 to 128000 // Adjusted for 360p
else -> null to null else -> null to null
} }
} }

View file

@ -28,7 +28,7 @@ androidx-work = "2.9.0"
coil = "2.6.0" coil = "2.6.0"
hilt = "2.51.1" hilt = "2.51.1"
compose-destinations = "1.10.2" compose-destinations = "1.10.2"
jellyfin = "1.5.0-beta.4" jellyfin = "1.5.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.0.0" kotlin = "2.0.0"
kotlinx-serialization = "1.7.0" kotlinx-serialization = "1.7.0"

View file

@ -19,7 +19,6 @@ import androidx.media3.common.TrackSelectionParameters
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel
import com.nomadics9.ananas.AppPreferences import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi import com.nomadics9.ananas.api.JellyfinApi
import com.nomadics9.ananas.models.FindroidSegment import com.nomadics9.ananas.models.FindroidSegment
@ -29,6 +28,7 @@ import com.nomadics9.ananas.models.Trickplay
import com.nomadics9.ananas.mpv.MPVPlayer import com.nomadics9.ananas.mpv.MPVPlayer
import com.nomadics9.ananas.player.video.R import com.nomadics9.ananas.player.video.R
import com.nomadics9.ananas.repository.JellyfinRepository import com.nomadics9.ananas.repository.JellyfinRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@ -40,12 +40,18 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodeSeekInfo import org.jellyfin.sdk.model.api.TranscodeSeekInfo
@ -66,6 +72,7 @@ constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel(), Player.Listener { ) : ViewModel(), Player.Listener {
val player: Player val player: Player
private var originalHeight: Int = 0
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
UiState( UiState(
@ -186,6 +193,7 @@ constructor(
.setSubtitleConfigurations(mediaSubtitles) .setSubtitleConfigurations(mediaSubtitles)
.build() .build()
mediaItems.add(mediaItem) mediaItems.add(mediaItem)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
@ -262,7 +270,8 @@ constructor(
val itemId = UUID.fromString(currentMediaItem.mediaId) val itemId = UUID.fromString(currentMediaItem.mediaId)
val seconds = player.currentPosition / 1000.0 val seconds = player.currentPosition / 1000.0
val currentSegment = segments[itemId]?.find { segment -> seconds in segment.startTime..<segment.endTime } val currentSegment =
segments[itemId]?.find { segment -> seconds in segment.startTime..<segment.endTime }
_uiState.update { it.copy(currentSegment = currentSegment) } _uiState.update { it.copy(currentSegment = currentSegment) }
Timber.tag("SegmentInfo").d("currentSegment: %s", currentSegment) Timber.tag("SegmentInfo").d("currentSegment: %s", currentSegment)
@ -286,7 +295,8 @@ constructor(
try { try {
items.first { it.itemId.toString() == player.currentMediaItem?.mediaId } items.first { it.itemId.toString() == player.currentMediaItem?.mediaId }
.let { item -> .let { item ->
val itemTitle = if (item.parentIndexNumber != null && item.indexNumber != null) { val itemTitle =
if (item.parentIndexNumber != null && item.indexNumber != null) {
if (item.indexNumberEnd == null) { if (item.indexNumberEnd == null) {
"S${item.parentIndexNumber}:E${item.indexNumber} - ${item.name}" "S${item.parentIndexNumber}:E${item.indexNumber} - ${item.name}"
} else { } else {
@ -322,13 +332,16 @@ constructor(
ExoPlayer.STATE_IDLE -> { ExoPlayer.STATE_IDLE -> {
stateString = "ExoPlayer.STATE_IDLE -" stateString = "ExoPlayer.STATE_IDLE -"
} }
ExoPlayer.STATE_BUFFERING -> { ExoPlayer.STATE_BUFFERING -> {
stateString = "ExoPlayer.STATE_BUFFERING -" stateString = "ExoPlayer.STATE_BUFFERING -"
} }
ExoPlayer.STATE_READY -> { ExoPlayer.STATE_READY -> {
stateString = "ExoPlayer.STATE_READY -" stateString = "ExoPlayer.STATE_READY -"
_uiState.update { it.copy(fileLoaded = true) } _uiState.update { it.copy(fileLoaded = true) }
} }
ExoPlayer.STATE_ENDED -> { ExoPlayer.STATE_ENDED -> {
stateString = "ExoPlayer.STATE_ENDED -" stateString = "ExoPlayer.STATE_ENDED -"
eventsChannel.trySend(PlayerEvents.NavigateBack) eventsChannel.trySend(PlayerEvents.NavigateBack)
@ -356,7 +369,10 @@ constructor(
player.trackSelectionParameters = player.trackSelectionParameters player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon() .buildUpon()
.setOverrideForType( .setOverrideForType(
TrackSelectionOverride(player.currentTracks.groups.filter { it.type == trackType && it.isSupported }[index].mediaTrackGroup, 0), TrackSelectionOverride(
player.currentTracks.groups.filter { it.type == trackType && it.isSupported }[index].mediaTrackGroup,
0
),
) )
.setTrackTypeDisabled(trackType, false) .setTrackTypeDisabled(trackType, false)
.build() .build()
@ -373,7 +389,10 @@ constructor(
Timber.d("Trickplay Resolution: ${trickplayInfo.width}") Timber.d("Trickplay Resolution: ${trickplayInfo.width}")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val maxIndex = ceil(trickplayInfo.thumbnailCount.toDouble().div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)).toInt() val maxIndex = ceil(
trickplayInfo.thumbnailCount.toDouble()
.div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)
).toInt()
val bitmaps = mutableListOf<Bitmap>() val bitmaps = mutableListOf<Bitmap>()
for (i in 0..maxIndex) { for (i in 0..maxIndex) {
@ -385,18 +404,32 @@ constructor(
val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
for (offsetY in 0..<trickplayInfo.height * trickplayInfo.tileHeight step trickplayInfo.height) { for (offsetY in 0..<trickplayInfo.height * trickplayInfo.tileHeight step trickplayInfo.height) {
for (offsetX in 0..<trickplayInfo.width * trickplayInfo.tileWidth step trickplayInfo.width) { for (offsetX in 0..<trickplayInfo.width * trickplayInfo.tileWidth step trickplayInfo.width) {
val bitmap = Bitmap.createBitmap(fullBitmap, offsetX, offsetY, trickplayInfo.width, trickplayInfo.height) val bitmap = Bitmap.createBitmap(
fullBitmap,
offsetX,
offsetY,
trickplayInfo.width,
trickplayInfo.height
)
bitmaps.add(bitmap) bitmaps.add(bitmap)
} }
} }
} }
} }
_uiState.update { it.copy(currentTrickplay = Trickplay(trickplayInfo.interval, bitmaps)) } _uiState.update {
it.copy(
currentTrickplay = Trickplay(
trickplayInfo.interval,
bitmaps
)
)
}
} }
} }
/** /**
* Get chapters of current item * Get chapters of current item
*
* @return list of [PlayerChapter] * @return list of [PlayerChapter]
*/ */
private fun getChapters(): List<PlayerChapter>? { private fun getChapters(): List<PlayerChapter>? {
@ -405,6 +438,7 @@ constructor(
/** /**
* Get the index of the current chapter * Get the index of the current chapter
*
* @return the index of the current chapter * @return the index of the current chapter
*/ */
private fun getCurrentChapterIndex(): Int? { private fun getCurrentChapterIndex(): Int? {
@ -421,6 +455,7 @@ constructor(
/** /**
* Get the index of the next chapter * Get the index of the next chapter
*
* @return the index of the next chapter * @return the index of the next chapter
*/ */
private fun getNextChapterIndex(): Int? { private fun getNextChapterIndex(): Int? {
@ -431,8 +466,10 @@ constructor(
} }
/** /**
* Get the index of the previous chapter. * Get the index of the previous chapter. Only use this for seeking as it
* Only use this for seeking as it will return the current chapter when player position is more than 5 seconds past the start of the chapter * will return the current chapter when player position is more than 5
* seconds past the start of the chapter
*
* @return the index of the previous chapter * @return the index of the previous chapter
*/ */
private fun getPreviousChapterIndex(): Int? { private fun getPreviousChapterIndex(): Int? {
@ -448,11 +485,13 @@ constructor(
} }
fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 } fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 }
fun isLastChapter(): Boolean? = getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 } fun isLastChapter(): Boolean? =
getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 }
/** /**
* Seek to chapter * Seek to chapter
* @param [chapterIndex] the index of the chapter to seek to *
* @param chapterIndex the index of the chapter to seek to
* @return the [PlayerChapter] which has been sought to * @return the [PlayerChapter] which has been sought to
*/ */
private fun seekToChapter(chapterIndex: Int): PlayerChapter? { private fun seekToChapter(chapterIndex: Int): PlayerChapter? {
@ -463,6 +502,7 @@ constructor(
/** /**
* Seek to the next chapter * Seek to the next chapter
*
* @return the [PlayerChapter] which has been sought to * @return the [PlayerChapter] which has been sought to
*/ */
fun seekToNextChapter(): PlayerChapter? { fun seekToNextChapter(): PlayerChapter? {
@ -470,8 +510,9 @@ constructor(
} }
/** /**
* Seek to the previous chapter * Seek to the previous chapter Will seek to start of current chapter if
* Will seek to start of current chapter if player position is more than 5 seconds past start of chapter * player position is more than 5 seconds past start of chapter
*
* @return the [PlayerChapter] which has been sought to * @return the [PlayerChapter] which has been sought to
*/ */
fun seekToPreviousChapter(): PlayerChapter? { fun seekToPreviousChapter(): PlayerChapter? {
@ -486,25 +527,26 @@ constructor(
private fun getTranscodeResolutions(preferredQuality: String): Int { private fun getTranscodeResolutions(preferredQuality: String): Int {
return when (preferredQuality) { return when (preferredQuality) {
"1080p" -> 1080 "1080p" -> 1080
"720p" -> 720 "720p - 2Mbps" -> 720
"480p" -> 480 "480p - 1Mbps" -> 480
"360p" -> 360 "360p - 800kbps" -> 360
else -> 1080 "Auto" -> 1
else -> 1
} }
} }
fun changeVideoQuality(quality: String) { fun changeVideoQuality(quality: String) {
val mediaId = player.currentMediaItem?.mediaId ?: return val mediaId = player.currentMediaItem?.mediaId ?: return
val itemId = UUID.fromString(mediaId) val itemId = UUID.fromString(mediaId)
//val playerItem = playerItemMap[itemId] ?: return
val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return
val currentPosition = player.currentPosition val currentPosition = player.currentPosition
viewModelScope.launch { viewModelScope.launch {
try { try {
val transcodingResolution = getTranscodeResolutions(quality) val transcodingResolution = getTranscodeResolutions(quality)
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(transcodingResolution) val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
if (transcodingResolution != null) { transcodingResolution
)
val deviceProfile = ClientCapabilitiesDto( val deviceProfile = ClientCapabilitiesDto(
supportedCommands = emptyList(), supportedCommands = emptyList(),
playableMediaTypes = emptyList(), playableMediaTypes = emptyList(),
@ -513,10 +555,10 @@ constructor(
deviceProfile = DeviceProfile( deviceProfile = DeviceProfile(
name = "AnanasUser", name = "AnanasUser",
id = jellyfinRepository.getUserId().toString(), id = jellyfinRepository.getUserId().toString(),
maxStaticBitrate = 1_000_000_000, maxStaticBitrate = videoBitRate,
maxStreamingBitrate = 1_000_000_000, maxStreamingBitrate = videoBitRate,
codecProfiles = emptyList(), codecProfiles = emptyList(),
containerProfiles = emptyList(), containerProfiles = listOf(),
directPlayProfiles = listOf( directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO), DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO), DirectPlayProfile(type = DlnaProfileType.AUDIO),
@ -524,19 +566,33 @@ constructor(
transcodingProfiles = listOf( transcodingProfiles = listOf(
TranscodingProfile( TranscodingProfile(
container = "ts", container = "ts",
context = EncodingContext.STREAMING,
protocol = MediaStreamProtocol.HLS, protocol = MediaStreamProtocol.HLS,
audioCodec = "aac", audioCodec = "aac,ac3,eac3",
videoCodec = "hevc", videoCodec = "hevc,h264",
type = DlnaProfileType.VIDEO, type = DlnaProfileType.VIDEO,
conditions = emptyList(), conditions = listOf(
ProfileCondition(
condition = ProfileConditionType.LESS_THAN_EQUAL,
property = ProfileConditionValue.VIDEO_BITRATE,
value = "8000000",
isRequired = true,
)
),
copyTimestamps = true, copyTimestamps = true,
enableSubtitlesInManifest = true, enableSubtitlesInManifest = true,
transcodeSeekInfo = TranscodeSeekInfo.AUTO, transcodeSeekInfo = TranscodeSeekInfo.AUTO,
) ),
), ),
subtitleProfiles = listOf( subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL)
), ),
) )
) )
@ -546,7 +602,7 @@ constructor(
PlaybackInfoDto( PlaybackInfoDto(
userId = jellyfinApi.userId!!, userId = jellyfinApi.userId!!,
enableTranscoding = true, enableTranscoding = true,
enableDirectPlay = true, enableDirectPlay = false,
enableDirectStream = true, enableDirectStream = true,
autoOpenLiveStream = true, autoOpenLiveStream = true,
deviceProfile = deviceProfile.deviceProfile, deviceProfile = deviceProfile.deviceProfile,
@ -554,47 +610,161 @@ constructor(
), ),
) )
val playSessionId = playbackInfo.content.playSessionId val playSessionId = playbackInfo.content.playSessionId
val getDeviceId =
jellyfinApi.devicesApi.getDeviceOptions(jellyfinApi.userId.toString())
val deviceId = getDeviceId.content.deviceId
if (playSessionId != null) {
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
deviceId,
playSessionId
)
}
val mediaSource = playbackInfo.content.mediaSources.firstOrNull() val mediaSource = playbackInfo.content.mediaSources.firstOrNull()
val transcodingUrl = mediaSource?.transcodingUrl if (mediaSource == null) {
Timber.e("Media source is null")
} else {
Timber.d("Media source found: $mediaSource")
}
val transcodingUrl = mediaSource!!.transcodingUrl
val mediaSubtitles = currentItem.externalSubtitles.map { externalSubtitle ->
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
.setMimeType(externalSubtitle.mimeType)
.build()
}
// Timber.tag("MediaStreams").d("Media Streams: %s", mediaSource?.mediaStreams)
//TODO: Embedded sub support
// val embeddedSubtitles = mediaSource?.mediaStreams
// ?.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
// ?.map { mediaStream ->
// MediaItem.SubtitleConfiguration.Builder(Uri.parse(mediaStream.deliveryUrl!!))
// .setMimeType(
// when (mediaStream.codec) {
// "subrip" -> MimeTypes.APPLICATION_SUBRIP
// "webvtt" -> MimeTypes.APPLICATION_SUBRIP
// "ass" -> MimeTypes.TEXT_SSA
// else -> MimeTypes.TEXT_UNKNOWN
// }
// )
// .setLanguage(mediaStream.language ?: "und")
// .setLabel(mediaStream.title ?: "Embedded Subtitle")
// .build()
// }
// ?.toMutableList() ?: mutableListOf()
// val allSubtitles = embeddedSubtitles.apply { addAll(mediaSubtitles) }
// URL METHOD
val baseUrl = jellyfinApi.api.baseUrl val baseUrl = jellyfinApi.api.baseUrl
val cleanBaseUrl = baseUrl?.removePrefix("http://")?.removePrefix("https://") val cleanBaseUrl = baseUrl?.removePrefix("http://")?.removePrefix("https://")
val newUri = Uri.parse(transcodingUrl).buildUpon() val staticUrl = jellyfinApi.videosApi.getVideoStreamUrl(
itemId,
static = true,
playSessionId = playSessionId,
deviceId = deviceId,
mediaSourceId = currentItem.mediaSourceId,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
)
val uri =
Uri.parse(transcodingUrl).buildUpon()
.scheme("https") .scheme("https")
.authority(cleanBaseUrl) .authority(cleanBaseUrl)
//.appendQueryParameter("ffmpegTranscoding", "true")
.appendQueryParameter("maxVideoBitrate", videoBitRate.toString())
.appendQueryParameter("TranscodeReasons", "ContainerBitrateExceedsLimit")
.appendQueryParameter("static", "false")
.appendQueryParameter("maxHeight", videoBitRate.toString())
.appendQueryParameter("PlaySessionId", playSessionId)
.build() .build()
fun Uri.Builder.setOrReplaceQueryParameter(
name: String,
value: String
): Uri.Builder {
val currentQueryParams = this.build().queryParameterNames
// Create a new builder for the URI
val newBuilder = Uri.parse(this.build().toString()).buildUpon()
// Track if the parameter was replaced
var parameterReplaced = false
// Re-add all parameters
currentQueryParams.forEach { param ->
val paramValue = this.build().getQueryParameter(param)
if (param == name) {
// Replace the parameter value
parameterReplaced = true
newBuilder.appendQueryParameter(name, value)
} else {
// Append the existing parameter
newBuilder.appendQueryParameter(param, paramValue)
}
}
// Append the new parameter only if it wasn't replaced
if (!parameterReplaced) {
newBuilder.appendQueryParameter(name, value)
}
return newBuilder
}
val uriBuilder = uri.buildUpon()
//.setOrReplaceQueryParameter("PlaySessionId", playSessionId!!)
if (transcodingResolution == 1) {
uriBuilder.setOrReplaceQueryParameter("EnableAdaptiveBitrateStreaming", "true")
uriBuilder.setOrReplaceQueryParameter("Static", "false")
uriBuilder.appendQueryParameter("MaxVideoHeight","1080" )
} else if (transcodingResolution == 720 || transcodingResolution == 480 || transcodingResolution == 360) {
uriBuilder.setOrReplaceQueryParameter(
"MaxVideoBitRate",
videoBitRate.toString()
)
uriBuilder.setOrReplaceQueryParameter("VideoBitrate", videoBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("AudioBitrate", audioBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("Static", "false")
uriBuilder.appendQueryParameter(
"MaxVideoHeight",
transcodingResolution.toString()
)
uriBuilder.appendQueryParameter("subtitleMethod", "External")
}
val newUri = uriBuilder.build()
Timber.e("URI IS %s", newUri)
val mediaItemBuilder = MediaItem.Builder() val mediaItemBuilder = MediaItem.Builder()
.setMediaId(currentItem.itemId.toString()) .setMediaId(currentItem.itemId.toString())
.setUri(newUri) if (transcodingResolution == 1080) {
mediaItemBuilder.setUri(staticUrl)
} else {
mediaItemBuilder.setUri(newUri)
}
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(currentItem.name) .setTitle(currentItem.name)
.build(), .build(),
) )
//.setSubtitleConfigurations(player.currentMediaItem!!.subtitleConfigurations) .setSubtitleConfigurations(mediaSubtitles)
player.setMediaItem(mediaItemBuilder.build()) player.setMediaItem(mediaItemBuilder.build())
player.prepare() player.prepare()
player.seekTo(currentPosition) player.seekTo(currentPosition)
player.play() player.play()
val originalHeight = mediaSource.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }?.height ?: -1
// Store the original height
this@PlayerActivityViewModel.originalHeight = originalHeight
//isQualityChangeInProgress = true //isQualityChangeInProgress = true
}else if (transcodingResolution == 1080) {
jellyfinRepository.getStreamUrl(itemId, currentItem.mediaSourceId)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} }
} }
} }
fun getOriginalHeight(): Int {
return originalHeight
} }
}
sealed interface PlayerEvents { sealed interface PlayerEvents {
data object NavigateBack : PlayerEvents data object NavigateBack : PlayerEvents

View file

@ -90,7 +90,7 @@ constructor(
Constants.PREF_PLAYER_SEEK_FORWARD_INC, Constants.PREF_PLAYER_SEEK_FORWARD_INC,
DEFAULT_SEEK_FORWARD_INCREMENT_MS.toString(), DEFAULT_SEEK_FORWARD_INCREMENT_MS.toString(),
)!!.toLongOrNull() ?: DEFAULT_SEEK_FORWARD_INCREMENT_MS )!!.toLongOrNull() ?: DEFAULT_SEEK_FORWARD_INCREMENT_MS
val playerMpv get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_MPV, true) val playerMpv get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_MPV, false)
val playerMpvHwdec get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_HWDEC, "mediacodec")!! val playerMpvHwdec get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_HWDEC, "mediacodec")!!
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu-next")!! val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu-next")!!
val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!! val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!!