Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add chapter skipping support to exoplayer client #1533

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
79 changes: 79 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jellyfin.mobile.player

import ChapterMarking
import android.annotation.SuppressLint
import android.app.Application
import android.media.AudioAttributes
Expand Down Expand Up @@ -33,6 +34,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver
Expand All @@ -45,6 +47,7 @@ import org.jellyfin.mobile.player.ui.DisplayPreferences
import org.jellyfin.mobile.player.ui.PlayState
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
import org.jellyfin.mobile.utils.TickUtils
import org.jellyfin.mobile.utils.applyDefaultAudioAttributes
import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes
import org.jellyfin.mobile.utils.extensions.scaleInRange
Expand All @@ -65,6 +68,7 @@ import org.jellyfin.sdk.api.operations.DisplayPreferencesApi
import org.jellyfin.sdk.api.operations.HlsSegmentApi
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.ChapterInfo
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
Expand Down Expand Up @@ -104,6 +108,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
val playerState: LiveData<Int> get() = _playerState
val decoderType: LiveData<DecoderType> get() = _decoderType

// Chapter Markings
private var chapterMarkings: List<ChapterMarking> = listOf()
private var chapterMarkingUpdateJob: Job? = null

private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error

Expand Down Expand Up @@ -285,6 +293,19 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
progressUpdateJob?.cancel()
}

private fun startChapterMarkingUpdates(){
chapterMarkingUpdateJob = viewModelScope.launch {
while (true) {
delay(Constants.CHAPTER_MARKING_UPDATE_DELAY)
playerOrNull?.setWatchedChapterMarkings()
}
}
}

private fun stopChapterMarkingUpdates(){
chapterMarkingUpdateJob?.cancel()
}

/**
* Updates the decoder of the [Player]. This will destroy the current player and
* recreate the player with the selected decoder type
Expand Down Expand Up @@ -326,6 +347,19 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
}

private fun Player.setWatchedChapterMarkings(){
val playbackPositionMs = currentPosition
val playbackPositionTicks = TickUtils.msToTicks(playbackPositionMs)

val chapters = mediaSourceOrNull?.item?.chapters ?: return
val currentChapterIdx = getCurrentChapterIdx(chapters, playbackPositionTicks) ?: return

chapterMarkings.forEachIndexed { i, m ->
val color = if (i <= currentChapterIdx) R.color.jellyfin_accent else R.color.unplayed
m.setColor(color)
}
}

private suspend fun Player.reportPlaybackState() {
val mediaSource = mediaSourceOrNull ?: return
val playbackPositionMillis = currentPosition
Expand Down Expand Up @@ -418,6 +452,40 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
playerOrNull?.seekToOffset(displayPreferences.skipForwardLength)
}

private fun getCurrentChapterIdx(chapters: List<ChapterInfo>, ticks: Long): Int?{
return chapters.indices.findLast { i -> ticks >= chapters[i].startPositionTicks }
}

fun previousChapter(){
val chapters = mediaSourceOrNull?.item?.chapters ?: return
val currentPosition = playerOrNull?.currentPosition ?: return
var ticks = TickUtils.msToTicks(currentPosition)

//Go back 10 seconds
ticks -= TickUtils.secToTicks(10)
if(ticks < 0) skipToPrevious()
else{
//The current chapter in this case is the one we want to go back to
val previousChapter = getCurrentChapterIdx(chapters, ticks) ?: return
val seekToMs = TickUtils.ticksToMs(chapters[previousChapter].startPositionTicks)
playerOrNull?.seekTo(seekToMs)
}
}

fun nextChapter(){
val chapters = mediaSourceOrNull?.item?.chapters ?: return
val currentPosition = playerOrNull?.currentPosition ?: return
val ticks = TickUtils.msToTicks(currentPosition)
val currentChapter = getCurrentChapterIdx(chapters, ticks) ?: return
val nextChapter = currentChapter + 1

if(nextChapter > chapters.size) skipToNext()
else{
val seekToMs = TickUtils.ticksToMs(chapters[nextChapter].startPositionTicks)
playerOrNull?.seekTo(seekToMs)
}
}

fun skipToPrevious() {
val player = playerOrNull ?: return
when {
Expand Down Expand Up @@ -508,8 +576,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
// Setup or stop regular progress updates
if (playbackState == Player.STATE_READY && playWhenReady) {
startProgressUpdates()
startChapterMarkingUpdates()
} else {
stopProgressUpdates()
stopChapterMarkingUpdates()
}

// Update media session
Expand Down Expand Up @@ -538,6 +608,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
}

override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
playerOrNull?.setWatchedChapterMarkings()
}

override fun onPlayerError(error: PlaybackException) {
if (error.cause is MediaCodecDecoderException && !fallbackPreferExtensionRenderers) {
Timber.e(error.cause, "Decoder failed, attempting to restart playback with decoder extensions preferred")
Expand All @@ -558,4 +633,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
releasePlayer()
}

fun setChapterMarkings(markings: List<ChapterMarking>){
chapterMarkings = markings
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.MediaInfoApi
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
Expand Down Expand Up @@ -60,7 +61,7 @@ class MediaSourceResolver(private val apiClient: ApiClient) {

// Load additional item info if possible
val item = try {
val response by itemsApi.getItemsByUserId(ids = listOf(itemId))
val response by itemsApi.getItemsByUserId(ids = listOf(itemId), fields = listOf(ItemFields.CHAPTERS))
response.items?.firstOrNull()
} catch (e: ApiClientException) {
Timber.e(e, "Failed to load item for media source $itemId")
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import android.content.Context
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import org.jellyfin.mobile.R

class ChapterMarking(private val context: Context, parent: ConstraintLayout, bias: Float) {
private val view: View = View(context).apply {
id = View.generateViewId()
layoutParams = ConstraintLayout.LayoutParams(
(3 * context.resources.displayMetrics.density).toInt(),
(15 * context.resources.displayMetrics.density).toInt(),
).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
startToStart = ConstraintLayout.LayoutParams.PARENT_ID
endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
horizontalBias = bias
}

background = ContextCompat.getDrawable(context, R.drawable.chapter_marking)
}

init {
parent.addView(view)
}

fun setColor(id: Int){
view.setBackgroundColor(ContextCompat.getColor(context, id))
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jellyfin.mobile.player.ui

import ChapterMarking
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.pm.ActivityInfo
Expand Down Expand Up @@ -278,6 +279,10 @@ class PlayerFragment : Fragment(), BackPressInterceptor {

fun onFastForward() = viewModel.fastForward()

fun onPreviousChapter() = viewModel.previousChapter()

fun onNextChapter() = viewModel.nextChapter()

/**
* @param callback called if track selection was successful and UI needs to be updated
*/
Expand Down Expand Up @@ -409,4 +414,8 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
window.brightness = BRIGHTNESS_OVERRIDE_NONE
}
}

fun setChapterMarkings(markings: List<ChapterMarking>){
viewModel.setChapterMarkings(markings)
}
}
51 changes: 51 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
package org.jellyfin.mobile.player.ui

