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:
parent
06a24568fa
commit
c39bdce845
26 changed files with 1107 additions and 17 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -55,6 +55,7 @@ val dummyEpisode = FindroidEpisode(
|
|||
seasonId = UUID.randomUUID(),
|
||||
communityRating = 9.2f,
|
||||
images = FindroidImages(),
|
||||
chapters = null,
|
||||
)
|
||||
|
||||
val dummyEpisodes = listOf(
|
||||
|
|
|
@ -55,6 +55,7 @@ val dummyMovie = FindroidMovie(
|
|||
endDate = null,
|
||||
trailer = "https://www.youtube.com/watch?v=puKWa8hrvA8",
|
||||
images = FindroidImages(),
|
||||
chapters = null,
|
||||
)
|
||||
|
||||
val dummyMovies = listOf(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
831
data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/4.json
Normal file
831
data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/4.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ interface FindroidItem {
|
|||
val playbackPositionTicks: Long
|
||||
val unplayedItemCount: Int?
|
||||
val images: FindroidImages
|
||||
val chapters: List<FindroidChapter>?
|
||||
}
|
||||
|
||||
suspend fun BaseItemDto.toFindroidItem(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue