feat: native 10.9 trickplay (#763)

* feat: native trickplay

TODO: update downloaded trickplay data

* chore: fix tv build

* fix: set dispatcher on image loading to remove flicker

* feat: download trickplay data

* refactor: simplify trickplay info by only loading a single resolution

* refactor: follow jellyfin naming of trickplay
This commit is contained in:
Jarne Demeulemeester 2024-06-22 18:21:24 +02:00 committed by GitHub
parent b41d8c9b51
commit c01ed644b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1145 additions and 353 deletions

View file

@ -141,9 +141,9 @@ class PlayerActivity : BasePlayerActivity() {
} }
} }
// Trick Play // Trickplay
previewScrubListener?.let { previewScrubListener?.let {
it.currentTrickPlay = currentTrickPlay it.currentTrickplay = currentTrickplay
} }
// Chapters // Chapters
@ -259,7 +259,7 @@ class PlayerActivity : BasePlayerActivity() {
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress) val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
timeBar.setAdMarkerColor(Color.WHITE) 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)
previewScrubListener = PreviewScrubListener( previewScrubListener = PreviewScrubListener(
imagePreview, imagePreview,

View file

@ -8,8 +8,8 @@ import androidx.media3.common.Player
import androidx.media3.ui.TimeBar import androidx.media3.ui.TimeBar
import coil.load import coil.load
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import dev.jdtech.jellyfin.utils.bif.BifData import dev.jdtech.jellyfin.models.Trickplay
import dev.jdtech.jellyfin.utils.bif.BifUtil import kotlinx.coroutines.Dispatchers
import timber.log.Timber import timber.log.Timber
class PreviewScrubListener( class PreviewScrubListener(
@ -17,14 +17,14 @@ class PreviewScrubListener(
private val timeBarView: View, private val timeBarView: View,
private val player: Player, private val player: Player,
) : TimeBar.OnScrubListener { ) : TimeBar.OnScrubListener {
var currentTrickPlay: BifData? = null var currentTrickplay: Trickplay? = null
private val roundedCorners = RoundedCornersTransformation(10f) private val roundedCorners = RoundedCornersTransformation(10f)
private var currentBitMap: Bitmap? = null private var currentBitMap: Bitmap? = null
override fun onScrubStart(timeBar: TimeBar, position: Long) { override fun onScrubStart(timeBar: TimeBar, position: Long) {
Timber.d("Scrubbing started at $position") Timber.d("Scrubbing started at $position")
if (currentTrickPlay == null) { if (currentTrickplay == null) {
return return
} }
@ -35,8 +35,8 @@ class PreviewScrubListener(
override fun onScrubMove(timeBar: TimeBar, position: Long) { override fun onScrubMove(timeBar: TimeBar, position: Long) {
Timber.d("Scrubbing to $position") Timber.d("Scrubbing to $position")
val currentBifData = currentTrickPlay ?: return val trickplay = currentTrickplay ?: return
val image = BifUtil.getTrickPlayFrame(position.toInt(), currentBifData) ?: return val image = trickplay.images[position.div(trickplay.interval).toInt()]
val parent = scrubbingPreview.parent as ViewGroup val parent = scrubbingPreview.parent as ViewGroup
@ -57,6 +57,7 @@ class PreviewScrubListener(
if (currentBitMap != image) { if (currentBitMap != image) {
scrubbingPreview.load(image) { scrubbingPreview.load(image) {
dispatcher(Dispatchers.Main.immediate)
transformations(roundedCorners) transformations(roundedCorners)
} }
currentBitMap = image currentBitMap = image

View file

@ -56,6 +56,7 @@ val dummyEpisode = FindroidEpisode(
communityRating = 9.2f, communityRating = 9.2f,
images = FindroidImages(), images = FindroidImages(),
chapters = null, chapters = null,
trickplayInfo = null,
) )
val dummyEpisodes = listOf( val dummyEpisodes = listOf(

View file

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

View file

@ -13,7 +13,8 @@ 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.FindroidSource import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.TrickPlayManifest import dev.jdtech.jellyfin.models.FindroidSources
import dev.jdtech.jellyfin.models.FindroidTrickplayInfo
import dev.jdtech.jellyfin.models.UiText import dev.jdtech.jellyfin.models.UiText
import dev.jdtech.jellyfin.models.toFindroidEpisodeDto import dev.jdtech.jellyfin.models.toFindroidEpisodeDto
import dev.jdtech.jellyfin.models.toFindroidMediaStreamDto import dev.jdtech.jellyfin.models.toFindroidMediaStreamDto
@ -21,13 +22,14 @@ import dev.jdtech.jellyfin.models.toFindroidMovieDto
import dev.jdtech.jellyfin.models.toFindroidSeasonDto import dev.jdtech.jellyfin.models.toFindroidSeasonDto
import dev.jdtech.jellyfin.models.toFindroidShowDto import dev.jdtech.jellyfin.models.toFindroidShowDto
import dev.jdtech.jellyfin.models.toFindroidSourceDto import dev.jdtech.jellyfin.models.toFindroidSourceDto
import dev.jdtech.jellyfin.models.toFindroidTrickplayInfoDto
import dev.jdtech.jellyfin.models.toFindroidUserDataDto import dev.jdtech.jellyfin.models.toFindroidUserDataDto
import dev.jdtech.jellyfin.models.toIntroDto import dev.jdtech.jellyfin.models.toIntroDto
import dev.jdtech.jellyfin.models.toTrickPlayManifestDto
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.Exception import kotlin.Exception
import kotlin.math.ceil
import dev.jdtech.jellyfin.core.R as CoreR import dev.jdtech.jellyfin.core.R as CoreR
class DownloaderImpl( class DownloaderImpl(
@ -46,12 +48,8 @@ class DownloaderImpl(
try { try {
val source = jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId } val source = jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId }
val intro = jellyfinRepository.getIntroTimestamps(item.id) val intro = jellyfinRepository.getIntroTimestamps(item.id)
val trickPlayManifest = jellyfinRepository.getTrickPlayManifest(item.id) val trickplayInfo = if (item is FindroidSources) {
val trickPlayData = if (trickPlayManifest != null) { item.trickplayInfo?.get(sourceId)
jellyfinRepository.getTrickPlayData(
item.id,
trickPlayManifest.widthResolutions.max(),
)
} else { } else {
null null
} }
@ -78,12 +76,12 @@ class DownloaderImpl(
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty())) database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId())) database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex) downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, sourceId, trickplayInfo)
}
if (intro != null) { if (intro != null) {
database.insertIntro(intro.toIntroDto(item.id)) database.insertIntro(intro.toIntroDto(item.id))
} }
if (trickPlayManifest != null && trickPlayData != null) {
downloadTrickPlay(item, trickPlayManifest, trickPlayData)
}
val request = DownloadManager.Request(source.path.toUri()) val request = DownloadManager.Request(source.path.toUri())
.setTitle(item.name) .setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
@ -107,12 +105,12 @@ class DownloaderImpl(
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty())) database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId())) database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex) downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, sourceId, trickplayInfo)
}
if (intro != null) { if (intro != null) {
database.insertIntro(intro.toIntroDto(item.id)) database.insertIntro(intro.toIntroDto(item.id))
} }
if (trickPlayManifest != null && trickPlayData != null) {
downloadTrickPlay(item, trickPlayManifest, trickPlayData)
}
val request = DownloadManager.Request(source.path.toUri()) val request = DownloadManager.Request(source.path.toUri())
.setTitle(item.name) .setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
@ -175,8 +173,7 @@ class DownloaderImpl(
database.deleteIntro(item.id) database.deleteIntro(item.id)
database.deleteTrickPlayManifest(item.id) File(context.filesDir, "trickplay/${item.id}").deleteRecursively()
File(context.filesDir, "trickplay/${item.id}.bif").delete()
} }
override suspend fun getProgress(downloadId: Long?): Pair<Int, Int> { override suspend fun getProgress(downloadId: Long?): Pair<Int, Int> {
@ -233,14 +230,37 @@ class DownloaderImpl(
} }
} }
private fun downloadTrickPlay( private suspend fun downloadTrickplayData(
item: FindroidItem, itemId: UUID,
trickPlayManifest: TrickPlayManifest, sourceId: String,
byteArray: ByteArray, trickplayInfo: FindroidTrickplayInfo,
) { ) {
database.insertTrickPlayManifest(trickPlayManifest.toTrickPlayManifestDto(item.id)) val maxIndex = ceil(trickplayInfo.thumbnailCount.toDouble().div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)).toInt()
File(context.filesDir, "trickplay").mkdirs() val byteArrays = mutableListOf<ByteArray>()
val file = File(context.filesDir, "trickplay/${item.id}.bif") for (i in 0..maxIndex) {
file.writeBytes(byteArray) jellyfinRepository.getTrickplayData(
itemId,
trickplayInfo.width,
i,
)?.let { byteArray ->
byteArrays.add(byteArray)
}
}
saveTrickplayData(itemId, sourceId, trickplayInfo, byteArrays)
}
private fun saveTrickplayData(
itemId: UUID,
sourceId: String,
trickplayInfo: FindroidTrickplayInfo,
byteArrays: List<ByteArray>,
) {
val basePath = "trickplay/$itemId/$sourceId"
database.insertTrickplayInfo(trickplayInfo.toFindroidTrickplayInfoDto(sourceId))
File(context.filesDir, basePath).mkdirs()
for ((i, byteArray) in byteArrays.withIndex()) {
val file = File(context.filesDir, "$basePath/$i")
file.writeBytes(byteArray)
}
} }
} }

