From c84ec082befaa04d06af93b2d38a92a7a4ef72f7 Mon Sep 17 00:00:00 2001 From: nomadics9 Date: Thu, 18 Jul 2024 03:59:38 +0300 Subject: [PATCH] feat: Embedded subtitle in transcoding stream / bugfixes: Download quality dialog loop / code: clean up --- .../fragments/EpisodeBottomSheetFragment.kt | 110 ++++++----- .../ananas/fragments/MovieFragment.kt | 114 +++++------ .../ananas/fragments/SeasonFragment.kt | 2 +- .../nomadics9/ananas/utils/DownloaderImpl.kt | 30 +-- .../ananas/repository/JellyfinRepository.kt | 7 +- .../repository/JellyfinRepositoryImpl.kt | 83 ++++++-- .../JellyfinRepositoryOfflineImpl.kt | 15 +- .../viewmodels/PlayerActivityViewModel.kt | 182 +++++++----------- .../com/nomadics9/ananas/AppPreferences.kt | 5 +- 9 files changed, 278 insertions(+), 270 deletions(-) diff --git a/app/phone/src/main/java/com/nomadics9/ananas/fragments/EpisodeBottomSheetFragment.kt b/app/phone/src/main/java/com/nomadics9/ananas/fragments/EpisodeBottomSheetFragment.kt index 7fdd9acf..da55f8d8 100644 --- a/app/phone/src/main/java/com/nomadics9/ananas/fragments/EpisodeBottomSheetFragment.kt +++ b/app/phone/src/main/java/com/nomadics9/ananas/fragments/EpisodeBottomSheetFragment.kt @@ -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() diff --git a/app/phone/src/main/java/com/nomadics9/ananas/fragments/MovieFragment.kt b/app/phone/src/main/java/com/nomadics9/ananas/fragments/MovieFragment.kt index d0bf32fe..2420c8e2 100644 --- a/app/phone/src/main/java/com/nomadics9/ananas/fragments/MovieFragment.kt +++ b/app/phone/src/main/java/com/nomadics9/ananas/fragments/MovieFragment.kt @@ -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() diff --git a/app/phone/src/main/java/com/nomadics9/ananas/fragments/SeasonFragment.kt b/app/phone/src/main/java/com/nomadics9/ananas/fragments/SeasonFragment.kt index d8caf6a4..714ff9a7 100644 --- a/app/phone/src/main/java/com/nomadics9/ananas/fragments/SeasonFragment.kt +++ b/app/phone/src/main/java/com/nomadics9/ananas/fragments/SeasonFragment.kt @@ -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() diff --git a/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt b/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt index c39d4945..f2667df7 100644 --- a/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt +++ b/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt @@ -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") diff --git a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt index 8802bb0b..e86ab602 100644 --- a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt +++ b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt @@ -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 - 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? @@ -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 diff --git a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt index d54ee1d9..2daace11 100644 --- a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt +++ b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt @@ -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 ) } diff --git a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt index 1131a437..9b8a1fa5 100644 --- a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt +++ b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt @@ -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, diff --git a/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt b/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt index b62a7cd0..4c271f54 100644 --- a/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt +++ b/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt @@ -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 diff --git a/preferences/src/main/java/com/nomadics9/ananas/AppPreferences.kt b/preferences/src/main/java/com/nomadics9/ananas/AppPreferences.kt index 59dbae90..7fabbfa9 100644 --- a/preferences/src/main/java/com/nomadics9/ananas/AppPreferences.kt +++ b/preferences/src/main/java/com/nomadics9/ananas/AppPreferences.kt @@ -150,8 +150,9 @@ constructor( val downloadQualityDefault get() = sharedPreferences.getBoolean( Constants.PREF_DOWNLOADS_QUALITY_DEFAULT, - false - ) + false, + ) + // Sorting var sortBy: String