Rework player to allow for playing multiple episodes in a row

This commit is contained in:
jarnedemeulemeester 2021-08-05 16:09:08 +02:00
parent 23a3937e86
commit d67f195789
No known key found for this signature in database
GPG key ID: 60884A0C1EBA43E5
10 changed files with 122 additions and 103 deletions

View file

@ -33,14 +33,14 @@ class PlayerActivity : AppCompatActivity() {
playerView.player = it playerView.player = it
}) })
viewModel.playbackStateListener.navigateBack.observe(this, { viewModel.navigateBack.observe(this, {
if (it) { if (it) {
onBackPressed() onBackPressed()
} }
}) })
if (viewModel.player.value == null) { if (viewModel.player.value == null) {
viewModel.initializePlayer(args.itemId, args.mediaSourceId, args.playbackPosition) viewModel.initializePlayer(args.items, args.playbackPosition)
} }
hideSystemUI() hideSystemUI()
} }

View file

@ -12,6 +12,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import java.util.* import java.util.*
@ -33,14 +34,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.viewModel = viewModel binding.viewModel = viewModel
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
viewModel.mediaSources.value?.get(0)?.id?.let { mediaSourceId ->
navigateToPlayerActivity( navigateToPlayerActivity(
args.episodeId, viewModel.playerItems.toTypedArray(),
mediaSourceId,
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
) )
} }
}
binding.checkButton.setOnClickListener { binding.checkButton.setOnClickListener {
when (viewModel.played.value) { when (viewModel.played.value) {
@ -95,14 +93,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
} }
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
itemId: UUID, playerItems: Array<PlayerItem>,
mediaSourceId: String,
playbackPosition: Long playbackPosition: Long
) { ) {
findNavController().navigate( findNavController().navigate(
EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity( EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity(
itemId, playerItems,
mediaSourceId,
playbackPosition playbackPosition
) )
) )

View file

@ -17,9 +17,9 @@ import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.*
@AndroidEntryPoint @AndroidEntryPoint
class MediaInfoFragment : Fragment() { class MediaInfoFragment : Fragment() {
@ -84,13 +84,10 @@ class MediaInfoFragment : Fragment() {
}) })
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { mediaSource -> viewModel.navigateToPlayer.observe(viewLifecycleOwner, { mediaSource ->
mediaSource.id?.let {
navigateToPlayerActivity( navigateToPlayerActivity(
args.itemId, arrayOf(PlayerItem(args.itemId, mediaSource.id!!)),
it,
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000) viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
) )
}
}) })
viewModel.played.observe(viewLifecycleOwner, { viewModel.played.observe(viewLifecycleOwner, {
@ -139,9 +136,8 @@ class MediaInfoFragment : Fragment() {
) )
} else { } else {
navigateToPlayerActivity( navigateToPlayerActivity(
args.itemId, arrayOf(PlayerItem(args.itemId, viewModel.mediaSources.value!![0].id!!)),
viewModel.mediaSources.value!![0].id!!, viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000),
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
) )
} }
} }
@ -185,14 +181,12 @@ class MediaInfoFragment : Fragment() {
} }
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
itemId: UUID, playerItems: Array<PlayerItem>,
mediaSourceId: String, playbackPosition: Long,
playbackPosition: Long
) { ) {
findNavController().navigate( findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity( MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
itemId, playerItems,
mediaSourceId,
playbackPosition playbackPosition
) )
) )

View file

@ -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

View file

@ -24,7 +24,12 @@ interface JellyfinRepository {
suspend fun getNextUp(seriesId: UUID? = null): List<BaseItemDto> suspend fun getNextUp(seriesId: UUID? = null): List<BaseItemDto>
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID, fields: List<ItemFields>? = null): List<BaseItemDto> suspend fun getEpisodes(
seriesId: UUID,
seasonId: UUID,
fields: List<ItemFields>? = null,
startIndex: Int? = null
): List<BaseItemDto>
suspend fun getMediaSources(itemId: UUID): List<MediaSourceInfo> suspend fun getMediaSources(itemId: UUID): List<MediaSourceInfo>