View file

@ -138,7 +138,6 @@
<string name="pref_player_intro_skipper_summary">Requiere que el complemento Intro Skipper de ConfusedPolarBear esté instalado en el servidor</string> <string name="pref_player_intro_skipper_summary">Requiere que el complemento Intro Skipper de ConfusedPolarBear esté instalado en el servidor</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string> <string name="episode_name_with_end">%1$d-%2$d. %3$s</string>
<string name="episode_name_extended_with_end">T%1$d:E%2$d-%3$d - %4$s</string> <string name="episode_name_extended_with_end">T%1$d:E%2$d-%3$d - %4$s</string>
<string name="pref_player_trick_play_summary">Requiere que el complemento Jellyscrub de nicknsy esté instalado en el servidor</string>
<string name="extra_info_summary">Mostrar información detallada de audio, video y subtítulos</string> <string name="extra_info_summary">Mostrar información detallada de audio, video y subtítulos</string>
<string name="offline_mode">Modo desconectado</string> <string name="offline_mode">Modo desconectado</string>
<string name="offline_mode_icon">Icono de modo desconectado</string> <string name="offline_mode_icon">Icono de modo desconectado</string>
@ -161,7 +160,6 @@
<string name="player_gestures_seek">Gesto de búsqueda</string> <string name="player_gestures_seek">Gesto de búsqueda</string>
<string name="player_gestures_seek_summary">Deslizar horizontalmente para buscar adelante o atrás</string> <string name="player_gestures_seek_summary">Deslizar horizontalmente para buscar adelante o atrás</string>
<string name="downloaded_indicator">Indicador de descargado</string> <string name="downloaded_indicator">Indicador de descargado</string>
<string name="pref_player_trick_play">Miniaturas (Trick Play)</string>
<string name="storage_name">%1$s (%2$d MB libres)</string> <string name="storage_name">%1$s (%2$d MB libres)</string>
<string name="preparing_download">Preparando descarga</string> <string name="preparing_download">Preparando descarga</string>
<string name="cancel_download">Cancelar descarga</string> <string name="cancel_download">Cancelar descarga</string>

View file

@ -142,7 +142,6 @@
<string name="settings_category_network">Мрежа</string> <string name="settings_category_network">Мрежа</string>
<string name="settings_socket_timeout">Лимит на сокета (ms)</string> <string name="settings_socket_timeout">Лимит на сокета (ms)</string>
<string name="pref_player_mpv_hwdec">Хардуерно декодиране</string> <string name="pref_player_mpv_hwdec">Хардуерно декодиране</string>
<string name="pref_player_trick_play_summary">Изисква Jellyscrub на nicknsy да бъде инсталиран на сървъра</string>
<string name="add_address">Добави адрес</string> <string name="add_address">Добави адрес</string>
<string name="audio">Аудио</string> <string name="audio">Аудио</string>
<string name="video">Видео</string> <string name="video">Видео</string>
@ -161,7 +160,6 @@
<string name="select_video_version_title">Изберете версия</string> <string name="select_video_version_title">Изберете версия</string>
<string name="theme_dark">Тъмна</string> <string name="theme_dark">Тъмна</string>
<string name="pref_player_intro_skipper">Пропускане на интрота</string> <string name="pref_player_intro_skipper">Пропускане на интрота</string>
<string name="pref_player_trick_play">Trick Play</string>
<string name="addresses">Адреси</string> <string name="addresses">Адреси</string>
<string name="add_server_address">Добави адрес на сървър</string> <string name="add_server_address">Добави адрес на сървър</string>
<string name="player_gestures_zoom">Жест за приближаване (zoom)</string> <string name="player_gestures_zoom">Жест за приближаване (zoom)</string>

View file

@ -136,8 +136,6 @@
<string name="pref_player_mpv_hwdec">Hardware-Dekodierung</string> <string name="pref_player_mpv_hwdec">Hardware-Dekodierung</string>
<string name="pref_player_mpv_vo">Videoausgang</string> <string name="pref_player_mpv_vo">Videoausgang</string>
<string name="pref_player_mpv_ao">Audioausgang</string> <string name="pref_player_mpv_ao">Audioausgang</string>
<string name="pref_player_trick_play">Trickspiel</string>
<string name="pref_player_trick_play_summary">Erfordert die Installation des Jellyscrub-Plugins von nicknsy auf dem Server</string>
<string name="subtitle_chip_text">CC</string> <string name="subtitle_chip_text">CC</string>
<string name="extra_info_summary">Anzeigen von detaillierten Infos zu Ton/Audio, Video und Untertiteln</string> <string name="extra_info_summary">Anzeigen von detaillierten Infos zu Ton/Audio, Video und Untertiteln</string>
<string name="video">Video</string> <string name="video">Video</string>

View file

@ -149,14 +149,12 @@
<string name="player_gestures_seek">Gesto de búsqueda</string> <string name="player_gestures_seek">Gesto de búsqueda</string>
<string name="player_gestures_seek_summary">Deslice horizontalmente para buscar hacia adelante o hacia atrás</string> <string name="player_gestures_seek_summary">Deslice horizontalmente para buscar hacia adelante o hacia atrás</string>
<string name="app_language">Idioma de la aplicación</string> <string name="app_language">Idioma de la aplicación</string>
<string name="pref_player_trick_play_summary">Requiere que el complemento Jellyscrub de nicknsy esté instalado en el servidor</string>
<string name="cancel_download">Cancelar descarga</string> <string name="cancel_download">Cancelar descarga</string>
<string name="cancel_download_message">¿Quiere cancelar la descarga\?</string> <string name="cancel_download_message">¿Quiere cancelar la descarga\?</string>
<string name="subtitle">Subtítulos</string> <string name="subtitle">Subtítulos</string>
<string name="subtitle_chip_text">CC</string> <string name="subtitle_chip_text">CC</string>
<string name="temp">temporáneo</string> <string name="temp">temporáneo</string>
<string name="downloaded_indicator">Indicador de descargado</string> <string name="downloaded_indicator">Indicador de descargado</string>
<string name="pref_player_trick_play">Miniaturas (Trick Play)</string>
<string name="preparing_download">Preparando la descarga</string> <string name="preparing_download">Preparando la descarga</string>
<string name="storage_name">%1$s (%2$d MB libres)</string> <string name="storage_name">%1$s (%2$d MB libres)</string>
<string name="not_enough_storage">Éste elemento requiere %1$s de almacenamiento pero solo hay disponible %2$s</string> <string name="not_enough_storage">Éste elemento requiere %1$s de almacenamiento pero solo hay disponible %2$s</string>

View file

@ -147,8 +147,6 @@
<string name="temp">temporáneo</string> <string name="temp">temporáneo</string>
<string name="extra_info_summary">Muestra información detallada de audio, video y subtítulos</string> <string name="extra_info_summary">Muestra información detallada de audio, video y subtítulos</string>
<string name="extra_info">Mostrar info extra</string> <string name="extra_info">Mostrar info extra</string>
<string name="pref_player_trick_play">Miniaturas (Trick Play)</string>
<string name="pref_player_trick_play_summary">Requiere que el plugin Jellyscrub de nicknsy esté instalado en el servidor</string>
<string name="offline_mode">Modo sin conexión</string> <string name="offline_mode">Modo sin conexión</string>
<string name="offline_mode_icon">Icono de Modo sin conexión</string> <string name="offline_mode_icon">Icono de Modo sin conexión</string>
<string name="offline_mode_go_online">Conectarse</string> <string name="offline_mode_go_online">Conectarse</string>

View file

@ -142,7 +142,6 @@
<string name="add_address">Ajouter une adresse</string> <string name="add_address">Ajouter une adresse</string>
<string name="add_server_address">Ajouter l\'adresse d\'un serveur</string> <string name="add_server_address">Ajouter l\'adresse d\'un serveur</string>
<string name="add">Ajouter</string> <string name="add">Ajouter</string>
<string name="pref_player_trick_play_summary">Nécessite que le plugin Jellyscrub de Nicknsy soit installé sur le serveur</string>
<string name="temp">temporaire</string> <string name="temp">temporaire</string>
<string name="subtitle_chip_text">CC</string> <string name="subtitle_chip_text">CC</string>
<string name="player_gestures_seek">Déplacement du curseur de lecture</string> <string name="player_gestures_seek">Déplacement du curseur de lecture</string>
@ -166,7 +165,6 @@
<string name="no_server_connection">Aucune connection au serveur Jellyfin, pour regarder hors-ligne activer ce mode</string> <string name="no_server_connection">Aucune connection au serveur Jellyfin, pour regarder hors-ligne activer ce mode</string>
<string name="cancel_download_message">Êtes-vous sûr de vouloir arrêter le téléchargement \?</string> <string name="cancel_download_message">Êtes-vous sûr de vouloir arrêter le téléchargement \?</string>
<string name="app_language">Langue de l\'application</string> <string name="app_language">Langue de l\'application</string>
<string name="pref_player_trick_play">Lecture Spéciale</string>
<string name="offline_mode">Mode hors-ligne</string> <string name="offline_mode">Mode hors-ligne</string>
<string name="preparing_download">Préparation du téléchargement</string> <string name="preparing_download">Préparation du téléchargement</string>
<string name="downloaded_indicator">Indicateur de téléchargements</string> <string name="downloaded_indicator">Indicateur de téléchargements</string>

