feat: Embedded subtitle in transcoding stream / bugfixes: Download quality dialog loop / code: clean up
This commit is contained in:
parent
5a8403f6a9
commit
c84ec082be
9 changed files with 278 additions and 270 deletions
|
@ -172,6 +172,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
}else if (!appPreferences.downloadQualityDefault) {
|
}else if (!appPreferences.downloadQualityDefault) {
|
||||||
createPickQualityDialog()
|
createPickQualityDialog()
|
||||||
} else {
|
} else {
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(){
|
||||||
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
|
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
|
||||||
binding.itemActions.progressDownload.isIndeterminate = true
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
binding.itemActions.progressDownload.isVisible = true
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
@ -225,7 +230,6 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
createDownloadPreparingDialog()
|
createDownloadPreparingDialog()
|
||||||
viewModel.download()
|
viewModel.download()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
dialog?.let {
|
dialog?.let {
|
||||||
|
@ -424,7 +428,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
builder.setPositiveButton("Download") { dialog, _ ->
|
builder.setPositiveButton("Download") { dialog, _ ->
|
||||||
appPreferences.downloadQuality = selectedQuality
|
appPreferences.downloadQuality = selectedQuality
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
handleDownload()
|
download()
|
||||||
}
|
}
|
||||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|
|
@ -209,6 +209,11 @@ class MovieFragment : Fragment() {
|
||||||
} else if (!appPreferences.downloadQualityDefault) {
|
} else if (!appPreferences.downloadQualityDefault) {
|
||||||
createPickQualityDialog()
|
createPickQualityDialog()
|
||||||
} else {
|
} else {
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download() {
|
||||||
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
|
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
|
||||||
binding.itemActions.progressDownload.isIndeterminate = true
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
binding.itemActions.progressDownload.isVisible = true
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
@ -262,7 +267,6 @@ class MovieFragment : Fragment() {
|
||||||
createDownloadPreparingDialog()
|
createDownloadPreparingDialog()
|
||||||
viewModel.download()
|
viewModel.download()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
@ -502,8 +506,8 @@ class MovieFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPickQualityDialog() {
|
private fun createPickQualityDialog() {
|
||||||
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
|
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries)
|
||||||
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
|
val qualityValues = resources.getStringArray(CoreR.array.quality_values)
|
||||||
val quality = appPreferences.downloadQuality
|
val quality = appPreferences.downloadQuality
|
||||||
val currentQualityIndex = qualityValues.indexOf(quality)
|
val currentQualityIndex = qualityValues.indexOf(quality)
|
||||||
var selectedQuality = quality
|
var selectedQuality = quality
|
||||||
|
@ -516,8 +520,8 @@ class MovieFragment : Fragment() {
|
||||||
}
|
}
|
||||||
builder.setPositiveButton("Download") { dialog, _ ->
|
builder.setPositiveButton("Download") { dialog, _ ->
|
||||||
appPreferences.downloadQuality = selectedQuality
|
appPreferences.downloadQuality = selectedQuality
|
||||||
|
download()
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
handleDownload()
|
|
||||||
}
|
}
|
||||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|
|
@ -240,8 +240,8 @@ class SeasonFragment : Fragment() {
|
||||||
}
|
}
|
||||||
builder.setPositiveButton("Download") { dialog, _ ->
|
builder.setPositiveButton("Download") { dialog, _ ->
|
||||||
appPreferences.downloadQuality = selectedQuality
|
appPreferences.downloadQuality = selectedQuality
|
||||||
dialog.dismiss()
|
|
||||||
onQualitySelected()
|
onQualitySelected()
|
||||||
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|
|
@ -6,10 +6,8 @@ import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.core.net.toFile
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.nomadics9.ananas.AppPreferences
|
import com.nomadics9.ananas.AppPreferences
|
||||||
import com.nomadics9.ananas.api.JellyfinApi
|
|
||||||
import com.nomadics9.ananas.database.ServerDatabaseDao
|
import com.nomadics9.ananas.database.ServerDatabaseDao
|
||||||
import com.nomadics9.ananas.models.FindroidEpisode
|
import com.nomadics9.ananas.models.FindroidEpisode
|
||||||
import com.nomadics9.ananas.models.FindroidItem
|
import com.nomadics9.ananas.models.FindroidItem
|
||||||
|
@ -29,34 +27,9 @@ import com.nomadics9.ananas.models.toFindroidSourceDto
|
||||||
import com.nomadics9.ananas.models.toFindroidTrickplayInfoDto
|
import com.nomadics9.ananas.models.toFindroidTrickplayInfoDto
|
||||||
import com.nomadics9.ananas.models.toFindroidUserDataDto
|
import com.nomadics9.ananas.models.toFindroidUserDataDto
|
||||||
import com.nomadics9.ananas.repository.JellyfinRepository
|
import com.nomadics9.ananas.repository.JellyfinRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
|
||||||
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
|
|
||||||
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.EncodingContext
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
|
||||||
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.SubtitleProfile
|
|
||||||
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
|
|
||||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.Exception
|
import kotlin.Exception
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
@ -419,7 +392,8 @@ class DownloaderImpl(
|
||||||
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate)
|
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate)
|
||||||
val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!!
|
val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!!
|
||||||
val playSessionId = playbackInfo.content.playSessionId!!
|
val playSessionId = playbackInfo.content.playSessionId!!
|
||||||
val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, mediaSourceId, playSessionId, maxBitrate, "ts")
|
val deviceId = jellyfinRepository.getDeviceId()
|
||||||
|
val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, deviceId, mediaSourceId, playSessionId, maxBitrate, "ts")
|
||||||
|
|
||||||
val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality)
|
val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality)
|
||||||
Timber.d("Constructed Transcode URL: $transcodeUri")
|
Timber.d("Constructed Transcode URL: $transcodeUri")
|
||||||
|
|
|
@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import org.jellyfin.sdk.api.client.Response
|
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.DeviceInfo
|
||||||
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
|
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.EncodingContext
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
|
@ -86,7 +87,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 getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>?
|
suspend fun getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>?
|
||||||
|
|
||||||
|
@ -124,7 +125,9 @@ interface JellyfinRepository {
|
||||||
|
|
||||||
suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile
|
suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile
|
||||||
|
|
||||||
suspend fun getVideoStreambyContainerUrl(itemId: UUID, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String
|
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 getPostedPlaybackInfo(itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse>
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ 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.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.api.client.extensions.hlsSegmentApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
@ -56,6 +57,7 @@ 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.TranscodeReason
|
||||||
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
|
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
|
||||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
|
@ -335,14 +337,27 @@ 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 {
|
||||||
|
val url = if (playSessionId != null) {
|
||||||
jellyfinApi.videosApi.getVideoStreamUrl(
|
jellyfinApi.videosApi.getVideoStreamUrl(
|
||||||
itemId,
|
itemId,
|
||||||
static = true,
|
static = true,
|
||||||
mediaSourceId = mediaSourceId,
|
mediaSourceId = mediaSourceId,
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
deviceId = getDeviceId(),
|
||||||
|
context = EncodingContext.STATIC
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
jellyfinApi.videosApi.getVideoStreamUrl(
|
||||||
|
itemId,
|
||||||
|
static = true,
|
||||||
|
mediaSourceId = mediaSourceId,
|
||||||
|
deviceId = getDeviceId(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
url
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
Timber.e(e)
|
||||||
""
|
""
|
||||||
|
@ -584,7 +599,7 @@ class JellyfinRepositoryImpl(
|
||||||
720 -> 2000000 to 384000 // Adjusted for 720p
|
720 -> 2000000 to 384000 // Adjusted for 720p
|
||||||
480 -> 1000000 to 384000 // Adjusted for 480p
|
480 -> 1000000 to 384000 // Adjusted for 480p
|
||||||
360 -> 800000 to 128000 // Adjusted for 360p
|
360 -> 800000 to 128000 // Adjusted for 360p
|
||||||
else -> 8000000 to 384000
|
else -> 12000000 to 384000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -660,10 +675,11 @@ class JellyfinRepositoryImpl(
|
||||||
return playbackInfo
|
return playbackInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getVideoStreambyContainerUrl(itemId: UUID, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String {
|
override suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String {
|
||||||
val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl(
|
val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl(
|
||||||
itemId,
|
itemId,
|
||||||
static = false,
|
static = false,
|
||||||
|
deviceId = deviceId,
|
||||||
mediaSourceId = mediaSourceId,
|
mediaSourceId = mediaSourceId,
|
||||||
playSessionId = playSessionId,
|
playSessionId = playSessionId,
|
||||||
videoBitRate = videoBitrate,
|
videoBitRate = videoBitrate,
|
||||||
|
@ -673,18 +689,63 @@ class JellyfinRepositoryImpl(
|
||||||
container = container,
|
container = container,
|
||||||
startTimeTicks = 0,
|
startTimeTicks = 0,
|
||||||
copyTimestamps = true,
|
copyTimestamps = true,
|
||||||
|
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
|
||||||
)
|
)
|
||||||
return url
|
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,
|
||||||
|
videoCodec = "hevc",
|
||||||
|
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 {
|
override suspend fun getDeviceId(): String {
|
||||||
val deviceId = jellyfinApi.devicesApi.getDevices(getUserId())
|
val devices = jellyfinApi.devicesApi.getDevices(getUserId())
|
||||||
return deviceId.toString()
|
return devices.content.items?.firstOrNull()?.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun stopEncodingProcess(playSessionId: String) {
|
override suspend fun stopEncodingProcess(playSessionId: String) {
|
||||||
|
val deviceId = getDeviceId()
|
||||||
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
|
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
|
||||||
deviceId = getDeviceId(),
|
deviceId = deviceId,
|
||||||
playSessionId = playSessionId
|
playSessionId = playSessionId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nomadics9.ananas.repository
|
package com.nomadics9.ananas.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.devicelock.DeviceId
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import com.nomadics9.ananas.AppPreferences
|
import com.nomadics9.ananas.AppPreferences
|
||||||
import com.nomadics9.ananas.api.JellyfinApi
|
import com.nomadics9.ananas.api.JellyfinApi
|
||||||
|
@ -26,6 +27,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.Response
|
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.DeviceInfo
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.EncodingContext
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
@ -177,7 +179,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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -304,6 +306,7 @@ class JellyfinRepositoryOfflineImpl(
|
||||||
|
|
||||||
override suspend fun getVideoStreambyContainerUrl(
|
override suspend fun getVideoStreambyContainerUrl(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
deviceId: String,
|
||||||
mediaSourceId: String,
|
mediaSourceId: String,
|
||||||
playSessionId: String,
|
playSessionId: String,
|
||||||
videoBitrate: Int,
|
videoBitrate: Int,
|
||||||
|
@ -312,6 +315,16 @@ class JellyfinRepositoryOfflineImpl(
|
||||||
TODO("Not yet implemented")
|
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(
|
override suspend fun getPostedPlaybackInfo(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
enableDirectStream: Boolean,
|
enableDirectStream: Boolean,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
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
|
||||||
|
@ -13,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
|
||||||
|
@ -40,22 +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.api.client.extensions.hlsSegmentApi
|
|
||||||
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
|
|
||||||
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.EncodingContext
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
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.SubtitleProfile
|
|
||||||
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
|
|
||||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -68,6 +56,7 @@ constructor(
|
||||||
private val application: Application,
|
private val application: Application,
|
||||||
private val jellyfinRepository: JellyfinRepository,
|
private val jellyfinRepository: JellyfinRepository,
|
||||||
private val appPreferences: AppPreferences,
|
private val appPreferences: AppPreferences,
|
||||||
|
private val jellyfinApi: JellyfinApi,
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel(), Player.Listener {
|
) : ViewModel(), Player.Listener {
|
||||||
val player: Player
|
val player: Player
|
||||||
|
@ -530,13 +519,12 @@ constructor(
|
||||||
"480p - 1Mbps" -> 480
|
"480p - 1Mbps" -> 480
|
||||||
"360p - 800kbps" -> 360
|
"360p - 800kbps" -> 360
|
||||||
"Auto" -> 1
|
"Auto" -> 1
|
||||||
else -> 1
|
else -> 1080
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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
|
||||||
|
|
||||||
|
@ -546,137 +534,97 @@ constructor(
|
||||||
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
|
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
|
||||||
transcodingResolution
|
transcodingResolution
|
||||||
)
|
)
|
||||||
val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "ts", EncodingContext.STREAMING)
|
val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "mkv", EncodingContext.STREAMING)
|
||||||
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,true,deviceProfile,videoBitRate)
|
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(currentItem.itemId,true,deviceProfile,videoBitRate)
|
||||||
val playSessionId = playbackInfo.content.playSessionId
|
val playSessionId = playbackInfo.content.playSessionId
|
||||||
if (playSessionId != null) {
|
if (playSessionId != null) {
|
||||||
jellyfinRepository.stopEncodingProcess(playSessionId)
|
jellyfinRepository.stopEncodingProcess(playSessionId)
|
||||||
}
|
}
|
||||||
val mediaSource = playbackInfo.content.mediaSources.firstOrNull()
|
val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true)
|
||||||
if (mediaSource == null) {
|
|
||||||
Timber.e("Media source is null")
|
|
||||||
} else {
|
val externalSubtitles = currentItem.externalSubtitles.map { externalSubtitle ->
|
||||||
Timber.d("Media source found: $mediaSource")
|
|
||||||
}
|
|
||||||
val transcodingUrl = mediaSource!!.transcodingUrl
|
|
||||||
val mediaSubtitles = currentItem.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) })
|
||||||
|
.setLanguage(externalSubtitle.language.ifBlank { "Unknown" })
|
||||||
.setMimeType(externalSubtitle.mimeType)
|
.setMimeType(externalSubtitle.mimeType)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Embedded sub support
|
val embeddedSubtitles = mediaSources[currentMediaItemIndex].mediaStreams
|
||||||
// val embeddedSubtitles = mediaSource?.mediaStreams
|
.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal && it.path != null }
|
||||||
// ?.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
|
.map { mediaStream ->
|
||||||
// ?.map { mediaStream ->
|
val test = mediaStream.codec
|
||||||
// MediaItem.SubtitleConfiguration.Builder(Uri.parse(mediaStream.deliveryUrl!!))
|
Timber.d("Deliver: %s", test)
|
||||||
// .setMimeType(
|
var deliveryUrl = mediaStream.path
|
||||||
// when (mediaStream.codec) {
|
Timber.d("Deliverurl: %s", deliveryUrl)
|
||||||
// "subrip" -> MimeTypes.APPLICATION_SUBRIP
|
if (mediaStream.codec == "webvtt") {
|
||||||
// "webvtt" -> MimeTypes.APPLICATION_SUBRIP
|
deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt")}
|
||||||
// "ass" -> MimeTypes.TEXT_SSA
|
MediaItem.SubtitleConfiguration.Builder(Uri.parse(deliveryUrl))
|
||||||
// else -> MimeTypes.TEXT_UNKNOWN
|
.setMimeType(
|
||||||
// }
|
when (mediaStream.codec) {
|
||||||
// )
|
"subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||||
// .setLanguage(mediaStream.language ?: "und")
|
"webvtt" -> MimeTypes.TEXT_VTT
|
||||||
// .setLabel(mediaStream.title ?: "Embedded Subtitle")
|
"ssa" -> MimeTypes.TEXT_SSA
|
||||||
// .build()
|
"pgs" -> MimeTypes.APPLICATION_PGS
|
||||||
// }
|
"ass" -> MimeTypes.TEXT_SSA // ASS is a subtitle format that is essentially an extension of SSA
|
||||||
// ?.toMutableList() ?: mutableListOf()
|
"srt" -> MimeTypes.APPLICATION_SUBRIP // SRT is another common name for SubRip
|
||||||
// val allSubtitles = embeddedSubtitles.apply { addAll(mediaSubtitles) }
|
"vtt" -> MimeTypes.TEXT_VTT // VTT is a common extension for WebVTT
|
||||||
|
"ttml" -> MimeTypes.APPLICATION_TTML // TTML (Timed Text Markup Language)
|
||||||
val baseUrl = jellyfinRepository.getBaseUrl()
|
"dfxp" -> MimeTypes.APPLICATION_TTML // DFXP is a profile of TTML
|
||||||
val cleanBaseUrl = baseUrl.removePrefix("http://").removePrefix("https://")
|
"stl" -> MimeTypes.APPLICATION_TTML // EBU STL (Subtitling Data Exchange Format)
|
||||||
val staticUrl = jellyfinRepository.getStreamUrl(itemId, currentItem.mediaSourceId)
|
"sbv" -> MimeTypes.APPLICATION_SUBRIP // YouTube's SBV format is similar to SubRip
|
||||||
|
else -> MimeTypes.TEXT_UNKNOWN
|
||||||
|
}
|
||||||
val uri =
|
)
|
||||||
Uri.parse(transcodingUrl).buildUpon()
|
.setLanguage(mediaStream.language?.ifBlank { "Unknown" })
|
||||||
.scheme("https")
|
.setLabel("Embedded")
|
||||||
.authority(cleanBaseUrl)
|
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
fun Uri.Builder.setOrReplaceQueryParameter(
|
|
||||||
name: String,
|
|
||||||
value: String
|
|
||||||
): Uri.Builder {
|
|
||||||
val currentQueryParams = this.build().queryParameterNames
|
|
||||||
|
|
||||||
// Create a new builder for the URI
|
val allSubtitles = embeddedSubtitles.apply { addAll(externalSubtitles) }
|
||||||
val newBuilder = Uri.parse(this.build().toString()).buildUpon()
|
|
||||||
|
|
||||||
// Track if the parameter was replaced
|
val url = if (transcodingResolution == 1080){
|
||||||
var parameterReplaced = false
|
jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId)
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
// Append the existing parameter
|
val mediaSourceId = mediaSources[currentMediaItemIndex].id
|
||||||
newBuilder.appendQueryParameter(param, paramValue)
|
val deviceId = jellyfinRepository.getDeviceId()
|
||||||
}
|
val url = jellyfinRepository.getTranscodedVideoStream(currentItem.itemId, deviceId ,mediaSourceId, playSessionId!!, videoBitRate)
|
||||||
}
|
val uriBuilder = url.toUri().buildUpon()
|
||||||
|
val apiKey = jellyfinApi.api.accessToken
|
||||||
// Append the new parameter only if it wasn't replaced
|
uriBuilder.appendQueryParameter("api_key",apiKey )
|
||||||
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("PlaySessionId", playSessionId)
|
|
||||||
uriBuilder.appendQueryParameter(
|
|
||||||
"MaxVideoHeight",
|
|
||||||
transcodingResolution.toString()
|
|
||||||
)
|
|
||||||
uriBuilder.appendQueryParameter("subtitleMethod", "External")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val newUri = uriBuilder.build()
|
val newUri = uriBuilder.build()
|
||||||
Timber.e("URI IS %s", newUri)
|
newUri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Timber.e("URI IS %s", url)
|
||||||
val mediaItemBuilder = MediaItem.Builder()
|
val mediaItemBuilder = MediaItem.Builder()
|
||||||
.setMediaId(currentItem.itemId.toString())
|
.setMediaId(currentItem.itemId.toString())
|
||||||
if (transcodingResolution == 1080) {
|
.setUri(url)
|
||||||
mediaItemBuilder.setUri(staticUrl)
|
.setSubtitleConfigurations(allSubtitles)
|
||||||
} else {
|
|
||||||
mediaItemBuilder.setUri(newUri)
|
|
||||||
}
|
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle(currentItem.name)
|
.setTitle(currentItem.name)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.setSubtitleConfigurations(mediaSubtitles)
|
|
||||||
|
|
||||||
|
|
||||||
|
player.pause()
|
||||||
player.setMediaItem(mediaItemBuilder.build())
|
player.setMediaItem(mediaItemBuilder.build())
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.seekTo(currentPosition)
|
player.seekTo(currentPosition)
|
||||||
|
playWhenReady = true
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
val originalHeight = mediaSource.mediaStreams
|
val originalHeight = mediaSources[currentMediaItemIndex].mediaStreams
|
||||||
?.firstOrNull { it.type == MediaStreamType.VIDEO }?.height ?: -1
|
.filter { it.type == MediaStreamType.VIDEO }
|
||||||
|
.map {mediaStream -> mediaStream.height}.first() ?: 1080
|
||||||
|
|
||||||
|
|
||||||
// Store the original height
|
// Store the original height
|
||||||
this@PlayerActivityViewModel.originalHeight = originalHeight
|
this@PlayerActivityViewModel.originalHeight = originalHeight
|
||||||
|
|
||||||
|
|
|
@ -150,9 +150,10 @@ constructor(
|
||||||
|
|
||||||
val downloadQualityDefault get() = sharedPreferences.getBoolean(
|
val downloadQualityDefault get() = sharedPreferences.getBoolean(
|
||||||
Constants.PREF_DOWNLOADS_QUALITY_DEFAULT,
|
Constants.PREF_DOWNLOADS_QUALITY_DEFAULT,
|
||||||
false
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
var sortBy: String
|
var sortBy: String
|
||||||
get() = sharedPreferences.getString(
|
get() = sharedPreferences.getString(
|
||||||
|
|
Loading…
Reference in a new issue