feat: Transcoding stream in player selection /code: prep repo for next commit transcoding downloads

This commit is contained in:
nomadics9 2024-07-19 02:20:55 +03:00
parent db79b50629
commit ccc6788a02
8 changed files with 489 additions and 4 deletions

View file

@ -33,6 +33,7 @@ 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 dev.jdtech.jellyfin.databinding.ActivityPlayerBinding import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
@ -82,6 +83,10 @@ class PlayerActivity : BasePlayerActivity() {
binding = ActivityPlayerBinding.inflate(layoutInflater) binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality)
changeQualityButton.setOnClickListener {
showQualitySelectionDialog()
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.playerView.player = viewModel.player binding.playerView.player = viewModel.player
@ -342,6 +347,23 @@ class PlayerActivity : BasePlayerActivity() {
} catch (_: IllegalArgumentException) { } } catch (_: IllegalArgumentException) { }
} }
private fun showQualitySelectionDialog() {
val height = viewModel.getOriginalHeight() // TODO: rewrite getting height stuff I don't like that its only update after changing quality
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")
.setItems(qualities) { _, which ->
val selectedQuality = qualities[which]
viewModel.changeVideoQuality(selectedQuality)
}
.show()
}
override fun onPictureInPictureModeChanged( override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean, isInPictureInPictureMode: Boolean,
newConfig: Configuration, newConfig: Configuration,

View file

@ -73,6 +73,24 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<!--TODO: Content Desc to Strings-->
<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

@ -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

@ -11,9 +11,13 @@ import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.models.SortBy
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.api.client.Response
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
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
@ -81,7 +85,7 @@ interface JellyfinRepository {
suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource> suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource>
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String
suspend fun getIntroTimestamps(itemId: UUID): Intro? suspend fun getIntroTimestamps(itemId: UUID): Intro?
@ -112,4 +116,18 @@ interface JellyfinRepository {
suspend fun getDownloads(): List<FindroidItem> suspend fun getDownloads(): List<FindroidItem>
fun getUserId(): UUID fun getUserId(): UUID
suspend fun getDeviceId(): String
suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int>
suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile
suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String
suspend fun getTranscodedVideoStream(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int): String
suspend fun getPostedPlaybackInfo(itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse>
suspend fun stopEncodingProcess(playSessionId: String)
} }

View file

@ -28,23 +28,35 @@ import io.ktor.util.toByteArray
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.Response
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.api.client.extensions.hlsSegmentApi
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
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceOptionsDto import org.jellyfin.sdk.model.api.DeviceOptionsDto
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.GeneralCommandType import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
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.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
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.TranscodingProfile
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -322,13 +334,14 @@ class JellyfinRepositoryImpl(
sources sources
} }
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String = override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
jellyfinApi.videosApi.getVideoStreamUrl( jellyfinApi.videosApi.getVideoStreamUrl(
itemId, itemId,
static = true, static = true,
mediaSourceId = mediaSourceId, mediaSourceId = mediaSourceId,
playSessionId = playSessionId
) )
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
@ -536,4 +549,165 @@ class JellyfinRepositoryImpl(
override fun getUserId(): UUID { override fun getUserId(): UUID {
return jellyfinApi.userId!! return jellyfinApi.userId!!
} }
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
return when (transcodeResolution) {
1080 -> 8000000 to 384000 // Adjusted for personal can be other values
720 -> 2000000 to 384000 // 720p
480 -> 1000000 to 384000 // 480p
360 -> 800000 to 128000 // 360p
else -> 12000000 to 384000 // its adaptive but setting max here
}
}
override suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile {
val deviceProfile = ClientCapabilitiesDto(
supportedCommands = emptyList(),
playableMediaTypes = emptyList(),
supportsMediaControl = true,
supportsPersistentIdentifier = true,
deviceProfile = DeviceProfile(
name = "AnanasUser",
id = getUserId().toString(),
maxStaticBitrate = maxBitrate,
maxStreamingBitrate = maxBitrate,
codecProfiles = emptyList(),
containerProfiles = listOf(),
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = container,
context = context,
protocol = MediaStreamProtocol.HLS,
audioCodec = "aac,ac3,eac3",
videoCodec = "hevc,h264",
type = DlnaProfileType.VIDEO,
conditions = listOf(
ProfileCondition(
condition = ProfileConditionType.LESS_THAN_EQUAL,
property = ProfileConditionValue.VIDEO_BITRATE,
value = "8000000",
isRequired = true,
)
),
copyTimestamps = true,
enableSubtitlesInManifest = true,
transcodeSeekInfo = TranscodeSeekInfo.AUTO,
),
),
subtitleProfiles = listOf(
SubtitleProfile("srt", 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)
),
)
)
return deviceProfile.deviceProfile!!
}
override suspend fun getPostedPlaybackInfo(itemId: UUID ,enableDirectStream: Boolean ,deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse> {
val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId = itemId,
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
enableTranscoding = true,
enableDirectPlay = false,
enableDirectStream = enableDirectStream,
autoOpenLiveStream = true,
deviceProfile = deviceProfile,
allowAudioStreamCopy = true,
allowVideoStreamCopy = true,
maxStreamingBitrate = maxBitrate,
)
)
return playbackInfo
}
override suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String {
val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl(
itemId,
static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
videoBitRate = videoBitrate,
audioBitRate = 384000,
videoCodec = "hevc",
audioCodec = "aac,ac3,eac3",
container = container,
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
)
return url
}
override suspend fun getTranscodedVideoStream(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int): String {
val isAuto = videoBitrate == 12000000
val url = if (!isAuto) {
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId,
static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
videoBitRate = videoBitrate,
enableAdaptiveBitrateStreaming = false,
audioBitRate = 384000, //could also be passed with audioBitrate but i preferred not as its not much data anyways
videoCodec = "hevc,h264",
audioCodec = "aac,ac3,eac3",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
context = EncodingContext.STREAMING,
segmentContainer = "ts",
transcodeReasons = "ContainerBitrateExceedsLimit",
)
} else {
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId,
static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
enableAdaptiveBitrateStreaming = true,
videoCodec = "hevc",
audioCodec = "aac,ac3,eac3",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
context = EncodingContext.STREAMING,
segmentContainer = "ts",
transcodeReasons = "ContainerBitrateExceedsLimit",
)
}
return url
}
override suspend fun getDeviceId(): String {
val devices = jellyfinApi.devicesApi.getDevices(getUserId())
return devices.content.items?.firstOrNull()?.id!!
}
override suspend fun stopEncodingProcess(playSessionId: String) {
val deviceId = getDeviceId()
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
deviceId = deviceId,
playSessionId = playSessionId
)
}
} }

View file

@ -23,9 +23,13 @@ import dev.jdtech.jellyfin.models.toIntro
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.Response
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
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
@ -173,7 +177,7 @@ class JellyfinRepositoryOfflineImpl(
database.getSources(itemId).map { it.toFindroidSource(database) } database.getSources(itemId).map { it.toFindroidSource(database) }
} }
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String { override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -285,4 +289,54 @@ class JellyfinRepositoryOfflineImpl(
override fun getUserId(): UUID { override fun getUserId(): UUID {
return jellyfinApi.userId!! return jellyfinApi.userId!!
} }
override suspend fun getDeviceId(): String {
TODO("Not yet implemented")
}
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
TODO("Not yet implemented")
}
override suspend fun buildDeviceProfile(
maxBitrate: Int,
container: String,
context: EncodingContext
): DeviceProfile {
TODO("Not yet implemented")
}
override suspend fun getVideoStreambyContainerUrl(
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
container: String
): String {
TODO("Not yet implemented")
}
override suspend fun getTranscodedVideoStream(
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int
): String {
TODO("Not yet implemented")
}
override suspend fun getPostedPlaybackInfo(
itemId: UUID,
enableDirectStream: Boolean,
deviceProfile: DeviceProfile,
maxBitrate: Int
): Response<PlaybackInfoResponse> {
TODO("Not yet implemented")
}
override suspend fun stopEncodingProcess(playSessionId: String) {
TODO("Not yet implemented")
}
} }

View file