View file

@ -137,7 +137,6 @@
<string name="libraries">Könyvtárak</string> <string name="libraries">Könyvtárak</string>
<string name="remove_user_dialog_text">Biztosan el akarod távolítani a következő felhasználót: %1$s</string> <string name="remove_user_dialog_text">Biztosan el akarod távolítani a következő felhasználót: %1$s</string>
<string name="downloaded_indicator">Letöltött indikátor</string> <string name="downloaded_indicator">Letöltött indikátor</string>
<string name="pref_player_trick_play_summary">nicknsy Jellyscrub bővítményének telepítve kell legyen a szerveren</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string> <string name="episode_name_with_end">%1$d-%2$d. %3$s</string>
<string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string> <string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string>
<string name="offline_mode_go_online">Váltás online módra</string> <string name="offline_mode_go_online">Váltás online módra</string>
@ -170,7 +169,6 @@
<string name="subtitle_chip_text">Felirat</string> <string name="subtitle_chip_text">Felirat</string>
<string name="remove_server_address">Szerver címének eltávolítása</string> <string name="remove_server_address">Szerver címének eltávolítása</string>
<string name="remove_server_address_dialog_text">Biztosan el akarod távolítani a %1$s szervert</string> <string name="remove_server_address_dialog_text">Biztosan el akarod távolítani a %1$s szervert</string>
<string name="pref_player_trick_play">Trükkös játék</string>
<string name="player_gestures_seek">Gesztus keresése</string> <string name="player_gestures_seek">Gesztus keresése</string>
<string name="picture_in_picture">Kép a képben</string> <string name="picture_in_picture">Kép a képben</string>
<string name="picture_in_picture_gesture">Kép a képben otthoni gesztus</string> <string name="picture_in_picture_gesture">Kép a képben otthoni gesztus</string>

View file

@ -147,8 +147,6 @@
<string name="extra_info">Mostra più informazioni</string> <string name="extra_info">Mostra più informazioni</string>
<string name="amoled_theme">Tema scuro AMOLED</string> <string name="amoled_theme">Tema scuro AMOLED</string>
<string name="amoled_theme_summary">Usa il tema AMOLED con lo sfondo nero</string> <string name="amoled_theme_summary">Usa il tema AMOLED con lo sfondo nero</string>
<string name="pref_player_trick_play">Anteprima</string>
<string name="pref_player_trick_play_summary">Richiede il plugin Jellyscrub di nicknsy installato sul server</string>
<string name="size">Dimensione</string> <string name="size">Dimensione</string>
<string name="privacy_policy_notice">Utilizzando Findroid accetti l\'<a href="https://raw.githubusercontent.com/jarnedemeulemeester/findroid/main/PRIVACY">informativa sulla privacy</a> che afferma che non raccogliamo alcun dato</string> <string name="privacy_policy_notice">Utilizzando Findroid accetti l\'<a href="https://raw.githubusercontent.com/jarnedemeulemeester/findroid/main/PRIVACY">informativa sulla privacy</a> che afferma che non raccogliamo alcun dato</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string> <string name="episode_name_with_end">%1$d-%2$d. %3$s</string>

View file

@ -147,7 +147,6 @@
<string name="player_gestures_zoom_summary">צבוט כדי להציג את הוידאו במסך מלא</string> <string name="player_gestures_zoom_summary">צבוט כדי להציג את הוידאו במסך מלא</string>
<string name="seek_back_increment">גודל קפיצה אחורה (מילי שניות)</string> <string name="seek_back_increment">גודל קפיצה אחורה (מילי שניות)</string>
<string name="dynamic_colors_summary">השתמש בצבעים דינמיים של Material You (זמין רק בגרסת אנדרואיד 12 ומעלה)</string> <string name="dynamic_colors_summary">השתמש בצבעים דינמיים של Material You (זמין רק בגרסת אנדרואיד 12 ומעלה)</string>
<string name="pref_player_trick_play_summary">דורש תוסף Jellyscrub של nicknsy מותקן על השרת</string>
<string name="size">גודל</string> <string name="size">גודל</string>
<string name="offline_mode_icon">סמל מצב לא מקוון</string> <string name="offline_mode_icon">סמל מצב לא מקוון</string>
<string name="not_enough_storage">פריט זה דורש %1$s של מקום פנוי אבל רק %2$s זמינים</string> <string name="not_enough_storage">פריט זה דורש %1$s של מקום פנוי אבל רק %2$s זמינים</string>
@ -171,7 +170,6 @@
<string name="downloaded_indicator">מחוון הורדה</string> <string name="downloaded_indicator">מחוון הורדה</string>
<string name="remove_server_address">הסר כתובת שרת</string> <string name="remove_server_address">הסר כתובת שרת</string>
<string name="remove_server_address_dialog_text">האם אתה בטוח שברצונך להסיר את כתובת השרת %1$s</string> <string name="remove_server_address_dialog_text">האם אתה בטוח שברצונך להסיר את כתובת השרת %1$s</string>
<string name="pref_player_trick_play">Trick Play</string>
<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>
</resources> </resources>

View file

@ -136,8 +136,6 @@
<string name="quick_connect">Quick Connect</string> <string name="quick_connect">Quick Connect</string>
<string name="pref_player_intro_skipper">Intro Skipper 기능</string> <string name="pref_player_intro_skipper">Intro Skipper 기능</string>
<string name="pref_player_intro_skipper_summary">서버에 ConfusedPolarBear의 Intro Skipper 플러그인이 설치되어 있어야 합니다.</string> <string name="pref_player_intro_skipper_summary">서버에 ConfusedPolarBear의 Intro Skipper 플러그인이 설치되어 있어야 합니다.</string>
<string name="pref_player_trick_play_summary">nicknsy\'s Jellyscrub 플러그인이 서버에 설치되어 있어야 합니다</string>
<string name="pref_player_trick_play">Trick Play</string>
<string name="internal">내부</string> <string name="internal">내부</string>
<string name="storage_name">%1$s (%2$d MB 사용 가능)</string> <string name="storage_name">%1$s (%2$d MB 사용 가능)</string>
<string name="external">외부</string> <string name="external">외부</string>

View file

@ -161,14 +161,12 @@
<string name="amoled_theme">AMOLED donker thema</string> <string name="amoled_theme">AMOLED donker thema</string>
<string name="amoled_theme_summary">Gebruik AMOLED thema met een compleet zwarte achtergrond</string> <string name="amoled_theme_summary">Gebruik AMOLED thema met een compleet zwarte achtergrond</string>
<string name="downloading_error">Fout tijdens het downloaden</string> <string name="downloading_error">Fout tijdens het downloaden</string>
<string name="pref_player_trick_play_summary">Verzoek voor nicknsy\'s Jellyscrub te installeren op de server</string>
<string name="temp">tijdelijk</string> <string name="temp">tijdelijk</string>
<string name="pref_player_intro_skipper">Intro Overslaan</string> <string name="pref_player_intro_skipper">Intro Overslaan</string>
<string name="subtitle_chip_text">CC</string> <string name="subtitle_chip_text">CC</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string> <string name="episode_name_with_end">%1$d-%2$d. %3$s</string>
<string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string> <string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string>
<string name="downloaded_indicator">Gedownloade indicator</string> <string name="downloaded_indicator">Gedownloade indicator</string>
<string name="pref_player_trick_play">Trick Play</string>
<string name="player_gestures_seek">Zoek gebaar</string> <string name="player_gestures_seek">Zoek gebaar</string>
<string name="no_users_found">Geen gebruikers gevonden</string> <string name="no_users_found">Geen gebruikers gevonden</string>
<string name="picture_in_picture">Scherm-in-scherm</string> <string name="picture_in_picture">Scherm-in-scherm</string>

View file

@ -147,8 +147,6 @@
<string name="extra_info_summary">Wyświetla szczegółowe informacje o audio, wideo i napisach</string> <string name="extra_info_summary">Wyświetla szczegółowe informacje o audio, wideo i napisach</string>
<string name="amoled_theme">Ciemny motyw AMOLED</string> <string name="amoled_theme">Ciemny motyw AMOLED</string>
<string name="amoled_theme_summary">Użyj motywu AMOLED z czystym czarnym tłem</string> <string name="amoled_theme_summary">Użyj motywu AMOLED z czystym czarnym tłem</string>
<string name="pref_player_trick_play">Trick Play</string>
<string name="pref_player_trick_play_summary">Wymaga zainstalowania na serwerze wtyczki Nicknsy\'s Jellyscrub</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string> <string name="episode_name_with_end">%1$d-%2$d. %3$s</string>
<string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string> <string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string>
<string name="offline_mode">Tryb offline</string> <string name="offline_mode">Tryb offline</string>

View file

