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
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v2
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle
run: ./gradlew lintDebug ktlintCheck
assemble:
@ -29,14 +29,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v2
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle
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.

View file

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

View file

@ -7,11 +7,13 @@ import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Rect
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.provider.Settings
import android.util.Rational
import android.view.View
import android.view.WindowManager
@ -27,15 +29,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.utils.PlayerGestureHelper
import dev.jdtech.jellyfin.utils.PreviewScrubListener
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
@ -57,6 +58,7 @@ class PlayerActivity : BasePlayerActivity() {
private var playerGestureHelper: PlayerGestureHelper? = null
override val viewModel: PlayerActivityViewModel by viewModels()
private var previewScrubListener: PreviewScrubListener? = null
private var wasZoom: Boolean = false
private val isPipSupported by lazy {
// Check if device has PiP feature
@ -113,10 +115,6 @@ class PlayerActivity : BasePlayerActivity() {
finish()
}
binding.playerView.findViewById<View>(R.id.back_button_alt).setOnClickListener {
finish()
}
val videoNameTextView = binding.playerView.findViewById<TextView>(R.id.video_name)
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
@ -166,6 +164,18 @@ class PlayerActivity : BasePlayerActivity() {
it.currentTrickPlay = currentTrickPlay
}
// Chapters
if (appPreferences.showChapterMarkers && currentChapters != null) {
currentChapters?.let { chapters ->
val playerControlView = findViewById<PlayerControlView>(R.id.exo_controller)
val numOfChapters = chapters.size
playerControlView.setExtraAdGroupMarkers(
LongArray(numOfChapters) { index -> chapters[index].startPosition },
BooleanArray(numOfChapters) { false },
)
}
}
// File Loaded
if (fileLoaded) {
audioButton.isEnabled = true
@ -187,6 +197,11 @@ class PlayerActivity : BasePlayerActivity() {
viewModel.eventsChannelFlow.collect { event ->
when (event) {
is PlayerEvents.NavigateBack -> finish()
is PlayerEvents.IsPlayingChanged -> {
if (appPreferences.playerPipGesture) {
setPictureInPictureParams(pipParams(event.isPlaying))
}
}
}
}
}
@ -256,9 +271,12 @@ class PlayerActivity : BasePlayerActivity() {
pictureInPicture()
}
// Set marker color
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
timeBar.setAdMarkerColor(Color.WHITE)
if (appPreferences.playerTrickPlay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
previewScrubListener = PreviewScrubListener(
imagePreview,
timeBar,
@ -281,12 +299,16 @@ class PlayerActivity : BasePlayerActivity() {
}
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()
}
}
private fun pipParams(): PictureInPictureParams {
private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams {
val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height)
val aspectRatio = binding.playerView.player?.videoSize?.let {
@ -314,24 +336,21 @@ class PlayerActivity : BasePlayerActivity() {
)
}
return PictureInPictureParams.Builder()
val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setSourceRectHint(sourceRectHint)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(enableAutoEnter)
}
return builder.build()
}
private fun pictureInPicture() {
if (!isPipSupported) {
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 {
enterPictureInPictureMode(pipParams())
@ -343,8 +362,35 @@ class PlayerActivity : BasePlayerActivity() {
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) {
when (isInPictureInPictureMode) {
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.SortDialogFragment
import dev.jdtech.jellyfin.models.FindroidBoxSet
import dev.jdtech.jellyfin.models.FindroidFolder
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
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.PlayerActivity
import dev.jdtech.jellyfin.isControlsLocked
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.mpv.MPVPlayer
import timber.log.Timber
import kotlin.math.abs
@ -37,7 +38,7 @@ class PlayerGestureHelper(
* 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.
*/
private var isZoomEnabled = false
var isZoomEnabled = false
/**
* Tracks a value during a swipe gesture (between multiple onScroll calls).
@ -55,9 +56,14 @@ class PlayerGestureHelper(
private var lastScaleEvent: Long = 0
private var playbackSpeedIncrease: Float = 2f
private var lastPlaybackSpeed: Float = 0f
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val screenHeight = Resources.getSystem().displayMetrics.heightPixels
private var currentNumberOfPointers: Int = 0
private val tapGestureDetector = GestureDetector(
playerView.context,
object : GestureDetector.SimpleOnGestureListener() {
@ -69,6 +75,22 @@ class PlayerGestureHelper(
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 {
// Disables double tap gestures if view is locked
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() {
val currentPosition = playerView.player?.currentPosition ?: 0
val fastForwardPosition = currentPosition + appPreferences.playerSeekForwardIncrement
@ -315,8 +386,8 @@ class PlayerGestureHelper(
lastScaleEvent = SystemClock.elapsedRealtime()
val scaleFactor = detector.scaleFactor
if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) {
isZoomEnabled = scaleFactor > 1
updateZoomMode(isZoomEnabled)
val enableZoom = scaleFactor > 1
updateZoomMode(enableZoom)
}
return true
}
@ -325,16 +396,17 @@ class PlayerGestureHelper(
},
).apply { isQuickScaleEnabled = false }
private fun updateZoomMode(enabled: Boolean) {
fun updateZoomMode(enabled: Boolean) {
if (playerView.player is MPVPlayer) {
(playerView.player as MPVPlayer).updateZoomMode(enabled)
} else {
playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
}
isZoomEnabled = enabled
}
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 {
if (visibility == View.VISIBLE) {
removeCallbacks(hideGestureVolumeIndicatorOverlayAction)
@ -361,6 +433,12 @@ class PlayerGestureHelper(
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
}
updateZoomMode(appPreferences.playerStartMaximized)
@Suppress("ClickableViewAccessibility")
playerView.setOnTouchListener { _, event ->
if (playerView.useController) {
currentNumberOfPointers = event.pointerCount
when (event.pointerCount) {
1 -> {
tapGestureDetector.onTouchEvent(event)

View file

@ -113,6 +113,37 @@
tools:ignore="ContentDescription" />
</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
android:id="@+id/image_ffwd_animation_ripple"
android:layout_width="50dp"

View file

@ -8,43 +8,6 @@
android:visibility="gone"
tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="horizontal">
<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"
@ -53,10 +16,8 @@
android:background="@drawable/rounded_corner"
android:contentDescription="@string/select_playback_speed"
android:padding="16dp"
android:layout_margin="8dp"
android:src="@drawable/ic_unlock"
app:tint="@android:color/white" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

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

View file

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

View file

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

View file

@ -23,9 +23,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.LibraryScreenDestination
import dev.jdtech.jellyfin.destinations.MovieScreenDestination
import dev.jdtech.jellyfin.destinations.ShowScreenDestination
import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.FindroidFolder
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
import dev.jdtech.jellyfin.models.FindroidShow
@ -65,6 +67,9 @@ fun LibraryScreen(
is FindroidShow -> {
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(),
communityRating = 9.2f,
images = FindroidImages(),
chapters = null,
)
val dummyEpisodes = listOf(

View file

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

View file

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

View file

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

View file

@ -93,7 +93,7 @@ class HomeViewModel @Inject internal constructor(
private suspend fun loadViews() = repository
.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) }
.filter { (_, latest) -> latest.isNotEmpty() }
.map { (view, latest) -> view.toView().apply { items = latest } }

View file

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

View file

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

View file

@ -97,7 +97,7 @@ constructor(
nameStringResource = R.string.pref_player_mpv_vo,
dependencies = listOf(Constants.PREF_PLAYER_MPV),
backendName = Constants.PREF_PLAYER_MPV_VO,
backendDefaultValue = "gpu",
backendDefaultValue = "gpu-next",
options = 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="play">Lejátszás</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>

View file

@ -188,4 +188,10 @@
<string name="skip_intro_button">Salta intro</string>
<string name="skip_credit_button">Prossimo episodio</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>

View file

@ -185,4 +185,10 @@
<string name="live_tv">TV ao vivo</string>
<string name="play">Reproduzir</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>

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_gesture_summary">在视频播放时使用主页按钮或手势进入画中画模式</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>

View file

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

View file

@ -19,7 +19,7 @@
app:title="@string/pref_player_mpv_hwdec"
app:useSimpleSummaryProvider="true" />
<ListPreference
app:defaultValue="gpu"
app:defaultValue="gpu-next"
app:dependency="pref_player_mpv"
app:entries="@array/mpv_vos"
app:entryValues="@array/mpv_vos"
@ -59,10 +59,21 @@
app:key="pref_player_gestures_seek"
app:summary="@string/player_gestures_seek_summary"
app:title="@string/player_gestures_seek" />
<SwitchPreferenceCompat
app:defaultValue="true"
app:dependency="pref_player_gestures"
app:key="pref_player_gestures_chapter_skip"
app:summary="@string/player_gestures_chapter_skip_summary"
app:title="@string/player_gestures_chapter_skip" />
<SwitchPreferenceCompat
app:dependency="pref_player_gestures_vb"
app:key="pref_player_brightness_remember"
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 app:title="@string/seeking">
@ -92,6 +103,13 @@
app:title="@string/pref_player_trick_play"
app:widgetLayout="@layout/preference_material3_switch" />
<SwitchPreferenceCompat
app:defaultValue="true"
app:key="pref_player_chapter_markers"
app:summary="@string/pref_player_chapter_markers_summary"
app:title="@string/pref_player_chapter_markers"
app:widgetLayout="@layout/preference_material3_switch" />
<PreferenceCategory app:title="@string/picture_in_picture">
<SwitchPreferenceCompat
app:key="pref_player_picture_in_picture_gesture"

View file

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

View file

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

View file

@ -20,9 +20,10 @@ import dev.jdtech.jellyfin.models.User
@Database(
entities = [Server::class, ServerAddress::class, User::class, FindroidMovieDto::class, FindroidShowDto::class, FindroidSeasonDto::class, FindroidEpisodeDto::class, FindroidSourceDto::class, FindroidMediaStreamDto::class, TrickPlayManifestDto::class, IntroDto::class, CreditDto::class, FindroidUserDataDto::class],
version = 3,
version = 4,
autoMigrations = [
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4),
],
)
@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")
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>
@Query("DELETE FROM episodes WHERE id = :id")

View file

@ -9,6 +9,7 @@ enum class CollectionType(val type: String) {
Books("books"),
LiveTv("livetv"),
BoxSets("boxsets"),
Mixed("null"),
Unknown("unknown"),
;
@ -19,11 +20,12 @@ enum class CollectionType(val type: String) {
Movies,
TvShows,
BoxSets,
Mixed,
)
fun fromString(string: String?): CollectionType {
if (string == null) {
return defaultValue
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 Mixed
}
return try {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 unplayedItemCount: Int?
val images: FindroidImages
val chapters: List<FindroidChapter>?
}
suspend fun BaseItemDto.toFindroidItem(
@ -32,6 +33,7 @@ suspend fun BaseItemDto.toFindroidItem(
BaseItemKind.SEASON -> toFindroidSeason(jellyfinRepository)
BaseItemKind.SERIES -> toFindroidShow(jellyfinRepository)
BaseItemKind.BOX_SET -> toFindroidBoxSet(jellyfinRepository)
BaseItemKind.FOLDER -> toFindroidFolder(jellyfinRepository)
else -> null
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
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
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
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 indexNumberEnd: Int? = null,
val externalSubtitles: List<ExternalSubtitle> = emptyList(),
val chapters: List<PlayerChapter>? = null,
) : Parcelable

View file

@ -50,7 +50,7 @@ class MPVPlayer(
private var trackSelectionParameters: TrackSelectionParameters = TrackSelectionParameters.Builder(context).build(),
private val seekBackIncrement: Long = C.DEFAULT_SEEK_BACK_INCREMENT_MS,
private val seekForwardIncrement: Long = C.DEFAULT_SEEK_FORWARD_INCREMENT_MS,
videoOutput: String = "gpu",
videoOutput: String = "gpu-next",
audioOutput: String = "audiotrack",
hwDec: String = "mediacodec",
) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener {
@ -77,9 +77,11 @@ class MPVPlayer(
// General
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
MPVLib.setOptionString("profile", "fast")
MPVLib.setOptionString("vo", videoOutput)
MPVLib.setOptionString("ao", audioOutput)
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// Hardware video decoding
MPVLib.setOptionString("hwdec", hwDec)
@ -108,8 +110,6 @@ class MPVPlayer(
MPVLib.setOptionString("save-position-on-quit", "no")
MPVLib.setOptionString("sub-font-provider", "none")
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()
@ -869,8 +869,11 @@ class MPVPlayer(
)
}
currentIndex = index
// Only set the playlist index when the index is not the currently playing item. Otherwise playback will be restarted.
// 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"))
MPVLib.setPropertyBoolean("pause", true)
}
listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener ->
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.models.Credits
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.player.video.R
@ -58,6 +59,7 @@ constructor(
currentIntro = null,
currentCredit = null,
currentTrickPlay = null,
currentChapters = null,
fileLoaded = false,
),
)
@ -76,12 +78,13 @@ constructor(
val currentIntro: Intro?,
val currentCredit: Credits?,
val currentTrickPlay: BifData?,
val currentChapters: List<PlayerChapter>?,
val fileLoaded: Boolean,
)
private var items: Array<PlayerItem> = arrayOf()
val trackSelector = DefaultTrackSelector(application)
private val trackSelector = DefaultTrackSelector(application)
var playWhenReady = true
private var currentMediaItemIndex = savedStateHandle["mediaItemIndex"] ?: 0
private var playbackPosition: Long = savedStateHandle["position"] ?: 0
@ -295,7 +298,7 @@ constructor(
} else {
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) }
@ -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 {
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 dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.ExternalSubtitle
import dev.jdtech.jellyfin.models.FindroidChapter
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
import dev.jdtech.jellyfin.models.FindroidSeason
import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.models.FindroidSourceType
import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.channels.Channel
@ -113,7 +115,7 @@ class PlayerViewModel @Inject internal constructor(
.getEpisodes(
seriesId = item.seriesId,
seasonId = item.seasonId,
fields = listOf(ItemFields.MEDIA_SOURCES),
fields = listOf(ItemFields.MEDIA_SOURCES, ItemFields.CHAPTERS),
startItemId = item.id,
limit = if (userConfig?.enableNextEpisodeAutoPlay != false) null else 1,
)
@ -166,8 +168,18 @@ class PlayerViewModel @Inject internal constructor(
indexNumber = if (this is FindroidEpisode) indexNumber else null,
indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
externalSubtitles = externalSubtitles,
chapters = chapters.toPlayerChapters(),
)
}
private fun List<FindroidChapter>?.toPlayerChapters(): List<PlayerChapter>? {
return this?.map { chapter ->
PlayerChapter(
startPosition = chapter.startPosition,
name = chapter.name,
)
}
}
}
sealed interface PlayerItemsEvent {

View file

@ -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_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="none">Nincs</string>
</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_trickplay">特技播放</string>
<string name="player_controls_picture_in_picture">进入画中画</string>
<string name="none"></string>
</resources>

View file

@ -47,10 +47,14 @@ constructor(
val playerGesturesVB get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_VB, true)
val playerGesturesZoom get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_ZOOM, true)
val playerGesturesSeek get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_SEEK, true)
val playerGesturesChapterSkip get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_GESTURES_CHAPTER_SKIP, true)
val playerBrightnessRemember get() =
sharedPreferences.getBoolean(Constants.PREF_PLAYER_BRIGHTNESS_REMEMBER, false)
val playerStartMaximized get() =
sharedPreferences.getBoolean(Constants.PREF_PLAYER_START_MAXIMIZED, false)
var playerBrightness: Float
get() = sharedPreferences.getFloat(
Constants.PREF_PLAYER_BRIGHTNESS,
@ -70,11 +74,12 @@ constructor(
DEFAULT_SEEK_FORWARD_INCREMENT_MS.toString(),
)!!.toLongOrNull() ?: DEFAULT_SEEK_FORWARD_INCREMENT_MS
val playerMpv get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_MPV, false)
val playerMpvHwdec get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_HWDEC, "mediacodec-copy")!!
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu")!!
val playerMpvHwdec get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_HWDEC, "mediacodec")!!
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu-next")!!
val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!!
val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
val playerTrickPlay get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_TRICK_PLAY, true)
val showChapterMarkers get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_CHAPTER_MARKERS, true)
val playerPipGesture get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_PIP_GESTURE, false)

View file

@ -16,7 +16,9 @@ object Constants {
const val PREF_PLAYER_GESTURES_VB = "pref_player_gestures_vb"
const val PREF_PLAYER_GESTURES_ZOOM = "pref_player_gestures_zoom"
const val PREF_PLAYER_GESTURES_SEEK = "pref_player_gestures_seek"
const val PREF_PLAYER_GESTURES_CHAPTER_SKIP = "pref_player_gestures_chapter_skip"
const val PREF_PLAYER_BRIGHTNESS_REMEMBER = "pref_player_brightness_remember"
const val PREF_PLAYER_START_MAXIMIZED = "pref_player_start_maximized"
const val PREF_PLAYER_BRIGHTNESS = "pref_player_brightness"
const val PREF_PLAYER_SEEK_BACK_INC = "pref_player_seek_back_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_INTRO_SKIPPER = "pref_player_intro_skipper"
const val PREF_PLAYER_TRICK_PLAY = "pref_player_trick_play"
const val PREF_PLAYER_CHAPTER_MARKERS = "pref_player_chapter_markers"
const val PREF_PLAYER_PIP_GESTURE = "pref_player_picture_in_picture_gesture"
const val PREF_AUDIO_LANGUAGE = "pref_audio_language"
const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language"