feat: Transcoding stream in player selection /code: prep repo for next commit transcoding downloads
This commit is contained in:
parent
db79b50629
commit
ccc6788a02
8 changed files with 489 additions and 4 deletions
|
@ -33,6 +33,7 @@ import androidx.media3.ui.DefaultTimeBar
|
|||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.navigation.navArgs
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
|
||||
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
|
||||
|
@ -82,6 +83,10 @@ class PlayerActivity : BasePlayerActivity() {
|
|||
|
||||
binding = ActivityPlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality)
|
||||
changeQualityButton.setOnClickListener {
|
||||
showQualitySelectionDialog()
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
binding.playerView.player = viewModel.player
|
||||
|
@ -342,6 +347,23 @@ class PlayerActivity : BasePlayerActivity() {
|
|||
} 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(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration,
|
||||
|
|
|
@ -73,6 +73,24 @@
|
|||
android:layout_height="0dp"
|
||||
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
|
||||
android:id="@+id/btn_pip"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
35
core/src/main/res/drawable/ic_quality.xml
Normal file
35
core/src/main/res/drawable/ic_quality.xml
Normal 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>
|
|
@ -11,9 +11,13 @@ import dev.jdtech.jellyfin.models.FindroidSource
|
|||
import dev.jdtech.jellyfin.models.Intro
|
||||
import dev.jdtech.jellyfin.models.SortBy
|
||||
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.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.PlaybackInfoResponse
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
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 getStreamUrl(itemId: UUID, mediaSourceId: String): String
|
||||
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String
|
||||
|
||||
suspend fun getIntroTimestamps(itemId: UUID): Intro?
|
||||
|
||||
|
@ -112,4 +116,18 @@ interface JellyfinRepository {
|
|||
suspend fun getDownloads(): List<FindroidItem>
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -28,23 +28,35 @@ import io.ktor.util.toByteArray
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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.hlsSegmentApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
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.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||
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.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
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.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.SortOrder
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
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 timber.log.Timber
|
||||
import java.io.File
|
||||
|
@ -322,13 +334,14 @@ class JellyfinRepositoryImpl(
|
|||
sources
|
||||
}
|
||||
|
||||
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String =
|
||||
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
jellyfinApi.videosApi.getVideoStreamUrl(
|
||||
itemId,
|
||||
static = true,
|
||||
mediaSourceId = mediaSourceId,
|
||||
playSessionId = playSessionId
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
|
@ -536,4 +549,165 @@ class JellyfinRepositoryImpl(
|
|||
override fun getUserId(): UUID {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -23,9 +23,13 @@ import dev.jdtech.jellyfin.models.toIntro
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
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.PlaybackInfoResponse
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
|
@ -173,7 +177,7 @@ class JellyfinRepositoryOfflineImpl(
|
|||
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")
|
||||
}
|
||||
|
||||
|
@ -285,4 +289,54 @@ class JellyfinRepositoryOfflineImpl(
|
|||
override fun getUserId(): UUID {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,10 @@ package dev.jdtech.jellyfin.viewmodels
|
|||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -12,6 +14,7 @@ import androidx.media3.common.AudioAttributes
|
|||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
|
@ -20,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.AppPreferences
|
||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||
import dev.jdtech.jellyfin.models.Intro
|
||||
import dev.jdtech.jellyfin.models.PlayerChapter
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
|
@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
@ -49,10 +55,12 @@ class PlayerActivityViewModel
|
|||
constructor(
|
||||
private val application: Application,
|
||||
private val jellyfinRepository: JellyfinRepository,
|
||||
private val jellyfinApi: JellyfinApi,
|
||||
private val appPreferences: AppPreferences,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), Player.Listener {
|
||||
val player: Player
|
||||
private var originalHeight: Int = 0
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
UiState(
|
||||
|
@ -455,7 +463,140 @@ constructor(
|
|||
super.onIsPlayingChanged(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 {
|
||||
data object NavigateBack : PlayerEvents
|
||||
|
|
|
@ -136,7 +136,28 @@ class PlayerViewModel @Inject internal constructor(
|
|||
} else {
|
||||
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 ->
|
||||
mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
|
||||
}
|
||||
|
@ -148,11 +169,13 @@ class PlayerViewModel @Inject internal constructor(
|
|||
when (mediaStream.codec) {
|
||||
"subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||
"webvtt" -> MimeTypes.APPLICATION_SUBRIP
|
||||
"pgs" -> MimeTypes.APPLICATION_PGS
|
||||
"ass" -> MimeTypes.TEXT_SSA
|
||||
else -> MimeTypes.TEXT_UNKNOWN
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val trickplayInfo = when (this) {
|
||||
is FindroidSources -> {
|
||||
this.trickplayInfo?.get(mediaSource.id)?.let {
|
||||
|
|
Loading…
Reference in a new issue