diff --git a/app/src/debug/res/drawable/ic_download_filled.xml b/app/src/debug/res/drawable/ic_download_filled.xml new file mode 100644 index 00000000..16e367f8 --- /dev/null +++ b/app/src/debug/res/drawable/ic_download_filled.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8daaf9e6..81895a50 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,8 +3,6 @@ package="dev.jdtech.jellyfin"> - diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt index e105bb58..0e38ca67 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt @@ -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()) diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index 26043ced..dffa0c9a 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -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) diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt index 2e48399e..7746500e 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -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( diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt index b61fd432..49a41d82 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt index 60047c8e..1b591b63 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt @@ -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()?.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 { +fun loadDownloadedEpisodes(): List { val items = mutableListOf() - val defaultStorage = getDownloadLocation(context) defaultStorage?.walk()?.forEach { if (it.isFile && it.extension == "") { try { @@ -154,6 +110,29 @@ fun loadDownloadedEpisodes(context: Context): List { 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): 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): 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 diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt index c8166bdc..7f84f090 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt @@ -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>() val downloadSections: LiveData> = _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 diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt index e5e8825d..3f279421 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt @@ -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() val favorite: LiveData = _favorite + private val _downloaded = MutableLiveData() + val downloaded: LiveData = _downloaded + private val _downloadEpisode = MutableLiveData() val downloadEpisode: LiveData = _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 } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt index 19248ada..2663c4ef 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt @@ -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) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt index c16f8238..42f70ed8 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt @@ -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() val favorite: LiveData = _favorite + private val _downloaded = MutableLiveData() + val downloaded: LiveData = _downloaded + private val _error = MutableLiveData() val error: LiveData = _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 } } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt index ed982f9e..ee1ccd82 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt @@ -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,17 +157,17 @@ constructor( override fun run() { viewModelScope.launch { 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){ - 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) } } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt index 824d7f7c..36256014 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt @@ -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() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6edba159..248f6c66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,7 +79,6 @@ Force software decoding Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts. Download - Cannot download files without storage permissions Delete Person Detail Detail unavailable