View file

@ -105,12 +105,17 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
override suspend fun getEpisodes( override suspend fun getEpisodes(
seriesId: UUID, seriesId: UUID,
seasonId: UUID, seasonId: UUID,
fields: List<ItemFields>? fields: List<ItemFields>?,
startIndex: Int?
): List<BaseItemDto> { ): List<BaseItemDto> {
val episodes: List<BaseItemDto> val episodes: List<BaseItemDto>
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
episodes = jellyfinApi.showsApi.getEpisodes( episodes = jellyfinApi.showsApi.getEpisodes(
seriesId, jellyfinApi.userId!!, seasonId = seasonId, fields = fields seriesId,
jellyfinApi.userId!!,
seasonId = seasonId,
fields = fields,
startIndex = startIndex
).content.items ?: listOf() ).content.items ?: listOf()
} }
return episodes return episodes

View file

@ -6,10 +6,10 @@ 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.MediaSourceInfo
import timber.log.Timber import timber.log.Timber
import java.text.DateFormat import java.text.DateFormat
import java.time.ZoneOffset import java.time.ZoneOffset
@ -32,15 +32,14 @@ constructor(
private val _dateString = MutableLiveData<String>() private val _dateString = MutableLiveData<String>()
val dateString: LiveData<String> = _dateString val dateString: LiveData<String> = _dateString
private val _mediaSources = MutableLiveData<List<MediaSourceInfo>>()
val mediaSources: LiveData<List<MediaSourceInfo>> = _mediaSources
private val _played = MutableLiveData<Boolean>() private val _played = MutableLiveData<Boolean>()
val played: LiveData<Boolean> = _played val played: LiveData<Boolean> = _played
private val _favorite = MutableLiveData<Boolean>() private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite val favorite: LiveData<Boolean> = _favorite
var playerItems: MutableList<PlayerItem> = mutableListOf()
fun loadEpisode(episodeId: UUID) { fun loadEpisode(episodeId: UUID) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@ -48,7 +47,7 @@ constructor(
_item.value = item _item.value = item
_runTime.value = "${item.runTimeTicks?.div(600000000)} min" _runTime.value = "${item.runTimeTicks?.div(600000000)} min"
_dateString.value = getDateString(item) _dateString.value = getDateString(item)
_mediaSources.value = jellyfinRepository.getMediaSources(episodeId) createPlayerItems(item)
_played.value = item.userData?.played _played.value = item.userData?.played
_favorite.value = item.userData?.isFavorite _favorite.value = item.userData?.isFavorite
} catch (e: Exception) { } 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) { fun markAsPlayed(itemId: UUID) {
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId) jellyfinRepository.markAsPlayed(itemId)

View file

@ -11,6 +11,7 @@ import androidx.preference.PreferenceManager
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
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.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -24,29 +25,23 @@ class PlayerActivityViewModel
constructor( constructor(
private val application: Application, private val application: Application,
private val jellyfinRepository: JellyfinRepository private val jellyfinRepository: JellyfinRepository
) : ViewModel() { ) : ViewModel(), Player.Listener {
private var _player = MutableLiveData<SimpleExoPlayer>() private var _player = MutableLiveData<SimpleExoPlayer>()
var player: LiveData<SimpleExoPlayer> = _player var player: LiveData<SimpleExoPlayer> = _player
private val _navigateBack = MutableLiveData<Boolean>()
val navigateBack: LiveData<Boolean> = _navigateBack
private var playWhenReady = true private var playWhenReady = true
private var currentWindow = 0 private var currentWindow = 0
private var playbackPosition: Long = 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) private val sp = PreferenceManager.getDefaultSharedPreferences(application)
init { fun initializePlayer(
_playbackStateListener = PlaybackStateListener() items: Array<PlayerItem>,
} playbackPosition: Long
) {
fun initializePlayer(itemId: UUID, mediaSourceId: String, playbackPosition: Long) {
this.itemId = itemId
val renderersFactory = val renderersFactory =
DefaultRenderersFactory(application).setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) DefaultRenderersFactory(application).setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
@ -61,33 +56,38 @@ constructor(
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.build() .build()
player.addListener(_playbackStateListener) player.addListener(this)
viewModelScope.launch { viewModelScope.launch {
val streamUrl = jellyfinRepository.getStreamUrl(itemId, mediaSourceId) val mediaItems: MutableList<MediaItem> = mutableListOf()
for (item in items) {
val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
Timber.d("Stream url: $streamUrl") Timber.d("Stream url: $streamUrl")
val mediaItem = val mediaItem =
MediaItem.Builder() MediaItem.Builder()
.setMediaId(itemId.toString()) .setMediaId(item.itemId.toString())
.setUri(streamUrl) .setUri(streamUrl)
.build() .build()
player.setMediaItem(mediaItem, playbackPosition) mediaItems.add(mediaItem)
}
player.setMediaItems(mediaItems, currentWindow, playbackPosition)
player.playWhenReady = playWhenReady player.playWhenReady = playWhenReady
player.prepare() player.prepare()
_player.value = player _player.value = player
jellyfinRepository.postPlaybackStart(itemId)
} }
pollPosition(player, itemId) pollPosition(player)
} }
private fun releasePlayer() { private fun releasePlayer() {
itemId?.let { itemId ->
_player.value?.let { player -> _player.value?.let { player ->
runBlocking { runBlocking {
jellyfinRepository.postPlaybackStop(itemId, player.currentPosition.times(10000)) jellyfinRepository.postPlaybackStop(
} UUID.fromString(player.currentMediaItem?.mediaId),
player.currentPosition.times(10000)
)
} }
} }
@ -95,32 +95,37 @@ constructor(
playWhenReady = player.value!!.playWhenReady playWhenReady = player.value!!.playWhenReady
playbackPosition = player.value!!.currentPosition playbackPosition = player.value!!.currentPosition
currentWindow = player.value!!.currentWindowIndex currentWindow = player.value!!.currentWindowIndex
player.value!!.removeListener(_playbackStateListener) player.value!!.removeListener(this)
player.value!!.release() player.value!!.release()
_player.value = null _player.value = null
} }
} }
private fun pollPosition(player: SimpleExoPlayer, itemId: UUID) { private fun pollPosition(player: SimpleExoPlayer) {
val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
val runnable: Runnable = object : Runnable { val runnable = object : Runnable {
override fun run() { override fun run() {
viewModelScope.launch { viewModelScope.launch {
if (player.currentMediaItem != null) {
jellyfinRepository.postPlaybackProgress( jellyfinRepository.postPlaybackProgress(
itemId, UUID.fromString(player.currentMediaItem!!.mediaId),
player.currentPosition.times(10000), player.currentPosition.times(10000),
!player.isPlaying !player.isPlaying
) )
} }
}
handler.postDelayed(this, 2000) handler.postDelayed(this, 2000)
} }
} }
handler.post(runnable) handler.post(runnable)
} }
class PlaybackStateListener : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
private val _navigateBack = MutableLiveData<Boolean>() Timber.d("Playing MediaItem: ${mediaItem?.mediaId}")
val navigateBack: LiveData<Boolean> = _navigateBack viewModelScope.launch {
jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId))
}
}
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
var stateString = "UNKNOWN_STATE -" var stateString = "UNKNOWN_STATE -"
@ -141,7 +146,6 @@ constructor(
} }
Timber.d("Changed player state to $stateString") Timber.d("Changed player state to $stateString")
} }
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()

View file

@ -151,11 +151,8 @@
android:label="activity_player" android:label="activity_player"
tools:layout="@layout/activity_player"> tools:layout="@layout/activity_player">
<argument <argument
android:name="itemId" android:name="items"
app:argType="java.util.UUID" /> app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
<argument
android:name="mediaSourceId"
app:argType="string" />
<argument <argument
android:name="playbackPosition" android:name="playbackPosition"
app:argType="long" /> app:argType="long" />