Merge branch 'main' into Skip-credit

This commit is contained in:
Cd16d 2024-02-25 16:32:47 +01:00 committed by GitHub
commit 3c6e03db89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1573 additions and 134 deletions

View file

@ -12,14 +12,14 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v2
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: 17 java-version: 17
distribution: temurin distribution: temurin
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew lintDebug ktlintCheck run: ./gradlew lintDebug ktlintCheck
assemble: assemble:
@ -29,14 +29,14 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v2
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: 17 java-version: 17
distribution: temurin distribution: temurin
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
# Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged. # Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged.

View file

@ -40,6 +40,9 @@ I am developing this application in my spare time.
- Subtitle codecs: SRT, VTT, SSA/ASS, DVDSUB - Subtitle codecs: SRT, VTT, SSA/ASS, DVDSUB
- Optionally force software decoding when hardware decoding has issues. - Optionally force software decoding when hardware decoding has issues.
- Picture-in-picture mode - Picture-in-picture mode
- Media chapters
- Timeline markers
- Chapter navigation gestures
## Planned features ## Planned features
- Android TV - Android TV

View file

@ -7,11 +7,13 @@ 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
import android.os.Bundle import android.os.Bundle
import android.os.Process import android.os.Process
import android.provider.Settings
import android.util.Rational import android.util.Rational
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
@ -27,15 +29,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope 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.AspectRatioFrameLayout
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
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.utils.PlayerGestureHelper import dev.jdtech.jellyfin.utils.PlayerGestureHelper
import dev.jdtech.jellyfin.utils.PreviewScrubListener import dev.jdtech.jellyfin.utils.PreviewScrubListener
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
@ -57,6 +58,7 @@ class PlayerActivity : BasePlayerActivity() {
private var playerGestureHelper: PlayerGestureHelper? = null private var playerGestureHelper: PlayerGestureHelper? = null
override val viewModel: PlayerActivityViewModel by viewModels() override val viewModel: PlayerActivityViewModel by viewModels()
private var previewScrubListener: PreviewScrubListener? = null private var previewScrubListener: PreviewScrubListener? = null
private var wasZoom: Boolean = false
private val isPipSupported by lazy { private val isPipSupported by lazy {
// Check if device has PiP feature // Check if device has PiP feature
@ -113,10 +115,6 @@ class PlayerActivity : BasePlayerActivity() {
finish() finish()
} }
binding.playerView.findViewById<View>(R.id.back_button_alt).setOnClickListener {
finish()
}
val videoNameTextView = binding.playerView.findViewById<TextView>(R.id.video_name) val videoNameTextView = binding.playerView.findViewById<TextView>(R.id.video_name)
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track) val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
@ -166,6 +164,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
@ -187,6 +197,11 @@ class PlayerActivity : BasePlayerActivity() {
viewModel.eventsChannelFlow.collect { event -> viewModel.eventsChannelFlow.collect { event ->
when (event) { when (event) {
is PlayerEvents.NavigateBack -> finish() is PlayerEvents.NavigateBack -> finish()
is PlayerEvents.IsPlayingChanged -> {
if (appPreferences.playerPipGesture) {
setPictureInPictureParams(pipParams(event.isPlaying))
}
}
} }
} }
} }
@ -256,9 +271,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,
@ -281,12 +299,16 @@ class PlayerActivity : BasePlayerActivity() {
} }
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
if (appPreferences.playerPipGesture && viewModel.player.isPlaying && !isControlsLocked) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
appPreferences.playerPipGesture &&
viewModel.player.isPlaying &&
!isControlsLocked
) {
pictureInPicture() pictureInPicture()
} }
} }
private fun pipParams(): PictureInPictureParams { private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams {
val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height) val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height)
val aspectRatio = binding.playerView.player?.videoSize?.let { val aspectRatio = binding.playerView.player?.videoSize?.let {
@ -314,24 +336,21 @@ class PlayerActivity : BasePlayerActivity() {
) )
} }
return PictureInPictureParams.Builder() val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio) .setAspectRatio(aspectRatio)
.setSourceRectHint(sourceRectHint) .setSourceRectHint(sourceRectHint)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(enableAutoEnter)
}
return builder.build()
} }
private fun pictureInPicture() { private fun pictureInPicture() {
if (!isPipSupported) { if (!isPipSupported) {
return return
} }
binding.playerView.useController = false
binding.playerView.findViewById<Button>(R.id.btn_skip_intro).isVisible = false
if (binding.playerView.player is MPVPlayer) {
(binding.playerView.player as MPVPlayer).updateZoomMode(false)
} else {
binding.playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
try { try {
enterPictureInPictureMode(pipParams()) enterPictureInPictureMode(pipParams())
@ -343,8 +362,35 @@ class PlayerActivity : BasePlayerActivity() {
newConfig: Configuration, newConfig: Configuration,
) { ) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) { when (isInPictureInPictureMode) {
binding.playerView.useController = true true -> {
binding.playerView.useController = false
binding.playerView.findViewById<Button>(R.id.btn_skip_intro).isVisible = false
wasZoom = playerGestureHelper?.isZoomEnabled ?: false
playerGestureHelper?.updateZoomMode(false)
// Brightness mode Auto
window.attributes = window.attributes.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
false -> {
binding.playerView.useController = true
playerGestureHelper?.updateZoomMode(wasZoom)
// Override auto brightness
window.attributes = window.attributes.apply {
screenBrightness = if (appPreferences.playerBrightnessRemember) {
appPreferences.playerBrightness
} else {
Settings.System.getInt(
contentResolver,
Settings.System.SCREEN_BRIGHTNESS,
).toFloat() / 255
}
}
}
} }
} }
} }

View file

@ -25,6 +25,7 @@ import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.SortDialogFragment import dev.jdtech.jellyfin.dialogs.SortDialogFragment
import dev.jdtech.jellyfin.models.FindroidBoxSet import dev.jdtech.jellyfin.models.FindroidBoxSet
import dev.jdtech.jellyfin.models.FindroidFolder
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.FindroidShow import dev.jdtech.jellyfin.models.FindroidShow
@ -222,6 +223,15 @@ class LibraryFragment : Fragment() {
), ),
) )
} }
is FindroidFolder -> {
findNavController().navigate(
LibraryFragmentDirections.actionLibraryFragmentSelf(
item.id,
item.name,
args.libraryType,
),
)
}
} }
} }
} }

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
@ -37,7 +38,7 @@ class PlayerGestureHelper(
* Tracks whether video content should fill the screen, cutting off unwanted content on the sides. * Tracks whether video content should fill the screen, cutting off unwanted content on the sides.
* Useful on wide-screen phones to remove black bars from some movies. * Useful on wide-screen phones to remove black bars from some movies.
*/ */
private var isZoomEnabled = false var isZoomEnabled = false
/** /**
* Tracks a value during a swipe gesture (between multiple onScroll calls). * Tracks a value during a swipe gesture (between multiple onScroll calls).
@ -55,9 +56,14 @@ class PlayerGestureHelper(
private var lastScaleEvent: Long = 0 private var lastScaleEvent: Long = 0
private var playbackSpeedIncrease: Float = 2f
private var lastPlaybackSpeed: Float = 0f
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val screenHeight = Resources.getSystem().displayMetrics.heightPixels private val screenHeight = Resources.getSystem().displayMetrics.heightPixels
private var currentNumberOfPointers: Int = 0
private val tapGestureDetector = GestureDetector( private val tapGestureDetector = GestureDetector(
playerView.context, playerView.context,
object : GestureDetector.SimpleOnGestureListener() { object : GestureDetector.SimpleOnGestureListener() {
@ -69,6 +75,22 @@ class PlayerGestureHelper(
return true return true
} }
override fun onLongPress(e: MotionEvent) {
// Disables long press gesture if view is locked
if (isControlsLocked) return
// Stop long press gesture when more than 1 pointer
if (currentNumberOfPointers > 1) return
// This is a temporary solution for chapter skipping.
// TODO: Remove this after implementing #636
if (appPreferences.playerGesturesChapterSkip) {
handleChapterSkip(e)
} else {
enableSpeedIncrease()
}
}
override fun onDoubleTap(e: MotionEvent): Boolean { override fun onDoubleTap(e: MotionEvent): Boolean {
// Disables double tap gestures if view is locked // Disables double tap gestures if view is locked
if (isControlsLocked) return false if (isControlsLocked) return false
@ -100,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
@ -315,8 +386,8 @@ class PlayerGestureHelper(
lastScaleEvent = SystemClock.elapsedRealtime() lastScaleEvent = SystemClock.elapsedRealtime()
val scaleFactor = detector.scaleFactor val scaleFactor = detector.scaleFactor
if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) { if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) {
isZoomEnabled = scaleFactor > 1 val enableZoom = scaleFactor > 1
updateZoomMode(isZoomEnabled) updateZoomMode(enableZoom)
} }
return true return true
} }
@ -325,16 +396,17 @@ class PlayerGestureHelper(
}, },
).apply { isQuickScaleEnabled = false } ).apply { isQuickScaleEnabled = false }
private fun updateZoomMode(enabled: Boolean) { fun updateZoomMode(enabled: Boolean) {
if (playerView.player is MPVPlayer) { if (playerView.player is MPVPlayer) {
(playerView.player as MPVPlayer).updateZoomMode(enabled) (playerView.player as MPVPlayer).updateZoomMode(enabled)
} else { } else {
playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
} }
isZoomEnabled = enabled
} }
private fun releaseAction(event: MotionEvent) { private fun releaseAction(event: MotionEvent) {
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
activity.binding.gestureVolumeLayout.apply { activity.binding.gestureVolumeLayout.apply {
if (visibility == View.VISIBLE) { if (visibility == View.VISIBLE) {
removeCallbacks(hideGestureVolumeIndicatorOverlayAction) removeCallbacks(hideGestureVolumeIndicatorOverlayAction)
@ -361,6 +433,12 @@ class PlayerGestureHelper(
swipeGestureValueTrackerProgress = -1L swipeGestureValueTrackerProgress = -1L
} }
} }
currentNumberOfPointers = 0
}
if (lastPlaybackSpeed > 0 && (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL)) {
playerView.player?.setPlaybackSpeed(lastPlaybackSpeed)
lastPlaybackSpeed = 0f
activity.binding.gestureSpeedLayout.visibility = View.GONE
} }
} }
@ -398,9 +476,12 @@ class PlayerGestureHelper(
activity.window.attributes.screenBrightness = appPreferences.playerBrightness activity.window.attributes.screenBrightness = appPreferences.playerBrightness
} }
updateZoomMode(appPreferences.playerStartMaximized)
@Suppress("ClickableViewAccessibility") @Suppress("ClickableViewAccessibility")
playerView.setOnTouchListener { _, event -> playerView.setOnTouchListener { _, event ->
if (playerView.useController) { if (playerView.useController) {
currentNumberOfPointers = event.pointerCount
when (event.pointerCount) { when (event.pointerCount) {
1 -> { 1 -> {
tapGestureDetector.onTouchEvent(event) tapGestureDetector.onTouchEvent(event)

View file

@ -113,6 +113,37 @@
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/gesture_speed_layout"
android:layout_width="wrap_content"
android:layout_height="64dp"
android:layout_gravity="center_horizontal|top"
android:layout_margin="16dp"
android:background="@drawable/overlay_background"
android:clickable="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/gesture_speed_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:gravity="center"
android:textSize="20sp"
android:textColor="@android:color/white" />
<ImageView
android:id="@+id/gesture_speed_image"
android:layout_width="36dp"
android:layout_height="24dp"
android:layout_marginHorizontal="16dp"
android:src="@drawable/ic_speed_forward"
tools:ignore="ContentDescription" />
</LinearLayout>
<ImageView <ImageView
android:id="@+id/image_ffwd_animation_ripple" android:id="@+id/image_ffwd_animation_ripple"
android:layout_width="50dp" android:layout_width="50dp"

View file

@ -8,55 +8,16 @@
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"> tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout <ImageButton
android:layout_width="match_parent" android:id="@+id/btn_unlock"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/rounded_corner"
android:contentDescription="@string/select_playback_speed"
android:padding="16dp"
android:layout_margin="8dp" android:layout_margin="8dp"
android:orientation="horizontal"> android:src="@drawable/ic_unlock"
app:tint="@android:color/white" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintEnd_toStartOf="@id/extra_buttons_alt"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/back_button_alt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_corner"
android:contentDescription="@string/player_controls_exit"
android:padding="16dp"
android:src="@drawable/ic_arrow_left" />
<Space
android:layout_width="16dp"
android:layout_height="0dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/extra_buttons_alt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/btn_unlock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/rounded_corner"
android:contentDescription="@string/select_playback_speed"
android:padding="16dp"
android:src="@drawable/ic_unlock"
app:tint="@android:color/white" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout> </FrameLayout>

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"
@ -81,9 +82,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

@ -123,6 +123,9 @@
<argument <argument
android:name="libraryType" android:name="libraryType"
app:argType="dev.jdtech.jellyfin.models.CollectionType" /> app:argType="dev.jdtech.jellyfin.models.CollectionType" />
<action
android:id="@+id/action_libraryFragment_self"
app:destination="@id/libraryFragment" />
</fragment> </fragment>
<fragment <fragment
android:id="@+id/showFragment" android:id="@+id/showFragment"

View file

@ -82,12 +82,15 @@ ktlint {
} }
dependencies { dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(projects.core) implementation(projects.core)
implementation(projects.data) implementation(projects.data)
implementation(projects.preferences) implementation(projects.preferences)
implementation(projects.player.core) implementation(projects.player.core)
implementation(projects.player.video) implementation(projects.player.video)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(composeBom)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.core) implementation(libs.androidx.core)

View file

@ -23,9 +23,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.LibraryScreenDestination
import dev.jdtech.jellyfin.destinations.MovieScreenDestination import dev.jdtech.jellyfin.destinations.MovieScreenDestination
import dev.jdtech.jellyfin.destinations.ShowScreenDestination import dev.jdtech.jellyfin.destinations.ShowScreenDestination
import dev.jdtech.jellyfin.models.CollectionType import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.FindroidFolder
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.FindroidShow import dev.jdtech.jellyfin.models.FindroidShow
@ -65,6 +67,9 @@ fun LibraryScreen(
is FindroidShow -> { is FindroidShow -> {
navigator.navigate(ShowScreenDestination(item.id)) navigator.navigate(ShowScreenDestination(item.id))
} }
is FindroidFolder -> {
navigator.navigate(LibraryScreenDestination(item.id, item.name, libraryType))
}
} }
}, },
) )

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

@ -1,8 +1,8 @@
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
object Versions { object Versions {
const val appCode = 22 const val appCode = 23
const val appName = "0.13.1" const val appName = "2024.02"
const val compileSdk = 34 const val compileSdk = 34
const val buildTools = "34.0.0" const val buildTools = "34.0.0"
@ -11,6 +11,6 @@ object Versions {
val java = JavaVersion.VERSION_17 val java = JavaVersion.VERSION_17
const val composeCompiler = "1.5.8" const val composeCompiler = "1.5.10"
const val ktlint = "0.50.0" const val ktlint = "0.50.0"
} }

View file

@ -53,11 +53,14 @@ ktlint {
} }
dependencies { dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(projects.data) implementation(projects.data)
implementation(projects.preferences) implementation(projects.preferences)
implementation(projects.player.core) implementation(projects.player.core)
implementation(libs.androidx.activity) implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(composeBom)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)

View file

@ -93,7 +93,7 @@ class HomeViewModel @Inject internal constructor(
private suspend fun loadViews() = repository private suspend fun loadViews() = repository
.getUserViews() .getUserViews()
.filter { view -> CollectionType.supported.any { it.type == view.collectionType } } .filter { view -> CollectionType.fromString(view.collectionType) in CollectionType.supported }
.map { view -> view to repository.getLatestMedia(view.id) } .map { view -> view to repository.getLatestMedia(view.id) }
.filter { (_, latest) -> latest.isNotEmpty() } .filter { (_, latest) -> latest.isNotEmpty() }
.map { (view, latest) -> view.toView().apply { items = latest } } .map { (view, latest) -> view.toView().apply { items = latest } }

View file

@ -48,15 +48,19 @@ constructor(
CollectionType.Movies -> listOf(BaseItemKind.MOVIE) CollectionType.Movies -> listOf(BaseItemKind.MOVIE)
CollectionType.TvShows -> listOf(BaseItemKind.SERIES) CollectionType.TvShows -> listOf(BaseItemKind.SERIES)
CollectionType.BoxSets -> listOf(BaseItemKind.BOX_SET) CollectionType.BoxSets -> listOf(BaseItemKind.BOX_SET)
CollectionType.Mixed -> listOf(BaseItemKind.FOLDER, BaseItemKind.MOVIE, BaseItemKind.SERIES)
else -> null else -> null
} }
val recursive = itemType == null || !itemType.contains(BaseItemKind.FOLDER)
viewModelScope.launch { viewModelScope.launch {
_uiState.emit(UiState.Loading) _uiState.emit(UiState.Loading)
try { try {
val items = jellyfinRepository.getItemsPaging( val items = jellyfinRepository.getItemsPaging(
parentId = parentId, parentId = parentId,
includeTypes = itemType, includeTypes = itemType,
recursive = true, recursive = recursive,
sortBy = sortBy, sortBy = sortBy,
sortOrder = sortOrder, sortOrder = sortOrder,
).cachedIn(viewModelScope) ).cachedIn(viewModelScope)

View file

@ -3,7 +3,6 @@ package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.FindroidCollection import dev.jdtech.jellyfin.models.FindroidCollection
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -35,9 +34,7 @@ constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.emit(UiState.Loading) _uiState.emit(UiState.Loading)
try { try {
val items = jellyfinRepository.getLibraries() val collections = jellyfinRepository.getLibraries()
val collections =
items.filter { collection -> collection.type in CollectionType.supported }
_uiState.emit(UiState.Normal(collections)) _uiState.emit(UiState.Normal(collections))
} catch (e: Exception) { } catch (e: Exception) {
_uiState.emit( _uiState.emit(

View file

@ -97,7 +97,7 @@ constructor(
nameStringResource = R.string.pref_player_mpv_vo, nameStringResource = R.string.pref_player_mpv_vo,
dependencies = listOf(Constants.PREF_PLAYER_MPV), dependencies = listOf(Constants.PREF_PLAYER_MPV),
backendName = Constants.PREF_PLAYER_MPV_VO, backendName = Constants.PREF_PLAYER_MPV_VO,
backendDefaultValue = "gpu", backendDefaultValue = "gpu-next",
options = R.array.mpv_vos, options = R.array.mpv_vos,
optionValues = R.array.mpv_vos, optionValues = R.array.mpv_vos,
), ),

View file

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="24dp"
android:viewportWidth="36"
android:viewportHeight="24">
<path
android:pathData="M14,19l9,-7l-9,-7l0,14z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M2,19l9,-7l-9,-7l0,14z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M26,19l9,-7l-9,-7l0,14z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="external">Ekstern</string>
</resources>

View file

@ -185,4 +185,6 @@
<string name="live_tv">Élő TV</string> <string name="live_tv">Élő TV</string>
<string name="play">Lejátszás</string> <string name="play">Lejátszás</string>
<string name="watch_trailer">Előzetes megtekintése</string> <string name="watch_trailer">Előzetes megtekintése</string>
<string name="player_start_maximized_summary">Videó megnyitása alapértelmezés szerint maximalizált módban</string>
<string name="player_start_maximized">Indítás maximalizálva</string>
</resources> </resources>

View file

@ -188,4 +188,10 @@
<string name="skip_intro_button">Salta intro</string> <string name="skip_intro_button">Salta intro</string>
<string name="skip_credit_button">Prossimo episodio</string> <string name="skip_credit_button">Prossimo episodio</string>
<string name="skip_credit_button_last">Chiudi player</string> <string name="skip_credit_button_last">Chiudi player</string>
<string name="player_gestures_chapter_skip">Gesto per le scene</string>
<string name="pref_player_chapter_markers">Marcatori delle scene</string>
<string name="pref_player_chapter_markers_summary">Mostra i marcatori delle scene sulla timebar</string>
<string name="player_gestures_chapter_skip_summary">Premere a lungo sul lato sinistro/destro per saltare le scene (disattiva il gesto di velocità 2x)</string>
<string name="player_start_maximized">Adatta video al display</string>
<string name="player_start_maximized_summary">Avvia di default il video adattato al display</string>
</resources> </resources>

View file

@ -185,4 +185,10 @@
<string name="live_tv">TV ao vivo</string> <string name="live_tv">TV ao vivo</string>
<string name="play">Reproduzir</string> <string name="play">Reproduzir</string>
<string name="watch_trailer">Assista o trailer</string> <string name="watch_trailer">Assista o trailer</string>
<string name="player_gestures_chapter_skip">Gesto do capítulo</string>
<string name="player_gestures_chapter_skip_summary">Pressione longamente no lado esquerdo/direito para pular capítulos (substitui o gesto de velocidade 2x)</string>
<string name="player_start_maximized">Iniciar maximizado</string>
<string name="player_start_maximized_summary">Abra o vídeo no modo maximizado por padrão</string>
<string name="pref_player_chapter_markers">Marcadores de capítulo</string>
<string name="pref_player_chapter_markers_summary">Exibir marcadores de capítulo na barra de tempo</string>
</resources> </resources>

View file

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="add_server">Sunucu ekle</string>
<string name="login">Giriş yap</string>
<string name="edit_text_server_address_hint">Sunucu adresi</string>
<string name="edit_text_password_hint">Şifre</string>
<string name="button_connect">Bağlan</string>
<string name="button_login">Giriş yap</string>
<string name="remove_server">Sunucuyu kaldır</string>
<string name="remove_user">Kullanıcıyı kaldır</string>
<string name="title_download">İndirilenler</string>
<string name="view_all">Hepsini göster</string>
<string name="title_media">Medyam</string>
<string name="title_home">Ana Sayfa</string>
<string name="remove">Kaldır</string>
<string name="cancel">İptal</string>
<string name="director">Yönetmen</string>
<string name="writers">Yazarlar</string>
<string name="trailer_button_description">Fragmanı izle</string>
<string name="genres">Türler</string>
<string name="retry">Yeniden dene</string>
<string name="play_button_description">Medyayı oynat</string>
<string name="next_up">Sıradaki</string>
<string name="continue_watching">İzlemeye Devam Et</string>
<string name="settings_category_language">Dil</string>
<string name="app_language">Uygulama dili</string>
<string name="settings_preferred_audio_language">Tercih edilen ses dili</string>
<string name="settings_category_download">İndirilenler</string>
<string name="episodes_label">Bölümler</string>
<string name="download_button_description">İndir</string>
<string name="person_detail_title">Detaylar</string>
<string name="close">Kapat</string>
<string name="sort_by_options_0">Başlık</string>
<string name="add_user">Kullanıcı ekle</string>
<string name="subtitle">Altyazılar</string>
<string name="add_server_error_not_found">Sunucu bulunamadı</string>
<string name="title_settings">Ayarlar</string>
<string name="select_server">Sunucu seç</string>
<string name="theme">Tema</string>
<string name="share">Paylaş</string>
<string name="edit_text_username_hint">Kullanıcı adı</string>
<string name="seasons">Sezonlar</string>
<string name="libraries">Kütüphaneler</string>
<string name="settings_category_device">Cihaz</string>
<string name="title_favorite">Favoriler</string>
<string name="search">Ara</string>
<string name="about">Hakkında</string>
<string name="player_brightness_remember">Parlaklık seviyesini hatırla</string>
<string name="login_error_wrong_username_password">Hatalı kullanıcı adı veya şifre</string>
<string name="sort_by_options_5">Çıkış Tarihi</string>
<string name="settings_category_servers">Sunucular</string>
<string name="movies_label">Filmler</string>
<string name="subtitles">Altyazılar</string>
<string name="add">Ekle</string>
<string name="users">Kullanıcılar</string>
<string name="settings_preferred_subtitle_language">Tercih edilen altyazı dili</string>
<string name="add_server_error_version">Desteklenmeyen sunucu sürümü: %1$s. Lütfen sunucunuzu güncelleyin</string>
<string name="latest_library">En son %1$s</string>
<string name="settings_category_player">Oynatıcı</string>
<string name="settings_category_appearance">Görünüş</string>
<string name="device_name">Cihaz adı</string>
<string name="view_details">Detayları göster</string>
<string name="unknown_error">Bilinmeyen hata</string>
<string name="app_info">Uygulama bilgisi</string>
<string name="hide">Gizle</string>
<string name="sort_by_options_1">IMDB Puanı</string>
<string name="amoled_theme">AMOLED karanlık tema</string>
<string name="theme_dark">Karanlık</string>
<string name="theme_light">Aydınlık</string>
<string name="audio">Ses</string>
<string name="add_address">Adres ekle</string>
<string name="add_server_address">Sunucu adresi ekle</string>
<string name="addresses">Adresler</string>
<string name="cancel_download">İndirmeyi iptal et</string>
<string name="stop_download">İndirmeyi duraklat</string>
<string name="no_users_found">Kullanıcı bulunamadı</string>
<string name="remove_from_favorites">Favorilerden çıkar</string>
<string name="add_to_favorites">Favorilere ekle</string>
<string name="select_user">Kullanıcı seç</string>
<string name="add_server_error_empty_address">Boş sunucu adresi</string>
<string name="theme_system">Sistemi takip et</string>
<string name="app_description">Üçüncü parti yerel Jellyfin uygulaması</string>
<string name="jellyfin_banner">Jellyfin afişi</string>
<string name="add_server_error_outdated">Sunucu sürümü güncel değil: %1$s. Lütfen sunucunuzu güncelleyin</string>
<string name="add_server_error_not_jellyfin">Jellyfin sunucusu değil: %1$s</string>
<string name="add_server_error_slow">Sunucu yanıt vermekte çok yavaş: %1$s</string>
<string name="remove_server_dialog_text">%1$s sunucusunu kaldırmak istediğinizden emin misiniz</string>
<string name="remove_user_dialog_text">%1$s kullanıcısını kaldırmak istediğinizden emin misiniz</string>
<string name="remove_server_address">Sunucu adresini kaldır</string>
<string name="remove_server_address_dialog_text">%1$s sunucu adresini kaldırmak istediğinizden emin misiniz</string>
<string name="error_loading_data">Veriler yüklenirken hata oluştu</string>
<string name="cast_amp_crew">Oyuncular ve Ekip</string>
<string name="episode_name">%1$d. %2$s</string>
<string name="episode_name_extended">S%1$d:B%2$d - %3$s</string>
<string name="episode_name_extended_with_end">S%1$d:B%2$d-%3$d - %4$s</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string>
<string name="series_poster">Dizi posteri</string>
<string name="no_favorites">Hiç favoriniz yok</string>
<string name="no_search_results">Arama sonucu bulunamadı</string>
<string name="settings_category_cache">Önbellek</string>
<string name="settings_use_cache_title">Resimleri önbelleğe al</string>
<string name="view_details_underlined"><u>Detayları göster</u></string>
<string name="privacy_policy">Gizlilik politikası</string>
<string name="download_mobile_data">Mobil veri kullanarak indir</string>
<string name="shows_label">Diziler</string>
<string name="sort_by">Şuna göre sırala</string>
<string name="sort_order">Sıralama düzeni</string>
<string name="gestures">Hareketler</string>
<string name="player_gestures_zoom">Yakınlaştırma hareketi</string>
<string name="sort_by_options_2">Ebeveyn Derecelendirmesi</string>
<string name="sort_by_options_3">Eklenme Tarihi</string>
<string name="sort_by_options_4">Oynatma Tarihi</string>
<string name="ascending">Artan</string>
<string name="runtime_minutes">%1$d dakika</string>
<string name="select_video_version_title">Sürüm seçiniz</string>
<string name="dynamic_colors">Dinamik renkler</string>
<string name="subtitles_summary">Altyazıların görünümünü özelleştir</string>
<string name="settings_category_network"></string>
<string name="settings_cache_size">Önbellek boyutu (MB)</string>
<string name="player_gestures">Oynatıcı hareketleri</string>
<string name="player_gestures_vb">Ses ve parlaklık hareketleri</string>
<string name="descending">Azalan</string>
<string name="offline_mode_go_online">Çevrimiçi ol</string>
<string name="search_hint">Filmleri, dizileri, bölümleri arayın…</string>
<string name="track_selection">[%1$s] %2$s (%3$s)</string>
</resources>

View file

@ -175,4 +175,16 @@
<string name="picture_in_picture">画中画</string> <string name="picture_in_picture">画中画</string>
<string name="picture_in_picture_gesture_summary">在视频播放时使用主页按钮或手势进入画中画模式</string> <string name="picture_in_picture_gesture_summary">在视频播放时使用主页按钮或手势进入画中画模式</string>
<string name="picture_in_picture_gesture">画中画返回手势</string> <string name="picture_in_picture_gesture">画中画返回手势</string>
<string name="select_user">选择用户</string>
<string name="live_tv">电视直播</string>
<string name="no_servers_found">没有找到服务器</string>
<string name="no_users_found">找不到用户</string>
<string name="unmark_as_played">取消标记已播放</string>
<string name="add_to_favorites">加入收藏</string>
<string name="remove_from_favorites">从收藏中删除</string>
<string name="mark_as_played">标记为已播放</string>
<string name="player_start_maximized">最大化启动</string>
<string name="player_start_maximized_summary">默认以最大化模式打开视频</string>
<string name="play">播放</string>
<string name="watch_trailer">观看预告</string>
</resources> </resources>

View file

@ -103,10 +103,14 @@
<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_summary">Open video in maximized mode by default</string>
<string name="sort_by_options_0">Title</string> <string name="sort_by_options_0">Title</string>
<string name="sort_by_options_1">IMDB Rating</string> <string name="sort_by_options_1">IMDB Rating</string>
<string name="sort_by_options_2">Parental Rating</string> <string name="sort_by_options_2">Parental Rating</string>
@ -143,6 +147,9 @@
<string name="pref_player_intro_skipper_summary">Requires <i>ConfusedPolarBear\'s</i> <b>Intro Skipper</b> plugin to be installed on the server.\nInstall <i>jumoog\'s</i> <b>Intro Skipper v0.1.8.0 or higher</b> to skip end credits.</string> <string name="pref_player_intro_skipper_summary">Requires <i>ConfusedPolarBear\'s</i> <b>Intro Skipper</b> plugin to be installed on the server.\nInstall <i>jumoog\'s</i> <b>Intro Skipper v0.1.8.0 or higher</b> to skip end credits.</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 <i>nicknsy\'s</i> <b>Jellyscrub</b> plugin to be installed on the server</string> <string name="pref_player_trick_play_summary">Requires <i>nicknsy\'s</i> <b>Jellyscrub</b> 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

@ -19,7 +19,7 @@
app:title="@string/pref_player_mpv_hwdec" app:title="@string/pref_player_mpv_hwdec"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<ListPreference <ListPreference
app:defaultValue="gpu" app:defaultValue="gpu-next"
app:dependency="pref_player_mpv" app:dependency="pref_player_mpv"
app:entries="@array/mpv_vos" app:entries="@array/mpv_vos"
app:entryValues="@array/mpv_vos" app:entryValues="@array/mpv_vos"
@ -59,10 +59,21 @@
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"
app:title="@string/player_brightness_remember" /> app:title="@string/player_brightness_remember" />
<SwitchPreferenceCompat
app:dependency="pref_player_gestures_zoom"
app:key="pref_player_start_maximized"
app:summary="@string/player_start_maximized_summary"
app:title="@string/player_start_maximized" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/seeking"> <PreferenceCategory app:title="@string/seeking">
@ -92,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

@ -20,9 +20,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, CreditDto::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, CreditDto::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

@ -205,7 +205,7 @@ interface ServerDatabaseDao {
@Query("SELECT * FROM episodes WHERE serverId = :serverId ORDER BY seriesName ASC, parentIndexNumber ASC, indexNumber ASC") @Query("SELECT * FROM episodes WHERE serverId = :serverId ORDER BY seriesName ASC, parentIndexNumber ASC, indexNumber ASC")
fun getEpisodesByServerId(serverId: String): List<FindroidEpisodeDto> fun getEpisodesByServerId(serverId: String): List<FindroidEpisodeDto>
@Query("SELECT episodes.id, episodes.serverId, episodes.seasonId, episodes.seriesId, episodes.name, episodes.seriesName, episodes.overview, episodes.indexNumber, episodes.indexNumberEnd, episodes.parentIndexNumber, episodes.runtimeTicks, episodes.premiereDate, episodes.communityRating FROM episodes INNER JOIN userdata ON episodes.id = userdata.itemId WHERE serverId = :serverId AND playbackPositionTicks > 0 ORDER BY episodes.parentIndexNumber ASC, episodes.indexNumber ASC") @Query("SELECT episodes.id, episodes.serverId, episodes.seasonId, episodes.seriesId, episodes.name, episodes.seriesName, episodes.overview, episodes.indexNumber, episodes.indexNumberEnd, episodes.parentIndexNumber, episodes.runtimeTicks, episodes.premiereDate, episodes.communityRating, episodes.chapters FROM episodes INNER JOIN userdata ON episodes.id = userdata.itemId WHERE serverId = :serverId AND playbackPositionTicks > 0 ORDER BY episodes.parentIndexNumber ASC, episodes.indexNumber ASC")
fun getEpisodeResumeItems(serverId: String): List<FindroidEpisodeDto> fun getEpisodeResumeItems(serverId: String): List<FindroidEpisodeDto>
@Query("DELETE FROM episodes WHERE id = :id") @Query("DELETE FROM episodes WHERE id = :id")

View file

@ -9,6 +9,7 @@ enum class CollectionType(val type: String) {
Books("books"), Books("books"),
LiveTv("livetv"), LiveTv("livetv"),
BoxSets("boxsets"), BoxSets("boxsets"),
Mixed("null"),
Unknown("unknown"), Unknown("unknown"),
; ;
@ -19,11 +20,12 @@ enum class CollectionType(val type: String) {
Movies, Movies,
TvShows, TvShows,
BoxSets, BoxSets,
Mixed,
) )
fun fromString(string: String?): CollectionType { fun fromString(string: String?): CollectionType {
if (string == null) { if (string == null) { // TODO jellyfin returns null as the collectiontype for mixed libraries. This is obviously wrong, but probably an upstream issue. Should be fixed whenever upstream fixes this
return defaultValue return Mixed
} }
return try { return try {

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

@ -0,0 +1,35 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
data class FindroidFolder(
override val id: UUID,
override val name: String,
override val originalTitle: String? = null,
override val overview: String = "",
override val played: Boolean,
override val favorite: Boolean,
override val canPlay: Boolean = false,
override val canDownload: Boolean = false,
override val sources: List<FindroidSource> = emptyList(),
override val runtimeTicks: Long = 0L,
override val playbackPositionTicks: Long = 0L,
override val unplayedItemCount: Int?,
override val images: FindroidImages,
override val chapters: List<FindroidChapter>? = null,
) : FindroidItem
fun BaseItemDto.toFindroidFolder(
jellyfinRepository: JellyfinRepository,
): FindroidFolder {
return FindroidFolder(
id = id,
name = name.orEmpty(),
played = userData?.played ?: false,
favorite = userData?.isFavorite ?: false,
unplayedItemCount = userData?.unplayedItemCount,
images = toFindroidImages(jellyfinRepository),
)
}

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(
@ -32,6 +33,7 @@ suspend fun BaseItemDto.toFindroidItem(
BaseItemKind.SEASON -> toFindroidSeason(jellyfinRepository) BaseItemKind.SEASON -> toFindroidSeason(jellyfinRepository)
BaseItemKind.SERIES -> toFindroidShow(jellyfinRepository) BaseItemKind.SERIES -> toFindroidShow(jellyfinRepository)
BaseItemKind.BOX_SET -> toFindroidBoxSet(jellyfinRepository) BaseItemKind.BOX_SET -> toFindroidBoxSet(jellyfinRepository)
BaseItemKind.FOLDER -> toFindroidFolder(jellyfinRepository)
else -> null else -> null
} }
} }

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

@ -215,7 +215,7 @@ class JellyfinRepositoryImpl(
val items = withContext(Dispatchers.IO) { val items = withContext(Dispatchers.IO) {
jellyfinApi.itemsApi.getResumeItems( jellyfinApi.itemsApi.getResumeItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
limit = 6, limit = 12,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
).content.items.orEmpty() ).content.items.orEmpty()
} }
@ -229,7 +229,7 @@ class JellyfinRepositoryImpl(
jellyfinApi.userLibraryApi.getLatestMedia( jellyfinApi.userLibraryApi.getLatestMedia(
jellyfinApi.userId!!, jellyfinApi.userId!!,
parentId = parentId, parentId = parentId,
limit = 12, limit = 16,
).content ).content
} }
return items.mapNotNull { return items.mapNotNull {
@ -252,7 +252,7 @@ class JellyfinRepositoryImpl(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.showsApi.getNextUp( jellyfinApi.showsApi.getNextUp(
jellyfinApi.userId!!, jellyfinApi.userId!!,
limit = 9, limit = 24,
seriesId = seriesId?.toString(), seriesId = seriesId?.toString(),
).content.items ).content.items
.orEmpty() .orEmpty()

View file

@ -1,16 +1,16 @@
[versions] [versions]
aboutlibraries = "10.10.0" aboutlibraries = "10.10.0"
android-plugin = "8.2.1" android-plugin = "8.2.2"
androidx-activity = "1.8.2" androidx-activity = "1.8.2"
androidx-appcompat = "1.6.1" androidx-appcompat = "1.6.1"
androidx-compose-material3 = "1.2.0-alpha09" androidx-compose-bom = "2024.02.01"
androidx-compose-ui = "1.6.0-alpha07" androidx-compose-material3 = "1.2.0"
androidx-constraintlayout = "2.1.4" androidx-constraintlayout = "2.1.4"
androidx-core = "1.12.0" androidx-core = "1.12.0"
androidx-hilt = "1.1.0" androidx-hilt = "1.2.0"
androidx-lifecycle = "2.6.2" androidx-lifecycle = "2.7.0"
androidx-media3 = "1.2.1" androidx-media3 = "1.2.1"
androidx-navigation = "2.7.6" androidx-navigation = "2.7.7"
androidx-paging = "3.2.1" androidx-paging = "3.2.1"
androidx-preference = "1.2.1" androidx-preference = "1.2.1"
androidx-recyclerview = "1.3.2" androidx-recyclerview = "1.3.2"
@ -18,15 +18,15 @@ androidx-room = "2.6.1"
androidx-swiperefreshlayout = "1.1.0" androidx-swiperefreshlayout = "1.1.0"
androidx-tv = "1.0.0-alpha10" androidx-tv = "1.0.0-alpha10"
androidx-work = "2.9.0" androidx-work = "2.9.0"
coil = "2.5.0" coil = "2.6.0"
hilt = "2.50" hilt = "2.50"
compose-destinations = "1.9.62" compose-destinations = "1.10.1"
jellyfin = "1.4.6" jellyfin = "1.4.6"
kotlin = "1.9.22" kotlin = "1.9.22"
kotlinx-serialization = "1.6.2" kotlinx-serialization = "1.6.3"
ksp = "1.9.22-1.0.17" ksp = "1.9.22-1.0.17"
ktlint = "12.1.0" ktlint = "12.1.0"
libmpv = "0.1.4" libmpv = "0.2.0"
material = "1.11.0" material = "1.11.0"
timber = "5.0.1" timber = "5.0.1"
@ -36,10 +36,11 @@ aboutlibraries = { module = "com.mikepenz:aboutlibraries", version.ref = "aboutl
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" } androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

20
gradlew.bat vendored
View file

@ -43,11 +43,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail

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

@ -50,7 +50,7 @@ class MPVPlayer(
private var trackSelectionParameters: TrackSelectionParameters = TrackSelectionParameters.Builder(context).build(), private var trackSelectionParameters: TrackSelectionParameters = TrackSelectionParameters.Builder(context).build(),
private val seekBackIncrement: Long = C.DEFAULT_SEEK_BACK_INCREMENT_MS, private val seekBackIncrement: Long = C.DEFAULT_SEEK_BACK_INCREMENT_MS,
private val seekForwardIncrement: Long = C.DEFAULT_SEEK_FORWARD_INCREMENT_MS, private val seekForwardIncrement: Long = C.DEFAULT_SEEK_FORWARD_INCREMENT_MS,
videoOutput: String = "gpu", videoOutput: String = "gpu-next",
audioOutput: String = "audiotrack", audioOutput: String = "audiotrack",
hwDec: String = "mediacodec", hwDec: String = "mediacodec",
) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener { ) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener {
@ -77,9 +77,11 @@ class MPVPlayer(
// General // General
MPVLib.setOptionString("config", "yes") MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path) MPVLib.setOptionString("config-dir", mpvDir.path)
MPVLib.setOptionString("profile", "fast")
MPVLib.setOptionString("vo", videoOutput) MPVLib.setOptionString("vo", videoOutput)
MPVLib.setOptionString("ao", audioOutput) MPVLib.setOptionString("ao", audioOutput)
MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// Hardware video decoding // Hardware video decoding
MPVLib.setOptionString("hwdec", hwDec) MPVLib.setOptionString("hwdec", hwDec)
@ -108,8 +110,6 @@ class MPVPlayer(
MPVLib.setOptionString("save-position-on-quit", "no") MPVLib.setOptionString("save-position-on-quit", "no")
MPVLib.setOptionString("sub-font-provider", "none") MPVLib.setOptionString("sub-font-provider", "none")
MPVLib.setOptionString("ytdl", "no") MPVLib.setOptionString("ytdl", "no")
// DR is known to ruin performance at least on Exynos devices, see mpv-android#508
MPVLib.setOptionString("vd-lavc-dr", "no")
MPVLib.init() MPVLib.init()
@ -869,8 +869,11 @@ class MPVPlayer(
) )
} }
currentIndex = index currentIndex = index
MPVLib.command(arrayOf("playlist-play-index", "$index")) // Only set the playlist index when the index is not the currently playing item. Otherwise playback will be restarted.
MPVLib.setPropertyBoolean("pause", true) // This is a problem on initial load when the first item is still loading causing duplicate external subtitle entries.
if (MPVLib.getPropertyInt("playlist-current-pos") != index) {
MPVLib.command(arrayOf("playlist-play-index", "$index"))
}
listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener ->
listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)
} }

View file

@ -20,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.models.Credits import dev.jdtech.jellyfin.models.Credits
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
@ -58,6 +59,7 @@ constructor(
currentIntro = null, currentIntro = null,
currentCredit = null, currentCredit = null,
currentTrickPlay = null, currentTrickPlay = null,
currentChapters = null,
fileLoaded = false, fileLoaded = false,
), ),
) )
@ -76,12 +78,13 @@ constructor(
val currentIntro: Intro?, val currentIntro: Intro?,
val currentCredit: Credits?, val currentCredit: Credits?,
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
@ -295,7 +298,7 @@ constructor(
} else { } else {
item.name item.name
} }
_uiState.update { it.copy(currentItemTitle = itemTitle) } _uiState.update { it.copy(currentItemTitle = itemTitle, currentChapters = item.chapters, fileLoaded = false) }
_uiState.update { it.copy(currentCredit = null) } _uiState.update { it.copy(currentCredit = null) }
@ -386,8 +389,97 @@ 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))
}
} }
sealed interface PlayerEvents { sealed interface PlayerEvents {
data object NavigateBack : PlayerEvents data object NavigateBack : PlayerEvents
data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents
} }

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

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="select_audio_track">Vælg lyd spor</string>
<string name="player_controls_play_pause">Afspilnings pause</string>
<string name="select_subtile_track">Vælg undertekster</string>
<string name="select_playback_speed">Vælg afspilnings hastighed</string>
<string name="select_a_version">Vælg en version</string>
<string name="external">Ekstern</string>
<string name="player_controls_picture_in_picture">Start billed i billed</string>
<string name="player_controls_lock">Låser afspilleren</string>
<string name="player_controls_skip_back">Hop tilbage</string>
<string name="player_controls_exit">Stop afspiller</string>
<string name="player_controls_skip_intro">Spring over intro</string>
<string name="player_controls_fast_forward">Spol frem</string>
<string name="player_controls_skip_forward">Spring frem</string>
<string name="player_trickplay">Spole afspille</string>
<string name="none">Ingen</string>
<string name="player_controls_progress">Process indikator</string>
<string name="player_controls_rewind">Spol tilbage</string>
</resources>

View file

@ -15,4 +15,5 @@
<string name="player_controls_progress">Idővonal</string> <string name="player_controls_progress">Idővonal</string>
<string name="player_trickplay">Trükkös játék</string> <string name="player_trickplay">Trükkös játék</string>
<string name="player_controls_picture_in_picture">Belépés kép a képben</string> <string name="player_controls_picture_in_picture">Belépés kép a képben</string>
<string name="none">Nincs</string>
</resources> </resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -15,4 +15,5 @@
<string name="player_controls_progress">进度条</string> <string name="player_controls_progress">进度条</string>
<string name="player_trickplay">特技播放</string> <string name="player_trickplay">特技播放</string>
<string name="player_controls_picture_in_picture">进入画中画</string> <string name="player_controls_picture_in_picture">进入画中画</string>
<string name="none"></string>
</resources> </resources>

View file

@ -47,10 +47,14 @@ 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)
val playerStartMaximized get() =
sharedPreferences.getBoolean(Constants.PREF_PLAYER_START_MAXIMIZED, false)
var playerBrightness: Float var playerBrightness: Float
get() = sharedPreferences.getFloat( get() = sharedPreferences.getFloat(
Constants.PREF_PLAYER_BRIGHTNESS, Constants.PREF_PLAYER_BRIGHTNESS,
@ -70,11 +74,12 @@ constructor(
DEFAULT_SEEK_FORWARD_INCREMENT_MS.toString(), DEFAULT_SEEK_FORWARD_INCREMENT_MS.toString(),
)!!.toLongOrNull() ?: DEFAULT_SEEK_FORWARD_INCREMENT_MS )!!.toLongOrNull() ?: DEFAULT_SEEK_FORWARD_INCREMENT_MS
val playerMpv get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_MPV, false) val playerMpv get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_MPV, false)
val playerMpvHwdec get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_HWDEC, "mediacodec-copy")!! val playerMpvHwdec get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_HWDEC, "mediacodec")!!
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu")!! val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu-next")!!
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,7 +16,9 @@ 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_BRIGHTNESS = "pref_player_brightness" const val PREF_PLAYER_BRIGHTNESS = "pref_player_brightness"
const val PREF_PLAYER_SEEK_BACK_INC = "pref_player_seek_back_inc" const val PREF_PLAYER_SEEK_BACK_INC = "pref_player_seek_back_inc"
const val PREF_PLAYER_SEEK_FORWARD_INC = "pref_player_seek_forward_inc" const val PREF_PLAYER_SEEK_FORWARD_INC = "pref_player_seek_forward_inc"
@ -26,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"