@ -147,8 +147,6 @@
<string name="amoled_theme_summary">Use o tema AMOLED com um fundo preto</string> <string name="amoled_theme_summary">Use o tema AMOLED com um fundo preto</string>
<string name="player_gestures_seek">Gesto de buscar</string> <string name="player_gestures_seek">Gesto de buscar</string>
<string name="player_gestures_seek_summary">Deslize horizontalmente para buscar para frente ou para trás</string> <string name="player_gestures_seek_summary">Deslize horizontalmente para buscar para frente ou para trás</string>
<string name="pref_player_trick_play_summary">Requer que o plug-in Jellyscrub do nicknsy seja instalado no servidor</string>
<string name="pref_player_trick_play">Miniatura de pré-visualização</string>
<string name="offline_mode_icon">Ícone do modo offline</string> <string name="offline_mode_icon">Ícone do modo offline</string>
<string name="not_enough_storage">Este item requer %1$s de armazenamento livre, mas apenas %2$s está disponível</string> <string name="not_enough_storage">Este item requer %1$s de armazenamento livre, mas apenas %2$s está disponível</string>
<string name="cancel_download_message">Tem certeza de que deseja cancelar o download\?</string> <string name="cancel_download_message">Tem certeza de que deseja cancelar o download\?</string>

View file

@ -125,14 +125,12 @@
<string name="pref_player_mpv_vo">Saida de video</string> <string name="pref_player_mpv_vo">Saida de video</string>
<string name="pref_player_mpv_ao">Saída de áudio</string> <string name="pref_player_mpv_ao">Saída de áudio</string>
<string name="add_address">Adicionar endereço</string> <string name="add_address">Adicionar endereço</string>
<string name="pref_player_trick_play">Jogo de truque</string>
<string name="episode_name">%1$d. %2$s</string> <string name="episode_name">%1$d. %2$s</string>
<string name="episode_name_extended">S%1$d:E%2$d - %3$s</string> <string name="episode_name_extended">S%1$d:E%2$d - %3$s</string>
<string name="remove_server_address_dialog_text">Tem certeza de que deseja remover o endereço do servidor %1$s</string> <string name="remove_server_address_dialog_text">Tem certeza de que deseja remover o endereço do servidor %1$s</string>
<string name="runtime_minutes">%1$d minutos</string> <string name="runtime_minutes">%1$d minutos</string>
<string name="pref_player_intro_skipper">Capitão de introdução</string> <string name="pref_player_intro_skipper">Capitão de introdução</string>
<string name="pref_player_intro_skipper_summary">Requer que o plugin Confused Polar Bears Intro Skipper esteja instalado no servidor</string> <string name="pref_player_intro_skipper_summary">Requer que o plugin Confused Polar Bears Intro Skipper esteja instalado no servidor</string>
<string name="pref_player_trick_play_summary">Requer que o plugin Jellyscrub do Nicknsy esteja instalado no servidor</string>
<string name="add_server_address">Adicionar endereço do servidor</string> <string name="add_server_address">Adicionar endereço do servidor</string>
<string name="player_gestures_seek">Procure gesto</string> <string name="player_gestures_seek">Procure gesto</string>
<string name="player_gestures_seek_summary">Deslize horizontalmente para avançar ou retroceder</string> <string name="player_gestures_seek_summary">Deslize horizontalmente para avançar ou retroceder</string>

View file

@ -80,7 +80,6 @@
<string name="pref_player_mpv_vo">Ieșire video</string> <string name="pref_player_mpv_vo">Ieșire video</string>
<string name="pref_player_mpv_ao">Ieșire audio</string> <string name="pref_player_mpv_ao">Ieșire audio</string>
<string name="pref_player_intro_skipper_summary">Necesită ca pluginul IntroSkipper de ConfusedPolarBear să fie instalat pe server</string> <string name="pref_player_intro_skipper_summary">Necesită ca pluginul IntroSkipper de ConfusedPolarBear să fie instalat pe server</string>
<string name="pref_player_trick_play_summary">Necesită ca pluginul Jellyscrub de nicknsy să fie instalat pe server</string>
<string name="addresses">Adrese</string> <string name="addresses">Adrese</string>
<string name="add_address">Adaugă o adresă</string> <string name="add_address">Adaugă o adresă</string>
<string name="add_server_address">Adaugă o adresă de server</string> <string name="add_server_address">Adaugă o adresă de server</string>

View file

@ -136,8 +136,6 @@
<string name="settings_request_timeout">Время ожидания запроса (мс)</string> <string name="settings_request_timeout">Время ожидания запроса (мс)</string>
<string name="settings_connect_timeout">Время ожидания соединения (мс)</string> <string name="settings_connect_timeout">Время ожидания соединения (мс)</string>
<string name="settings_socket_timeout">Время ожидания сокета (мс)</string> <string name="settings_socket_timeout">Время ожидания сокета (мс)</string>
<string name="pref_player_trick_play_summary">Требуется, чтобы на сервере был установлен плагин Jellyscrub от nicknsy</string>
<string name="pref_player_trick_play">Миниатюры предпросмотра (Trick Play)</string>
<string name="subtitle">Субтитры</string> <string name="subtitle">Субтитры</string>
<string name="extra_info_summary">Показывает дополнительную информацию о Аудио, Видео и Субтитрах</string> <string name="extra_info_summary">Показывает дополнительную информацию о Аудио, Видео и Субтитрах</string>
<string name="extra_info">Показывать дополнительную информацию</string> <string name="extra_info">Показывать дополнительную информацию</string>

View file

@ -147,8 +147,6 @@
<string name="temp">dočasné</string> <string name="temp">dočasné</string>
<string name="amoled_theme">AMOLED temný motív</string> <string name="amoled_theme">AMOLED temný motív</string>
<string name="amoled_theme_summary">Použiť AMOLED motív s úplne čiernym pozadím</string> <string name="amoled_theme_summary">Použiť AMOLED motív s úplne čiernym pozadím</string>
<string name="pref_player_trick_play">Miniatúry (Trick Play)</string>
<string name="pref_player_trick_play_summary">Potrebuje aby bol na serveri nainštalovaný Jellyscrub plugin od nicknsy</string>
<string name="episode_name_with_end">%1$d:%2$d. %3$s</string> <string name="episode_name_with_end">%1$d:%2$d. %3$s</string>
<string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string> <string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string>
<string name="offline_mode">Offline Režim</string> <string name="offline_mode">Offline Režim</string>

View file

@ -139,7 +139,6 @@
<string name="track_selection">[%1$s] %2$s (%3$s)</string> <string name="track_selection">[%1$s] %2$s (%3$s)</string>
<string name="settings_connect_timeout">Časovna omejitev povezave (ms)</string> <string name="settings_connect_timeout">Časovna omejitev povezave (ms)</string>
<string name="cancel_download_message">Ali ste prepričani, da želite preklicati prenos\?</string> <string name="cancel_download_message">Ali ste prepričani, da želite preklicati prenos\?</string>
<string name="pref_player_trick_play_summary">Zahteva namestitev vtičnika nicknsy Jellyscrub na strežniku</string>
<string name="size">Velikost</string> <string name="size">Velikost</string>
<string name="offline_mode">Način brez povezave</string> <string name="offline_mode">Način brez povezave</string>
<string name="offline_mode_go_online">izhod iz načina brez povezave</string> <string name="offline_mode_go_online">izhod iz načina brez povezave</string>

View file

@ -93,7 +93,6 @@
<string name="add_user">Lägg till användare</string> <string name="add_user">Lägg till användare</string>
<string name="pref_player_mpv_vo">Videooutput</string> <string name="pref_player_mpv_vo">Videooutput</string>
<string name="pref_player_mpv_ao">Ljudoutput</string> <string name="pref_player_mpv_ao">Ljudoutput</string>
<string name="pref_player_trick_play">Trickspel</string>
<string name="addresses">Adresser</string> <string name="addresses">Adresser</string>
<string name="add_address">Lägg till adress</string> <string name="add_address">Lägg till adress</string>
<string name="add_server_address">Lägg till serveradress</string> <string name="add_server_address">Lägg till serveradress</string>
@ -146,7 +145,6 @@
<string name="pref_player_mpv_hwdec">Hårdvaruavkodning</string> <string name="pref_player_mpv_hwdec">Hårdvaruavkodning</string>
<string name="pref_player_intro_skipper">Introskippare</string> <string name="pref_player_intro_skipper">Introskippare</string>
<string name="pref_player_intro_skipper_summary">Kräver ConfusedPolarBears Intro Skipper-plugin installerat på servern</string> <string name="pref_player_intro_skipper_summary">Kräver ConfusedPolarBears Intro Skipper-plugin installerat på servern</string>
<string name="pref_player_trick_play_summary">Kräver nicknsys Jellyscrub-plugin installerat på servern</string>
<string name="add_server_error_outdated">Serverversionen är ej aktuell: %1$s. Vänligen uppdatera din server</string> <string name="add_server_error_outdated">Serverversionen är ej aktuell: %1$s. Vänligen uppdatera din server</string>
<string name="add_server_error_version">Serverversion stöds ej: %1$s Vänligen uppdatera din server</string> <string name="add_server_error_version">Serverversion stöds ej: %1$s Vänligen uppdatera din server</string>
</resources> </resources>

View file

@ -100,8 +100,6 @@
<string name="pref_player_mpv_ao">Виведення аудіо</string> <string name="pref_player_mpv_ao">Виведення аудіо</string>
<string name="pref_player_intro_skipper">Intro Skipper (Пропуск інтро)</string> <string name="pref_player_intro_skipper">Intro Skipper (Пропуск інтро)</string>
<string name="pref_player_intro_skipper_summary">Потребує встановлення на сервері плагіна Intro Skipper від ConfusedPolarBear</string> <string name="pref_player_intro_skipper_summary">Потребує встановлення на сервері плагіна Intro Skipper від ConfusedPolarBear</string>
<string name="pref_player_trick_play">Мініатюри попереднього перегляду (Trick Play)</string>
<string name="pref_player_trick_play_summary">Потрібно встановити на сервері плагін Jellyscrub від nicknsy</string>
<string name="addresses">Адреси</string> <string name="addresses">Адреси</string>
<string name="add_address">Додати адресу</string> <string name="add_address">Додати адресу</string>
<string name="add_server_address">Додати адресу сервера</string> <string name="add_server_address">Додати адресу сервера</string>