import ChapterMarking
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.core.view.size
import androidx.core.view.updateLayoutParams
import org.jellyfin.mobile.R
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.sdk.model.api.ChapterInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
Expand All @@ -32,8 +36,11 @@ class PlayerMenus(

private val context = playerBinding.root.context
private val qualityOptionsProvider: QualityOptionsProvider by inject()
private val playPauseContainer: View by playerControlsBinding::playPauseContainer
private val previousButton: View by playerControlsBinding::previousButton
private val nextButton: View by playerControlsBinding::nextButton
private val previousChapterButton: View by playerControlsBinding::previousChapterButton
private val nextChapterButton: View by playerControlsBinding::nextChapterButton
private val lockScreenButton: View by playerControlsBinding::lockScreenButton
private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton
private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton
Expand All @@ -47,6 +54,7 @@ class PlayerMenus(
private val speedMenu: PopupMenu = createSpeedMenu()
private val qualityMenu: PopupMenu = createQualityMenu()
private val decoderMenu: PopupMenu = createDecoderMenu()
private val chapterMarkingContainer: ConstraintLayout by playerControlsBinding::chapterMarkingContainer

private var subtitleCount = 0
private var subtitlesEnabled = false
Expand All @@ -58,6 +66,12 @@ class PlayerMenus(
nextButton.setOnClickListener {
fragment.onSkipToNext()
}
previousChapterButton.setOnClickListener{
fragment.onPreviousChapter()
}
nextChapterButton.setOnClickListener {
fragment.onNextChapter()
}
lockScreenButton.setOnClickListener {
fragment.playerLockScreenHelper.lockScreen()
}
Expand Down Expand Up @@ -104,6 +118,11 @@ class PlayerMenus(
// previousButton is always enabled and will rewind if at the start of the queue
nextButton.isEnabled = hasNext

val chapters = mediaSource.item?.chapters
updateLayoutConstraints(!chapters.isNullOrEmpty())
val runTimeTicks = mediaSource.item?.runTimeTicks
setChapterMarkings(chapters, runTimeTicks)

val videoStream = mediaSource.selectedVideoStream

val audioStreams = mediaSource.audioStreams
Expand Down Expand Up @@ -161,6 +180,38 @@ class PlayerMenus(
).joinToString("\n\n")
}

private fun updateLayoutConstraints(hasChapters: Boolean){
if(hasChapters){
previousButton.updateLayoutParams<ConstraintLayout.LayoutParams> { endToStart = previousChapterButton.id }
nextButton.updateLayoutParams<ConstraintLayout.LayoutParams> { startToEnd = nextChapterButton.id }
previousChapterButton.visibility = View.VISIBLE
nextChapterButton.visibility = View.VISIBLE
}
else{
previousButton.updateLayoutParams<ConstraintLayout.LayoutParams> { endToStart = playPauseContainer.id }
nextButton.updateLayoutParams<ConstraintLayout.LayoutParams> { startToEnd = playPauseContainer.id }
previousChapterButton.visibility = View.GONE
nextChapterButton.visibility = View.GONE
}
}

private fun setChapterMarkings(chapters: List<ChapterInfo>?, runTimeTicks: Long?){
chapterMarkingContainer.removeAllViews()

if(chapters.isNullOrEmpty() || runTimeTicks == null){
fragment.setChapterMarkings(mutableListOf())
return
}

val chapterMarkings: MutableList<ChapterMarking> = mutableListOf()
chapters.forEach { ch ->
val bias = ch.startPositionTicks.toFloat() / runTimeTicks
val marking = ChapterMarking(context, chapterMarkingContainer, bias)
chapterMarkings.add(marking)
}
fragment.setChapterMarkings(chapterMarkings)
}

private fun buildMediaStreamsInfo(
mediaStreams: List<MediaStream>,
@StringRes prefix: Int,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ object Constants {
const val LANGUAGE_UNDEFINED = "und"
const val TICKS_PER_MILLISECOND = 10000
const val PLAYER_TIME_UPDATE_RATE = 10000L
const val CHAPTER_MARKING_UPDATE_DELAY = 1000L
const val DEFAULT_CONTROLS_TIMEOUT_MS = 2500
const val SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL = 64
const val DEFAULT_CENTER_OVERLAY_TIMEOUT_MS = 250
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.jellyfin.mobile.utils

class TickUtils {
companion object{
fun ticksToMs(ticks: Long) = ticks / Constants.TICKS_PER_MILLISECOND
fun msToTicks(ms: Long) = ms * Constants.TICKS_PER_MILLISECOND
fun secToTicks(sec: Long) = msToTicks(sec * 1000)
}
}
8 changes: 8 additions & 0 deletions app/src/main/res/drawable/chapter_marking.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/unplayed"/>
<corners android:radius="0dp"/>
<size
android:width="3dp"
android:height="15dp"/>
</shape>
9 changes: 9 additions & 0 deletions app/src/main/res/drawable/ic_skip_next_chapter_black_32dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="32dp"
android:width="32dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M18.4,10.6C16.55,9 14.15,8 11.5,8C6.85,8 2.92,11.03 1.54,15.22L3.9,16C4.95,12.81 7.95,10.5 11.5,10.5C13.45,10.5 15.23,11.22 16.62,12.38L13,16H22V7L18.4,10.6Z" />
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="32dp"
android:width="32dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z" />
</vector>
Loading
Loading