From d67f1957895db91991d7f83df31f6da4291dddbf Mon Sep 17 00:00:00 2001 From: jarnedemeulemeester Date: Thu, 5 Aug 2021 16:09:08 +0200 Subject: [PATCH] Rework player to allow for playing multiple episodes in a row --- .../dev/jdtech/jellyfin/PlayerActivity.kt | 4 +- .../fragments/EpisodeBottomSheetFragment.kt | 18 +-- .../jellyfin/fragments/MediaInfoFragment.kt | 26 ++-- .../dev/jdtech/jellyfin/models/PlayerItem.kt | 11 ++ .../jellyfin/repository/JellyfinRepository.kt | 7 +- .../repository/JellyfinRepositoryImpl.kt | 9 +- .../viewmodels/EpisodeBottomSheetViewModel.kt | 17 ++- .../viewmodels/PlayerActivityViewModel.kt | 124 +++++++++--------- app/src/main/res/layout/activity_player.xml | 2 +- .../main/res/navigation/main_navigation.xml | 7 +- 10 files changed, 122 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt diff --git a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt index baef10a4..7ff211ae 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt @@ -33,14 +33,14 @@ class PlayerActivity : AppCompatActivity() { playerView.player = it }) - viewModel.playbackStateListener.navigateBack.observe(this, { + viewModel.navigateBack.observe(this, { if (it) { onBackPressed() } }) if (viewModel.player.value == null) { - viewModel.initializePlayer(args.itemId, args.mediaSourceId, args.playbackPosition) + viewModel.initializePlayer(args.items, args.playbackPosition) } hideSystemUI() } 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 632f2f67..d6de7156 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -12,6 +12,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding +import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import java.util.* @@ -33,13 +34,10 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { binding.viewModel = viewModel binding.playButton.setOnClickListener { - viewModel.mediaSources.value?.get(0)?.id?.let { mediaSourceId -> - navigateToPlayerActivity( - args.episodeId, - mediaSourceId, - viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) - ) - } + navigateToPlayerActivity( + viewModel.playerItems.toTypedArray(), + viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) + ) } binding.checkButton.setOnClickListener { @@ -95,14 +93,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } private fun navigateToPlayerActivity( - itemId: UUID, - mediaSourceId: String, + playerItems: Array, playbackPosition: Long ) { findNavController().navigate( EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity( - itemId, - mediaSourceId, + playerItems, playbackPosition ) ) 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 1404ea62..315970c9 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -17,9 +17,9 @@ import dev.jdtech.jellyfin.adapters.PersonListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment +import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import org.jellyfin.sdk.model.api.BaseItemDto -import java.util.* @AndroidEntryPoint class MediaInfoFragment : Fragment() { @@ -84,13 +84,10 @@ class MediaInfoFragment : Fragment() { }) viewModel.navigateToPlayer.observe(viewLifecycleOwner, { mediaSource -> - mediaSource.id?.let { - navigateToPlayerActivity( - args.itemId, - it, - viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) - ) - } + navigateToPlayerActivity( + arrayOf(PlayerItem(args.itemId, mediaSource.id!!)), + viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) + ) }) viewModel.played.observe(viewLifecycleOwner, { @@ -139,9 +136,8 @@ class MediaInfoFragment : Fragment() { ) } else { navigateToPlayerActivity( - args.itemId, - viewModel.mediaSources.value!![0].id!!, - viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) + arrayOf(PlayerItem(args.itemId, viewModel.mediaSources.value!![0].id!!)), + viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000), ) } } @@ -185,14 +181,12 @@ class MediaInfoFragment : Fragment() { } private fun navigateToPlayerActivity( - itemId: UUID, - mediaSourceId: String, - playbackPosition: Long + playerItems: Array, + playbackPosition: Long, ) { findNavController().navigate( MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity( - itemId, - mediaSourceId, + playerItems, playbackPosition ) ) diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt new file mode 100644 index 00000000..0f6852d4 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt @@ -0,0 +1,11 @@ +package dev.jdtech.jellyfin.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.* + +@Parcelize +data class PlayerItem( + val itemId: UUID, + val mediaSourceId: String +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt index 64abe807..84ed4781 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt @@ -24,7 +24,12 @@ interface JellyfinRepository { suspend fun getNextUp(seriesId: UUID? = null): List - suspend fun getEpisodes(seriesId: UUID, seasonId: UUID, fields: List? = null): List + suspend fun getEpisodes( + seriesId: UUID, + seasonId: UUID, + fields: List? = null, + startIndex: Int? = null + ): List suspend fun getMediaSources(itemId: UUID): List diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt index 6008226f..297def39 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt @@ -105,12 +105,17 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep override suspend fun getEpisodes( seriesId: UUID, seasonId: UUID, - fields: List? + fields: List?, + startIndex: Int? ): List { val episodes: List withContext(Dispatchers.IO) { episodes = jellyfinApi.showsApi.getEpisodes( - seriesId, jellyfinApi.userId!!, seasonId = seasonId, fields = fields + seriesId, + jellyfinApi.userId!!, + seasonId = seasonId, + fields = fields, + startIndex = startIndex ).content.items ?: listOf() } return episodes 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 03728292..ab089019 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt @@ -6,10 +6,10 @@ 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.MediaSourceInfo import timber.log.Timber import java.text.DateFormat import java.time.ZoneOffset @@ -32,15 +32,14 @@ constructor( private val _dateString = MutableLiveData() val dateString: LiveData = _dateString - private val _mediaSources = MutableLiveData>() - val mediaSources: LiveData> = _mediaSources - private val _played = MutableLiveData() val played: LiveData = _played private val _favorite = MutableLiveData() val favorite: LiveData = _favorite + var playerItems: MutableList = mutableListOf() + fun loadEpisode(episodeId: UUID) { viewModelScope.launch { try { @@ -48,7 +47,7 @@ constructor( _item.value = item _runTime.value = "${item.runTimeTicks?.div(600000000)} min" _dateString.value = getDateString(item) - _mediaSources.value = jellyfinRepository.getMediaSources(episodeId) + createPlayerItems(item) _played.value = item.userData?.played _favorite.value = item.userData?.isFavorite } catch (e: Exception) { @@ -57,6 +56,14 @@ constructor( } } + private suspend fun createPlayerItems(startEpisode: BaseItemDto) { + val episodes = jellyfinRepository.getEpisodes(startEpisode.seriesId!!, startEpisode.seasonId!!, startIndex = startEpisode.indexNumber?.minus(1)) + for (episode in episodes) { + val mediaSources = jellyfinRepository.getMediaSources(episode.id) + playerItems.add(PlayerItem(episode.id, mediaSources[0].id!!)) + } + } + fun markAsPlayed(itemId: UUID) { viewModelScope.launch { jellyfinRepository.markAsPlayed(itemId) 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 77790360..8fa8fafe 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt @@ -11,6 +11,7 @@ import androidx.preference.PreferenceManager import com.google.android.exoplayer2.* import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import dagger.hilt.android.lifecycle.HiltViewModel +import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -24,29 +25,23 @@ class PlayerActivityViewModel constructor( private val application: Application, private val jellyfinRepository: JellyfinRepository -) : ViewModel() { +) : ViewModel(), Player.Listener { private var _player = MutableLiveData() var player: LiveData = _player + private val _navigateBack = MutableLiveData() + val navigateBack: LiveData = _navigateBack private var playWhenReady = true private var currentWindow = 0 private var playbackPosition: Long = 0 - private var _playbackStateListener: PlaybackStateListener - - private var itemId: UUID? = null - - val playbackStateListener: PlaybackStateListener - get() = _playbackStateListener private val sp = PreferenceManager.getDefaultSharedPreferences(application) - init { - _playbackStateListener = PlaybackStateListener() - } - - fun initializePlayer(itemId: UUID, mediaSourceId: String, playbackPosition: Long) { - this.itemId = itemId + fun initializePlayer( + items: Array, + playbackPosition: Long + ) { val renderersFactory = DefaultRenderersFactory(application).setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) @@ -61,33 +56,38 @@ constructor( .setTrackSelector(trackSelector) .build() - player.addListener(_playbackStateListener) + player.addListener(this) viewModelScope.launch { - val streamUrl = jellyfinRepository.getStreamUrl(itemId, mediaSourceId) - Timber.d("Stream url: $streamUrl") - val mediaItem = - MediaItem.Builder() - .setMediaId(itemId.toString()) - .setUri(streamUrl) - .build() - player.setMediaItem(mediaItem, playbackPosition) + val mediaItems: MutableList = mutableListOf() + + for (item in items) { + val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId) + Timber.d("Stream url: $streamUrl") + val mediaItem = + MediaItem.Builder() + .setMediaId(item.itemId.toString()) + .setUri(streamUrl) + .build() + mediaItems.add(mediaItem) + } + + player.setMediaItems(mediaItems, currentWindow, playbackPosition) player.playWhenReady = playWhenReady player.prepare() _player.value = player - - jellyfinRepository.postPlaybackStart(itemId) } - pollPosition(player, itemId) + pollPosition(player) } private fun releasePlayer() { - itemId?.let { itemId -> - _player.value?.let { player -> - runBlocking { - jellyfinRepository.postPlaybackStop(itemId, player.currentPosition.times(10000)) - } + _player.value?.let { player -> + runBlocking { + jellyfinRepository.postPlaybackStop( + UUID.fromString(player.currentMediaItem?.mediaId), + player.currentPosition.times(10000) + ) } } @@ -95,22 +95,24 @@ constructor( playWhenReady = player.value!!.playWhenReady playbackPosition = player.value!!.currentPosition currentWindow = player.value!!.currentWindowIndex - player.value!!.removeListener(_playbackStateListener) + player.value!!.removeListener(this) player.value!!.release() _player.value = null } } - private fun pollPosition(player: SimpleExoPlayer, itemId: UUID) { + private fun pollPosition(player: SimpleExoPlayer) { val handler = Handler(Looper.getMainLooper()) - val runnable: Runnable = object : Runnable { + val runnable = object : Runnable { override fun run() { viewModelScope.launch { - jellyfinRepository.postPlaybackProgress( - itemId, - player.currentPosition.times(10000), - !player.isPlaying - ) + if (player.currentMediaItem != null) { + jellyfinRepository.postPlaybackProgress( + UUID.fromString(player.currentMediaItem!!.mediaId), + player.currentPosition.times(10000), + !player.isPlaying + ) + } } handler.postDelayed(this, 2000) } @@ -118,31 +120,33 @@ constructor( handler.post(runnable) } - class PlaybackStateListener : Player.Listener { - private val _navigateBack = MutableLiveData() - val navigateBack: LiveData = _navigateBack - - override fun onPlaybackStateChanged(state: Int) { - var stateString = "UNKNOWN_STATE -" - when (state) { - ExoPlayer.STATE_IDLE -> { - stateString = "ExoPlayer.STATE_IDLE -" - } - ExoPlayer.STATE_BUFFERING -> { - stateString = "ExoPlayer.STATE_BUFFERING -" - } - ExoPlayer.STATE_READY -> { - stateString = "ExoPlayer.STATE_READY -" - } - ExoPlayer.STATE_ENDED -> { - stateString = "ExoPlayer.STATE_ENDED -" - _navigateBack.value = true - } - } - Timber.d("Changed player state to $stateString") + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + Timber.d("Playing MediaItem: ${mediaItem?.mediaId}") + viewModelScope.launch { + jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId)) } } + override fun onPlaybackStateChanged(state: Int) { + var stateString = "UNKNOWN_STATE -" + when (state) { + ExoPlayer.STATE_IDLE -> { + stateString = "ExoPlayer.STATE_IDLE -" + } + ExoPlayer.STATE_BUFFERING -> { + stateString = "ExoPlayer.STATE_BUFFERING -" + } + ExoPlayer.STATE_READY -> { + stateString = "ExoPlayer.STATE_READY -" + } + ExoPlayer.STATE_ENDED -> { + stateString = "ExoPlayer.STATE_ENDED -" + _navigateBack.value = true + } + } + Timber.d("Changed player state to $stateString") + } + override fun onCleared() { super.onCleared() Timber.d("Clearing Player ViewModel") diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 3d53e4e8..49020401 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -11,6 +11,6 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black" - app:show_subtitle_button="true"/> + app:show_subtitle_button="true" /> diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index a4b6e910..3f64d1d2 100644 --- a/app/src/main/res/navigation/main_navigation.xml +++ b/app/src/main/res/navigation/main_navigation.xml @@ -151,11 +151,8 @@ android:label="activity_player" tools:layout="@layout/activity_player"> - + android:name="items" + app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />