From cc42439d3bea467bc4499e047ec87ff8a1bc14b9 Mon Sep 17 00:00:00 2001 From: CeruleanRed Date: Mon, 30 Dec 2024 21:51:11 +0100 Subject: [PATCH 1/7] Add chapter skipping functionality --- .../jellyfin/mobile/player/PlayerViewModel.kt | 36 +++++++++++++++++++ .../player/source/MediaSourceResolver.kt | 3 +- .../mobile/player/ui/PlayerFragment.kt | 4 +++ .../jellyfin/mobile/player/ui/PlayerMenus.kt | 8 +++++ .../org/jellyfin/mobile/utils/TickUtils.kt | 9 +++++ .../res/layout/exo_player_control_view.xml | 30 ++++++++++++++-- 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt 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..b004d97ba 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -45,6 +45,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 +66,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 @@ -418,6 +420,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 { 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/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index 11a84c568..cf63290bd 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 @@ -278,6 +278,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 */ 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..344d1398e 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 @@ -34,6 +34,8 @@ class PlayerMenus( private val qualityOptionsProvider: QualityOptionsProvider by inject() 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 @@ -58,6 +60,12 @@ class PlayerMenus( nextButton.setOnClickListener { fragment.onSkipToNext() } + previousChapterButton.setOnClickListener{ + fragment.onPreviousChapter() + } + nextChapterButton.setOnClickListener { + fragment.onNextChapter() + } lockScreenButton.setOnClickListener { fragment.playerLockScreenHelper.lockScreen() } 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..2608c1215 --- /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 / 10_000 + fun msToTicks(ms: Long) = ms * 10_000 + fun secToTicks(sec: Int) = sec * 10_000_000 + } +} 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..af2dfb943 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -28,6 +28,19 @@ android:padding="@dimen/exo_center_icon_padding" android:src="@drawable/ic_skip_previous_black_32dp" android:tint="?android:textColorPrimary" + app:layout_constraintBottom_toBottomOf="@id/previous_chapter_button" + app:layout_constraintEnd_toStartOf="@id/previous_chapter_button" + app:layout_constraintTop_toTopOf="@id/previous_chapter_button" /> + + @@ -62,18 +75,31 @@ + + Date: Mon, 30 Dec 2024 22:15:58 +0100 Subject: [PATCH 2/7] Make chapter buttons disappear if no chapters are present --- .../jellyfin/mobile/player/ui/PlayerMenus.kt | 21 +++++++++++++++++++ .../res/layout/exo_player_control_view.xml | 8 +++---- 2 files changed, 25 insertions(+), 4 deletions(-) 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 344d1398e..5d886dcde 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 @@ -7,9 +7,11 @@ 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 @@ -32,6 +34,7 @@ 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 @@ -108,10 +111,28 @@ class PlayerMenus( } } + 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 + } + } + fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) { // previousButton is always enabled and will rewind if at the start of the queue nextButton.isEnabled = hasNext + val hasChapters = mediaSource.item?.chapters?.isNotEmpty() ?: false + updateLayoutConstraints(hasChapters) + val videoStream = mediaSource.selectedVideoStream val audioStreams = mediaSource.audioStreams 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 af2dfb943..1e8b7869a 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -28,9 +28,9 @@ android:padding="@dimen/exo_center_icon_padding" android:src="@drawable/ic_skip_previous_black_32dp" android:tint="?android:textColorPrimary" - app:layout_constraintBottom_toBottomOf="@id/previous_chapter_button" + app:layout_constraintBottom_toBottomOf="@id/play_pause_container" app:layout_constraintEnd_toStartOf="@id/previous_chapter_button" - app:layout_constraintTop_toTopOf="@id/previous_chapter_button" /> + app:layout_constraintTop_toTopOf="@id/play_pause_container" /> + app:layout_constraintTop_toTopOf="@id/play_pause_container" /> Date: Mon, 30 Dec 2024 22:30:22 +0100 Subject: [PATCH 3/7] Add correct image files --- .../res/drawable/ic_skip_next_chapter_black_32dp.xml | 9 +++++++++ .../res/drawable/ic_skip_previous_chapter_black_32dp.xml | 9 +++++++++ app/src/main/res/layout/exo_player_control_view.xml | 4 ++-- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_skip_next_chapter_black_32dp.xml create mode 100644 app/src/main/res/drawable/ic_skip_previous_chapter_black_32dp.xml 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 1e8b7869a..1f8d62959 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -39,7 +39,7 @@ android:layout_marginEnd="@dimen/exo_center_controls_distance" android:background="@drawable/ripple_background_circular" android:padding="@dimen/exo_center_icon_padding" - android:src="@drawable/ic_rewind_black_32dp" + android:src="@drawable/ic_skip_previous_chapter_black_32dp" android:tint="?android:textColorPrimary" app:layout_constraintBottom_toBottomOf="@id/play_pause_container" app:layout_constraintEnd_toStartOf="@id/play_pause_container" @@ -81,7 +81,7 @@ android:layout_marginStart="@dimen/exo_center_controls_distance" android:background="@drawable/ripple_background_circular" android:padding="@dimen/exo_center_icon_padding" - android:src="@drawable/ic_fast_forward_black_32dp" + android:src="@drawable/ic_skip_next_chapter_black_32dp" android:tint="?android:textColorPrimary" app:layout_constraintBottom_toBottomOf="@id/play_pause_container" app:layout_constraintStart_toEndOf="@id/play_pause_container" From ce647fbf00ee3888475e798285aab6b05b1e8df7 Mon Sep 17 00:00:00 2001 From: CeruleanRed Date: Tue, 31 Dec 2024 15:08:24 +0100 Subject: [PATCH 4/7] Add chapter markings on seekbar --- .../mobile/player/ui/ChapterMarking.kt | 29 ++++++++++++ .../jellyfin/mobile/player/ui/PlayerMenus.kt | 18 +++++++- app/src/main/res/drawable/chapter_marking.xml | 8 ++++ .../res/layout/exo_player_control_view.xml | 46 ++++++++++++++----- app/src/main/res/values/colors.xml | 3 ++ 5 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt create mode 100644 app/src/main/res/drawable/chapter_marking.xml 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..3348aa105 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt @@ -0,0 +1,29 @@ +import android.content.Context +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import org.jellyfin.mobile.R + +class ChapterMarkingView(private val context: Context) { + fun createView(parent: ConstraintLayout, marginStart: Int): View { + val 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 + setMargins(marginStart, 0, 0, 0) + } + + val chapterMarking = ContextCompat.getDrawable(context, R.drawable.chapter_marking) + chapterMarking?.setTint(ContextCompat.getColor(context, R.color.unplayed)) + background = chapterMarking + } + + parent.addView(view) + return view + } +} 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 5d886dcde..4d2228473 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 ChapterMarkingView import android.view.Menu import android.view.MenuItem import android.view.View @@ -10,6 +11,7 @@ import androidx.annotation.StringRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.get import androidx.core.view.isVisible +import androidx.core.view.marginStart import androidx.core.view.size import androidx.core.view.updateLayoutParams import org.jellyfin.mobile.R @@ -52,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 @@ -130,9 +133,22 @@ class PlayerMenus( // previousButton is always enabled and will rewind if at the start of the queue nextButton.isEnabled = hasNext - val hasChapters = mediaSource.item?.chapters?.isNotEmpty() ?: false + val chapters = mediaSource.item?.chapters + val hasChapters = chapters?.isNotEmpty() ?: false updateLayoutConstraints(hasChapters) + chapterMarkingContainer.removeAllViews() + val runTimeTicks = mediaSource.item?.runTimeTicks + if(hasChapters && runTimeTicks != null){ + val chapterMarkingView = ChapterMarkingView(this.context) + val containerWidth = chapterMarkingContainer.width + chapters?.forEach { ch -> + val percent = ch.startPositionTicks.toDouble() / runTimeTicks + val marginStart = (percent * containerWidth).toInt() + val view = chapterMarkingView.createView(chapterMarkingContainer, marginStart) + } + } + val videoStream = mediaSource.selectedVideoStream val audioStreams = mediaSource.audioStreams 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..4a671cc0c --- /dev/null +++ b/app/src/main/res/drawable/chapter_marking.xml @@ -0,0 +1,8 @@ + + + + + + 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 1f8d62959..5afdba49b 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -106,13 +106,13 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/exo_player_controls_display_padding" android:textColor="?android:textColorPrimary" - app:layout_constraintBottom_toBottomOf="@id/exo_progress" + app:layout_constraintBottom_toBottomOf="@id/seek_bar_container" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@id/exo_progress" + app:layout_constraintTop_toTopOf="@id/seek_bar_container" tools:text="33:01" /> - + app:layout_constraintStart_toEndOf="@id/exo_position"> + + + + + + #101010 #60000000 #cc000000 + + + #333333 From 8a0d099f11ba6692323437a0645dd368994d18ba Mon Sep 17 00:00:00 2001 From: CeruleanRed Date: Tue, 31 Dec 2024 15:16:27 +0100 Subject: [PATCH 5/7] Move chapter markings to separate function --- .../jellyfin/mobile/player/ui/PlayerMenus.kt | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) 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 4d2228473..2fb711a06 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 @@ -19,6 +19,7 @@ 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 @@ -114,40 +115,14 @@ class PlayerMenus( } } - 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 - } - } - fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) { // previousButton is always enabled and will rewind if at the start of the queue nextButton.isEnabled = hasNext val chapters = mediaSource.item?.chapters - val hasChapters = chapters?.isNotEmpty() ?: false - updateLayoutConstraints(hasChapters) - - chapterMarkingContainer.removeAllViews() + updateLayoutConstraints(!chapters.isNullOrEmpty()) val runTimeTicks = mediaSource.item?.runTimeTicks - if(hasChapters && runTimeTicks != null){ - val chapterMarkingView = ChapterMarkingView(this.context) - val containerWidth = chapterMarkingContainer.width - chapters?.forEach { ch -> - val percent = ch.startPositionTicks.toDouble() / runTimeTicks - val marginStart = (percent * containerWidth).toInt() - val view = chapterMarkingView.createView(chapterMarkingContainer, marginStart) - } - } + setChapterMarkings(chapters, runTimeTicks) val videoStream = mediaSource.selectedVideoStream @@ -206,6 +181,35 @@ 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) return + + val chapterMarkingView = ChapterMarkingView(this.context) + val containerWidth = chapterMarkingContainer.width + chapters.forEach { ch -> + val percent = ch.startPositionTicks.toDouble() / runTimeTicks + val marginStart = (percent * containerWidth).toInt() + val view = chapterMarkingView.createView(chapterMarkingContainer, marginStart) + } + } + private fun buildMediaStreamsInfo( mediaStreams: List, @StringRes prefix: Int, From 676efc3210962da45353cf74730c80231f3d1813 Mon Sep 17 00:00:00 2001 From: CeruleanRed Date: Wed, 1 Jan 2025 12:07:45 +0100 Subject: [PATCH 6/7] Update chapter markings when video progress changes --- .../jellyfin/mobile/player/PlayerViewModel.kt | 43 +++++++++++++++++++ .../mobile/player/ui/ChapterMarking.kt | 37 ++++++++-------- .../mobile/player/ui/PlayerFragment.kt | 5 +++ .../jellyfin/mobile/player/ui/PlayerMenus.kt | 14 +++--- .../org/jellyfin/mobile/utils/Constants.kt | 1 + .../org/jellyfin/mobile/utils/TickUtils.kt | 6 +-- app/src/main/res/drawable/chapter_marking.xml | 2 +- 7 files changed, 81 insertions(+), 27 deletions(-) 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 b004d97ba..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 @@ -106,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 @@ -287,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 @@ -328,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 @@ -544,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 @@ -574,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") @@ -594,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/ui/ChapterMarking.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt index 3348aa105..f064b99c7 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt @@ -4,26 +4,27 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import org.jellyfin.mobile.R -class ChapterMarkingView(private val context: Context) { - fun createView(parent: ConstraintLayout, marginStart: Int): View { - val 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 - setMargins(marginStart, 0, 0, 0) - } - - val chapterMarking = ContextCompat.getDrawable(context, R.drawable.chapter_marking) - chapterMarking?.setTint(ContextCompat.getColor(context, R.color.unplayed)) - background = chapterMarking +class ChapterMarking(private val context: Context, parent: ConstraintLayout, marginStart: Int) { + 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 + setMargins(marginStart, 0, 0, 0) } + background = ContextCompat.getDrawable(context, R.drawable.chapter_marking) + } + + init { parent.addView(view) - return 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 cf63290bd..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 @@ -413,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 2fb711a06..1be61fdd1 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,6 +1,6 @@ package org.jellyfin.mobile.player.ui -import ChapterMarkingView +import ChapterMarking import android.view.Menu import android.view.MenuItem import android.view.View @@ -11,7 +11,6 @@ import androidx.annotation.StringRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.get import androidx.core.view.isVisible -import androidx.core.view.marginStart import androidx.core.view.size import androidx.core.view.updateLayoutParams import org.jellyfin.mobile.R @@ -199,15 +198,20 @@ class PlayerMenus( private fun setChapterMarkings(chapters: List?, runTimeTicks: Long?){ chapterMarkingContainer.removeAllViews() - if(chapters.isNullOrEmpty() || runTimeTicks == null) return + if(chapters.isNullOrEmpty() || runTimeTicks == null){ + fragment.setChapterMarkings(mutableListOf()) + return + } - val chapterMarkingView = ChapterMarkingView(this.context) + val chapterMarkings: MutableList = mutableListOf() val containerWidth = chapterMarkingContainer.width chapters.forEach { ch -> val percent = ch.startPositionTicks.toDouble() / runTimeTicks val marginStart = (percent * containerWidth).toInt() - val view = chapterMarkingView.createView(chapterMarkingContainer, marginStart) + val marking = ChapterMarking(context, chapterMarkingContainer, marginStart) + chapterMarkings.add(marking) } + fragment.setChapterMarkings(chapterMarkings) } private fun buildMediaStreamsInfo( 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 index 2608c1215..70df58212 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/TickUtils.kt @@ -2,8 +2,8 @@ package org.jellyfin.mobile.utils class TickUtils { companion object{ - fun ticksToMs(ticks: Long) = ticks / 10_000 - fun msToTicks(ms: Long) = ms * 10_000 - fun secToTicks(sec: Int) = sec * 10_000_000 + 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 index 4a671cc0c..41c78b063 100644 --- a/app/src/main/res/drawable/chapter_marking.xml +++ b/app/src/main/res/drawable/chapter_marking.xml @@ -1,6 +1,6 @@ - + Date: Wed, 1 Jan 2025 12:38:29 +0100 Subject: [PATCH 7/7] Use horizontal bias instead of margin to maintain proper positioning in both landscape and portrait modes --- .../java/org/jellyfin/mobile/player/ui/ChapterMarking.kt | 5 +++-- .../main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt | 6 ++---- 2 files changed, 5 insertions(+), 6 deletions(-) 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 index f064b99c7..c7c06bf94 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/ChapterMarking.kt @@ -4,7 +4,7 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import org.jellyfin.mobile.R -class ChapterMarking(private val context: Context, parent: ConstraintLayout, marginStart: Int) { +class ChapterMarking(private val context: Context, parent: ConstraintLayout, bias: Float) { private val view: View = View(context).apply { id = View.generateViewId() layoutParams = ConstraintLayout.LayoutParams( @@ -14,7 +14,8 @@ class ChapterMarking(private val context: Context, parent: ConstraintLayout, mar topToTop = ConstraintLayout.LayoutParams.PARENT_ID bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID startToStart = ConstraintLayout.LayoutParams.PARENT_ID - setMargins(marginStart, 0, 0, 0) + endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + horizontalBias = bias } background = ContextCompat.getDrawable(context, R.drawable.chapter_marking) 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 1be61fdd1..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 @@ -204,11 +204,9 @@ class PlayerMenus( } val chapterMarkings: MutableList = mutableListOf() - val containerWidth = chapterMarkingContainer.width chapters.forEach { ch -> - val percent = ch.startPositionTicks.toDouble() / runTimeTicks - val marginStart = (percent * containerWidth).toInt() - val marking = ChapterMarking(context, chapterMarkingContainer, marginStart) + val bias = ch.startPositionTicks.toFloat() / runTimeTicks + val marking = ChapterMarking(context, chapterMarkingContainer, bias) chapterMarkings.add(marking) } fragment.setChapterMarkings(chapterMarkings)