diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index 17c61caf..925f70f3 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -157,70 +157,80 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } binding.itemActions.downloadButton.setOnClickListener { - if (viewModel.item.isDownloaded()) { - viewModel.deleteEpisode() - binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) - } else if (viewModel.item.isDownloading()) { - createCancelDialog() - } 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@setOnClickListener - } - 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@setOnClickListener - } - createDownloadPreparingDialog() - viewModel.download() - } + handleDownload() } return binding.root } + private fun handleDownload() { + if (viewModel.item.isDownloaded()) { + viewModel.deleteEpisode() + binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) + } else if (viewModel.item.isDownloading()) { + createCancelDialog() + }else if (!appPreferences.downloadQualityDefault) { + createPickQualityDialog() + } else { + 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?) { dialog?.let { val sheet = it as BottomSheetDialog @@ -402,6 +412,31 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { dialog.show() } + private fun createPickQualityDialog() { + 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 + + + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle("Download Quality") + builder.setSingleChoiceItems(qualityEntries, currentQualityIndex) { _, which -> + selectedQuality = qualityValues[which] + } + builder.setPositiveButton("Download") { dialog, _ -> + appPreferences.downloadQuality = selectedQuality + dialog.dismiss() + download() + } + builder.setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + val dialog = builder.create() + dialog.show() + } + private fun navigateToPlayerActivity( playerItems: Array, ) { diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt index ed6b8894..b5aded7c 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt @@ -192,65 +192,7 @@ class MovieFragment : Fragment() { } binding.itemActions.downloadButton.setOnClickListener { - if (viewModel.item.isDownloaded()) { - viewModel.deleteItem() - binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) - } else if (viewModel.item.isDownloading()) { - createCancelDialog() - } 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@setOnClickListener - } - 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@setOnClickListener - } - createDownloadPreparingDialog() - viewModel.download() - } + handleDownload() } binding.peopleRecyclerView.adapter = PersonListAdapter { person -> @@ -258,6 +200,74 @@ class MovieFragment : Fragment() { } } + private fun handleDownload() { + if (viewModel.item.isDownloaded()) { + viewModel.deleteItem() + binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download) + } else if (viewModel.item.isDownloading()) { + createCancelDialog() + } else if (!appPreferences.downloadQualityDefault) { + createPickQualityDialog() + } else { + 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() @@ -495,6 +505,31 @@ class MovieFragment : Fragment() { dialog.show() } + private fun createPickQualityDialog() { + 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 + + + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle("Download Quality") + builder.setSingleChoiceItems(qualityEntries, currentQualityIndex) { _, which -> + selectedQuality = qualityValues[which] + } + builder.setPositiveButton("Download") { dialog, _ -> + appPreferences.downloadQuality = selectedQuality + download() + dialog.dismiss() + } + builder.setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + val dialog = builder.create() + dialog.show() + } + private fun navigateToPlayerActivity( playerItems: Array, ) { diff --git a/core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt b/core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt index 9b0d2090..d04f9110 100644 --- a/core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt +++ b/core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt @@ -26,6 +26,8 @@ import dev.jdtech.jellyfin.models.toFindroidTrickplayInfoDto import dev.jdtech.jellyfin.models.toFindroidUserDataDto import dev.jdtech.jellyfin.models.toIntroDto import dev.jdtech.jellyfin.repository.JellyfinRepository +import org.jellyfin.sdk.model.api.EncodingContext +import org.jellyfin.sdk.model.api.MediaStreamType import java.io.File import java.util.UUID import kotlin.Exception @@ -82,15 +84,29 @@ class DownloaderImpl( if (intro != null) { database.insertIntro(intro.toIntroDto(item.id)) } - val request = DownloadManager.Request(source.path.toUri()) - .setTitle(item.name) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationUri(path) - val downloadId = downloadManager.enqueue(request) - database.setSourceDownloadId(source.id, downloadId) - return Pair(downloadId, null) + if (appPreferences.downloadQuality != "Original") { + downloadEmbeddedMediaStreams(item, source,storageIndex) + val transcodingUrl =getTranscodedUrl(item.id,appPreferences.downloadQuality!!) + val request = DownloadManager.Request(transcodingUrl) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) + val downloadId = downloadManager.enqueue(request) + database.setSourceDownloadId(source.id, downloadId) + return Pair(downloadId, null) + }else { + val request = DownloadManager.Request(source.path.toUri()) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) + val downloadId = downloadManager.enqueue(request) + database.setSourceDownloadId(source.id, downloadId) + return Pair(downloadId, null) + } } is FindroidEpisode -> { @@ -111,15 +127,29 @@ class DownloaderImpl( if (intro != null) { database.insertIntro(intro.toIntroDto(item.id)) } - val request = DownloadManager.Request(source.path.toUri()) - .setTitle(item.name) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationUri(path) - val downloadId = downloadManager.enqueue(request) - database.setSourceDownloadId(source.id, downloadId) - return Pair(downloadId, null) + if (appPreferences.downloadQuality != "Original") { + downloadEmbeddedMediaStreams(item, source,storageIndex) + val transcodingUrl = getTranscodedUrl(item.id, appPreferences.downloadQuality!!) + val request = DownloadManager.Request(transcodingUrl) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) + val downloadId = downloadManager.enqueue(request) + database.setSourceDownloadId(source.id, downloadId) + return Pair(downloadId, null) + }else { + val request = DownloadManager.Request(source.path.toUri()) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) + val downloadId = downloadManager.enqueue(request) + database.setSourceDownloadId(source.id, downloadId) + return Pair(downloadId, null) + } } } return Pair(-1, null) @@ -230,6 +260,45 @@ class DownloaderImpl( } } + private fun downloadEmbeddedMediaStreams( + item: FindroidItem, + source: FindroidSource, + storageIndex: Int = 0 + ) { + val storageLocation = context.getExternalFilesDirs(null)[storageIndex] + val subtitleStreams = source.mediaStreams.filter { !it.isExternal && it.type == MediaStreamType.SUBTITLE && it.path != null } + for (mediaStream in subtitleStreams) { + var deliveryUrl = mediaStream.path!! + if (mediaStream.codec == "webvtt") { + deliveryUrl = deliveryUrl.replace("Stream.srt", "Stream.vtt") + } + val id = UUID.randomUUID() + val streamPath = Uri.fromFile( + File( + storageLocation, + "downloads/${item.id}.${source.id}.$id.download" + ) + ) + database.insertMediaStream( + mediaStream.toFindroidMediaStreamDto( + id, + source.id, + streamPath.path.orEmpty() + ) + ) + val request = DownloadManager.Request(Uri.parse(deliveryUrl)) + .setTitle(mediaStream.title) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) + .setDestinationUri(streamPath) + + val downloadId = downloadManager.enqueue(request) + database.setMediaStreamDownloadId(id, downloadId) + } + } + + private suspend fun downloadTrickplayData( itemId: UUID, sourceId: String, @@ -263,4 +332,47 @@ class DownloaderImpl( file.writeBytes(byteArray) } } + + private suspend fun getTranscodedUrl(itemId: UUID, quality: String): Uri? { + val maxBitrate = when (quality) { + "720p" -> 2000000 // 2 Mbps + "480p" -> 1000000 // 1 Mbps + "360p" -> 800000 // 800Kbps + else -> 2000000 + } + + return try { + + val deviceProfile = jellyfinRepository.buildDeviceProfile(maxBitrate,"mkv", EncodingContext.STATIC) + val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate) + val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!! + val playSessionId = playbackInfo.content.playSessionId!! + val deviceId = jellyfinRepository.getDeviceId() + val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, deviceId, mediaSourceId, playSessionId, maxBitrate, "ts") + + val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality) + transcodeUri + } catch (e: Exception) { + null + } + } + + // TODO: I believe building upon the uri is not necessary anymore all is handled in the sdk api + private fun buildTranscodeUri( + transcodingUrl: String, + maxBitrate: Int, + quality: String + ): Uri { + val resolution = when (quality) { + "720p" -> "720" + "480p" -> "480" + "360p" -> "360" + else -> "720" + } + return Uri.parse(transcodingUrl).buildUpon() + .appendQueryParameter("MaxVideoHeight", resolution) + .appendQueryParameter("MaxVideoBitRate", maxBitrate.toString()) + .appendQueryParameter("subtitleMethod", "External") + .build() + } } diff --git a/core/src/main/res/values/string_arrays.xml b/core/src/main/res/values/string_arrays.xml index 6e92f5c0..d198af5a 100644 --- a/core/src/main/res/values/string_arrays.xml +++ b/core/src/main/res/values/string_arrays.xml @@ -25,4 +25,16 @@ audiotrack opensles + + Original + 720p - 2Mbps + 480p - 1Mbps + 360p - 800Kbps + + + Original + 720p + 480p + 360p + \ No newline at end of file diff --git a/core/src/main/res/xml/fragment_settings_downloads.xml b/core/src/main/res/xml/fragment_settings_downloads.xml index 358972ee..9ebeb356 100644 --- a/core/src/main/res/xml/fragment_settings_downloads.xml +++ b/core/src/main/res/xml/fragment_settings_downloads.xml @@ -9,4 +9,16 @@ android:defaultValue="false" app:key="pref_downloads_roaming" app:title="@string/download_roaming" /> + + + \ No newline at end of file diff --git a/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt b/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt index eb7e9dca..c88d2d9d 100644 --- a/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt +++ b/preferences/src/main/java/dev/jdtech/jellyfin/AppPreferences.kt @@ -123,6 +123,19 @@ constructor( false, ) + var downloadQuality get() = sharedPreferences.getString( + Constants.PREF_DOWNLOADS_QUALITY, + "Original") + set(value) { + sharedPreferences.edit().putString(Constants.PREF_DOWNLOADS_QUALITY, value).apply() + } + + val downloadQualityDefault get() = sharedPreferences.getBoolean( + Constants.PREF_DOWNLOADS_QUALITY_DEFAULT, + false, + ) + + // Sorting var sortBy: String get() = sharedPreferences.getString( diff --git a/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt b/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt index cca99608..852dac19 100644 --- a/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt +++ b/preferences/src/main/java/dev/jdtech/jellyfin/Constants.kt @@ -42,6 +42,8 @@ object Constants { const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout" const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data" const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming" + const val PREF_DOWNLOADS_QUALITY = "pref_downloads_quality" + const val PREF_DOWNLOADS_QUALITY_DEFAULT = "pref_downloads_quality_default" const val PREF_SORT_BY = "pref_sort_by" const val PREF_SORT_ORDER = "pref_sort_order" const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"