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,59 +172,63 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}else if (!appPreferences.downloadQualityDefault) {
|
||||
createPickQualityDialog()
|
||||
} else {
|
||||
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
|
||||
binding.itemActions.progressDownload.isIndeterminate = true
|
||||
binding.itemActions.progressDownload.isVisible = true
|
||||
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
|
||||
val storageDialog = getStorageSelectionDialog(
|
||||
requireContext(),
|
||||
onItemSelected = { storageIndex ->
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex, storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return@getStorageSelectionDialog
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(storageIndex = storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
storageDialog.show()
|
||||
return
|
||||
}
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download()
|
||||
download()
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(){
|
||||
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
|
||||
binding.itemActions.progressDownload.isIndeterminate = true
|
||||
binding.itemActions.progressDownload.isVisible = true
|
||||
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
|
||||
val storageDialog = getStorageSelectionDialog(
|
||||
requireContext(),
|
||||
onItemSelected = { storageIndex ->
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex, storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return@getStorageSelectionDialog
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(storageIndex = storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
storageDialog.show()
|
||||
return
|
||||
}
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -424,7 +428,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
builder.setPositiveButton("Download") { dialog, _ ->
|
||||
appPreferences.downloadQuality = selectedQuality
|
||||
dialog.dismiss()
|
||||
handleDownload()
|
||||
download()
|
||||
}
|
||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
|
|
|
@ -209,61 +209,65 @@ class MovieFragment : Fragment() {
|
|||
} else if (!appPreferences.downloadQualityDefault) {
|
||||
createPickQualityDialog()
|
||||
} else {
|
||||
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
|
||||
binding.itemActions.progressDownload.isIndeterminate = true
|
||||
binding.itemActions.progressDownload.isVisible = true
|
||||
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
|
||||
val storageDialog = getStorageSelectionDialog(
|
||||
requireContext(),
|
||||
onItemSelected = { storageIndex ->
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex, storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return@getStorageSelectionDialog
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(storageIndex = storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
storageDialog.show()
|
||||
return
|
||||
}
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download()
|
||||
download()
|
||||
}
|
||||
}
|
||||
|
||||
private fun download() {
|
||||
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
|
||||
binding.itemActions.progressDownload.isIndeterminate = true
|
||||
binding.itemActions.progressDownload.isVisible = true
|
||||
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
|
||||
val storageDialog = getStorageSelectionDialog(
|
||||
requireContext(),
|
||||
onItemSelected = { storageIndex ->
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex, storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return@getStorageSelectionDialog
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(storageIndex = storageIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
storageDialog.show()
|
||||
return
|
||||
}
|
||||
if (viewModel.item.sources.size > 1) {
|
||||
val dialog = getVideoVersionDialog(
|
||||
requireContext(),
|
||||
viewModel.item,
|
||||
onItemSelected = { sourceIndex ->
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download(sourceIndex)
|
||||
},
|
||||
onCancel = {
|
||||
binding.itemActions.progressDownload.isVisible = false
|
||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||
},
|
||||
)
|
||||
dialog.show()
|
||||
return
|
||||
}
|
||||
createDownloadPreparingDialog()
|
||||
viewModel.download()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
@ -502,8 +506,8 @@ class MovieFragment : Fragment() {
|
|||
}
|
||||
|
||||
private fun createPickQualityDialog() {
|
||||
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
|
||||
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
|
||||
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries)
|
||||
val qualityValues = resources.getStringArray(CoreR.array.quality_values)
|
||||
val quality = appPreferences.downloadQuality
|
||||
val currentQualityIndex = qualityValues.indexOf(quality)
|
||||
var selectedQuality = quality
|
||||
|
@ -516,8 +520,8 @@ class MovieFragment : Fragment() {
|
|||
}
|
||||
builder.setPositiveButton("Download") { dialog, _ ->
|
||||
appPreferences.downloadQuality = selectedQuality
|
||||
download()
|
||||
dialog.dismiss()
|
||||
handleDownload()
|
||||
}
|
||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
|
|
|
@ -240,8 +240,8 @@ class SeasonFragment : Fragment() {
|
|||
}
|
||||
builder.setPositiveButton("Download") { dialog, _ ->
|
||||
appPreferences.downloadQuality = selectedQuality
|
||||
dialog.dismiss()
|
||||
onQualitySelected()
|
||||
dialog.dismiss()
|
||||
}
|
||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
|
|
|
@ -6,10 +6,8 @@ import android.net.Uri
|
|||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.text.format.Formatter
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.nomadics9.ananas.AppPreferences
|
||||
import com.nomadics9.ananas.api.JellyfinApi
|
||||
import com.nomadics9.ananas.database.ServerDatabaseDao
|
||||
import com.nomadics9.ananas.models.FindroidEpisode
|
||||
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.toFindroidUserDataDto
|
||||
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.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 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 kotlin.Exception
|
||||
import kotlin.math.ceil
|
||||
|
@ -419,7 +392,8 @@ class DownloaderImpl(
|
|||
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate)
|
||||
val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!!
|
||||
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)
|
||||
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.model.api.BaseItemDto
|
||||
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.DeviceProfile
|
||||
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 getStreamUrl(itemId: UUID, mediaSourceId: String): String
|
||||
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String
|
||||
|
||||
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 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>
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ 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
|
||||
|
@ -56,6 +57,7 @@ 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.TranscodeReason
|
||||
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
|
||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
|
@ -335,14 +337,27 @@ 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,
|
||||
)
|
||||
val url = if (playSessionId != null) {
|
||||
jellyfinApi.videosApi.getVideoStreamUrl(
|
||||
itemId,
|
||||
static = true,
|
||||
mediaSourceId = mediaSourceId,
|
||||
playSessionId = playSessionId,
|
||||
deviceId = getDeviceId(),
|
||||
context = EncodingContext.STATIC
|
||||
)
|
||||
} else {
|
||||
jellyfinApi.videosApi.getVideoStreamUrl(
|
||||
itemId,
|
||||
static = true,
|
||||
mediaSourceId = mediaSourceId,
|
||||
deviceId = getDeviceId(),
|
||||
)
|
||||
}
|
||||
url
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
""
|
||||
|
@ -584,7 +599,7 @@ class JellyfinRepositoryImpl(
|
|||
720 -> 2000000 to 384000 // Adjusted for 720p
|
||||
480 -> 1000000 to 384000 // Adjusted for 480p
|
||||
360 -> 800000 to 128000 // Adjusted for 360p
|
||||
else -> 8000000 to 384000
|
||||
else -> 12000000 to 384000
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -660,10 +675,11 @@ class JellyfinRepositoryImpl(
|
|||
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(
|
||||
itemId,
|
||||
static = false,
|
||||
deviceId = deviceId,
|
||||
mediaSourceId = mediaSourceId,
|
||||
playSessionId = playSessionId,
|
||||
videoBitRate = videoBitrate,
|
||||
|
@ -673,18 +689,63 @@ class JellyfinRepositoryImpl(
|
|||
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,
|
||||
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 {
|
||||
val deviceId = jellyfinApi.devicesApi.getDevices(getUserId())
|
||||
return deviceId.toString()
|
||||
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 = getDeviceId(),
|
||||
deviceId = deviceId,
|
||||
playSessionId = playSessionId
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.nomadics9.ananas.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.devicelock.DeviceId
|
||||
import androidx.paging.PagingData
|
||||
import com.nomadics9.ananas.AppPreferences
|
||||
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.model.api.BaseItemDto
|
||||
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.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
|
@ -177,7 +179,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")
|
||||
}
|
||||
|
||||
|
@ -304,6 +306,7 @@ class JellyfinRepositoryOfflineImpl(
|
|||
|
||||
override suspend fun getVideoStreambyContainerUrl(
|
||||
itemId: UUID,
|
||||
deviceId: String,
|
||||
mediaSourceId: String,
|
||||
playSessionId: String,
|
||||
videoBitrate: Int,
|
||||
|
@ -312,6 +315,16 @@ class JellyfinRepositoryOfflineImpl(
|
|||
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,
|
||||
|
|
|
@ -6,6 +6,7 @@ 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
|
||||
|
@ -13,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
|
||||
|
@ -40,22 +42,8 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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.MediaStreamProtocol
|
||||
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 java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
@ -68,6 +56,7 @@ constructor(
|
|||
private val application: Application,
|
||||
private val jellyfinRepository: JellyfinRepository,
|
||||
private val appPreferences: AppPreferences,
|
||||
private val jellyfinApi: JellyfinApi,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), Player.Listener {
|
||||
val player: Player
|
||||
|
@ -530,13 +519,12 @@ constructor(
|
|||
"480p - 1Mbps" -> 480
|
||||
"360p - 800kbps" -> 360
|
||||
"Auto" -> 1
|
||||
else -> 1
|
||||
else -> 1080
|
||||
}
|
||||
}
|
||||
|
||||
fun changeVideoQuality(quality: String) {
|
||||
val mediaId = player.currentMediaItem?.mediaId ?: return
|
||||
val itemId = UUID.fromString(mediaId)
|
||||
val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return
|
||||
val currentPosition = player.currentPosition
|
||||
|
||||
|
@ -546,137 +534,97 @@ constructor(
|
|||
val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
|
||||
transcodingResolution
|
||||
)
|
||||
val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "ts", EncodingContext.STREAMING)
|
||||
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,true,deviceProfile,videoBitRate)
|
||||
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 mediaSource = playbackInfo.content.mediaSources.firstOrNull()
|
||||
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 ->
|
||||
val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true)
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 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) }
|
||||
|
||||
val baseUrl = jellyfinRepository.getBaseUrl()
|
||||
val cleanBaseUrl = baseUrl.removePrefix("http://").removePrefix("https://")
|
||||
val staticUrl = jellyfinRepository.getStreamUrl(itemId, currentItem.mediaSourceId)
|
||||
|
||||
|
||||
val uri =
|
||||
Uri.parse(transcodingUrl).buildUpon()
|
||||
.scheme("https")
|
||||
.authority(cleanBaseUrl)
|
||||
.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)
|
||||
}
|
||||
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 // ASS is a subtitle format that is essentially an extension of SSA
|
||||
"srt" -> MimeTypes.APPLICATION_SUBRIP // SRT is another common name for SubRip
|
||||
"vtt" -> MimeTypes.TEXT_VTT // VTT is a common extension for WebVTT
|
||||
"ttml" -> MimeTypes.APPLICATION_TTML // TTML (Timed Text Markup Language)
|
||||
"dfxp" -> MimeTypes.APPLICATION_TTML // DFXP is a profile of TTML
|
||||
"stl" -> MimeTypes.APPLICATION_TTML // EBU STL (Subtitling Data Exchange Format)
|
||||
"sbv" -> MimeTypes.APPLICATION_SUBRIP // YouTube's SBV format is similar to SubRip
|
||||
else -> MimeTypes.TEXT_UNKNOWN
|
||||
}
|
||||
)
|
||||
.setLanguage(mediaStream.language?.ifBlank { "Unknown" })
|
||||
.setLabel("Embedded")
|
||||
.build()
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
// Append the new parameter only if it wasn't replaced
|
||||
if (!parameterReplaced) {
|
||||
newBuilder.appendQueryParameter(name, value)
|
||||
}
|
||||
|
||||
return newBuilder
|
||||
}
|
||||
val allSubtitles = embeddedSubtitles.apply { addAll(externalSubtitles) }
|
||||
|
||||
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 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
|
||||
uriBuilder.appendQueryParameter("api_key",apiKey )
|
||||
val newUri = uriBuilder.build()
|
||||
newUri.toString()
|
||||
}
|
||||
|
||||
|
||||
val newUri = uriBuilder.build()
|
||||
Timber.e("URI IS %s", newUri)
|
||||
|
||||
Timber.e("URI IS %s", url)
|
||||
val mediaItemBuilder = MediaItem.Builder()
|
||||
.setMediaId(currentItem.itemId.toString())
|
||||
if (transcodingResolution == 1080) {
|
||||
mediaItemBuilder.setUri(staticUrl)
|
||||
} else {
|
||||
mediaItemBuilder.setUri(newUri)
|
||||
}
|
||||
.setUri(url)
|
||||
.setSubtitleConfigurations(allSubtitles)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(currentItem.name)
|
||||
.build(),
|
||||
)
|
||||
.setSubtitleConfigurations(mediaSubtitles)
|
||||
|
||||
|
||||
player.pause()
|
||||
player.setMediaItem(mediaItemBuilder.build())
|
||||
player.prepare()
|
||||
player.seekTo(currentPosition)
|
||||
playWhenReady = true
|
||||
player.play()
|
||||
|
||||
val originalHeight = mediaSource.mediaStreams
|
||||
?.firstOrNull { it.type == MediaStreamType.VIDEO }?.height ?: -1
|
||||
val originalHeight = mediaSources[currentMediaItemIndex].mediaStreams
|
||||
.filter { it.type == MediaStreamType.VIDEO }
|
||||
.map {mediaStream -> mediaStream.height}.first() ?: 1080
|
||||
|
||||
|
||||
// Store the original height
|
||||
this@PlayerActivityViewModel.originalHeight = originalHeight
|
||||
|
||||
|
|
|
@ -150,8 +150,9 @@ constructor(
|
|||
|
||||
val downloadQualityDefault get() = sharedPreferences.getBoolean(
|
||||
Constants.PREF_DOWNLOADS_QUALITY_DEFAULT,
|
||||
false
|
||||
)
|
||||
false,
|
||||
)
|
||||
|
||||
|
||||
// Sorting
|
||||
var sortBy: String
|
||||
|
|
Loading…
Reference in a new issue