From 308d97068f04d2c40ddbf174a5cbe836207a06f4 Mon Sep 17 00:00:00 2001 From: lsrom Date: Tue, 26 Oct 2021 18:11:22 +0200 Subject: [PATCH] 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> --- .../dialogs/VideoVersionDialogFragment.kt | 21 +-- .../fragments/EpisodeBottomSheetFragment.kt | 80 +++++----- .../jellyfin/fragments/MediaInfoFragment.kt | 97 +++++------ .../viewmodels/EpisodeBottomSheetViewModel.kt | 68 +------- .../jellyfin/viewmodels/MediaInfoViewModel.kt | 106 +------------ .../viewmodels/PlayerActivityViewModel.kt | 12 +- .../jellyfin/viewmodels/PlayerViewModel.kt | 150 ++++++++++++++++++ 7 files changed, 258 insertions(+), 276 deletions(-) create mode 100644 app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt index 432eec99..6cccf5b4 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt @@ -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") } } \ No newline at end of file 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 e4fadaf3..c87fbc6d 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -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, ) { 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 103fd145..74c00cb6 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -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,19 +145,14 @@ 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( - parentFragmentManager, - "videoversiondialog" - ) - } else { - viewModel.preparePlayerItems() - } + + viewModel.item.value?.let { item -> + playerViewModel.loadPlayerItems(item) { + VideoVersionDialogFragment(item, playerViewModel).show( + parentFragmentManager, + "videoversiondialog" + ) } - } else if (args.itemType == "Series") { - viewModel.preparePlayerItems() } } @@ -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( 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 33ff30b5..cba60e78 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt @@ -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() val favorite: LiveData = _favorite - private val _navigateToPlayer = MutableLiveData() - val navigateToPlayer: LiveData = _navigateToPlayer - - var playerItems: MutableList = mutableListOf() - - private val _playerItemsError = MutableLiveData() - val playerItemsError: LiveData = _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 - } } \ No newline at end of file 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 ff032f3b..54e3a94f 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt @@ -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>() val seasons: LiveData> = _seasons - private val _navigateToPlayer = MutableLiveData>() - val navigateToPlayer: LiveData> = _navigateToPlayer - private val _played = MutableLiveData() val played: LiveData = _played @@ -65,11 +59,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { private val _error = MutableLiveData() val error: LiveData = _error - var playerItems: MutableList = mutableListOf() - - private val _playerItemsError = MutableLiveData() - val playerItemsError: LiveData = _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 - } } \ 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 4d177982..e24b42cc 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt @@ -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,9 +119,8 @@ constructor( player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition) player.prepare() player.play() + pollPosition(player) } - - pollPosition(player) } private fun releasePlayer() { diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt new file mode 100644 index 00000000..19d653ef --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerViewModel.kt @@ -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(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 { + 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 = 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 { + 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 { + 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): PlayerItemState() +} \ No newline at end of file