feat: chapters (#466)

* Add chapter markers and "skip chapter" on long press

* Fix linting problems

- Missing comma
- Unused import
- Comment block

* Add preferences options

* Drop chapter support for ExoPlayer

* Fix linting

* Remove Trailing spaces

* Remove TODO from marker color

* Move code to function

* Optimize imports

* Fix crash on episode skip

* Disable player control view animation

* Avoid crash when there are no chapters for media item

* Skip to next episode when skipping last chapter

* Load chapters from Jellyfin API instead of MPV Player

* Remove chapter gesture

* Fix build

* Fix linting

* Fix linting

* Support chapters with offline media

* Remove debug print

* Add chapter skipping

* Remove trailing spaces

* fix(chapters): display correct chapter while seeking

* refactor: faster and cleaner `getCurrentChapterIndex`

* refactor: seek to start of current chapter if player position is more than 5 seconds past start of chapter

* refactor: change "Matroska chapters" to just "Chapters"

The chapters feature also works for MP4 files so just make it generic

* Bump database version

* Add auto-migration for database version bump

* Save database schema

* chore: clean up

---------

Co-authored-by: Jarne Demeulemeester <jarnedemeulemeester@gmail.com>
This commit is contained in:
Natanel Shitrit 2024-02-17 17:45:07 +02:00 committed by GitHub
parent 06a24568fa
commit c39bdce845
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1107 additions and 17 deletions

View file

@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Rect
import android.media.AudioManager
import android.os.Build
@ -29,6 +30,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs
import dagger.hilt.android.AndroidEntryPoint
@ -144,6 +146,18 @@ class PlayerActivity : BasePlayerActivity() {
it.currentTrickPlay = currentTrickPlay
}
// Chapters
if (appPreferences.showChapterMarkers && currentChapters != null) {
currentChapters?.let { chapters ->
val playerControlView = findViewById<PlayerControlView>(R.id.exo_controller)
val numOfChapters = chapters.size
playerControlView.setExtraAdGroupMarkers(
LongArray(numOfChapters) { index -> chapters[index].startPosition },
BooleanArray(numOfChapters) { false },
)
}
}
// File Loaded
if (fileLoaded) {
audioButton.isEnabled = true
@ -239,9 +253,12 @@ class PlayerActivity : BasePlayerActivity() {
pictureInPicture()
}
// Set marker color
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
timeBar.setAdMarkerColor(Color.WHITE)
if (appPreferences.playerTrickPlay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
previewScrubListener = PreviewScrubListener(
imagePreview,
timeBar,

View file

@ -23,6 +23,7 @@ import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.Constants
import dev.jdtech.jellyfin.PlayerActivity
import dev.jdtech.jellyfin.isControlsLocked
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.mpv.MPVPlayer
import timber.log.Timber
import kotlin.math.abs
@ -74,7 +75,6 @@ class PlayerGestureHelper(
return true
}
@SuppressLint("SetTextI18n")
override fun onLongPress(e: MotionEvent) {
// Disables long press gesture if view is locked
if (isControlsLocked) return
@ -82,13 +82,12 @@ class PlayerGestureHelper(
// Stop long press gesture when more than 1 pointer
if (currentNumberOfPointers > 1) return
playerView.player?.let {
if (it.isPlaying) {
lastPlaybackSpeed = it.playbackParameters.speed
it.setPlaybackSpeed(playbackSpeedIncrease)
activity.binding.gestureSpeedText.text = playbackSpeedIncrease.toString() + "x"
activity.binding.gestureSpeedLayout.visibility = View.VISIBLE
}
// This is a temporary solution for chapter skipping.
// TODO: Remove this after implementing #636
if (appPreferences.playerGesturesChapterSkip) {
handleChapterSkip(e)
} else {
enableSpeedIncrease()
}
}
@ -123,6 +122,55 @@ class PlayerGestureHelper(
},
)
@SuppressLint("SetTextI18n")
private fun enableSpeedIncrease() {
playerView.player?.let {
if (it.isPlaying) {
lastPlaybackSpeed = it.playbackParameters.speed
it.setPlaybackSpeed(playbackSpeedIncrease)
activity.binding.gestureSpeedText.text = playbackSpeedIncrease.toString() + "x"
activity.binding.gestureSpeedLayout.visibility = View.VISIBLE
}
}
}
private fun handleChapterSkip(e: MotionEvent) {
if (isControlsLocked) {
return
}
val viewWidth = playerView.measuredWidth
val areaWidth = viewWidth / 5 // Divide the view into 5 parts: 2:1:2
// Define the areas and their boundaries
val leftmostAreaStart = 0
val middleAreaStart = areaWidth * 2
val rightmostAreaStart = middleAreaStart + areaWidth
when (e.x.toInt()) {
in leftmostAreaStart until middleAreaStart -> {
activity.viewModel.seekToPreviousChapter()?.let { chapter ->
displayChapter(chapter)
}
}
in rightmostAreaStart until viewWidth -> {
if (activity.viewModel.isLastChapter() == true) {
playerView.player?.seekToNextMediaItem()
return
}
activity.viewModel.seekToNextChapter()?.let { chapter ->
displayChapter(chapter)
}
}
else -> return
}
}
private fun displayChapter(chapter: PlayerChapter) {
activity.binding.progressScrubberLayout.visibility = View.VISIBLE
activity.binding.progressScrubberText.text = chapter.name ?: ""
}
private fun fastForward() {
val currentPosition = playerView.player?.currentPosition ?: 0
val fastForwardPosition = currentPosition + appPreferences.playerSeekForwardIncrement

View file

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/exo_controller">
<androidx.media3.ui.AspectRatioFrameLayout
android:id="@id/exo_content_frame"
@ -82,9 +83,10 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<View
android:id="@id/exo_controller_placeholder"
<androidx.media3.ui.PlayerControlView
android:id="@id/exo_controller"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
app:animation_enabled="false"/>
</merge>

View file

@ -55,6 +55,7 @@ val dummyEpisode = FindroidEpisode(
seasonId = UUID.randomUUID(),
communityRating = 9.2f,
images = FindroidImages(),
chapters = null,
)
val dummyEpisodes = listOf(

View file

@ -55,6 +55,7 @@ val dummyMovie = FindroidMovie(
endDate = null,
trailer = "https://www.youtube.com/watch?v=puKWa8hrvA8",
images = FindroidImages(),
chapters = null,
)
val dummyMovies = listOf(

View file

@ -103,8 +103,10 @@
<string name="player_gestures_vb">Volume and brightness gestures</string>
<string name="player_gestures_zoom">Zoom gesture</string>
<string name="player_gestures_seek">Seek gesture</string>
<string name="player_gestures_chapter_skip">Chapter gesture</string>
<string name="player_gestures_vb_summary">Swipe up and down on the right side of the screen to change the volume and on the left side to change the brightness</string>
<string name="player_gestures_zoom_summary">Pinch to fill the screen with the video</string>
<string name="player_gestures_chapter_skip_summary">Long press on Left / Right side to skip chapters (overrides 2x speed gesture)</string>
<string name="player_gestures_seek_summary">Swipe horizontally to seek forwards or backwards</string>
<string name="player_brightness_remember">Remember brightness level</string>
<string name="player_start_maximized">Start maximized</string>
@ -145,6 +147,8 @@
<string name="pref_player_intro_skipper_summary">Requires ConfusedPolarBear\'s Intro Skipper plugin to be installed on the server</string>
<string name="pref_player_trick_play">Trick Play</string>
<string name="pref_player_trick_play_summary">Requires nicknsy\'s Jellyscrub plugin to be installed on the server</string>
<string name="pref_player_chapter_markers">Chapter markers</string>
<string name="pref_player_chapter_markers_summary">Display chapter markers on the timebar</string>
<string name="addresses">Addresses</string>
<string name="add_address">Add address</string>
<string name="add_server_address">Add server address</string>

View file

@ -59,6 +59,12 @@
app:key="pref_player_gestures_seek"
app:summary="@string/player_gestures_seek_summary"
app:title="@string/player_gestures_seek" />
<SwitchPreferenceCompat
app:defaultValue="true"
app:dependency="pref_player_gestures"
app:key="pref_player_gestures_chapter_skip"
app:summary="@string/player_gestures_chapter_skip_summary"
app:title="@string/player_gestures_chapter_skip" />
<SwitchPreferenceCompat
app:dependency="pref_player_gestures_vb"
app:key="pref_player_brightness_remember"
@ -97,6 +103,13 @@
app:title="@string/pref_player_trick_play"
app:widgetLayout="@layout/preference_material3_switch" />
<SwitchPreferenceCompat
app:defaultValue="true"
app:key="pref_player_chapter_markers"
app:summary="@string/pref_player_chapter_markers_summary"
app:title="@string/pref_player_chapter_markers"
app:widgetLayout="@layout/preference_material3_switch" />
<PreferenceCategory app:title="@string/picture_in_picture">
<SwitchPreferenceCompat
app:key="pref_player_picture_in_picture_gesture"

View file

@ -0,0 +1,831 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "45100d543bb99759e0a8886a70d5caa2",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currentServerAddressId` TEXT, `currentUserId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currentServerAddressId",
"columnName": "currentServerAddressId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "currentUserId",
"columnName": "currentUserId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "serverAddresses",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_serverAddresses_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_serverAddresses_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accessToken` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "movies",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT, `name` TEXT NOT NULL, `originalTitle` TEXT, `overview` TEXT NOT NULL, `runtimeTicks` INTEGER NOT NULL, `premiereDate` INTEGER, `communityRating` REAL, `officialRating` TEXT, `status` TEXT NOT NULL, `productionYear` INTEGER, `endDate` INTEGER, `chapters` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "originalTitle",
"columnName": "originalTitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "overview",
"columnName": "overview",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "runtimeTicks",
"columnName": "runtimeTicks",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "premiereDate",
"columnName": "premiereDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "communityRating",
"columnName": "communityRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "officialRating",
"columnName": "officialRating",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "productionYear",
"columnName": "productionYear",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "chapters",
"columnName": "chapters",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "shows",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT, `name` TEXT NOT NULL, `originalTitle` TEXT, `overview` TEXT NOT NULL, `runtimeTicks` INTEGER NOT NULL, `communityRating` REAL, `officialRating` TEXT, `status` TEXT NOT NULL, `productionYear` INTEGER, `endDate` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "originalTitle",
"columnName": "originalTitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "overview",
"columnName": "overview",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "runtimeTicks",
"columnName": "runtimeTicks",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "communityRating",
"columnName": "communityRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "officialRating",
"columnName": "officialRating",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "productionYear",
"columnName": "productionYear",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "seasons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `name` TEXT NOT NULL, `seriesName` TEXT NOT NULL, `overview` TEXT NOT NULL, `indexNumber` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`seriesId`) REFERENCES `shows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "seriesName",
"columnName": "seriesName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "overview",
"columnName": "overview",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "indexNumber",
"columnName": "indexNumber",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seasons_seriesId",
"unique": false,
"columnNames": [
"seriesId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_seasons_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
}
],
"foreignKeys": [
{
"table": "shows",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"seriesId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "episodes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT, `seasonId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `name` TEXT NOT NULL, `seriesName` TEXT NOT NULL, `overview` TEXT NOT NULL, `indexNumber` INTEGER NOT NULL, `indexNumberEnd` INTEGER, `parentIndexNumber` INTEGER NOT NULL, `runtimeTicks` INTEGER NOT NULL, `premiereDate` INTEGER, `communityRating` REAL, `chapters` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`seasonId`) REFERENCES `seasons`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`seriesId`) REFERENCES `shows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "seasonId",
"columnName": "seasonId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "seriesName",
"columnName": "seriesName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "overview",
"columnName": "overview",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "indexNumber",
"columnName": "indexNumber",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "indexNumberEnd",
"columnName": "indexNumberEnd",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "parentIndexNumber",
"columnName": "parentIndexNumber",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "runtimeTicks",
"columnName": "runtimeTicks",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "premiereDate",
"columnName": "premiereDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "communityRating",
"columnName": "communityRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "chapters",
"columnName": "chapters",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_episodes_seasonId",
"unique": false,
"columnNames": [
"seasonId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_seasonId` ON `${TABLE_NAME}` (`seasonId`)"
},
{
"name": "index_episodes_seriesId",
"unique": false,
"columnNames": [
"seriesId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
}
],
"foreignKeys": [
{
"table": "seasons",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"seasonId"
],
"referencedColumns": [
"id"
]
},
{
"table": "shows",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"seriesId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itemId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `path` TEXT NOT NULL, `downloadId` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "downloadId",
"columnName": "downloadId",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mediastreams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `displayTitle` TEXT, `language` TEXT NOT NULL, `type` TEXT NOT NULL, `codec` TEXT NOT NULL, `isExternal` INTEGER NOT NULL, `path` TEXT NOT NULL, `channelLayout` TEXT, `videoRangeType` TEXT, `height` INTEGER, `width` INTEGER, `videoDoViTitle` TEXT, `downloadId` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayTitle",
"columnName": "displayTitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "codec",
"columnName": "codec",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isExternal",
"columnName": "isExternal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "channelLayout",
"columnName": "channelLayout",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "videoRangeType",
"columnName": "videoRangeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "videoDoViTitle",
"columnName": "videoDoViTitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downloadId",
"columnName": "downloadId",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "trickPlayManifests",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `version` TEXT NOT NULL, `resolution` INTEGER NOT NULL, PRIMARY KEY(`itemId`))",
"fields": [
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "resolution",
"columnName": "resolution",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"itemId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "intros",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `start` REAL NOT NULL, `end` REAL NOT NULL, `showAt` REAL NOT NULL, `hideAt` REAL NOT NULL, PRIMARY KEY(`itemId`))",
"fields": [
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "showAt",
"columnName": "showAt",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hideAt",
"columnName": "hideAt",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"itemId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "userdata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `itemId` TEXT NOT NULL, `played` INTEGER NOT NULL, `favorite` INTEGER NOT NULL, `playbackPositionTicks` INTEGER NOT NULL, `toBeSynced` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favorite",
"columnName": "favorite",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playbackPositionTicks",
"columnName": "playbackPositionTicks",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toBeSynced",
"columnName": "toBeSynced",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '45100d543bb99759e0a8886a70d5caa2')"
]
}
}

View file

@ -1,6 +1,9 @@
package dev.jdtech.jellyfin.database
import androidx.room.TypeConverter
import dev.jdtech.jellyfin.models.FindroidChapter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.DateTime
import java.time.ZoneOffset
import java.util.UUID
@ -25,4 +28,14 @@ class Converters {
fun fromLongToDatetime(value: Long?): DateTime? {
return value?.let { DateTime.ofEpochSecond(it, 0, ZoneOffset.UTC) }
}
@TypeConverter
fun fromFindroidChaptersToString(value: List<FindroidChapter>?): String? {
return value?.let { Json.encodeToString(value) }
}
@TypeConverter
fun fromStringToFindroidChapters(value: String?): List<FindroidChapter>? {
return value?.let { Json.decodeFromString(value) }
}
}

View file

@ -19,9 +19,10 @@ import dev.jdtech.jellyfin.models.User
@Database(
entities = [Server::class, ServerAddress::class, User::class, FindroidMovieDto::class, FindroidShowDto::class, FindroidSeasonDto::class, FindroidEpisodeDto::class, FindroidSourceDto::class, FindroidMediaStreamDto::class, TrickPlayManifestDto::class, IntroDto::class, FindroidUserDataDto::class],
version = 3,
version = 4,
autoMigrations = [
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4),
],
)
@TypeConverters(Converters::class)

View file

@ -18,6 +18,7 @@ data class FindroidBoxSet(
override val playbackPositionTicks: Long = 0L,
override val unplayedItemCount: Int? = null,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>? = null,
) : FindroidItem
fun BaseItemDto.toFindroidBoxSet(

View file

@ -0,0 +1,25 @@
package dev.jdtech.jellyfin.models
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemDto
@Serializable
data class FindroidChapter(
/**
* The start position.
*/
val startPosition: Long,
/**
* The name.
*/
val name: String? = null,
)
fun BaseItemDto.toFindroidChapters(): List<FindroidChapter>? {
return chapters?.map { chapter ->
FindroidChapter(
startPosition = chapter.startPositionTicks / 10000,
name = chapter.name,
)
}
}

View file

@ -19,6 +19,7 @@ data class FindroidCollection(
override val unplayedItemCount: Int? = null,
val type: CollectionType,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>? = null,
) : FindroidItem
fun BaseItemDto.toFindroidCollection(

View file

@ -31,6 +31,7 @@ data class FindroidEpisode(
override val unplayedItemCount: Int? = null,
val missing: Boolean = false,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>?,
) : FindroidItem, FindroidSources
suspend fun BaseItemDto.toFindroidEpisode(
@ -65,6 +66,7 @@ suspend fun BaseItemDto.toFindroidEpisode(
communityRating = communityRating,
missing = locationType == LocationType.VIRTUAL,
images = toFindroidImages(jellyfinRepository),
chapters = toFindroidChapters(),
)
} catch (_: NullPointerException) {
null
@ -94,5 +96,6 @@ fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UU
seasonId = seasonId,
communityRating = communityRating,
images = FindroidImages(),
chapters = chapters,
)
}

View file

@ -43,6 +43,7 @@ data class FindroidEpisodeDto(
val runtimeTicks: Long,
val premiereDate: LocalDateTime?,
val communityRating: Float?,
val chapters: List<FindroidChapter>?,
)
fun FindroidEpisode.toFindroidEpisodeDto(serverId: String? = null): FindroidEpisodeDto {
@ -60,5 +61,6 @@ fun FindroidEpisode.toFindroidEpisodeDto(serverId: String? = null): FindroidEpis
runtimeTicks = runtimeTicks,
premiereDate = premiereDate,
communityRating = communityRating,
chapters = chapters,
)
}

View file

@ -20,6 +20,7 @@ interface FindroidItem {
val playbackPositionTicks: Long
val unplayedItemCount: Int?
val images: FindroidImages
val chapters: List<FindroidChapter>?
}
suspend fun BaseItemDto.toFindroidItem(

View file

@ -31,6 +31,7 @@ data class FindroidMovie(
val trailer: String?,
override val unplayedItemCount: Int? = null,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>?,
) : FindroidItem, FindroidSources
suspend fun BaseItemDto.toFindroidMovie(
@ -64,6 +65,7 @@ suspend fun BaseItemDto.toFindroidMovie(
endDate = endDate,
trailer = remoteTrailers?.getOrNull(0)?.url,
images = toFindroidImages(jellyfinRepository),
chapters = toFindroidChapters(),
)
}
@ -91,5 +93,6 @@ fun FindroidMovieDto.toFindroidMovie(database: ServerDatabaseDao, userId: UUID):
sources = database.getSources(id).map { it.toFindroidSource(database) },
trailer = null,
images = FindroidImages(),
chapters = chapters,
)
}

View file

@ -20,6 +20,7 @@ data class FindroidMovieDto(
val status: String,
val productionYear: Int?,
val endDate: LocalDateTime?,
val chapters: List<FindroidChapter>?,
)
fun FindroidMovie.toFindroidMovieDto(serverId: String? = null): FindroidMovieDto {
@ -36,5 +37,6 @@ fun FindroidMovie.toFindroidMovieDto(serverId: String? = null): FindroidMovieDto
status = status,
productionYear = productionYear,
endDate = endDate,
chapters = chapters,
)
}

View file

@ -24,6 +24,7 @@ data class FindroidSeason(
override val playbackPositionTicks: Long = 0L,
override val unplayedItemCount: Int?,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>? = null,
) : FindroidItem
fun BaseItemDto.toFindroidSeason(

View file

@ -31,6 +31,7 @@ data class FindroidShow(
val endDate: DateTime?,
val trailer: String?,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>? = null,
) : FindroidItem
fun BaseItemDto.toFindroidShow(

View file

@ -0,0 +1,16 @@
package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class PlayerChapter(
/**
* The start position.
*/
val startPosition: Long,
/**
* The name.
*/
val name: String? = null,
) : Parcelable

View file

@ -15,4 +15,5 @@ data class PlayerItem(
val indexNumber: Int? = null,
val indexNumberEnd: Int? = null,
val externalSubtitles: List<ExternalSubtitle> = emptyList(),
val chapters: List<PlayerChapter>? = null,
) : Parcelable

View file

@ -19,6 +19,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.player.video.R
@ -56,6 +57,7 @@ constructor(
currentItemTitle = "",
currentIntro = null,
currentTrickPlay = null,
currentChapters = null,
fileLoaded = false,
),
)
@ -72,12 +74,13 @@ constructor(
val currentItemTitle: String,
val currentIntro: Intro?,
val currentTrickPlay: BifData?,
val currentChapters: List<PlayerChapter>?,
val fileLoaded: Boolean,
)
private var items: Array<PlayerItem> = arrayOf()
val trackSelector = DefaultTrackSelector(application)
private val trackSelector = DefaultTrackSelector(application)
var playWhenReady = true
private var currentMediaItemIndex = savedStateHandle["mediaItemIndex"] ?: 0
private var playbackPosition: Long = savedStateHandle["position"] ?: 0
@ -277,7 +280,7 @@ constructor(
} else {
item.name
}
_uiState.update { it.copy(currentItemTitle = itemTitle, fileLoaded = false) }
_uiState.update { it.copy(currentItemTitle = itemTitle, currentChapters = item.chapters, fileLoaded = false) }
jellyfinRepository.postPlaybackStart(item.itemId)
@ -367,6 +370,89 @@ constructor(
}
}
/**
* Get chapters of current item
* @return list of [PlayerChapter]
*/
private fun getChapters(): List<PlayerChapter>? {
return uiState.value.currentChapters
}
/**
* Get the index of the current chapter
* @return the index of the current chapter
*/
private fun getCurrentChapterIndex(): Int? {
val chapters = getChapters() ?: return null
for (i in chapters.indices.reversed()) {
if (chapters[i].startPosition < player.currentPosition) {
return i
}
}
return null
}
/**
* Get the index of the next chapter
* @return the index of the next chapter
*/
private fun getNextChapterIndex(): Int? {
val chapters = getChapters() ?: return null
val currentChapterIndex = getCurrentChapterIndex() ?: return null
return minOf(chapters.size - 1, currentChapterIndex + 1)
}
/**
* Get the index of the previous chapter.
* Only use this for seeking as it will return the current chapter when player position is more than 5 seconds past the start of the chapter
* @return the index of the previous chapter
*/
private fun getPreviousChapterIndex(): Int? {
val chapters = getChapters() ?: return null
val currentChapterIndex = getCurrentChapterIndex() ?: return null
// Return current chapter when more than 5 seconds past chapter start
if (player.currentPosition > chapters[currentChapterIndex].startPosition + 5000L) {
return currentChapterIndex
}
return maxOf(0, currentChapterIndex - 1)
}
fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 }
fun isLastChapter(): Boolean? = getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 }
/**
* Seek to chapter
* @param [chapterIndex] the index of the chapter to seek to
* @return the [PlayerChapter] which has been sought to
*/
private fun seekToChapter(chapterIndex: Int): PlayerChapter? {
return getChapters()?.getOrNull(chapterIndex)?.also { chapter ->
player.seekTo(chapter.startPosition)
}
}
/**
* Seek to the next chapter
* @return the [PlayerChapter] which has been sought to
*/
fun seekToNextChapter(): PlayerChapter? {
return getNextChapterIndex()?.let { seekToChapter(it) }
}
/**
* Seek to the previous chapter
* Will seek to start of current chapter if player position is more than 5 seconds past start of chapter
* @return the [PlayerChapter] which has been sought to
*/
fun seekToPreviousChapter(): PlayerChapter? {
return getPreviousChapterIndex()?.let { seekToChapter(it) }
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying))

View file

@ -6,12 +6,14 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.MimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.ExternalSubtitle
import dev.jdtech.jellyfin.models.FindroidChapter
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
import dev.jdtech.jellyfin.models.FindroidSeason
import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.models.FindroidSourceType
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.channels.Channel
@ -113,7 +115,7 @@ class PlayerViewModel @Inject internal constructor(
.getEpisodes(
seriesId = item.seriesId,
seasonId = item.seasonId,
fields = listOf(ItemFields.MEDIA_SOURCES),
fields = listOf(ItemFields.MEDIA_SOURCES, ItemFields.CHAPTERS),
startItemId = item.id,
limit = if (userConfig?.enableNextEpisodeAutoPlay != false) null else 1,
)
@ -166,8 +168,18 @@ class PlayerViewModel @Inject internal constructor(
indexNumber = if (this is FindroidEpisode) indexNumber else null,
indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
externalSubtitles = externalSubtitles,
chapters = chapters.toPlayerChapters(),
)
}
private fun List<FindroidChapter>?.toPlayerChapters(): List<PlayerChapter>? {
return this?.map { chapter ->
PlayerChapter(
startPosition = chapter.startPosition,
name = chapter.name,
)
}
}
}
sealed interface PlayerItemsEvent {

View file

@ -47,6 +47,7 @@ constructor(
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
val playerGesturesSeek get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_SEEK, true)
val playerGesturesChapterSkip get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_CHAPTER_SKIP, true)
val playerBrightnessRemember get() =
sharedPreferences.getBoolean(Constants.PREF_PLAYER_BRIGHTNESS_REMEMBER, false)
@ -78,6 +79,7 @@ constructor(
val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!!
val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
val playerTrickPlay get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_TRICK_PLAY, true)
val showChapterMarkers get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_CHAPTER_MARKERS, true)
val playerPipGesture get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_PIP_GESTURE, false)

View file

@ -16,6 +16,7 @@ object Constants {
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"
const val PREF_PLAYER_GESTURES_SEEK = "pref_player_gestures_seek"
const val PREF_PLAYER_GESTURES_CHAPTER_SKIP = "pref_player_gestures_chapter_skip"
const val PREF_PLAYER_BRIGHTNESS_REMEMBER = "pref_player_brightness_remember"
const val PREF_PLAYER_START_MAXIMIZED = "pref_player_start_maximized"
const val PREF_PLAYER_BRIGHTNESS = "pref_player_brightness"
@ -27,6 +28,7 @@ object Constants {
const val PREF_PLAYER_MPV_AO = "pref_player_mpv_ao"
const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper"
const val PREF_PLAYER_TRICK_PLAY = "pref_player_trick_play"
const val PREF_PLAYER_CHAPTER_MARKERS = "pref_player_chapter_markers"
const val PREF_PLAYER_PIP_GESTURE = "pref_player_picture_in_picture_gesture"
const val PREF_AUDIO_LANGUAGE = "pref_audio_language"
const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language"