Improve offline playback (#68)

* Fix download playback tracking bug

* Remove unused permission

* Add overview text to downloadmetadata

* Add visual indicator of whether item is downloaded

* Use downloaded item when available

* Fix "null" overview text in download metadata

* Fix crash when playing downloaded file with mpv

* Clean up

Co-authored-by: jarnedemeulemeester <jarnedemeulemeester@gmail.com>
This commit is contained in:
Jcuhfehl 2021-11-27 12:18:41 +01:00 committed by GitHub
parent 8c5d0bebf0
commit 598c11f299
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 141 additions and 92 deletions

View file

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21,15v4a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2v-4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/red"
android:strokeLineCap="round"/>
<path
android:pathData="M7,10l5,5l5,-5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/red"
android:strokeLineCap="round"/>
<path
android:pathData="M12,15L12,3"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/red"
android:strokeLineCap="round"/>
</vector>

View file

@ -3,8 +3,6 @@
package="dev.jdtech.jellyfin">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />

View file

@ -13,6 +13,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationView
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel
@AndroidEntryPoint
@ -57,6 +58,8 @@ class MainActivity : AppCompatActivity() {
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
}
loadDownloadLocation(applicationContext)
viewModel.navigateToAddServer.observe(this, {
if (it) {
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())

View file

@ -94,6 +94,15 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.favoriteButton.setImageResource(drawable)
})
viewModel.downloaded.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(drawable)
})
viewModel.downloadEpisode.observe(viewLifecycleOwner, {
if (it) {
requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)

View file

@ -129,6 +129,15 @@ class MediaInfoFragment : Fragment() {
binding.favoriteButton.setImageResource(drawable)
})
viewModel.downloaded.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(drawable)
})
binding.trailerButton.setOnClickListener {
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent(

View file

@ -14,5 +14,7 @@ data class DownloadMetadata(
val indexNumber: Int? = null,
val playbackPosition: Long? = null,
val playedPercentage: Double? = null,
val seriesId: UUID? = null
val seriesId: UUID? = null,
val played: Boolean? = null,
val overview: String? = null
) : Parcelable

View file

@ -1,17 +1,11 @@
package dev.jdtech.jellyfin.utils
import android.Manifest
import android.app.DownloadManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.models.DownloadMetadata
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem
@ -22,46 +16,9 @@ import timber.log.Timber
import java.io.File
import java.util.UUID
var defaultStorage: File? = null
fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) {
// Storage permission for downloads isn't necessary from Android 10 onwards
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
@Suppress("MagicNumber")
Timber.d("REQUESTING PERMISSION")
if (ContextCompat.checkSelfPermission(
context.requireActivity(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
if (ActivityCompat.shouldShowRequestPermissionRationale(
context.requireActivity(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
) {
ActivityCompat.requestPermissions(
context.requireActivity(),
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1
)
} else {
ActivityCompat.requestPermissions(
context.requireActivity(),
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1
)
}
}
val granted = ContextCompat.checkSelfPermission(
context.requireActivity(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
context.requireContext().toast(R.string.download_no_storage_permission)
return
}
}
val defaultStorage = getDownloadLocation(context.requireContext())
Timber.d(defaultStorage.toString())
val downloadRequest = DownloadManager.Request(uri)
.setTitle(downloadRequestItem.metadata.name)
.setDescription("Downloading")
@ -75,16 +32,13 @@ fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context:
)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
downloadFile(downloadRequest, 1, context.requireContext())
downloadFile(downloadRequest, context.requireContext())
createMetadataFile(
downloadRequestItem.metadata,
downloadRequestItem.itemId,
context.requireContext()
)
downloadRequestItem.itemId)
}
private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context: Context) {
val defaultStorage = getDownloadLocation(context)
private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID) {
val metadataFile = File(defaultStorage, "${itemId}.metadata")
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
@ -100,6 +54,8 @@ private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context
out.println(metadata.playbackPosition.toString())
out.println(metadata.playedPercentage.toString())
out.println(metadata.seriesId.toString())
out.println(metadata.played.toString())
out.println(if (metadata.overview != null) metadata.overview.replace("\n", "\\n") else "")
}
} else if (metadata.type == "Movie") {
metadataFile.printWriter().use { out ->
@ -108,13 +64,14 @@ private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context
out.println(metadata.name.toString())
out.println(metadata.playbackPosition.toString())
out.println(metadata.playedPercentage.toString())
out.println(metadata.played.toString())
out.println(if (metadata.overview != null) metadata.overview.replace("\n", "\\n") else "")
}
}
}
private fun downloadFile(request: DownloadManager.Request, downloadMethod: Int, context: Context) {
require(downloadMethod >= 0) { "Download method hasn't been set" }
private fun downloadFile(request: DownloadManager.Request, context: Context) {
request.apply {
setAllowedOverMetered(false)
setAllowedOverRoaming(false)
@ -122,13 +79,12 @@ private fun downloadFile(request: DownloadManager.Request, downloadMethod: Int,
context.getSystemService<DownloadManager>()?.enqueue(request)
}
private fun getDownloadLocation(context: Context): File? {
return context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
fun loadDownloadLocation(context: Context) {
defaultStorage = context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
}
fun loadDownloadedEpisodes(context: Context): List<PlayerItem> {
fun loadDownloadedEpisodes(): List<PlayerItem> {
val items = mutableListOf<PlayerItem>()
val defaultStorage = getDownloadLocation(context)
defaultStorage?.walk()?.forEach {
if (it.isFile && it.extension == "") {
try {
@ -154,6 +110,29 @@ fun loadDownloadedEpisodes(context: Context): List<PlayerItem> {
return items.toList()
}
fun itemIsDownloaded(itemId: UUID): Boolean {
val file = File(defaultStorage!!, itemId.toString())
if (file.isFile && file.extension == "") {
if (File(defaultStorage, "${itemId}.metadata").exists()){
return true
}
}
return false
}
fun getDownloadPlayerItem(itemId: UUID): PlayerItem? {
val file = File(defaultStorage!!, itemId.toString())
try{
val metadataFile = File(defaultStorage, "${file.name}.metadata").readLines()
val metadata = parseMetadataFile(metadataFile)
return PlayerItem(metadata.name, UUID.fromString(file.name), "", metadata.playbackPosition!!, file.absolutePath, metadata)
} catch (e: Exception) {
file.delete()
Timber.e(e)
}
return null
}
fun deleteDownloadedEpisode(uri: String) {
try {
File(uri).delete()
@ -175,7 +154,7 @@ fun postDownloadPlaybackProgress(uri: String, playbackPosition: Long, playedPerc
metadataArray[3] = playbackPosition.toString()
metadataArray[4] = playedPercentage.toString()
}
Timber.d("PLAYEDPERCENTAGE $playedPercentage")
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
metadataFile.printWriter().use { out ->
metadataArray.forEach {
@ -204,7 +183,8 @@ fun downloadMetadataToBaseItemDto(metadata: DownloadMetadata): BaseItemDto {
parentIndexNumber = metadata.parentIndexNumber,
indexNumber = metadata.indexNumber,
userData = userData,
seriesId = metadata.seriesId
seriesId = metadata.seriesId,
overview = metadata.overview
)
}
@ -218,7 +198,9 @@ fun baseItemDtoToDownloadMetadata(item: BaseItemDto): DownloadMetadata {
indexNumber = item.indexNumber,
playbackPosition = item.userData?.playbackPositionTicks ?: 0,
playedPercentage = item.userData?.playedPercentage,
seriesId = item.seriesId
seriesId = item.seriesId,
played = item.userData?.played,
overview = item.overview
)
}
@ -237,7 +219,9 @@ fun parseMetadataFile(metadataFile: List<String>): DownloadMetadata {
} else {
metadataFile[7].toDouble()
},
seriesId = UUID.fromString(metadataFile[8])
seriesId = UUID.fromString(metadataFile[8]),
played = metadataFile[9].toBoolean(),
overview = metadataFile[10].replace("\\n", "\n")
)
} else {
return DownloadMetadata(
@ -250,12 +234,14 @@ fun parseMetadataFile(metadataFile: List<String>): DownloadMetadata {
} else {
metadataFile[4].toDouble()
},
played = metadataFile[5].toBoolean(),
overview = metadataFile[6].replace("\\n", "\n")
)
}
}
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) {
val items = loadDownloadedEpisodes(context)
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository) {
val items = loadDownloadedEpisodes()
items.forEach() {
try {
val localPlaybackProgress = it.metadata?.playbackPosition
@ -268,6 +254,10 @@ suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context
var playbackProgress: Long = 0
var playedPercentage = 0.0
if (it.metadata?.played == true || item.userData?.played == true){
return@forEach
}
if (localPlaybackProgress != null) {
if (localPlaybackProgress > playbackProgress) {
playbackProgress = localPlaybackProgress

View file

@ -1,28 +1,18 @@
package dev.jdtech.jellyfin.viewmodels
import android.annotation.SuppressLint
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@HiltViewModel
class DownloadViewModel
@Inject
constructor(
private val application: Application,
) : ViewModel() {
class DownloadViewModel : ViewModel() {
private val _downloadSections = MutableLiveData<List<DownloadSection>>()
val downloadSections: LiveData<List<DownloadSection>> = _downloadSections
@ -42,7 +32,7 @@ constructor(
_finishedLoading.value = false
viewModelScope.launch {
try {
val items = loadDownloadedEpisodes(application)
val items = loadDownloadedEpisodes()
if (items.isEmpty()) {
_downloadSections.value = listOf()
_finishedLoading.value = true

View file

@ -13,6 +13,7 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
import dev.jdtech.jellyfin.utils.itemIsDownloaded
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
@ -46,6 +47,9 @@ constructor(
private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite
private val _downloaded = MutableLiveData<Boolean>()
val downloaded: LiveData<Boolean> = _downloaded
private val _downloadEpisode = MutableLiveData<Boolean>()
val downloadEpisode: LiveData<Boolean> = _downloadEpisode
@ -56,6 +60,7 @@ constructor(
fun loadEpisode(episodeId: UUID) {
viewModelScope.launch {
try {
_downloaded.value = itemIsDownloaded(episodeId)
val item = jellyfinRepository.getItem(episodeId)
_item.value = item
_runTime.value = "${item.runTimeTicks?.div(600000000)} min"
@ -129,5 +134,6 @@ constructor(
fun doneDownloadEpisode() {
_downloadEpisode.value = false
_downloaded.value = true
}
}

View file

@ -27,7 +27,7 @@ import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject internal constructor(
private val application: Application,
application: Application,
private val repository: JellyfinRepository
) : ViewModel() {
@ -67,7 +67,7 @@ class HomeViewModel @Inject internal constructor(
views.postValue(updated)
withContext(Dispatchers.Default) {
syncPlaybackProgress(repository, application)
syncPlaybackProgress(repository)
}
state.tryEmit(Loading(inProgress = false))
} catch (e: Exception) {

View file

@ -12,6 +12,7 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
import dev.jdtech.jellyfin.utils.itemIsDownloaded
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -61,6 +62,9 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite
private val _downloaded = MutableLiveData<Boolean>()
val downloaded: LiveData<Boolean> = _downloaded
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
@ -75,6 +79,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
_error.value = null
viewModelScope.launch {
try {
_downloaded.value = itemIsDownloaded(itemId)
_item.value = jellyfinRepository.getItem(itemId)
_actors.value = getActors(_item.value!!)
_director.value = getDirector(_item.value!!)
@ -201,5 +206,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
fun doneDownloadMedia() {
_downloadMedia.value = false
_downloaded.value = true
}
}

View file

@ -107,6 +107,7 @@ constructor(
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
else -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
}
playFromDownloads = item.mediaSourceUri.isNotEmpty()
Timber.d("Stream url: $streamUrl")
val mediaItem =
@ -156,6 +157,9 @@ constructor(
override fun run() {
viewModelScope.launch {
if (player.currentMediaItem != null) {
if(playFromDownloads){
postDownloadPlaybackProgress(items[0].mediaSourceUri, player.currentPosition, (player.currentPosition.toDouble()/player.duration.toDouble()).times(100)) //TODO Automatically use the correct item
}
try {
jellyfinRepository.postPlaybackProgress(
UUID.fromString(player.currentMediaItem!!.mediaId),
@ -165,9 +169,6 @@ constructor(
} catch (e: Exception) {
Timber.e(e)
}
if(playFromDownloads){
postDownloadPlaybackProgress(items[0].mediaSourceUri, player.currentPosition, (player.currentPosition.toDouble()/player.duration.toDouble()).times(100)) //TODO Automaticcaly use the correct item
}
}
}
handler.postDelayed(this, 2000)

View file

@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.getDownloadPlayerItem
import dev.jdtech.jellyfin.utils.itemIsDownloaded
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
@ -35,8 +37,15 @@ class PlayerViewModel @Inject internal constructor(
fun loadPlayerItems(
item: BaseItemDto,
mediaSourceIndex: Int = 0,
onVersionSelectRequired: () -> Unit = { Unit }
onVersionSelectRequired: () -> Unit = { }
) {
if (itemIsDownloaded(item.id)) {
val playerItem = getDownloadPlayerItem(item.id)
if (playerItem != null) {
loadOfflinePlayerItems(playerItem)
return
}
}
Timber.d("Loading player items for item ${item.id}")
if (item.mediaSources.orEmpty().size > 1) {
onVersionSelectRequired()

View file

@ -79,7 +79,6 @@
<string name="force_software_decoding">Force software decoding</string>
<string name="force_software_decoding_summary">Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts.</string>
<string name="download_button_description">Download</string>
<string name="download_no_storage_permission">Cannot download files without storage permissions</string>
<string name="delete_button_description">Delete</string>
<string name="person_detail_title">Person Detail</string>
<string name="error_getting_person_id">Detail unavailable</string>