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.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
@ -29,6 +30,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs import androidx.navigation.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -144,6 +146,18 @@ class PlayerActivity : BasePlayerActivity() {
it.currentTrickPlay = currentTrickPlay 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 // File Loaded
if (fileLoaded) { if (fileLoaded) {
audioButton.isEnabled = true audioButton.isEnabled = true
@ -239,9 +253,12 @@ class PlayerActivity : BasePlayerActivity() {
pictureInPicture() pictureInPicture()
} }
// Set marker color
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
timeBar.setAdMarkerColor(Color.WHITE)
if (appPreferences.playerTrickPlay) { if (appPreferences.playerTrickPlay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview) val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
previewScrubListener = PreviewScrubListener( previewScrubListener = PreviewScrubListener(
imagePreview, imagePreview,
timeBar, timeBar,

View file

@ -23,6 +23,7 @@ import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.Constants import dev.jdtech.jellyfin.Constants
import dev.jdtech.jellyfin.PlayerActivity import dev.jdtech.jellyfin.PlayerActivity
import dev.jdtech.jellyfin.isControlsLocked import dev.jdtech.jellyfin.isControlsLocked
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.mpv.MPVPlayer import dev.jdtech.jellyfin.mpv.MPVPlayer
import timber.log.Timber import timber.log.Timber
import kotlin.math.abs import kotlin.math.abs
@ -74,7 +75,6 @@ class PlayerGestureHelper(
return true return true
} }
@SuppressLint("SetTextI18n")
override fun onLongPress(e: MotionEvent) { override fun onLongPress(e: MotionEvent) {
// Disables long press gesture if view is locked // Disables long press gesture if view is locked
if (isControlsLocked) return if (isControlsLocked) return
@ -82,13 +82,12 @@ class PlayerGestureHelper(
// Stop long press gesture when more than 1 pointer // Stop long press gesture when more than 1 pointer
if (currentNumberOfPointers > 1) return if (currentNumberOfPointers > 1) return
playerView.player?.let { // This is a temporary solution for chapter skipping.
if (it.isPlaying) { // TODO: Remove this after implementing #636
lastPlaybackSpeed = it.playbackParameters.speed if (appPreferences.playerGesturesChapterSkip) {
it.setPlaybackSpeed(playbackSpeedIncrease) handleChapterSkip(e)
activity.binding.gestureSpeedText.text = playbackSpeedIncrease.toString() + "x" } else {
activity.binding.gestureSpeedLayout.visibility = View.VISIBLE 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() { private fun fastForward() {
val currentPosition = playerView.player?.currentPosition ?: 0 val currentPosition = playerView.player?.currentPosition ?: 0
val fastForwardPosition = currentPosition + appPreferences.playerSeekForwardIncrement val fastForwardPosition = currentPosition + appPreferences.playerSeekForwardIncrement

View file

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

View file

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

View file

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

View file

@ -103,8 +103,10 @@
<string name="player_gestures_vb">Volume and brightness gestures</string> <string name="player_gestures_vb">Volume and brightness gestures</string>
<string name="player_gestures_zoom">Zoom gesture</string> <string name="player_gestures_zoom">Zoom gesture</string>
<string name="player_gestures_seek">Seek 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_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_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_gestures_seek_summary">Swipe horizontally to seek forwards or backwards</string>
<string name="player_brightness_remember">Remember brightness level</string> <string name="player_brightness_remember">Remember brightness level</string>
<string name="player_start_maximized">Start maximized</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_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">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_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="addresses">Addresses</string>
<string name="add_address">Add address</string> <string name="add_address">Add address</string>
<string name="add_server_address">Add server address</string> <string name="add_server_address">Add server address</string>

View file

@ -59,6 +59,12 @@
app:key="pref_player_gestures_seek" app:key="pref_player_gestures_seek"
app:summary="@string/player_gestures_seek_summary" app:summary="@string/player_gestures_seek_summary"
app:title="@string/player_gestures_seek" /> 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 <SwitchPreferenceCompat
app:dependency="pref_player_gestures_vb" app:dependency="pref_player_gestures_vb"
app:key="pref_player_brightness_remember" app:key="pref_player_brightness_remember"
@ -97,6 +103,13 @@
app:title="@string/pref_player_trick_play" app:title="@string/pref_player_trick_play"
app:widgetLayout="@layout/preference_material3_switch" /> 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"> <PreferenceCategory app:title="@string/picture_in_picture">
<SwitchPreferenceCompat <SwitchPreferenceCompat
app:key="pref_player_picture_in_picture_gesture" 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 package dev.jdtech.jellyfin.database
import androidx.room.TypeConverter 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 org.jellyfin.sdk.model.DateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@ -25,4 +28,14 @@ class Converters {
fun fromLongToDatetime(value: Long?): DateTime? { fun fromLongToDatetime(value: Long?): DateTime? {
return value?.let { DateTime.ofEpochSecond(it, 0, ZoneOffset.UTC) } 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( @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], 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 = [ autoMigrations = [
AutoMigration(from = 2, to = 3), AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,6 +31,7 @@ data class FindroidShow(
val endDate: DateTime?, val endDate: DateTime?,
val trailer: String?, val trailer: String?,
override val images: FindroidImages, override val images: FindroidImages,
override val chapters: List<FindroidChapter>? = null,
) : FindroidItem ) : FindroidItem
fun BaseItemDto.toFindroidShow( 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 indexNumber: Int? = null,
val indexNumberEnd: Int? = null, val indexNumberEnd: Int? = null,
val externalSubtitles: List<ExternalSubtitle> = emptyList(), val externalSubtitles: List<ExternalSubtitle> = emptyList(),
val chapters: List<PlayerChapter>? = null,
) : Parcelable ) : Parcelable

View file

@ -19,6 +19,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.player.video.R import dev.jdtech.jellyfin.player.video.R
@ -56,6 +57,7 @@ constructor(
currentItemTitle = "", currentItemTitle = "",
currentIntro = null, currentIntro = null,
currentTrickPlay = null, currentTrickPlay = null,
currentChapters = null,
fileLoaded = false, fileLoaded = false,
), ),
) )
@ -72,12 +74,13 @@ constructor(
val currentItemTitle: String, val currentItemTitle: String,
val currentIntro: Intro?, val currentIntro: Intro?,
val currentTrickPlay: BifData?, val currentTrickPlay: BifData?,
val currentChapters: List<PlayerChapter>?,
val fileLoaded: Boolean, val fileLoaded: Boolean,
) )
private var items: Array<PlayerItem> = arrayOf() private var items: Array<PlayerItem> = arrayOf()
val trackSelector = DefaultTrackSelector(application) private val trackSelector = DefaultTrackSelector(application)
var playWhenReady = true var playWhenReady = true
private var currentMediaItemIndex = savedStateHandle["mediaItemIndex"] ?: 0 private var currentMediaItemIndex = savedStateHandle["mediaItemIndex"] ?: 0
private var playbackPosition: Long = savedStateHandle["position"] ?: 0 private var playbackPosition: Long = savedStateHandle["position"] ?: 0
@ -277,7 +280,7 @@ constructor(
} else { } else {
item.name 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) 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) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying)) eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying))

View file

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

View file

@ -47,6 +47,7 @@ constructor(
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true) val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true) val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
val playerGesturesSeek get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_SEEK, 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() = val playerBrightnessRemember get() =
sharedPreferences.getBoolean(Constants.PREF_PLAYER_BRIGHTNESS_REMEMBER, false) 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 playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!!
val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true) val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
val playerTrickPlay get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_TRICK_PLAY, 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) 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_VB = "pref_player_gestures_vb"
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom" 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_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_BRIGHTNESS_REMEMBER = "pref_player_brightness_remember"
const val PREF_PLAYER_START_MAXIMIZED = "pref_player_start_maximized" const val PREF_PLAYER_START_MAXIMIZED = "pref_player_start_maximized"
const val PREF_PLAYER_BRIGHTNESS = "pref_player_brightness" 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_MPV_AO = "pref_player_mpv_ao"
const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper" const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper"
const val PREF_PLAYER_TRICK_PLAY = "pref_player_trick_play" 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_PLAYER_PIP_GESTURE = "pref_player_picture_in_picture_gesture"
const val PREF_AUDIO_LANGUAGE = "pref_audio_language" const val PREF_AUDIO_LANGUAGE = "pref_audio_language"
const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language" const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language"