Rework how player items are created

Add support for intros and improve loading speed
This commit is contained in:
jarnedemeulemeester 2021-08-26 15:36:56 +02:00
parent fb1755e8b8
commit 25ac5524d7
No known key found for this signature in database
GPG key ID: 60884A0C1EBA43E5
11 changed files with 160 additions and 89 deletions

View file

@ -38,7 +38,7 @@ class PlayerActivity : AppCompatActivity() {
}) })
if (viewModel.player.value == null) { if (viewModel.player.value == null) {
viewModel.initializePlayer(args.items, args.playbackPosition) viewModel.initializePlayer(args.items)
} }
hideSystemUI() hideSystemUI()
} }

View file

@ -11,12 +11,12 @@ class VideoVersionDialogFragment(
private val viewModel: MediaInfoViewModel private val viewModel: MediaInfoViewModel
) : DialogFragment() { ) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val items = viewModel.mediaSources.value!!.map { it.name } val items = viewModel.item.value?.mediaSources?.map { it.name }
return activity?.let { return activity?.let {
val builder = AlertDialog.Builder(it) val builder = AlertDialog.Builder(it)
builder.setTitle("Select a version") builder.setTitle("Select a version")
.setItems(items.toTypedArray()) { _, which -> .setItems(items?.toTypedArray()) { _, which ->
viewModel.navigateToPlayer(viewModel.mediaSources.value!![which]) viewModel.preparePlayerItems(which)
} }
builder.create() builder.create()
} ?: throw IllegalStateException("Activity cannot be null") } ?: throw IllegalStateException("Activity cannot be null")

View file

@ -91,10 +91,14 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
if (it) { if (it) {
navigateToPlayerActivity( navigateToPlayerActivity(
viewModel.playerItems.toTypedArray(), viewModel.playerItems.toTypedArray(),
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
) )
viewModel.doneNavigateToPlayer() viewModel.doneNavigateToPlayer()
binding.playButton.setImageDrawable(ContextCompat.getDrawable(requireActivity(), R.drawable.ic_play)) binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE binding.progressCircular.visibility = View.INVISIBLE
} }
}) })
@ -102,7 +106,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage -> viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
if (errorMessage != null) { if (errorMessage != null) {
binding.playerItemsError.visibility = View.VISIBLE binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable(ContextCompat.getDrawable(requireActivity(), R.drawable.ic_play)) binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE binding.progressCircular.visibility = View.INVISIBLE
} else { } else {
binding.playerItemsError.visibility = View.GONE binding.playerItemsError.visibility = View.GONE
@ -110,7 +119,9 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}) })
binding.playerItemsErrorDetails.setOnClickListener { binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(viewModel.playerItemsError.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") ErrorDialogFragment(
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
).show(parentFragmentManager, "errordialog")
} }
viewModel.loadEpisode(args.episodeId) viewModel.loadEpisode(args.episodeId)
@ -120,12 +131,10 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>, playerItems: Array<PlayerItem>,
playbackPosition: Long
) { ) {
findNavController().navigate( findNavController().navigate(
EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity( EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToPlayerActivity(
playerItems, playerItems,
playbackPosition
) )
) )
} }

View file

