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:
parent
8c5d0bebf0
commit
598c11f299
14 changed files with 141 additions and 92 deletions
27
app/src/debug/res/drawable/ic_download_filled.xml
Normal file
27
app/src/debug/res/drawable/ic_download_filled.xml
Normal 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>
|
|
@ -3,8 +3,6 @@
|
||||||
package="dev.jdtech.jellyfin">
|
package="dev.jdtech.jellyfin">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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.software.leanback" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
|
@ -13,6 +13,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
|
import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
|
||||||
import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
|
import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
|
||||||
|
import dev.jdtech.jellyfin.utils.loadDownloadLocation
|
||||||
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -57,6 +58,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
|
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadDownloadLocation(applicationContext)
|
||||||
|
|
||||||
viewModel.navigateToAddServer.observe(this, {
|
viewModel.navigateToAddServer.observe(this, {
|
||||||
if (it) {
|
if (it) {
|
||||||
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
|
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
|
||||||
|
|
|
@ -94,6 +94,15 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
binding.favoriteButton.setImageResource(drawable)
|
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, {
|
viewModel.downloadEpisode.observe(viewLifecycleOwner, {
|
||||||
if (it) {
|
if (it) {
|
||||||
requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)
|
requestDownload(Uri.parse(viewModel.downloadRequestItem.uri), viewModel.downloadRequestItem, this)
|
||||||
|
|
|
@ -129,6 +129,15 @@ class MediaInfoFragment : Fragment() {
|
||||||
binding.favoriteButton.setImageResource(drawable)
|
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 {
|
binding.trailerButton.setOnClickListener {
|
||||||
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
||||||
val intent = Intent(
|
val intent = Intent(
|
||||||
|
|
|
@ -14,5 +14,7 @@ data class DownloadMetadata(
|
||||||
val indexNumber: Int? = null,
|
val indexNumber: Int? = null,
|
||||||
val playbackPosition: Long? = null,
|
val playbackPosition: Long? = null,
|
||||||
val playedPercentage: Double? = null,
|
val playedPercentage: Double? = null,
|
||||||
val seriesId: UUID? = null
|
val seriesId: UUID? = null,
|
||||||
|
val played: Boolean? = null,
|
||||||
|
val overview: String? = null
|
||||||
) : Parcelable
|
) : Parcelable
|
|
@ -1,17 +1,11 @@
|
||||||
package dev.jdtech.jellyfin.utils
|
package dev.jdtech.jellyfin.utils
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import dev.jdtech.jellyfin.R
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadMetadata
|
import dev.jdtech.jellyfin.models.DownloadMetadata
|
||||||
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
|
@ -22,46 +16,9 @@ import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
var defaultStorage: File? = null
|
||||||
|
|
||||||
fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) {
|
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)
|
val downloadRequest = DownloadManager.Request(uri)
|
||||||
.setTitle(downloadRequestItem.metadata.name)
|
.setTitle(downloadRequestItem.metadata.name)
|
||||||
.setDescription("Downloading")
|
.setDescription("Downloading")
|
||||||
|
@ -75,16 +32,13 @@ fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context:
|
||||||
)
|
)
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
|
if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
|
||||||
downloadFile(downloadRequest, 1, context.requireContext())
|
downloadFile(downloadRequest, context.requireContext())
|
||||||
createMetadataFile(
|
createMetadataFile(
|
||||||
downloadRequestItem.metadata,
|
downloadRequestItem.metadata,
|
||||||
downloadRequestItem.itemId,
|
downloadRequestItem.itemId)
|
||||||
context.requireContext()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context: Context) {
|
private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID) {
|
||||||
val defaultStorage = getDownloadLocation(context)
|
|
||||||
val metadataFile = File(defaultStorage, "${itemId}.metadata")
|
val metadataFile = File(defaultStorage, "${itemId}.metadata")
|
||||||
|
|
||||||
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
|
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.playbackPosition.toString())
|
||||||
out.println(metadata.playedPercentage.toString())
|
out.println(metadata.playedPercentage.toString())
|
||||||
out.println(metadata.seriesId.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") {
|
} else if (metadata.type == "Movie") {
|
||||||
metadataFile.printWriter().use { out ->
|
metadataFile.printWriter().use { out ->
|
||||||
|
@ -108,13 +64,14 @@ private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID, context
|
||||||
out.println(metadata.name.toString())
|
out.println(metadata.name.toString())
|
||||||
out.println(metadata.playbackPosition.toString())
|
out.println(metadata.playbackPosition.toString())
|
||||||
out.println(metadata.playedPercentage.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) {
|
private fun downloadFile(request: DownloadManager.Request, context: Context) {
|
||||||
require(downloadMethod >= 0) { "Download method hasn't been set" }
|
|
||||||
request.apply {
|
request.apply {
|
||||||
setAllowedOverMetered(false)
|
setAllowedOverMetered(false)
|
||||||
setAllowedOverRoaming(false)
|
setAllowedOverRoaming(false)
|
||||||
|
@ -122,13 +79,12 @@ private fun downloadFile(request: DownloadManager.Request, downloadMethod: Int,
|
||||||
context.getSystemService<DownloadManager>()?.enqueue(request)
|
context.getSystemService<DownloadManager>()?.enqueue(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDownloadLocation(context: Context): File? {
|
fun loadDownloadLocation(context: Context) {
|
||||||
return context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
|
defaultStorage = context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadDownloadedEpisodes(context: Context): List<PlayerItem> {
|
fun loadDownloadedEpisodes(): List<PlayerItem> {
|
||||||
val items = mutableListOf<PlayerItem>()
|
val items = mutableListOf<PlayerItem>()
|
||||||
val defaultStorage = getDownloadLocation(context)
|
|
||||||
defaultStorage?.walk()?.forEach {
|
defaultStorage?.walk()?.forEach {
|
||||||
if (it.isFile && it.extension == "") {
|
if (it.isFile && it.extension == "") {
|
||||||
try {
|
try {
|
||||||
|
@ -154,6 +110,29 @@ fun loadDownloadedEpisodes(context: Context): List<PlayerItem> {
|
||||||
return items.toList()
|
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) {
|
fun deleteDownloadedEpisode(uri: String) {
|
||||||
try {
|
try {
|
||||||
File(uri).delete()
|
File(uri).delete()
|
||||||
|
@ -175,7 +154,7 @@ fun postDownloadPlaybackProgress(uri: String, playbackPosition: Long, playedPerc
|
||||||
metadataArray[3] = playbackPosition.toString()
|
metadataArray[3] = playbackPosition.toString()
|
||||||
metadataArray[4] = playedPercentage.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.writeText("") //This might be necessary to make sure that the metadata file is empty
|
||||||
metadataFile.printWriter().use { out ->
|
metadataFile.printWriter().use { out ->
|
||||||
metadataArray.forEach {
|
metadataArray.forEach {
|
||||||
|
@ -204,7 +183,8 @@ fun downloadMetadataToBaseItemDto(metadata: DownloadMetadata): BaseItemDto {
|
||||||
parentIndexNumber = metadata.parentIndexNumber,
|
parentIndexNumber = metadata.parentIndexNumber,
|
||||||
indexNumber = metadata.indexNumber,
|
indexNumber = metadata.indexNumber,
|
||||||
userData = userData,
|
userData = userData,
|
||||||
seriesId = metadata.seriesId
|
seriesId = metadata.seriesId,
|
||||||
|
overview = metadata.overview
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,7 +198,9 @@ fun baseItemDtoToDownloadMetadata(item: BaseItemDto): DownloadMetadata {
|
||||||
indexNumber = item.indexNumber,
|
indexNumber = item.indexNumber,
|
||||||
playbackPosition = item.userData?.playbackPositionTicks ?: 0,
|
playbackPosition = item.userData?.playbackPositionTicks ?: 0,
|
||||||
playedPercentage = item.userData?.playedPercentage,
|
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 {
|
} else {
|
||||||
metadataFile[7].toDouble()
|
metadataFile[7].toDouble()
|
||||||
},
|
},
|
||||||
seriesId = UUID.fromString(metadataFile[8])
|
seriesId = UUID.fromString(metadataFile[8]),
|
||||||
|
played = metadataFile[9].toBoolean(),
|
||||||
|
overview = metadataFile[10].replace("\\n", "\n")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return DownloadMetadata(
|
return DownloadMetadata(
|
||||||
|
@ -250,12 +234,14 @@ fun parseMetadataFile(metadataFile: List<String>): DownloadMetadata {
|
||||||
} else {
|
} else {
|
||||||
metadataFile[4].toDouble()
|
metadataFile[4].toDouble()
|
||||||
},
|
},
|
||||||
|
played = metadataFile[5].toBoolean(),
|
||||||
|
overview = metadataFile[6].replace("\\n", "\n")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) {
|
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository) {
|
||||||
val items = loadDownloadedEpisodes(context)
|
val items = loadDownloadedEpisodes()
|
||||||
items.forEach() {
|
items.forEach() {
|
||||||
try {
|
try {
|
||||||
val localPlaybackProgress = it.metadata?.playbackPosition
|
val localPlaybackProgress = it.metadata?.playbackPosition
|
||||||
|
@ -268,6 +254,10 @@ suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context
|
||||||
var playbackProgress: Long = 0
|
var playbackProgress: Long = 0
|
||||||
var playedPercentage = 0.0
|
var playedPercentage = 0.0
|
||||||
|
|
||||||
|
if (it.metadata?.played == true || item.userData?.played == true){
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
if (localPlaybackProgress != null) {
|
if (localPlaybackProgress != null) {
|
||||||
if (localPlaybackProgress > playbackProgress) {
|
if (localPlaybackProgress > playbackProgress) {
|
||||||
playbackProgress = localPlaybackProgress
|
playbackProgress = localPlaybackProgress
|
||||||
|
|
|
@ -1,28 +1,18 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSection
|
import dev.jdtech.jellyfin.models.DownloadSection
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
|
||||||
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
|
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
|
||||||
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
class DownloadViewModel : ViewModel() {
|
||||||
class DownloadViewModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val application: Application,
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _downloadSections = MutableLiveData<List<DownloadSection>>()
|
private val _downloadSections = MutableLiveData<List<DownloadSection>>()
|
||||||
val downloadSections: LiveData<List<DownloadSection>> = _downloadSections
|
val downloadSections: LiveData<List<DownloadSection>> = _downloadSections
|
||||||
|
|
||||||
|
@ -42,7 +32,7 @@ constructor(
|
||||||
_finishedLoading.value = false
|
_finishedLoading.value = false
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val items = loadDownloadedEpisodes(application)
|
val items = loadDownloadedEpisodes()
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
_downloadSections.value = listOf()
|
_downloadSections.value = listOf()
|
||||||
_finishedLoading.value = true
|
_finishedLoading.value = true
|
||||||
|
|
|
@ -13,6 +13,7 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
|
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
|
||||||
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
||||||
|
import dev.jdtech.jellyfin.utils.itemIsDownloaded
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
@ -46,6 +47,9 @@ constructor(
|
||||||
private val _favorite = MutableLiveData<Boolean>()
|
private val _favorite = MutableLiveData<Boolean>()
|
||||||
val favorite: LiveData<Boolean> = _favorite
|
val favorite: LiveData<Boolean> = _favorite
|
||||||
|
|
||||||
|
private val _downloaded = MutableLiveData<Boolean>()
|
||||||
|
val downloaded: LiveData<Boolean> = _downloaded
|
||||||
|
|
||||||
private val _downloadEpisode = MutableLiveData<Boolean>()
|
private val _downloadEpisode = MutableLiveData<Boolean>()
|
||||||
val downloadEpisode: LiveData<Boolean> = _downloadEpisode
|
val downloadEpisode: LiveData<Boolean> = _downloadEpisode
|
||||||
|
|
||||||
|
@ -56,6 +60,7 @@ constructor(
|
||||||
fun loadEpisode(episodeId: UUID) {
|
fun loadEpisode(episodeId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
_downloaded.value = itemIsDownloaded(episodeId)
|
||||||
val item = jellyfinRepository.getItem(episodeId)
|
val item = jellyfinRepository.getItem(episodeId)
|
||||||
_item.value = item
|
_item.value = item
|
||||||
_runTime.value = "${item.runTimeTicks?.div(600000000)} min"
|
_runTime.value = "${item.runTimeTicks?.div(600000000)} min"
|
||||||
|
@ -129,5 +134,6 @@ constructor(
|
||||||
|
|
||||||
fun doneDownloadEpisode() {
|
fun doneDownloadEpisode() {
|
||||||
_downloadEpisode.value = false
|
_downloadEpisode.value = false
|
||||||
|
_downloaded.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -27,7 +27,7 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject internal constructor(
|
class HomeViewModel @Inject internal constructor(
|
||||||
private val application: Application,
|
application: Application,
|
||||||
private val repository: JellyfinRepository
|
private val repository: JellyfinRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ class HomeViewModel @Inject internal constructor(
|
||||||
views.postValue(updated)
|
views.postValue(updated)
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
syncPlaybackProgress(repository, application)
|
syncPlaybackProgress(repository)
|
||||||
}
|
}
|
||||||
state.tryEmit(Loading(inProgress = false))
|
state.tryEmit(Loading(inProgress = false))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
|
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
|
||||||
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
||||||
|
import dev.jdtech.jellyfin.utils.itemIsDownloaded
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -61,6 +62,9 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
private val _favorite = MutableLiveData<Boolean>()
|
private val _favorite = MutableLiveData<Boolean>()
|
||||||
val favorite: LiveData<Boolean> = _favorite
|
val favorite: LiveData<Boolean> = _favorite
|
||||||
|
|
||||||
|
private val _downloaded = MutableLiveData<Boolean>()
|
||||||
|
val downloaded: LiveData<Boolean> = _downloaded
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
private val _error = MutableLiveData<String>()
|
||||||
val error: LiveData<String> = _error
|
val error: LiveData<String> = _error
|
||||||
|
|
||||||
|
@ -75,6 +79,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
_error.value = null
|
_error.value = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
_downloaded.value = itemIsDownloaded(itemId)
|
||||||
_item.value = jellyfinRepository.getItem(itemId)
|
_item.value = jellyfinRepository.getItem(itemId)
|
||||||
_actors.value = getActors(_item.value!!)
|
_actors.value = getActors(_item.value!!)
|
||||||
_director.value = getDirector(_item.value!!)
|
_director.value = getDirector(_item.value!!)
|
||||||
|
@ -201,5 +206,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
|
|
||||||
fun doneDownloadMedia() {
|
fun doneDownloadMedia() {
|
||||||
_downloadMedia.value = false
|
_downloadMedia.value = false
|
||||||
|
_downloaded.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -107,6 +107,7 @@ constructor(
|
||||||
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
|
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
|
||||||
else -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
|
else -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
|
||||||
}
|
}
|
||||||
|
playFromDownloads = item.mediaSourceUri.isNotEmpty()
|
||||||
|
|
||||||
Timber.d("Stream url: $streamUrl")
|
Timber.d("Stream url: $streamUrl")
|
||||||
val mediaItem =
|
val mediaItem =
|
||||||
|
@ -156,17 +157,17 @@ constructor(
|
||||||
override fun run() {
|
override fun run() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (player.currentMediaItem != null) {
|
if (player.currentMediaItem != null) {
|
||||||
try {
|
|
||||||
jellyfinRepository.postPlaybackProgress(
|
|
||||||
UUID.fromString(player.currentMediaItem!!.mediaId),
|
|
||||||
player.currentPosition.times(10000),
|
|
||||||
!player.isPlaying
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e)
|
|
||||||
}
|
|
||||||
if(playFromDownloads){
|
if(playFromDownloads){
|
||||||
postDownloadPlaybackProgress(items[0].mediaSourceUri, player.currentPosition, (player.currentPosition.toDouble()/player.duration.toDouble()).times(100)) //TODO Automaticcaly use the correct item
|
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),
|
||||||
|
player.currentPosition.times(10000),
|
||||||
|
!player.isPlaying
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
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.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
|
@ -35,8 +37,15 @@ class PlayerViewModel @Inject internal constructor(
|
||||||
fun loadPlayerItems(
|
fun loadPlayerItems(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
mediaSourceIndex: Int = 0,
|
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}")
|
Timber.d("Loading player items for item ${item.id}")
|
||||||
if (item.mediaSources.orEmpty().size > 1) {
|
if (item.mediaSources.orEmpty().size > 1) {
|
||||||
onVersionSelectRequired()
|
onVersionSelectRequired()
|
||||||
|
|
|
@ -79,7 +79,6 @@
|
||||||
<string name="force_software_decoding">Force software decoding</string>
|
<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="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_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="delete_button_description">Delete</string>
|
||||||
<string name="person_detail_title">Person Detail</string>
|
<string name="person_detail_title">Person Detail</string>
|
||||||
<string name="error_getting_person_id">Detail unavailable</string>
|
<string name="error_getting_person_id">Detail unavailable</string>
|
||||||
|
|
Loading…
Reference in a new issue