@ -3,8 +3,10 @@ package dev.jdtech.jellyfin.viewmodels
import android.app.Application import android.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -12,6 +14,7 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.TrackSelectionParameters
@ -20,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerChapter import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
@ -38,6 +42,8 @@ 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.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -49,10 +55,12 @@ class PlayerActivityViewModel
constructor( constructor(
private val application: Application, private val application: Application,
private val jellyfinRepository: JellyfinRepository, private val jellyfinRepository: JellyfinRepository,
private val jellyfinApi: JellyfinApi,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
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(
@ -455,8 +463,141 @@ constructor(
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying)) eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying))
} }
private fun getTranscodeResolutions(preferredQuality: String): Int {
return when (preferredQuality) {
"1080p" -> 1080 // TODO: 1080p this logic is based on 1080p being original
"720p - 2Mbps" -> 720
"480p - 1Mbps" -> 480
"360p - 800kbps" -> 360
"Auto" -> 1
else -> 1080 //default to Original
}
}
fun changeVideoQuality(quality: String) {
val mediaId = player.currentMediaItem?.mediaId ?: return
val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return
val currentPosition = player.currentPosition
viewModelScope.launch {
try {
val transcodingResolution = getTranscodeResolutions(quality)
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
transcodingResolution
)
val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "mkv", EncodingContext.STREAMING)
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(currentItem.itemId,true,deviceProfile,videoBitRate)
val playSessionId = playbackInfo.content.playSessionId
if (playSessionId != null) {
jellyfinRepository.stopEncodingProcess(playSessionId)
}
val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true)
// TODO: can maybe tidy the sub stuff up
val externalSubtitles = currentItem.externalSubtitles.map { externalSubtitle ->
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
.setLanguage(externalSubtitle.language.ifBlank { "Unknown" })
.setMimeType(externalSubtitle.mimeType)
.build()
}
val embeddedSubtitles = mediaSources[currentMediaItemIndex].mediaStreams
.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal && it.path != null }
.map { mediaStream ->
val test = mediaStream.codec
Timber.d("Deliver: %s", test)
var deliveryUrl = mediaStream.path
Timber.d("Deliverurl: %s", deliveryUrl)
if (mediaStream.codec == "webvtt") {
deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt")}
MediaItem.SubtitleConfiguration.Builder(Uri.parse(deliveryUrl))
.setMimeType(
when (mediaStream.codec) {
"subrip" -> MimeTypes.APPLICATION_SUBRIP
"webvtt" -> MimeTypes.TEXT_VTT
"ssa" -> MimeTypes.TEXT_SSA
"pgs" -> MimeTypes.APPLICATION_PGS
"ass" -> MimeTypes.TEXT_SSA
"srt" -> MimeTypes.APPLICATION_SUBRIP
"vtt" -> MimeTypes.TEXT_VTT
"ttml" -> MimeTypes.APPLICATION_TTML
"dfxp" -> MimeTypes.APPLICATION_TTML
"stl" -> MimeTypes.APPLICATION_TTML
"sbv" -> MimeTypes.APPLICATION_SUBRIP
else -> MimeTypes.TEXT_UNKNOWN
}
)
.setLanguage(mediaStream.language.ifBlank { "Unknown" })
.setLabel("Embedded")
.build()
}
.toMutableList()
val allSubtitles =
if (transcodingResolution == 1080) {
externalSubtitles
}else {
embeddedSubtitles.apply { addAll(externalSubtitles) }
}
val url = if (transcodingResolution == 1080){
jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId)
} else {
val mediaSourceId = mediaSources[currentMediaItemIndex].id
val deviceId = jellyfinRepository.getDeviceId()
val url = jellyfinRepository.getTranscodedVideoStream(currentItem.itemId, deviceId ,mediaSourceId, playSessionId!!, videoBitRate)
val uriBuilder = url.toUri().buildUpon()
val apiKey = jellyfinApi.api.accessToken // TODO: add in repo
uriBuilder.appendQueryParameter("api_key",apiKey )
val newUri = uriBuilder.build()
newUri.toString()
}
Timber.e("URI IS %s", url)
val mediaItemBuilder = MediaItem.Builder()
.setMediaId(currentItem.itemId.toString())
.setUri(url)
.setSubtitleConfigurations(allSubtitles)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(currentItem.name)
.build(),
)
player.pause()
player.setMediaItem(mediaItemBuilder.build())
player.prepare()
player.seekTo(currentPosition)
playWhenReady = true
player.play()
val originalHeight = mediaSources[currentMediaItemIndex].mediaStreams
.filter { it.type == MediaStreamType.VIDEO }
.map {mediaStream -> mediaStream.height}.first() ?: 1080
// Store the original height
this@PlayerActivityViewModel.originalHeight = originalHeight
//isQualityChangeInProgress = true
} catch (e: Exception) {
Timber.e(e)
}
}
}
fun getOriginalHeight(): Int {
return originalHeight
}
} }
sealed interface PlayerEvents { sealed interface PlayerEvents {
data object NavigateBack : PlayerEvents data object NavigateBack : PlayerEvents
data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents

View file

@ -136,7 +136,28 @@ class PlayerViewModel @Inject internal constructor(
} else { } else {
mediaSources[mediaSourceIndex] mediaSources[mediaSourceIndex]
} }
val externalSubtitles = mediaSource.mediaStreams // Embedded Sub externally for offline prep next commit
val externalSubtitles = if (mediaSource.type.toString() == "LOCAL" ) {
mediaSource.mediaStreams
.filter { mediaStream ->
mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
}
.map { mediaStream ->
ExternalSubtitle(
mediaStream.title,
mediaStream.language,
Uri.parse(mediaStream.path!!),
when (mediaStream.codec) {
"subrip" -> MimeTypes.APPLICATION_SUBRIP
"webvtt" -> MimeTypes.APPLICATION_SUBRIP
"pgs" -> MimeTypes.APPLICATION_PGS
"ass" -> MimeTypes.TEXT_SSA
else -> MimeTypes.TEXT_UNKNOWN
},
)
}
}else {
mediaSource.mediaStreams
.filter { mediaStream -> .filter { mediaStream ->
mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank() mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
} }
@ -148,11 +169,13 @@ class PlayerViewModel @Inject internal constructor(
when (mediaStream.codec) { when (mediaStream.codec) {
"subrip" -> MimeTypes.APPLICATION_SUBRIP "subrip" -> MimeTypes.APPLICATION_SUBRIP
"webvtt" -> MimeTypes.APPLICATION_SUBRIP "webvtt" -> MimeTypes.APPLICATION_SUBRIP
"pgs" -> MimeTypes.APPLICATION_PGS
"ass" -> MimeTypes.TEXT_SSA "ass" -> MimeTypes.TEXT_SSA
else -> MimeTypes.TEXT_UNKNOWN else -> MimeTypes.TEXT_UNKNOWN
}, },
) )
} }
}
val trickplayInfo = when (this) { val trickplayInfo = when (this) {
is FindroidSources -> { is FindroidSources -> {
this.trickplayInfo?.get(mediaSource.id)?.let { this.trickplayInfo?.get(mediaSource.id)?.let {