@ -63,7 +63,10 @@ class MediaInfoFragment : Fragment() {
} }
binding.errorLayout.errorDetailsButton.setOnClickListener { binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
} }
viewModel.item.observe(viewLifecycleOwner, { item -> viewModel.item.observe(viewLifecycleOwner, { item ->
@ -91,8 +94,7 @@ class MediaInfoFragment : Fragment() {
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems -> viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems ->
if (playerItems != null) { if (playerItems != null) {
navigateToPlayerActivity( navigateToPlayerActivity(
playerItems, playerItems
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000)
) )
viewModel.doneNavigatingToPlayer() viewModel.doneNavigatingToPlayer()
binding.playButton.setImageDrawable( binding.playButton.setImageDrawable(
@ -139,7 +141,9 @@ class MediaInfoFragment : Fragment() {
}) })
binding.playerItemsErrorDetails.setOnClickListener { binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(viewModel.playerItemsError.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog") ErrorDialogFragment(
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
).show(parentFragmentManager, "errordialog")
} }
binding.trailerButton.setOnClickListener { binding.trailerButton.setOnClickListener {
@ -164,29 +168,14 @@ class MediaInfoFragment : Fragment() {
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 (args.itemType == "Movie") {
if (!viewModel.mediaSources.value.isNullOrEmpty()) { if (viewModel.item.value?.mediaSources != null) {
if (viewModel.mediaSources.value!!.size > 1) { if (viewModel.item.value?.mediaSources?.size!! > 1) {
VideoVersionDialogFragment(viewModel).show( VideoVersionDialogFragment(viewModel).show(
parentFragmentManager, parentFragmentManager,
"videoversiondialog" "videoversiondialog"
) )
} else { } else {
navigateToPlayerActivity( viewModel.preparePlayerItems()
arrayOf(
PlayerItem(
args.itemId,
viewModel.mediaSources.value!![0].id!!
)
),
viewModel.item.value!!.userData!!.playbackPositionTicks.div(10000),
)
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
} }
} }
} else if (args.itemType == "Series") { } else if (args.itemType == "Series") {
@ -232,12 +221,10 @@ class MediaInfoFragment : Fragment() {
private fun navigateToPlayerActivity( private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>, playerItems: Array<PlayerItem>,
playbackPosition: Long,
) { ) {
findNavController().navigate( findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity( MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
playerItems, playerItems,
playbackPosition
) )
) )
} }

View file

@ -7,5 +7,6 @@ import java.util.*
@Parcelize @Parcelize
data class PlayerItem( data class PlayerItem(
val itemId: UUID, val itemId: UUID,
val mediaSourceId: String val mediaSourceId: String,
val playbackPosition: Long
) : Parcelable ) : Parcelable

View file

@ -54,4 +54,6 @@ interface JellyfinRepository {
suspend fun markAsPlayed(itemId: UUID) suspend fun markAsPlayed(itemId: UUID)
suspend fun markAsUnplayed(itemId: UUID) suspend fun markAsUnplayed(itemId: UUID)
suspend fun getIntros(itemId: UUID): List<BaseItemDto>
} }

View file

@ -266,4 +266,14 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
jellyfinApi.playStateApi.markUnplayedItem(jellyfinApi.userId!!, itemId) jellyfinApi.playStateApi.markUnplayedItem(jellyfinApi.userId!!, itemId)
} }
} }
override suspend fun getIntros(itemId: UUID): List<BaseItemDto> {
val intros: List<BaseItemDto>
withContext(Dispatchers.IO) {
intros =
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
?: listOf()
}
return intros
}
} }

View file

