feat: Download transcoded media
This commit is contained in:
parent
ccc6788a02
commit
062781a43d
7 changed files with 357 additions and 136 deletions
|
@ -157,12 +157,26 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.itemActions.downloadButton.setOnClickListener {
|
binding.itemActions.downloadButton.setOnClickListener {
|
||||||
|
handleDownload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownload() {
|
||||||
if (viewModel.item.isDownloaded()) {
|
if (viewModel.item.isDownloaded()) {
|
||||||
viewModel.deleteEpisode()
|
viewModel.deleteEpisode()
|
||||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||||
} else if (viewModel.item.isDownloading()) {
|
} else if (viewModel.item.isDownloading()) {
|
||||||
createCancelDialog()
|
createCancelDialog()
|
||||||
|
}else if (!appPreferences.downloadQualityDefault) {
|
||||||
|
createPickQualityDialog()
|
||||||
} else {
|
} else {
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(){
|
||||||
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
|
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
|
||||||
binding.itemActions.progressDownload.isIndeterminate = true
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
binding.itemActions.progressDownload.isVisible = true
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
@ -195,7 +209,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
storageDialog.show()
|
storageDialog.show()
|
||||||
return@setOnClickListener
|
return
|
||||||
}
|
}
|
||||||
if (viewModel.item.sources.size > 1) {
|
if (viewModel.item.sources.size > 1) {
|
||||||
val dialog = getVideoVersionDialog(
|
val dialog = getVideoVersionDialog(
|
||||||
|
@ -211,15 +225,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
dialog.show()
|
dialog.show()
|
||||||
return@setOnClickListener
|
return
|
||||||
}
|
}
|
||||||
createDownloadPreparingDialog()
|
createDownloadPreparingDialog()
|
||||||
viewModel.download()
|
viewModel.download()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
dialog?.let {
|
dialog?.let {
|
||||||
|
@ -402,6 +412,31 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
dialog.show()
|
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(
|
private fun navigateToPlayerActivity(
|
||||||
playerItems: Array<PlayerItem>,
|
playerItems: Array<PlayerItem>,
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -192,12 +192,28 @@ class MovieFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.itemActions.downloadButton.setOnClickListener {
|
binding.itemActions.downloadButton.setOnClickListener {
|
||||||
|
handleDownload()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
||||||
|
navigateToPersonDetail(person.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownload() {
|
||||||
if (viewModel.item.isDownloaded()) {
|
if (viewModel.item.isDownloaded()) {
|
||||||
viewModel.deleteItem()
|
viewModel.deleteItem()
|
||||||
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
binding.itemActions.downloadButton.setIconResource(CoreR.drawable.ic_download)
|
||||||
} else if (viewModel.item.isDownloading()) {
|
} else if (viewModel.item.isDownloading()) {
|
||||||
createCancelDialog()
|
createCancelDialog()
|
||||||
|
} else if (!appPreferences.downloadQualityDefault) {
|
||||||
|
createPickQualityDialog()
|
||||||
} else {
|
} else {
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download() {
|
||||||
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
|
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
|
||||||
binding.itemActions.progressDownload.isIndeterminate = true
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
binding.itemActions.progressDownload.isVisible = true
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
@ -230,7 +246,7 @@ class MovieFragment : Fragment() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
storageDialog.show()
|
storageDialog.show()
|
||||||
return@setOnClickListener
|
return
|
||||||
}
|
}
|
||||||
if (viewModel.item.sources.size > 1) {
|
if (viewModel.item.sources.size > 1) {
|
||||||
val dialog = getVideoVersionDialog(
|
val dialog = getVideoVersionDialog(
|
||||||
|
@ -246,17 +262,11 @@ class MovieFragment : Fragment() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
dialog.show()
|
dialog.show()
|
||||||
return@setOnClickListener
|
return
|
||||||
}
|
}
|
||||||
createDownloadPreparingDialog()
|
createDownloadPreparingDialog()
|
||||||
viewModel.download()
|
viewModel.download()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
|
||||||
navigateToPersonDetail(person.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
@ -495,6 +505,31 @@ class MovieFragment : Fragment() {
|
||||||
dialog.show()
|
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(
|
private fun navigateToPlayerActivity(
|
||||||
playerItems: Array<PlayerItem>,
|
playerItems: Array<PlayerItem>,
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -26,6 +26,8 @@ import dev.jdtech.jellyfin.models.toFindroidTrickplayInfoDto
|
||||||
import dev.jdtech.jellyfin.models.toFindroidUserDataDto
|
import dev.jdtech.jellyfin.models.toFindroidUserDataDto
|
||||||
import dev.jdtech.jellyfin.models.toIntroDto
|
import dev.jdtech.jellyfin.models.toIntroDto
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
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.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.Exception
|
import kotlin.Exception
|
||||||
|
@ -82,6 +84,19 @@ class DownloaderImpl(
|
||||||
if (intro != null) {
|
if (intro != null) {
|
||||||
database.insertIntro(intro.toIntroDto(item.id))
|
database.insertIntro(intro.toIntroDto(item.id))
|
||||||
}
|
}
|
||||||
|
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())
|
val request = DownloadManager.Request(source.path.toUri())
|
||||||
.setTitle(item.name)
|
.setTitle(item.name)
|
||||||
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
|
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
|
||||||
|
@ -92,6 +107,7 @@ class DownloaderImpl(
|
||||||
database.setSourceDownloadId(source.id, downloadId)
|
database.setSourceDownloadId(source.id, downloadId)
|
||||||
return Pair(downloadId, null)
|
return Pair(downloadId, null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is FindroidEpisode -> {
|
is FindroidEpisode -> {
|
||||||
database.insertShow(
|
database.insertShow(
|
||||||
|
@ -111,6 +127,19 @@ class DownloaderImpl(
|
||||||
if (intro != null) {
|
if (intro != null) {
|
||||||
database.insertIntro(intro.toIntroDto(item.id))
|
database.insertIntro(intro.toIntroDto(item.id))
|
||||||
}
|
}
|
||||||
|
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())
|
val request = DownloadManager.Request(source.path.toUri())
|
||||||
.setTitle(item.name)
|
.setTitle(item.name)
|
||||||
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
|
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
|
||||||
|
@ -122,6 +151,7 @@ class DownloaderImpl(
|
||||||
return Pair(downloadId, null)
|
return Pair(downloadId, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Pair(-1, null)
|
return Pair(-1, null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
try {
|
try {
|
||||||
|
@ -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(
|
private suspend fun downloadTrickplayData(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
sourceId: String,
|
sourceId: String,
|
||||||
|
@ -263,4 +332,47 @@ class DownloaderImpl(
|
||||||
file.writeBytes(byteArray)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,4 +25,16 @@
|
||||||
<item>audiotrack</item>
|
<item>audiotrack</item>
|
||||||
<item>opensles</item>
|
<item>opensles</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string-array name="quality_entries">
|
||||||
|
<item>Original</item>
|
||||||
|
<item>720p - 2Mbps</item>
|
||||||
|
<item>480p - 1Mbps</item>
|
||||||
|
<item>360p - 800Kbps</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="quality_values">
|
||||||
|
<item>Original</item>
|
||||||
|
<item>720p</item>
|
||||||
|
<item>480p</item>
|
||||||
|
<item>360p</item>
|
||||||
|
</string-array>
|
||||||
</resources>
|
</resources>
|
|
@ -9,4 +9,16 @@
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
app:key="pref_downloads_roaming"
|
app:key="pref_downloads_roaming"
|
||||||
app:title="@string/download_roaming" />
|
app:title="@string/download_roaming" />
|
||||||
|
<!-- TODO: Strings -->
|
||||||
|
<ListPreference
|
||||||
|
android:key="pref_downloads_quality"
|
||||||
|
android:title="Download Quality"
|
||||||
|
android:defaultValue="Original"
|
||||||
|
android:entries="@array/quality_entries"
|
||||||
|
android:entryValues="@array/quality_values"
|
||||||
|
android:summary="%s" />
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="false"
|
||||||
|
app:key="pref_downloads_quality_default"
|
||||||
|
app:summary="Default to picked Download Quality" />
|
||||||
</PreferenceScreen>
|
</PreferenceScreen>
|
|
@ -123,6 +123,19 @@ constructor(
|
||||||
false,
|
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
|
// Sorting
|
||||||
var sortBy: String
|
var sortBy: String
|
||||||
get() = sharedPreferences.getString(
|
get() = sharedPreferences.getString(
|
||||||
|
|
|
@ -42,6 +42,8 @@ object Constants {
|
||||||
const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout"
|
const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout"
|
||||||
const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data"
|
const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data"
|
||||||
const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming"
|
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_BY = "pref_sort_by"
|
||||||
const val PREF_SORT_ORDER = "pref_sort_order"
|
const val PREF_SORT_ORDER = "pref_sort_order"
|
||||||
const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"
|
const val PREF_DISPLAY_EXTRA_INFO = "pref_display_extra_info"
|
||||||
|
|
Loading…
Reference in a new issue