View file

@ -156,9 +156,7 @@
<string name="temp">tạm</string> <string name="temp">tạm</string>
<string name="extra_info">Hiện thị thêm thông tin</string> <string name="extra_info">Hiện thị thêm thông tin</string>
<string name="video">Video</string> <string name="video">Video</string>
<string name="pref_player_trick_play">Tua mượt mà</string>
<string name="extra_info_summary">Hiển thị thêm các thông tin về âm thanh, video và phụ đề</string> <string name="extra_info_summary">Hiển thị thêm các thông tin về âm thanh, video và phụ đề</string>
<string name="pref_player_trick_play_summary">Yêu cầu phần mở rộng Jellyscrub của nicknsy đã được cài đặt trên máy chủ</string>
<string name="storage_name">%1$s (%2$d MB trống)</string> <string name="storage_name">%1$s (%2$d MB trống)</string>
<string name="amoled_theme">Chủ đề màu tối (AMOLED)</string> <string name="amoled_theme">Chủ đề màu tối (AMOLED)</string>
<string name="amoled_theme_summary">Sử dụng chủ đề cho màn hình AMOLED với nền màu đen tuyệt đối</string> <string name="amoled_theme_summary">Sử dụng chủ đề cho màn hình AMOLED với nền màu đen tuyệt đối</string>

View file

@ -145,8 +145,6 @@
<string name="extra_info">显示额外信息</string> <string name="extra_info">显示额外信息</string>
<string name="extra_info_summary">显示关于音频、视频和字幕的详细信息</string> <string name="extra_info_summary">显示关于音频、视频和字幕的详细信息</string>
<string name="temp">temp</string> <string name="temp">temp</string>
<string name="pref_player_trick_play">跳转预览</string>
<string name="pref_player_trick_play_summary">需要在服务器上安装 nicknsy 的 Jellyscrub 插件</string>
<string name="amoled_theme">AMOLED 深色模式</string> <string name="amoled_theme">AMOLED 深色模式</string>
<string name="amoled_theme_summary">使用带有纯黑背景的 AMOLED 主题</string> <string name="amoled_theme_summary">使用带有纯黑背景的 AMOLED 主题</string>
<string name="offline_mode_go_online">上线</string> <string name="offline_mode_go_online">上线</string>

View file

@ -138,7 +138,6 @@
<string name="add">添加</string> <string name="add">添加</string>
<string name="episode_name_with_end">%1$d-%2$d. %3$s</string> <string name="episode_name_with_end">%1$d-%2$d. %3$s</string>
<string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string> <string name="episode_name_extended_with_end">S%1$d:E%2$d-%3$d - %4$s</string>
<string name="pref_player_trick_play_summary">需要在服務器上安裝 nicknsy 的 Jellyscrub 插件</string>
<string name="downloading_error">下載時出錯</string> <string name="downloading_error">下載時出錯</string>
<string name="size">大小</string> <string name="size">大小</string>
<string name="no_server_connection">未連接到 Jellyfin 服務器,要離線觀看請啟用離線模式</string> <string name="no_server_connection">未連接到 Jellyfin 服務器,要離線觀看請啟用離線模式</string>
@ -173,7 +172,6 @@
<string name="picture_in_picture_gesture_summary">影片播放時使用主頁按鈕或手勢進入畫中畫</string> <string name="picture_in_picture_gesture_summary">影片播放時使用主頁按鈕或手勢進入畫中畫</string>
<string name="remove_server_address">刪除伺服器位址</string> <string name="remove_server_address">刪除伺服器位址</string>
<string name="remove_server_address_dialog_text">您確定要刪除伺服器位址嗎%1$s</string> <string name="remove_server_address_dialog_text">您確定要刪除伺服器位址嗎%1$s</string>
<string name="pref_player_trick_play">跳轉預覽</string>
<string name="temp">臨時文件</string> <string name="temp">臨時文件</string>
<string name="no_servers_found">未找到伺服器</string> <string name="no_servers_found">未找到伺服器</string>
<string name="no_users_found">未找到相應的用戶</string> <string name="no_users_found">未找到相應的用戶</string>

View file

@ -146,8 +146,8 @@
<string name="pref_player_mpv_ao">Audio output</string> <string name="pref_player_mpv_ao">Audio output</string>
<string name="pref_player_intro_skipper">Intro Skipper</string> <string name="pref_player_intro_skipper">Intro Skipper</string>
<string name="pref_player_intro_skipper_summary">Requires ConfusedPolarBear\'s Intro Skipper plugin to be installed on the server</string> <string name="pref_player_intro_skipper_summary">Requires ConfusedPolarBear\'s Intro Skipper plugin to be installed on the server</string>
<string name="pref_player_trick_play">Trick Play</string> <string name="pref_player_trickplay">Trickplay</string>
<string name="pref_player_trick_play_summary">Requires nicknsy\'s Jellyscrub plugin to be installed on the server</string> <string name="pref_player_trickplay_summary">Display preview images while scrubbing</string>
<string name="pref_player_chapter_markers">Chapter markers</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="pref_player_chapter_markers_summary">Display chapter markers on the timebar</string>
<string name="addresses">Addresses</string> <string name="addresses">Addresses</string>

View file

@ -98,9 +98,9 @@
<SwitchPreferenceCompat <SwitchPreferenceCompat
app:defaultValue="true" app:defaultValue="true"
app:key="pref_player_trick_play" app:key="pref_player_trickplay"
app:summary="@string/pref_player_trick_play_summary" app:summary="@string/pref_player_trickplay_summary"
app:title="@string/pref_player_trick_play" app:title="@string/pref_player_trickplay"
app:widgetLayout="@layout/preference_material3_switch" /> app:widgetLayout="@layout/preference_material3_switch" />
<SwitchPreferenceCompat <SwitchPreferenceCompat

View file

@ -0,0 +1,873 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "d675d95afd81500a1121fe8b696cd070",
"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": "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": []
},
{
"tableName": "trickplayInfos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `tileWidth` INTEGER NOT NULL, `tileHeight` INTEGER NOT NULL, `thumbnailCount` INTEGER NOT NULL, `interval` INTEGER NOT NULL, `bandwidth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tileWidth",
"columnName": "tileWidth",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tileHeight",
"columnName": "tileHeight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailCount",
"columnName": "thumbnailCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "interval",
"columnName": "interval",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bandwidth",
"columnName": "bandwidth",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId"
]
},
"indices": [],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"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, 'd675d95afd81500a1121fe8b696cd070')"
]
}
}

View file

@ -12,6 +12,7 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.sessionApi import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -59,6 +60,7 @@ class JellyfinApi(
val sessionApi = api.sessionApi val sessionApi = api.sessionApi
val showsApi = api.tvShowsApi val showsApi = api.tvShowsApi
val systemApi = api.systemApi val systemApi = api.systemApi
val trickplayApi = api.trickplayApi
val userApi = api.userApi val userApi = api.userApi
val userLibraryApi = api.userLibraryApi val userLibraryApi = api.userLibraryApi
val videosApi = api.videosApi val videosApi = api.videosApi

View file

@ -2,30 +2,36 @@ package dev.jdtech.jellyfin.database
import androidx.room.AutoMigration import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.DeleteTable
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import dev.jdtech.jellyfin.models.FindroidEpisodeDto import dev.jdtech.jellyfin.models.FindroidEpisodeDto
import dev.jdtech.jellyfin.models.FindroidMediaStreamDto import dev.jdtech.jellyfin.models.FindroidMediaStreamDto
import dev.jdtech.jellyfin.models.FindroidMovieDto import dev.jdtech.jellyfin.models.FindroidMovieDto
import dev.jdtech.jellyfin.models.FindroidSeasonDto import dev.jdtech.jellyfin.models.FindroidSeasonDto
import dev.jdtech.jellyfin.models.FindroidShowDto import dev.jdtech.jellyfin.models.FindroidShowDto
import dev.jdtech.jellyfin.models.FindroidSourceDto import dev.jdtech.jellyfin.models.FindroidSourceDto
import dev.jdtech.jellyfin.models.FindroidTrickplayInfoDto
import dev.jdtech.jellyfin.models.FindroidUserDataDto import dev.jdtech.jellyfin.models.FindroidUserDataDto
import dev.jdtech.jellyfin.models.IntroDto import dev.jdtech.jellyfin.models.IntroDto
import dev.jdtech.jellyfin.models.Server import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.ServerAddress import dev.jdtech.jellyfin.models.ServerAddress
import dev.jdtech.jellyfin.models.TrickPlayManifestDto
import dev.jdtech.jellyfin.models.User import dev.jdtech.jellyfin.models.User
@Database( @Database(
entities = [Server::class, ServerAddress::class, User::class, FindroidMovieDto::class, FindroidShowDto::class, FindroidSeasonDto::class, FindroidEpisodeDto::class, FindroidSourceDto::class, FindroidMediaStreamDto::class, TrickPlayManifestDto::class, IntroDto::class, FindroidUserDataDto::class], entities = [Server::class, ServerAddress::class, User::class, FindroidMovieDto::class, FindroidShowDto::class, FindroidSeasonDto::class, FindroidEpisodeDto::class, FindroidSourceDto::class, FindroidMediaStreamDto::class, IntroDto::class, FindroidUserDataDto::class, FindroidTrickplayInfoDto::class],
version = 4, version = 5,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 2, to = 3), AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4), AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5, spec = ServerDatabase.TrickplayMigration::class),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class ServerDatabase : RoomDatabase() { abstract class ServerDatabase : RoomDatabase() {
abstract fun getServerDatabaseDao(): ServerDatabaseDao abstract fun getServerDatabaseDao(): ServerDatabaseDao
@DeleteTable(tableName = "trickPlayManifests")
class TrickplayMigration : AutoMigrationSpec
} }

View file

@ -12,6 +12,7 @@ import dev.jdtech.jellyfin.models.FindroidMovieDto
import dev.jdtech.jellyfin.models.FindroidSeasonDto import dev.jdtech.jellyfin.models.FindroidSeasonDto
import dev.jdtech.jellyfin.models.FindroidShowDto import dev.jdtech.jellyfin.models.FindroidShowDto
import dev.jdtech.jellyfin.models.FindroidSourceDto import dev.jdtech.jellyfin.models.FindroidSourceDto
import dev.jdtech.jellyfin.models.FindroidTrickplayInfoDto
import dev.jdtech.jellyfin.models.FindroidUserDataDto import dev.jdtech.jellyfin.models.FindroidUserDataDto
import dev.jdtech.jellyfin.models.IntroDto import dev.jdtech.jellyfin.models.IntroDto
import dev.jdtech.jellyfin.models.Server import dev.jdtech.jellyfin.models.Server
@ -20,7 +21,6 @@ import dev.jdtech.jellyfin.models.ServerWithAddressAndUser
import dev.jdtech.jellyfin.models.ServerWithAddresses import dev.jdtech.jellyfin.models.ServerWithAddresses
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
import dev.jdtech.jellyfin.models.ServerWithUsers import dev.jdtech.jellyfin.models.ServerWithUsers
import dev.jdtech.jellyfin.models.TrickPlayManifestDto
import dev.jdtech.jellyfin.models.User import dev.jdtech.jellyfin.models.User
import java.util.UUID import java.util.UUID
@ -147,15 +147,6 @@ interface ServerDatabaseDao {
@Query("UPDATE userdata SET favorite = :favorite WHERE userId = :userId AND itemId = :itemId") @Query("UPDATE userdata SET favorite = :favorite WHERE userId = :userId AND itemId = :itemId")
fun setFavorite(userId: UUID, itemId: UUID, favorite: Boolean) fun setFavorite(userId: UUID, itemId: UUID, favorite: Boolean)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertTrickPlayManifest(trickPlayManifestDto: TrickPlayManifestDto)
@Query("SELECT * FROM trickPlayManifests WHERE itemId = :itemId")
fun getTrickPlayManifest(itemId: UUID): TrickPlayManifestDto?
@Query("DELETE FROM trickPlayManifests WHERE itemId = :itemId")
fun deleteTrickPlayManifest(itemId: UUID)
@Query("SELECT * FROM movies ORDER BY name ASC") @Query("SELECT * FROM movies ORDER BY name ASC")
fun getMovies(): List<FindroidMovieDto> fun getMovies(): List<FindroidMovieDto>
@ -270,4 +261,10 @@ interface ServerDatabaseDao {
@Query("SELECT * FROM episodes WHERE serverId = :serverId AND name LIKE '%' || :name || '%'") @Query("SELECT * FROM episodes WHERE serverId = :serverId AND name LIKE '%' || :name || '%'")
fun searchEpisodes(serverId: String, name: String): List<FindroidEpisodeDto> fun searchEpisodes(serverId: String, name: String): List<FindroidEpisodeDto>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertTrickplayInfo(trickplayInfoDto: FindroidTrickplayInfoDto)
@Query("SELECT * FROM trickplayInfos WHERE sourceId = :sourceId")
fun getTrickplayInfo(sourceId: String): FindroidTrickplayInfoDto?
} }

View file

@ -32,6 +32,7 @@ data class FindroidEpisode(
val missing: Boolean = false, val missing: Boolean = false,
override val images: FindroidImages, override val images: FindroidImages,
override val chapters: List<FindroidChapter>?, override val chapters: List<FindroidChapter>?,
override val trickplayInfo: Map<String, FindroidTrickplayInfo>?,
) : FindroidItem, FindroidSources ) : FindroidItem, FindroidSources
suspend fun BaseItemDto.toFindroidEpisode( suspend fun BaseItemDto.toFindroidEpisode(
@ -67,6 +68,7 @@ suspend fun BaseItemDto.toFindroidEpisode(
missing = locationType == LocationType.VIRTUAL, missing = locationType == LocationType.VIRTUAL,
images = toFindroidImages(jellyfinRepository), images = toFindroidImages(jellyfinRepository),
chapters = toFindroidChapters(), chapters = toFindroidChapters(),
trickplayInfo = trickplay?.mapValues { it.value[it.value.keys.max()]!!.toFindroidTrickplayInfo() },
) )
} catch (_: NullPointerException) { } catch (_: NullPointerException) {
null null
@ -75,6 +77,13 @@ suspend fun BaseItemDto.toFindroidEpisode(
fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UUID): FindroidEpisode { fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UUID): FindroidEpisode {
val userData = database.getUserDataOrCreateNew(id, userId) val userData = database.getUserDataOrCreateNew(id, userId)
val sources = database.getSources(id).map { it.toFindroidSource(database) }
val trickplayInfos = mutableMapOf<String, FindroidTrickplayInfo>()
for (source in sources) {
database.getTrickplayInfo(source.id)?.toFindroidTrickplayInfo()?.let {
trickplayInfos[source.id] = it
}
}
return FindroidEpisode( return FindroidEpisode(
id = id, id = id,
name = name, name = name,
@ -83,7 +92,7 @@ fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UU
indexNumber = indexNumber, indexNumber = indexNumber,
indexNumberEnd = indexNumberEnd, indexNumberEnd = indexNumberEnd,
parentIndexNumber = parentIndexNumber, parentIndexNumber = parentIndexNumber,
sources = database.getSources(id).map { it.toFindroidSource(database) }, sources = sources,
played = userData.played, played = userData.played,
favorite = userData.favorite, favorite = userData.favorite,
canPlay = true, canPlay = true,
@ -97,5 +106,6 @@ fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UU
communityRating = communityRating, communityRating = communityRating,
images = FindroidImages(), images = FindroidImages(),
chapters = chapters, chapters = chapters,
trickplayInfo = trickplayInfos,
) )
} }

View file

@ -32,6 +32,7 @@ data class FindroidMovie(
override val unplayedItemCount: Int? = null, override val unplayedItemCount: Int? = null,
override val images: FindroidImages, override val images: FindroidImages,
override val chapters: List<FindroidChapter>?, override val chapters: List<FindroidChapter>?,
override val trickplayInfo: Map<String, FindroidTrickplayInfo>?,
) : FindroidItem, FindroidSources ) : FindroidItem, FindroidSources
suspend fun BaseItemDto.toFindroidMovie( suspend fun BaseItemDto.toFindroidMovie(
@ -66,11 +67,19 @@ suspend fun BaseItemDto.toFindroidMovie(
trailer = remoteTrailers?.getOrNull(0)?.url, trailer = remoteTrailers?.getOrNull(0)?.url,
images = toFindroidImages(jellyfinRepository), images = toFindroidImages(jellyfinRepository),
chapters = toFindroidChapters(), chapters = toFindroidChapters(),
trickplayInfo = trickplay?.mapValues { it.value[it.value.keys.max()]!!.toFindroidTrickplayInfo() },
) )
} }
fun FindroidMovieDto.toFindroidMovie(database: ServerDatabaseDao, userId: UUID): FindroidMovie { fun FindroidMovieDto.toFindroidMovie(database: ServerDatabaseDao, userId: UUID): FindroidMovie {
val userData = database.getUserDataOrCreateNew(id, userId) val userData = database.getUserDataOrCreateNew(id, userId)
val sources = database.getSources(id).map { it.toFindroidSource(database) }
val trickplayInfos = mutableMapOf<String, FindroidTrickplayInfo>()
for (source in sources) {
database.getTrickplayInfo(source.id)?.toFindroidTrickplayInfo()?.let {
trickplayInfos[source.id] = it
}
}
return FindroidMovie( return FindroidMovie(
id = id, id = id,
name = name, name = name,
@ -94,5 +103,6 @@ fun FindroidMovieDto.toFindroidMovie(database: ServerDatabaseDao, userId: UUID):
trailer = null, trailer = null,
images = FindroidImages(), images = FindroidImages(),
chapters = chapters, chapters = chapters,
trickplayInfo = trickplayInfos,
) )
} }

View file

@ -3,4 +3,5 @@ package dev.jdtech.jellyfin.models
interface FindroidSources { interface FindroidSources {
val sources: List<FindroidSource> val sources: List<FindroidSource>
val runtimeTicks: Long val runtimeTicks: Long
val trickplayInfo: Map<String, FindroidTrickplayInfo>?
} }

View file

@ -0,0 +1,37 @@
package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.TrickplayInfo
data class FindroidTrickplayInfo(
val width: Int,
val height: Int,
val tileWidth: Int,
val tileHeight: Int,
val thumbnailCount: Int,
val interval: Int,
val bandwidth: Int,
)
fun TrickplayInfo.toFindroidTrickplayInfo(): FindroidTrickplayInfo {
return FindroidTrickplayInfo(
width = width,
height = height,
tileWidth = tileWidth,
tileHeight = tileHeight,
thumbnailCount = thumbnailCount,
interval = interval,
bandwidth = bandwidth,
)
}
fun FindroidTrickplayInfoDto.toFindroidTrickplayInfo(): FindroidTrickplayInfo {
return FindroidTrickplayInfo(
width = width,
height = height,
tileWidth = tileWidth,
tileHeight = tileHeight,
thumbnailCount = thumbnailCount,
interval = interval,
bandwidth = bandwidth,
)
}

View file

@ -0,0 +1,41 @@
package dev.jdtech.jellyfin.models
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "trickplayInfos",
foreignKeys = [
ForeignKey(
entity = FindroidSourceDto::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("sourceId"),
onDelete = ForeignKey.CASCADE,
),
],
)
data class FindroidTrickplayInfoDto(
@PrimaryKey
val sourceId: String,
val width: Int,
val height: Int,
val tileWidth: Int,
val tileHeight: Int,
val thumbnailCount: Int,
val interval: Int,
val bandwidth: Int,
)
fun FindroidTrickplayInfo.toFindroidTrickplayInfoDto(sourceId: String): FindroidTrickplayInfoDto {
return FindroidTrickplayInfoDto(
sourceId = sourceId,
width = width,
height = height,
tileWidth = tileWidth,
tileHeight = tileHeight,
thumbnailCount = thumbnailCount,
interval = interval,
bandwidth = bandwidth,
)
}

View file

@ -1,19 +0,0 @@
package dev.jdtech.jellyfin.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TrickPlayManifest(
@SerialName("Version")
val version: String,
@SerialName("WidthResolutions")
val widthResolutions: List<Int>,
)
fun TrickPlayManifestDto.toTrickPlayManifest(): TrickPlayManifest {
return TrickPlayManifest(
version = version,
widthResolutions = listOf(resolution),
)
}

View file

@ -1,21 +0,0 @@
package dev.jdtech.jellyfin.models
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "trickPlayManifests")
data class TrickPlayManifestDto(
@PrimaryKey
val itemId: UUID,
val version: String,
val resolution: Int,
)
fun TrickPlayManifest.toTrickPlayManifestDto(itemId: UUID): TrickPlayManifestDto {
return TrickPlayManifestDto(
itemId = itemId,
version = version,
resolution = widthResolutions.max(),
)
}

View file

@ -10,7 +10,6 @@ import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.models.FindroidSource import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.models.SortBy
import dev.jdtech.jellyfin.models.TrickPlayManifest
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -86,9 +85,7 @@ interface JellyfinRepository {
suspend fun getIntroTimestamps(itemId: UUID): Intro? suspend fun getIntroTimestamps(itemId: UUID): Intro?
suspend fun getTrickPlayManifest(itemId: UUID): TrickPlayManifest? suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray?
suspend fun getTrickPlayData(itemId: UUID, width: Int): ByteArray?
suspend fun postCapabilities() suspend fun postCapabilities()

View file

@ -16,7 +16,6 @@ import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.models.FindroidSource import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.models.SortBy
import dev.jdtech.jellyfin.models.TrickPlayManifest
import dev.jdtech.jellyfin.models.toFindroidCollection import dev.jdtech.jellyfin.models.toFindroidCollection
import dev.jdtech.jellyfin.models.toFindroidEpisode import dev.jdtech.jellyfin.models.toFindroidEpisode
import dev.jdtech.jellyfin.models.toFindroidItem import dev.jdtech.jellyfin.models.toFindroidItem
@ -25,9 +24,7 @@ import dev.jdtech.jellyfin.models.toFindroidSeason
import dev.jdtech.jellyfin.models.toFindroidShow import dev.jdtech.jellyfin.models.toFindroidShow
import dev.jdtech.jellyfin.models.toFindroidSource import dev.jdtech.jellyfin.models.toFindroidSource
import dev.jdtech.jellyfin.models.toIntro import dev.jdtech.jellyfin.models.toIntro
import dev.jdtech.jellyfin.models.toTrickPlayManifest import io.ktor.util.toByteArray
import io.ktor.util.cio.toByteArray
import io.ktor.utils.io.ByteReadChannel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -361,46 +358,17 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getTrickPlayManifest(itemId: UUID): TrickPlayManifest? = override suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val trickPlayManifest = database.getTrickPlayManifest(itemId)
if (trickPlayManifest != null) {
return@withContext trickPlayManifest.toTrickPlayManifest()
}
// https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/Api/TrickplayController.cs
val pathParameters = mutableMapOf<String, UUID>()
pathParameters["itemId"] = itemId
try { try {
return@withContext jellyfinApi.api.get<TrickPlayManifest>( try {
"/Trickplay/{itemId}/GetManifest", val sources = File(context.filesDir, "trickplay/$itemId").listFiles()
pathParameters, if (sources != null) {
).content return@withContext File(sources.first(), index.toString()).readBytes()
} catch (e: Exception) { }
return@withContext null } catch (_: Exception) { }
}
}
override suspend fun getTrickPlayData(itemId: UUID, width: Int): ByteArray? = return@withContext jellyfinApi.trickplayApi.getTrickplayTileImage(itemId, width, index).content.toByteArray()
withContext(Dispatchers.IO) {
val trickPlayManifest = database.getTrickPlayManifest(itemId)
if (trickPlayManifest != null) {
return@withContext File(
context.filesDir,
"trickplay/$itemId.bif",
).readBytes()
}
// https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/Api/TrickplayController.cs
val pathParameters = mutableMapOf<String, Any>()
pathParameters["itemId"] = itemId
pathParameters["width"] = width
try {
return@withContext jellyfinApi.api.get<ByteReadChannel>(
"/Trickplay/{itemId}/{width}/GetBIF",
pathParameters,
).content.toByteArray()
} catch (e: Exception) { } catch (e: Exception) {
return@withContext null return@withContext null
} }

View file

@ -14,14 +14,12 @@ import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.models.FindroidSource import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.models.SortBy
import dev.jdtech.jellyfin.models.TrickPlayManifest
import dev.jdtech.jellyfin.models.toFindroidEpisode import dev.jdtech.jellyfin.models.toFindroidEpisode
import dev.jdtech.jellyfin.models.toFindroidMovie import dev.jdtech.jellyfin.models.toFindroidMovie
import dev.jdtech.jellyfin.models.toFindroidSeason import dev.jdtech.jellyfin.models.toFindroidSeason
import dev.jdtech.jellyfin.models.toFindroidShow import dev.jdtech.jellyfin.models.toFindroidShow
import dev.jdtech.jellyfin.models.toFindroidSource import dev.jdtech.jellyfin.models.toFindroidSource
import dev.jdtech.jellyfin.models.toIntro import dev.jdtech.jellyfin.models.toIntro
import dev.jdtech.jellyfin.models.toTrickPlayManifest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -184,21 +182,14 @@ class JellyfinRepositoryOfflineImpl(
database.getIntro(itemId)?.toIntro() database.getIntro(itemId)?.toIntro()
} }
override suspend fun getTrickPlayManifest(itemId: UUID): TrickPlayManifest? = override suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.getTrickPlayManifest(itemId)?.toTrickPlayManifest() try {
} val sources = File(context.filesDir, "trickplay/$itemId").listFiles() ?: return@withContext null
File(sources.first(), index.toString()).readBytes()
override suspend fun getTrickPlayData(itemId: UUID, width: Int): ByteArray? = } catch (e: Exception) {
withContext(Dispatchers.IO) { null
val trickPlayManifest = database.getTrickPlayManifest(itemId)
if (trickPlayManifest != null) {
return@withContext File(
context.filesDir,
"trickplay/$itemId.bif",
).readBytes()
} }
null
} }
override suspend fun postCapabilities() {} override suspend fun postCapabilities() {}

View file

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

View file

@ -0,0 +1,8 @@
package dev.jdtech.jellyfin.models
import android.graphics.Bitmap
data class Trickplay(
val interval: Int,
val images: List<Bitmap>,
)

View file

@ -0,0 +1,15 @@
package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class TrickplayInfo(
val width: Int,
val height: Int,
val tileWidth: Int,
val tileHeight: Int,
val thumbnailCount: Int,
val interval: Int,
val bandwidth: Int,
) : Parcelable

View file

@ -1,11 +0,0 @@
package dev.jdtech.jellyfin.utils.bif
import android.graphics.Bitmap
data class BifData(
val version: Int,
val timestampMultiplier: Int,
val imageCount: Int,
val images: Map<Int, Bitmap>,
val imageWidth: Int,
)

View file

@ -1,3 +0,0 @@
package dev.jdtech.jellyfin.utils.bif
data class BifIndexEntry(val timestamp: Int, val offset: Int)

View file