@ -10,6 +10,7 @@ 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 timber.log.Timber import timber.log.Timber
import java.text.DateFormat import java.text.DateFormat
import java.time.ZoneOffset import java.time.ZoneOffset
@ -74,17 +75,39 @@ constructor(
} }
private suspend fun createPlayerItems(startEpisode: BaseItemDto) { 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.id, intro.mediaSources?.get(0)?.id!!, 0))
introsCount += 1
}
}
val episodes = jellyfinRepository.getEpisodes( val episodes = jellyfinRepository.getEpisodes(
startEpisode.seriesId!!, startEpisode.seriesId!!,
startEpisode.seasonId!!, startEpisode.seasonId!!,
startItemId = startEpisode.id startItemId = startEpisode.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
) )
for (episode in episodes) { for (episode in episodes) {
val mediaSources = jellyfinRepository.getMediaSources(episode.id) if (episode.mediaSources.isNullOrEmpty()) continue
if (mediaSources.isEmpty()) continue playerItems.add(
playerItems.add(PlayerItem(episode.id, mediaSources[0].id!!)) PlayerItem(
episode.id,
episode.mediaSources?.get(0)?.id!!,
playbackPosition
)
)
} }
if (playerItems.isEmpty()) throw Exception("No playable items found")
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
} }
fun markAsPlayed(itemId: UUID) { fun markAsPlayed(itemId: UUID) {

View file

@ -13,7 +13,7 @@ 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.MediaSourceInfo import org.jellyfin.sdk.model.api.ItemFields
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -52,9 +52,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 _mediaSources = MutableLiveData<List<MediaSourceInfo>>()
val mediaSources: LiveData<List<MediaSourceInfo>> = _mediaSources
private val _navigateToPlayer = MutableLiveData<Array<PlayerItem>>() private val _navigateToPlayer = MutableLiveData<Array<PlayerItem>>()
val navigateToPlayer: LiveData<Array<PlayerItem>> = _navigateToPlayer val navigateToPlayer: LiveData<Array<PlayerItem>> = _navigateToPlayer
@ -91,9 +88,6 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
_nextUp.value = getNextUp(itemId) _nextUp.value = getNextUp(itemId)
_seasons.value = jellyfinRepository.getSeasons(itemId) _seasons.value = jellyfinRepository.getSeasons(itemId)
} }
if (itemType == "Movie") {
_mediaSources.value = jellyfinRepository.getMediaSources(itemId)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
_error.value = e.toString() _error.value = e.toString()
@ -183,11 +177,11 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
} }
} }
fun preparePlayerItems() { fun preparePlayerItems(mediaSourceIndex: Int? = null) {
_playerItemsError.value = null _playerItemsError.value = null
viewModelScope.launch { viewModelScope.launch {
try { try {
createPlayerItems(_item.value!!) createPlayerItems(_item.value!!, mediaSourceIndex)
_navigateToPlayer.value = playerItems.toTypedArray() _navigateToPlayer.value = playerItems.toTypedArray()
} catch (e: Exception) { } catch (e: Exception) {
_playerItemsError.value = e.message _playerItemsError.value = e.message
@ -195,35 +189,76 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
} }
} }
private suspend fun createPlayerItems(series: BaseItemDto) { private suspend fun createPlayerItems(series: BaseItemDto, mediaSourceIndex: Int? = null) {
if (nextUp.value != null) { playerItems.clear()
val startEpisode = nextUp.value!!
val episodes = jellyfinRepository.getEpisodes( val playbackPosition = item.value?.userData?.playbackPositionTicks?.div(10000) ?: 0
startEpisode.seriesId!!,
startEpisode.seasonId!!, // Intros
startItemId = startEpisode.id var introsCount = 0
)
for (episode in episodes) { if (playbackPosition <= 0) {
val mediaSources = jellyfinRepository.getMediaSources(episode.id) val intros = jellyfinRepository.getIntros(series.id)
if (mediaSources.isEmpty()) continue for (intro in intros) {
playerItems.add(PlayerItem(episode.id, mediaSources[0].id!!)) if (intro.mediaSources.isNullOrEmpty()) continue
playerItems.add(PlayerItem(intro.id, intro.mediaSources?.get(0)?.id!!, 0))
introsCount += 1
} }
} else { }
for (season in seasons.value!!) {
if (season.indexNumber == 0) continue when (series.type) {
val episodes = jellyfinRepository.getEpisodes(series.id, season.id) "Movie" -> {
for (episode in episodes) { playerItems.add(
val mediaSources = jellyfinRepository.getMediaSources(episode.id) PlayerItem(
if (mediaSources.isEmpty()) continue series.id,
playerItems.add(PlayerItem(episode.id, mediaSources[0].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
playerItems.add(
PlayerItem(
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
playerItems.add(
PlayerItem(
episode.id,
episode.mediaSources?.get(0)?.id!!,
0
)
)
}
}
} }
} }
} }
if (playerItems.isEmpty()) throw Exception("No playable items found")
}
fun navigateToPlayer(mediaSource: MediaSourceInfo) { if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
_navigateToPlayer.value = arrayOf(PlayerItem(item.value!!.id, mediaSource.id!!))
} }
fun doneNavigatingToPlayer() { fun doneNavigatingToPlayer() {

View file

@ -39,8 +39,7 @@ constructor(
private val sp = PreferenceManager.getDefaultSharedPreferences(application) private val sp = PreferenceManager.getDefaultSharedPreferences(application)
fun initializePlayer( fun initializePlayer(
items: Array<PlayerItem>, items: Array<PlayerItem>
playbackPosition: Long
) { ) {
val renderersFactory = val renderersFactory =
@ -61,18 +60,22 @@ constructor(
viewModelScope.launch { viewModelScope.launch {
val mediaItems: MutableList<MediaItem> = mutableListOf() val mediaItems: MutableList<MediaItem> = mutableListOf()
for (item in items) { try {
val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId) for (item in items) {
Timber.d("Stream url: $streamUrl") val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
val mediaItem = Timber.d("Stream url: $streamUrl")
MediaItem.Builder() val mediaItem =
.setMediaId(item.itemId.toString()) MediaItem.Builder()
.setUri(streamUrl) .setMediaId(item.itemId.toString())
.build() .setUri(streamUrl)
mediaItems.add(mediaItem) .build()
mediaItems.add(mediaItem)
}
} catch (e: Exception) {
Timber.e(e)
} }
player.setMediaItems(mediaItems, currentWindow, playbackPosition) player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
player.playWhenReady = playWhenReady player.playWhenReady = playWhenReady
player.prepare() player.prepare()
_player.value = player _player.value = player
@ -131,7 +134,11 @@ constructor(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Timber.d("Playing MediaItem: ${mediaItem?.mediaId}") Timber.d("Playing MediaItem: ${mediaItem?.mediaId}")
viewModelScope.launch { viewModelScope.launch {
jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId)) try {
jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId))
} catch (e: Exception) {
Timber.e(e)
}
} }
} }

View file

@ -160,9 +160,6 @@
<argument <argument
android:name="items" android:name="items"
app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" /> app:argType="dev.jdtech.jellyfin.models.PlayerItem[]" />
<argument
android:name="playbackPosition"
app:argType="long" />
</activity> </activity>
<fragment <fragment
android:id="@+id/favoriteFragment" android:id="@+id/favoriteFragment"