feat: Embedded subtitle in transcoding stream / bugfixes: Download quality dialog loop / code: clean up

This commit is contained in:
nomadics9 2024-07-18 03:59:38 +03:00
parent 5a8403f6a9
commit c84ec082be
9 changed files with 278 additions and 270 deletions

View file

@ -172,59 +172,63 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}else if (!appPreferences.downloadQualityDefault) { }else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog() createPickQualityDialog()
} else { } else {
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent) download()
binding.itemActions.progressDownload.isIndeterminate = true }
binding.itemActions.progressDownload.isVisible = true }
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
val storageDialog = getStorageSelectionDialog( private fun download(){
requireContext(), binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
onItemSelected = { storageIndex -> binding.itemActions.progressDownload.isIndeterminate = true
if (viewModel.item.sources.size > 1) { binding.itemActions.progressDownload.isVisible = true
val dialog = getVideoVersionDialog( if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
requireContext(), val storageDialog = getStorageSelectionDialog(
viewModel.item, requireContext(),
onItemSelected = { sourceIndex -> onItemSelected = { storageIndex ->
createDownloadPreparingDialog() if (viewModel.item.sources.size > 1) {
viewModel.download(sourceIndex, storageIndex) val dialog = getVideoVersionDialog(
}, requireContext(),
onCancel = { viewModel.item,
binding.itemActions.progressDownload.isVisible = false onItemSelected = { sourceIndex ->
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) createDownloadPreparingDialog()
}, viewModel.download(sourceIndex, storageIndex)
) },
dialog.show() onCancel = {
return@getStorageSelectionDialog binding.itemActions.progressDownload.isVisible = false
} binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
createDownloadPreparingDialog() },
viewModel.download(storageIndex = storageIndex) )
}, dialog.show()
onCancel = { return@getStorageSelectionDialog
binding.itemActions.progressDownload.isVisible = false }
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) createDownloadPreparingDialog()
}, viewModel.download(storageIndex = storageIndex)
) },
storageDialog.show() onCancel = {
return binding.itemActions.progressDownload.isVisible = false
} binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
if (viewModel.item.sources.size > 1) { },
val dialog = getVideoVersionDialog( )
requireContext(), storageDialog.show()
viewModel.item, return
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()
} }
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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -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()

View file

@ -209,61 +209,65 @@ class MovieFragment : Fragment() {
} else if (!appPreferences.downloadQualityDefault) { } else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog() createPickQualityDialog()
} else { } else {
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent) download()
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()
} }
} }
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() { 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()

View file

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

View file

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

View file

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

View file

@ -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 {
jellyfinApi.videosApi.getVideoStreamUrl( val url = if (playSessionId != null) {
itemId, jellyfinApi.videosApi.getVideoStreamUrl(
static = true, itemId,
mediaSourceId = mediaSourceId, 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) { } 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
) )
} }

View file

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

View file

@ -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()
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)
}
} }
.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() val url = if (transcodingResolution == 1080){
//.setOrReplaceQueryParameter("PlaySessionId", playSessionId!!) jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId)
} else {
if (transcodingResolution == 1) { val mediaSourceId = mediaSources[currentMediaItemIndex].id
uriBuilder.setOrReplaceQueryParameter("EnableAdaptiveBitrateStreaming", "true") val deviceId = jellyfinRepository.getDeviceId()
uriBuilder.setOrReplaceQueryParameter("Static", "false") val url = jellyfinRepository.getTranscodedVideoStream(currentItem.itemId, deviceId ,mediaSourceId, playSessionId!!, videoBitRate)
uriBuilder.appendQueryParameter("MaxVideoHeight","1080" ) val uriBuilder = url.toUri().buildUpon()
} else if (transcodingResolution == 720 || transcodingResolution == 480 || transcodingResolution == 360) { val apiKey = jellyfinApi.api.accessToken
uriBuilder.setOrReplaceQueryParameter( uriBuilder.appendQueryParameter("api_key",apiKey )
"MaxVideoBitRate", val newUri = uriBuilder.build()
videoBitRate.toString() newUri.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()
Timber.e("URI IS %s", newUri) 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

View file

@ -150,8 +150,9 @@ 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