@ -1,77 +0,0 @@
package dev.jdtech.jellyfin.utils.bif
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import timber.log.Timber
import java.nio.ByteBuffer
object BifUtil {
/* https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/Api/trickplay.js */
private val BIF_MAGIC_NUMBERS = byteArrayOf(0x89.toByte(), 0x42.toByte(), 0x49.toByte(), 0x46.toByte(), 0x0D.toByte(), 0x0A.toByte(), 0x1A.toByte(), 0x0A.toByte())
private const val SUPPORTED_BIF_VERSION = 0
fun trickPlayDecode(array: ByteArray, width: Int): BifData? {
val data = Indexed8Array(array)
Timber.d("BIF file size: ${data.limit()}")
for (b in BIF_MAGIC_NUMBERS) {
if (data.read() != b) {
Timber.d("Attempted to read invalid bif file.")
return null
}
}
val bifVersion = data.readInt32()
if (bifVersion != SUPPORTED_BIF_VERSION) {
Timber.d("Client only supports BIF v$SUPPORTED_BIF_VERSION but file is v$bifVersion")
return null
}
Timber.d("BIF version: $bifVersion")
val bifImgCount = data.readInt32()
if (bifImgCount <= 0) {
Timber.d("BIF file contains no images.")
return null
}
Timber.d("BIF image count: $bifImgCount")
var timestampMultiplier = data.readInt32()
if (timestampMultiplier == 0) timestampMultiplier = 1000
data.addPosition(44) // Reserved
val bifIndex = mutableListOf<BifIndexEntry>()
for (i in 0 until bifImgCount) {
bifIndex.add(BifIndexEntry(data.readInt32(), data.readInt32()))
}
val bifImages = mutableMapOf<Int, Bitmap>()
for (i in bifIndex.indices) {
val indexEntry = bifIndex[i]
val timestamp = indexEntry.timestamp
val offset = indexEntry.offset
val nextOffset = bifIndex.getOrNull(i + 1)?.offset ?: data.limit()
val imageBuffer = ByteBuffer.wrap(data.array(), offset, nextOffset - offset).order(data.order())
val imageBytes = ByteArray(imageBuffer.remaining())
imageBuffer.get(imageBytes)
val bmp = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
bifImages[timestamp] = bmp
}
return BifData(bifVersion, timestampMultiplier, bifImgCount, bifImages, width)
}
fun getTrickPlayFrame(playerTimestamp: Int, data: BifData): Bitmap? {
val multiplier = data.timestampMultiplier
val images = data.images
val frame = playerTimestamp / multiplier
return images[frame]
}
}

View file

@ -1,37 +0,0 @@
package dev.jdtech.jellyfin.utils.bif
import java.nio.ByteOrder
class Indexed8Array(private val array: ByteArray) {
private var readIndex = 0
fun read(): Byte {
return array[readIndex++]
}
fun addPosition(amount: Int) {
readIndex += amount
}
fun readInt32(): Int {
val b1 = read().toInt().and(0xFF)
val b2 = read().toInt().and(0xFF)
val b3 = read().toInt().and(0xFF)
val b4 = read().toInt().and(0xFF)
return b1 or (b2 shl 8) or (b3 shl 16) or (b4 shl 24)
}
fun array(): ByteArray {
return array
}
fun limit(): Int {
return array.size
}
fun order(): ByteOrder {
return ByteOrder.BIG_ENDIAN
}
}

View file

@ -1,6 +1,8 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
@ -21,12 +23,12 @@ import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.models.Intro import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerChapter import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.models.Trickplay
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
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.bif.BifData
import dev.jdtech.jellyfin.utils.bif.BifUtil
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -35,9 +37,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.ceil
@HiltViewModel @HiltViewModel
class PlayerActivityViewModel class PlayerActivityViewModel
@ -54,7 +58,7 @@ constructor(
UiState( UiState(
currentItemTitle = "", currentItemTitle = "",
currentIntro = null, currentIntro = null,
currentTrickPlay = null, currentTrickplay = null,
currentChapters = null, currentChapters = null,
fileLoaded = false, fileLoaded = false,
), ),
@ -66,12 +70,10 @@ constructor(
private val intros: MutableMap<UUID, Intro> = mutableMapOf() private val intros: MutableMap<UUID, Intro> = mutableMapOf()
private val trickPlays: MutableMap<UUID, BifData> = mutableMapOf()
data class UiState( data class UiState(
val currentItemTitle: String, val currentItemTitle: String,
val currentIntro: Intro?, val currentIntro: Intro?,
val currentTrickPlay: BifData?, val currentTrickplay: Trickplay?,
val currentChapters: List<PlayerChapter>?, val currentChapters: List<PlayerChapter>?,
val fileLoaded: Boolean, val fileLoaded: Boolean,
) )
@ -208,7 +210,7 @@ constructor(
} }
} }
_uiState.update { it.copy(currentTrickPlay = null) } _uiState.update { it.copy(currentTrickplay = null) }
playWhenReady = false playWhenReady = false
playbackPosition = 0L playbackPosition = 0L
currentMediaItemIndex = 0 currentMediaItemIndex = 0
@ -277,8 +279,8 @@ constructor(
jellyfinRepository.postPlaybackStart(item.itemId) jellyfinRepository.postPlaybackStart(item.itemId)
if (appPreferences.playerTrickPlay) { if (appPreferences.playerTrickplay) {
getTrickPlay(item.itemId) getTrickplay(item)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -339,28 +341,31 @@ constructor(
playbackSpeed = speed playbackSpeed = speed
} }
private suspend fun getTrickPlay(itemId: UUID) { private suspend fun getTrickplay(item: PlayerItem) {
if (trickPlays[itemId] != null) return val trickplayInfo = item.trickplayInfo ?: return
jellyfinRepository.getTrickPlayManifest(itemId) Timber.d("Trickplay Resolution: ${trickplayInfo.width}")
?.let { trickPlayManifest ->
val widthResolution =
trickPlayManifest.widthResolutions.max()
Timber.d("Trickplay Resolution: $widthResolution")
jellyfinRepository.getTrickPlayData( withContext(Dispatchers.Default) {
itemId, val maxIndex = ceil(trickplayInfo.thumbnailCount.toDouble().div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)).toInt()
widthResolution, val bitmaps = mutableListOf<Bitmap>()
for (i in 0..maxIndex) {
jellyfinRepository.getTrickplayData(
item.itemId,
trickplayInfo.width,
i,
)?.let { byteArray -> )?.let { byteArray ->
val trickPlayData = val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
BifUtil.trickPlayDecode(byteArray, widthResolution) for (offsetY in 0..<trickplayInfo.height * trickplayInfo.tileHeight step trickplayInfo.height) {
for (offsetX in 0..<trickplayInfo.width * trickplayInfo.tileWidth step trickplayInfo.width) {
trickPlayData?.let { bifData -> val bitmap = Bitmap.createBitmap(fullBitmap, offsetX, offsetY, trickplayInfo.width, trickplayInfo.height)
Timber.d("Trickplay Images: ${bifData.imageCount}") bitmaps.add(bitmap)
trickPlays[itemId] = bifData }
_uiState.update { it.copy(currentTrickPlay = trickPlays[itemId]) }
} }
} }
} }
_uiState.update { it.copy(currentTrickplay = Trickplay(trickplayInfo.interval, bitmaps)) }
}
} }
/** /**

View file

@ -13,8 +13,10 @@ 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.FindroidSources
import dev.jdtech.jellyfin.models.PlayerChapter import dev.jdtech.jellyfin.models.PlayerChapter
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.models.TrickplayInfo
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@ -115,7 +117,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, ItemFields.CHAPTERS), fields = listOf(ItemFields.MEDIA_SOURCES, ItemFields.CHAPTERS, ItemFields.TRICKPLAY),
startItemId = item.id, startItemId = item.id,
limit = if (userConfig?.enableNextEpisodeAutoPlay != false) null else 1, limit = if (userConfig?.enableNextEpisodeAutoPlay != false) null else 1,
) )
@ -151,6 +153,22 @@ class PlayerViewModel @Inject internal constructor(
}, },
) )
} }
val trickplayInfo = when (this) {
is FindroidSources -> {
this.trickplayInfo?.get(mediaSource.id)?.let {
TrickplayInfo(
width = it.width,
height = it.height,
tileWidth = it.tileWidth,
tileHeight = it.tileHeight,
thumbnailCount = it.thumbnailCount,
interval = it.interval,
bandwidth = it.bandwidth,
)
}
}
else -> null
}
return PlayerItem( return PlayerItem(
name = name, name = name,
itemId = id, itemId = id,
@ -162,6 +180,7 @@ class PlayerViewModel @Inject internal constructor(
indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null, indexNumberEnd = if (this is FindroidEpisode) indexNumberEnd else null,
externalSubtitles = externalSubtitles, externalSubtitles = externalSubtitles,
chapters = chapters.toPlayerChapters(), chapters = chapters.toPlayerChapters(),
trickplayInfo = trickplayInfo,
) )
} }

View file

@ -78,7 +78,7 @@ constructor(
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu-next")!! 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_TRICKPLAY, true)
val showChapterMarkers get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_CHAPTER_MARKERS, 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

@ -27,7 +27,7 @@ object Constants {
const val PREF_PLAYER_MPV_VO = "pref_player_mpv_vo" const val PREF_PLAYER_MPV_VO = "pref_player_mpv_vo"
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_TRICKPLAY = "pref_player_trickplay"
const val PREF_PLAYER_CHAPTER_MARKERS = "pref_player_chapter_markers" 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"