Refactor playback code (#55)
* Refactor playback code * Fix back state when playing media and rotating device Problem was playerItems were re-emitted on fragment creation after config change. LiveData by design emit on every subscribe (observe) so to avoid that there are several possibilities. 1) easiest, observe playerItems not in onCreate but in playButton.clickListener. Stupid, since then we need to remember to only observe in this special place. 2) SingleLiveData - kind of hacky since LiveData were designed to behave this way so we don't want to go against their design. 3) Use Kotlin flow instead. I chose the flow approach since it's Kotlin native and modern way to do things and behaves much more Rx-like. Since now we need to call collect instead of observe and launch in coroutine, I added utility method to make this easier. Also, in the future we might want to improve this further, either by coming up with new way entirely or by at least moving this to parent fragment from which all fragments that want to play media will inherit and thus making it easy to use and maintain. Co-authored-by: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com>
This commit is contained in:
parent
28014eaadf
commit
308d97068f
7 changed files with 258 additions and 276 deletions
|
@ -5,21 +5,24 @@ import android.os.Bundle
|
|||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dev.jdtech.jellyfin.R
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
class VideoVersionDialogFragment(
|
||||
private val viewModel: MediaInfoViewModel
|
||||
private val item: BaseItemDto,
|
||||
private val viewModel: PlayerViewModel
|
||||
) : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val items = viewModel.item.value?.mediaSources?.map { it.name }
|
||||
return activity?.let {
|
||||
val builder = MaterialAlertDialogBuilder(it)
|
||||
builder.setTitle(getString(R.string.select_a_version))
|
||||
.setItems(items?.toTypedArray()) { _, which ->
|
||||
viewModel.preparePlayerItems(which)
|
||||
}
|
||||
builder.create()
|
||||
val items = item.mediaSources?.map { it.name }?.toTypedArray()
|
||||
return activity?.let { activity ->
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.select_a_version)
|
||||
.setItems(items) { _, which ->
|
||||
viewModel.loadPlayerItems(item, which)
|
||||
}.create()
|
||||
} ?: throw IllegalStateException("Activity cannot be null")
|
||||
}
|
||||
}
|
|
@ -6,7 +6,9 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
|
@ -16,6 +18,8 @@ import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
|
|||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||
|
@ -23,6 +27,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
|
||||
private lateinit var binding: EpisodeBottomSheetBinding
|
||||
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
|
||||
private val playerViewModel: PlayerViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
|
@ -37,7 +42,16 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.playButton.setOnClickListener {
|
||||
binding.playButton.setImageResource(android.R.color.transparent)
|
||||
binding.progressCircular.visibility = View.VISIBLE
|
||||
viewModel.preparePlayerItems()
|
||||
viewModel.item.value?.let {
|
||||
playerViewModel.loadPlayerItems(it)
|
||||
}
|
||||
}
|
||||
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkButton.setOnClickListener {
|
||||
|
@ -87,48 +101,38 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.favoriteButton.setImageResource(drawable)
|
||||
})
|
||||
|
||||
viewModel.navigateToPlayer.observe(viewLifecycleOwner, {
|
||||
if (it) {
|
||||
navigateToPlayerActivity(
|
||||
viewModel.playerItems.toTypedArray(),
|
||||
)
|
||||
viewModel.doneNavigateToPlayer()
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
|
||||
if (errorMessage != null) {
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
} else {
|
||||
binding.playerItemsError.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment(
|
||||
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
|
||||
).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
|
||||
viewModel.loadEpisode(args.episodeId)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||
navigateToPlayerActivity(items.items.toTypedArray())
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.message)
|
||||
|
||||
binding.playerItemsError.isVisible = true
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToPlayerActivity(
|
||||
playerItems: Array<PlayerItem>,
|
||||
) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import android.widget.Toast
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
@ -22,7 +23,9 @@ import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
|
|||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import timber.log.Timber
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import java.util.UUID
|
||||
|
||||
|
@ -31,6 +34,7 @@ class MediaInfoFragment : Fragment() {
|
|||
|
||||
private lateinit var binding: FragmentMediaInfoBinding
|
||||
private val viewModel: MediaInfoViewModel by viewModels()
|
||||
private val playerViewModel: PlayerViewModel by viewModels()
|
||||
|
||||
private val args: MediaInfoFragmentArgs by navArgs()
|
||||
|
||||
|
@ -65,13 +69,6 @@ class MediaInfoFragment : Fragment() {
|
|||
viewModel.loadData(args.itemId, args.itemType)
|
||||
}
|
||||
|
||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
|
||||
parentFragmentManager,
|
||||
"errordialog"
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.item.observe(viewLifecycleOwner, { item ->
|
||||
if (item.originalTitle != item.name) {
|
||||
binding.originalTitle.visibility = View.VISIBLE
|
||||
|
@ -94,21 +91,12 @@ class MediaInfoFragment : Fragment() {
|
|||
}
|
||||
})
|
||||
|
||||
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems ->
|
||||
if (playerItems != null) {
|
||||
navigateToPlayerActivity(
|
||||
playerItems
|
||||
)
|
||||
viewModel.doneNavigatingToPlayer()
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.played.observe(viewLifecycleOwner, {
|
||||
val drawable = when (it) {
|
||||
|
@ -128,27 +116,6 @@ class MediaInfoFragment : Fragment() {
|
|||
binding.favoriteButton.setImageResource(drawable)
|
||||
})
|
||||
|
||||
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
|
||||
if (errorMessage != null) {
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
} else {
|
||||
binding.playerItemsError.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment(
|
||||
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
|
||||
).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
|
||||
binding.trailerButton.setOnClickListener {
|
||||
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
||||
val intent = Intent(
|
||||
|
@ -178,20 +145,15 @@ class MediaInfoFragment : Fragment() {
|
|||
binding.playButton.setOnClickListener {
|
||||
binding.playButton.setImageResource(android.R.color.transparent)
|
||||
binding.progressCircular.visibility = View.VISIBLE
|
||||
if (args.itemType == "Movie") {
|
||||
if (viewModel.item.value?.mediaSources != null) {
|
||||
if (viewModel.item.value?.mediaSources?.size!! > 1) {
|
||||
VideoVersionDialogFragment(viewModel).show(
|
||||
|
||||
viewModel.item.value?.let { item ->
|
||||
playerViewModel.loadPlayerItems(item) {
|
||||
VideoVersionDialogFragment(item, playerViewModel).show(
|
||||
parentFragmentManager,
|
||||
"videoversiondialog"
|
||||
)
|
||||
} else {
|
||||
viewModel.preparePlayerItems()
|
||||
}
|
||||
}
|
||||
} else if (args.itemType == "Series") {
|
||||
viewModel.preparePlayerItems()
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkButton.setOnClickListener {
|
||||
|
@ -211,6 +173,33 @@ class MediaInfoFragment : Fragment() {
|
|||
viewModel.loadData(args.itemId, args.itemType)
|
||||
}
|
||||
|
||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||
navigateToPlayerActivity(items.items.toTypedArray())
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.message)
|
||||
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_play
|
||||
)
|
||||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
||||
findNavController().navigate(
|
||||
MediaInfoFragmentDirections.actionMediaInfoFragmentToEpisodeBottomSheetFragment(
|
||||
|
|
|
@ -6,16 +6,14 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.LocationType
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
import java.time.ZoneOffset
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -40,14 +38,6 @@ constructor(
|
|||
private val _favorite = MutableLiveData<Boolean>()
|
||||
val favorite: LiveData<Boolean> = _favorite
|
||||
|
||||
private val _navigateToPlayer = MutableLiveData<Boolean>()
|
||||
val navigateToPlayer: LiveData<Boolean> = _navigateToPlayer
|
||||
|
||||
var playerItems: MutableList<PlayerItem> = mutableListOf()
|
||||
|
||||
private val _playerItemsError = MutableLiveData<String>()
|
||||
val playerItemsError: LiveData<String> = _playerItemsError
|
||||
|
||||
fun loadEpisode(episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
@ -63,56 +53,6 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun preparePlayerItems() {
|
||||
_playerItemsError.value = null
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
createPlayerItems(_item.value!!)
|
||||
_navigateToPlayer.value = true
|
||||
} catch (e: Exception) {
|
||||
_playerItemsError.value = e.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createPlayerItems(startEpisode: BaseItemDto) {
|
||||
playerItems.clear()
|
||||
|
||||
val playbackPosition = startEpisode.userData?.playbackPositionTicks?.div(10000) ?: 0
|
||||
// Intros
|
||||
var introsCount = 0
|
||||
|
||||
if (playbackPosition <= 0) {
|
||||
val intros = jellyfinRepository.getIntros(startEpisode.id)
|
||||
for (intro in intros) {
|
||||
if (intro.mediaSources.isNullOrEmpty()) continue
|
||||
playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0))
|
||||
introsCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
val episodes = jellyfinRepository.getEpisodes(
|
||||
startEpisode.seriesId!!,
|
||||
startEpisode.seasonId!!,
|
||||
startItemId = startEpisode.id,
|
||||
fields = listOf(ItemFields.MEDIA_SOURCES)
|
||||
)
|
||||
for (episode in episodes) {
|
||||
if (episode.mediaSources.isNullOrEmpty()) continue
|
||||
if (episode.locationType == LocationType.VIRTUAL) continue
|
||||
playerItems.add(
|
||||
PlayerItem(
|
||||
episode.name,
|
||||
episode.id,
|
||||
episode.mediaSources?.get(0)?.id!!,
|
||||
playbackPosition
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
|
||||
}
|
||||
|
||||
fun markAsPlayed(itemId: UUID) {
|
||||
viewModelScope.launch {
|
||||
jellyfinRepository.markAsPlayed(itemId)
|
||||
|
@ -151,8 +91,4 @@ constructor(
|
|||
item.premiereDate.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun doneNavigateToPlayer() {
|
||||
_navigateToPlayer.value = false
|
||||
}
|
||||
}
|
|
@ -6,17 +6,14 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.LocationType
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -53,9 +50,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
|||
private val _seasons = MutableLiveData<List<BaseItemDto>>()
|
||||
val seasons: LiveData<List<BaseItemDto>> = _seasons
|
||||
|
||||
private val _navigateToPlayer = MutableLiveData<Array<PlayerItem>>()
|
||||
val navigateToPlayer: LiveData<Array<PlayerItem>> = _navigateToPlayer
|
||||
|
||||
private val _played = MutableLiveData<Boolean>()
|
||||
val played: LiveData<Boolean> = _played
|
||||
|
||||
|
@ -65,11 +59,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
|||
private val _error = MutableLiveData<String>()
|
||||
val error: LiveData<String> = _error
|
||||
|
||||
var playerItems: MutableList<PlayerItem> = mutableListOf()
|
||||
|
||||
private val _playerItemsError = MutableLiveData<String>()
|
||||
val playerItemsError: LiveData<String> = _playerItemsError
|
||||
|
||||
fun loadData(itemId: UUID, itemType: String) {
|
||||
_error.value = null
|
||||
viewModelScope.launch {
|
||||
|
@ -177,97 +166,4 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
|||
else -> dateString
|
||||
}
|
||||
}
|
||||
|
||||
fun preparePlayerItems(mediaSourceIndex: Int? = null) {
|
||||
_playerItemsError.value = null
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
createPlayerItems(_item.value!!, mediaSourceIndex)
|
||||
_navigateToPlayer.value = playerItems.toTypedArray()
|
||||
} catch (e: Exception) {
|
||||
_playerItemsError.value = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createPlayerItems(series: BaseItemDto, mediaSourceIndex: Int? = null) {
|
||||
playerItems.clear()
|
||||
|
||||
val playbackPosition = item.value?.userData?.playbackPositionTicks?.div(10000) ?: 0
|
||||
|
||||
// Intros
|
||||
var introsCount = 0
|
||||
|
||||
if (playbackPosition <= 0) {
|
||||
val intros = jellyfinRepository.getIntros(series.id)
|
||||
for (intro in intros) {
|
||||
if (intro.mediaSources.isNullOrEmpty()) continue
|
||||
playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0))
|
||||
introsCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
when (series.type) {
|
||||
"Movie" -> {
|
||||
playerItems.add(
|
||||
PlayerItem(
|
||||
series.name,
|
||||
series.id,
|
||||
series.mediaSources?.get(mediaSourceIndex ?: 0)?.id!!,
|
||||
playbackPosition
|
||||
)
|
||||
)
|
||||
}
|
||||
"Series" -> {
|
||||
if (nextUp.value != null) {
|
||||
val startEpisode = nextUp.value!!
|
||||
val episodes = jellyfinRepository.getEpisodes(
|
||||
startEpisode.seriesId!!,
|
||||
startEpisode.seasonId!!,
|
||||
startItemId = startEpisode.id,
|
||||
fields = listOf(ItemFields.MEDIA_SOURCES)
|
||||
)
|
||||
for (episode in episodes) {
|
||||
if (episode.mediaSources.isNullOrEmpty()) continue
|
||||
if (episode.locationType == LocationType.VIRTUAL) continue
|
||||
playerItems.add(
|
||||
PlayerItem(
|
||||
episode.name,
|
||||
episode.id,
|
||||
episode.mediaSources?.get(0)?.id!!,
|
||||
0
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
for (season in seasons.value!!) {
|
||||
if (season.indexNumber == 0) continue
|
||||
val episodes = jellyfinRepository.getEpisodes(
|
||||
series.id,
|
||||
season.id,
|
||||
fields = listOf(ItemFields.MEDIA_SOURCES)
|
||||
)
|
||||
for (episode in episodes) {
|
||||
if (episode.mediaSources.isNullOrEmpty()) continue
|
||||
if (episode.locationType == LocationType.VIRTUAL) continue
|
||||
playerItems.add(
|
||||
PlayerItem(
|
||||
episode.name,
|
||||
episode.id,
|
||||
episode.mediaSources?.get(0)?.id!!,
|
||||
0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
|
||||
}
|
||||
|
||||
fun doneNavigatingToPlayer() {
|
||||
_navigateToPlayer.value = null
|
||||
}
|
||||
}
|
|
@ -8,7 +8,12 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.BasePlayer
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
|
@ -18,7 +23,7 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -114,10 +119,9 @@ constructor(
|
|||
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
|
||||
pollPosition(player)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releasePlayer() {
|
||||
player.let { player ->
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
package dev.jdtech.jellyfin.viewmodels
|
||||
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.LocationType.VIRTUAL
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PlayerViewModel @Inject internal constructor(
|
||||
private val repository: JellyfinRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val playerItems = MutableSharedFlow<PlayerItemState>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
fun onPlaybackRequested(scope: LifecycleCoroutineScope, collector: (PlayerItemState) -> Unit) {
|
||||
scope.launch { playerItems.collect { collector(it) } }
|
||||
}
|
||||
|
||||
fun loadPlayerItems(
|
||||
item: BaseItemDto,
|
||||
mediaSourceIndex: Int = 0,
|
||||
onVersionSelectRequired: () -> Unit = { Unit }
|
||||
) {
|
||||
Timber.d("Loading player items for item ${item.id}")
|
||||
if (item.mediaSources.orEmpty().size > 1) {
|
||||
onVersionSelectRequired()
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val playbackPosition = item.userData?.playbackPositionTicks?.div(10000) ?: 0
|
||||
|
||||
val items = try {
|
||||
createItems(item, playbackPosition, mediaSourceIndex).let(::PlayerItems)
|
||||
} catch (e: Exception) {
|
||||
PlayerItemError(e.message.orEmpty())
|
||||
}
|
||||
|
||||
playerItems.tryEmit(items)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
) = if (playbackPosition <= 0) {
|
||||
prepareIntros(item) + prepareMediaPlayerItems(
|
||||
item,
|
||||
playbackPosition,
|
||||
mediaSourceIndex
|
||||
)
|
||||
} else {
|
||||
prepareMediaPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
}
|
||||
|
||||
private suspend fun prepareIntros(item: BaseItemDto): List<PlayerItem> {
|
||||
return repository
|
||||
.getIntros(item.id)
|
||||
.filter { it.mediaSources != null && it.mediaSources?.isNotEmpty() == true }
|
||||
.map { intro ->
|
||||
PlayerItem(
|
||||
intro.name,
|
||||
intro.id,
|
||||
intro.mediaSources?.get(0)?.id!!,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prepareMediaPlayerItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
): List<PlayerItem> = when (item.type) {
|
||||
"Movie" -> itemToMoviePlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
"Series" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
"Episode" -> itemToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun itemToMoviePlayerItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
) = listOf(
|
||||
PlayerItem(
|
||||
item.name,
|
||||
item.id,
|
||||
item.mediaSources?.get(mediaSourceIndex)?.id!!,
|
||||
playbackPosition
|
||||
)
|
||||
)
|
||||
|
||||
private suspend fun itemToPlayerItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
): List<PlayerItem> {
|
||||
val nextUp = repository.getNextUp(item.seriesId)
|
||||
|
||||
return if (nextUp.isEmpty()) {
|
||||
repository
|
||||
.getSeasons(item.seriesId!!)
|
||||
.flatMap { episodesToPlayerItems(item, playbackPosition, mediaSourceIndex) }
|
||||
} else {
|
||||
episodesToPlayerItems(item, playbackPosition, mediaSourceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun episodesToPlayerItems(
|
||||
item: BaseItemDto,
|
||||
playbackPosition: Long,
|
||||
mediaSourceIndex: Int
|
||||
): List<PlayerItem> {
|
||||
val episodes = repository.getEpisodes(
|
||||
seriesId = item.seriesId!!,
|
||||
seasonId = item.seasonId!!,
|
||||
fields = listOf(ItemFields.MEDIA_SOURCES),
|
||||
startItemId = item.id
|
||||
)
|
||||
|
||||
return episodes
|
||||
.filter { it.mediaSources != null && it.mediaSources?.isNotEmpty() == true }
|
||||
.filter { it.locationType != VIRTUAL }
|
||||
.map { episode ->
|
||||
PlayerItem(
|
||||
episode.name,
|
||||
episode.id,
|
||||
episode.mediaSources?.get(mediaSourceIndex)?.id!!,
|
||||
playbackPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class PlayerItemState
|
||||
|
||||
data class PlayerItemError(val message: String): PlayerItemState()
|
||||
data class PlayerItems(val items: List<PlayerItem>): PlayerItemState()
|
||||
}
|
Loading…
Reference in a new issue