diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 4c6b3c6d5..ce3382531 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -1,5 +1,6 @@ package org.jellyfin.mobile.player +import ChapterMarking import android.annotation.SuppressLint import android.app.Application import android.media.AudioAttributes @@ -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 @@ -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 @@ -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 @@ -104,6 +108,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), val playerState: LiveData get() = _playerState val decoderType: LiveData get() = _decoderType + // Chapter Markings + private var chapterMarkings: List = listOf() + private var chapterMarkingUpdateJob: Job? = null + private val _error = MutableLiveData() val error: LiveData = _error @@ -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 @@ -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 @@ -418,6 +452,40 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), playerOrNull?.seekToOffset(displayPreferences.skipForwardLength) } + private fun getCurrentChapterIdx(chapters: List, 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 { @@ -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 @@ -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") @@ -558,4 +633,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) releasePlayer() } + + fun setChapterMarkings(markings: List){ + chapterMarkings = markings + } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt index 51fcbd8b7..c92d52e02 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt @@ -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 @@ -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") diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt new file mode 100644 index 000000000..c7c06bf94 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index 11a84c568..e14228e4e 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -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 @@ -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 */ @@ -409,4 +414,8 @@ class PlayerFragment : Fragment(), BackPressInterceptor { window.brightness = BRIGHTNESS_OVERRIDE_NONE } } + + fun setChapterMarkings(markings: List){ + viewModel.setChapterMarkings(markings) + } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt index 0768a371b..52f41577d 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt @@ -1,5 +1,6 @@ package org.jellyfin.mobile.player.ui +import ChapterMarking import android.view.Menu import android.view.MenuItem import android.view.View @@ -7,14 +8,17 @@ 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 @@ -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 @@ -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 @@ -58,6 +66,12 @@ class PlayerMenus( nextButton.setOnClickListener { fragment.onSkipToNext() } + previousChapterButton.setOnClickListener{ + fragment.onPreviousChapter() + } + nextChapterButton.setOnClickListener { + fragment.onNextChapter() + } lockScreenButton.setOnClickListener { fragment.playerLockScreenHelper.lockScreen() } @@ -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 @@ -161,6 +180,38 @@ class PlayerMenus( ).joinToString("\n\n") } + private fun updateLayoutConstraints(hasChapters: Boolean){ + if(hasChapters){ + previousButton.updateLayoutParams { endToStart = previousChapterButton.id } + nextButton.updateLayoutParams { startToEnd = nextChapterButton.id } + previousChapterButton.visibility = View.VISIBLE + nextChapterButton.visibility = View.VISIBLE + } + else{ + previousButton.updateLayoutParams { endToStart = playPauseContainer.id } + nextButton.updateLayoutParams { startToEnd = playPauseContainer.id } + previousChapterButton.visibility = View.GONE + nextChapterButton.visibility = View.GONE + } + } + + private fun setChapterMarkings(chapters: List?, runTimeTicks: Long?){ + chapterMarkingContainer.removeAllViews() + + if(chapters.isNullOrEmpty() || runTimeTicks == null){ + fragment.setChapterMarkings(mutableListOf()) + return + } + + val chapterMarkings: MutableList = 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, @StringRes prefix: Int, diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt index 57158181c..8f53c4262 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt new file mode 100644 index 000000000..70df58212 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt @@ -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) + } +} diff --git a/app/src/main/res/drawable/chapter_marking.xml b/app/src/main/res/drawable/chapter_marking.xml new file mode 100644 index 000000000..41c78b063 --- /dev/null +++ b/app/src/main/res/drawable/chapter_marking.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_skip_next_chapter_black_32dp.xml b/app/src/main/res/drawable/ic_skip_next_chapter_black_32dp.xml new file mode 100644 index 000000000..517d08a04 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next_chapter_black_32dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_previous_chapter_black_32dp.xml b/app/src/main/res/drawable/ic_skip_previous_chapter_black_32dp.xml new file mode 100644 index 000000000..aca9d1a09 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous_chapter_black_32dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml index 24206ac9a..5afdba49b 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -29,6 +29,19 @@ android:src="@drawable/ic_skip_previous_black_32dp" android:tint="?android:textColorPrimary" app:layout_constraintBottom_toBottomOf="@id/play_pause_container" + app:layout_constraintEnd_toStartOf="@id/previous_chapter_button" + app:layout_constraintTop_toTopOf="@id/play_pause_container" /> + + @@ -61,6 +74,19 @@ tools:visibility="gone" /> + + - + app:layout_constraintStart_toEndOf="@id/exo_position"> + + + + + + #101010 #60000000 #cc000000 + + + #333333