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:
lsrom 2021-10-26 18:11:22 +02:00 committed by GitHub
parent 28014eaadf
commit 308d97068f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 258 additions and 276 deletions

View file

@ -5,21 +5,24 @@ import android.os.Bundle
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R 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 dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import java.lang.IllegalStateException import java.lang.IllegalStateException
class VideoVersionDialogFragment( class VideoVersionDialogFragment(
private val viewModel: MediaInfoViewModel private val item: BaseItemDto,
private val viewModel: PlayerViewModel
) : DialogFragment() { ) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val items = viewModel.item.value?.mediaSources?.map { it.name } val items = item.mediaSources?.map { it.name }?.toTypedArray()
return activity?.let { return activity?.let { activity ->
val builder = MaterialAlertDialogBuilder(it) MaterialAlertDialogBuilder(activity)
builder.setTitle(getString(R.string.select_a_version)) .setTitle(R.string.select_a_version)
.setItems(items?.toTypedArray()) { _, which -> .setItems(items) { _, which ->
viewModel.preparePlayerItems(which) viewModel.loadPlayerItems(item, which)
} }.create()
builder.create()
} ?: throw IllegalStateException("Activity cannot be null") } ?: throw IllegalStateException("Activity cannot be null")
} }
} }

View file

@ -6,7 +6,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.google.android.material.bottomsheet.BottomSheetDialogFragment 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.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
@ -23,6 +27,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
private lateinit var binding: EpisodeBottomSheetBinding private lateinit var binding: EpisodeBottomSheetBinding
private val viewModel: EpisodeBottomSheetViewModel by viewModels() private val viewModel: EpisodeBottomSheetViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -37,7 +42,16 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE 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 { binding.checkButton.setOnClickListener {
@ -87,48 +101,38 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.favoriteButton.setImageResource(drawable) 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) viewModel.loadEpisode(args.episodeId)
return binding.root 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( private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>, playerItems: Array<PlayerItem>,
) { ) {

View file

@ -10,6 +10,7 @@ import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint 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.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
@ -31,6 +34,7 @@ class MediaInfoFragment : Fragment() {
private lateinit var binding: FragmentMediaInfoBinding private lateinit var binding: FragmentMediaInfoBinding
private val viewModel: MediaInfoViewModel by viewModels() private val viewModel: MediaInfoViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaInfoFragmentArgs by navArgs() private val args: MediaInfoFragmentArgs by navArgs()
@ -65,13 +69,6 @@ class MediaInfoFragment : Fragment() {
viewModel.loadData(args.itemId, args.itemType) 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 -> viewModel.item.observe(viewLifecycleOwner, { item ->
if (item.originalTitle != item.name) { if (item.originalTitle != item.name) {
binding.originalTitle.visibility = View.VISIBLE binding.originalTitle.visibility = View.VISIBLE
@ -94,21 +91,12 @@ class MediaInfoFragment : Fragment() {
} }
}) })
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems -> playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
if (playerItems != null) { when (playerItems) {
navigateToPlayerActivity( is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
playerItems is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
) }
viewModel.doneNavigatingToPlayer()
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
} }
})
viewModel.played.observe(viewLifecycleOwner, { viewModel.played.observe(viewLifecycleOwner, {
val drawable = when (it) { val drawable = when (it) {
@ -128,27 +116,6 @@ class MediaInfoFragment : Fragment() {
binding.favoriteButton.setImageResource(drawable) 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 { 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(
@ -178,20 +145,15 @@ class MediaInfoFragment : Fragment() {
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE binding.progressCircular.visibility = View.VISIBLE
if (args.itemType == "Movie") {
if (viewModel.item.value?.mediaSources != null) { viewModel.item.value?.let { item ->
if (viewModel.item.value?.mediaSources?.size!! > 1) { playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(viewModel).show( VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager, parentFragmentManager,
"videoversiondialog" "videoversiondialog"
) )
} else {
viewModel.preparePlayerItems()
} }
} }
} else if (args.itemType == "Series") {
viewModel.preparePlayerItems()
}
} }
binding.checkButton.setOnClickListener { binding.checkButton.setOnClickListener {
@ -211,6 +173,33 @@ class MediaInfoFragment : Fragment() {
viewModel.loadData(args.itemId, args.itemType) 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) { private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
findNavController().navigate( findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToEpisodeBottomSheetFragment( MediaInfoFragmentDirections.actionMediaInfoFragmentToEpisodeBottomSheetFragment(

View file

@ -6,16 +6,14 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
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.LocationType
import timber.log.Timber import timber.log.Timber
import java.text.DateFormat import java.text.DateFormat
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.* import java.util.Date
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -40,14 +38,6 @@ constructor(
private val _favorite = MutableLiveData<Boolean>() private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite 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) { fun loadEpisode(episodeId: UUID) {
viewModelScope.launch { viewModelScope.launch {
try { 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) { fun markAsPlayed(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId) jellyfinRepository.markAsPlayed(itemId)
@ -151,8 +91,4 @@ constructor(
item.premiereDate.toString() item.premiereDate.toString()
} }
} }
fun doneNavigateToPlayer() {
_navigateToPlayer.value = false
}
} }

View file

@ -6,17 +6,14 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson 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 timber.log.Timber
import java.util.* import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -53,9 +50,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _seasons = MutableLiveData<List<BaseItemDto>>() private val _seasons = MutableLiveData<List<BaseItemDto>>()
val seasons: LiveData<List<BaseItemDto>> = _seasons val seasons: LiveData<List<BaseItemDto>> = _seasons
private val _navigateToPlayer = MutableLiveData<Array<PlayerItem>>()
val navigateToPlayer: LiveData<Array<PlayerItem>> = _navigateToPlayer
private val _played = MutableLiveData<Boolean>() private val _played = MutableLiveData<Boolean>()
val played: LiveData<Boolean> = _played val played: LiveData<Boolean> = _played
@ -65,11 +59,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
private val _error = MutableLiveData<String>() private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error 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) { fun loadData(itemId: UUID, itemType: String) {
_error.value = null _error.value = null
viewModelScope.launch { viewModelScope.launch {
@ -177,97 +166,4 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> dateString 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
}
} }

View file

@ -8,7 +8,12 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager 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 com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
@ -18,7 +23,7 @@ import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -114,10 +119,9 @@ constructor(
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition) player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
player.prepare() player.prepare()
player.play() player.play()
}
pollPosition(player) pollPosition(player)
} }
}
private fun releasePlayer() { private fun releasePlayer() {
player.let { player -> player.let { player ->

View file

@ -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()
}