bugfixes: getDeviceId() / code: New Enum VideoQuality

This commit is contained in:
nomadics9 2024-07-20 06:31:53 +03:00
parent 36dd8480e1
commit 4e8ee15d0a
8 changed files with 1450 additions and 1172 deletions

View file

@ -24,19 +24,16 @@ import android.widget.ImageView
import android.widget.Space import android.widget.Space
import android.widget.TextView import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs import androidx.navigation.navArgs
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import com.nomadics9.ananas.databinding.ActivityPlayerBinding import com.nomadics9.ananas.databinding.ActivityPlayerBinding
import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment
import com.nomadics9.ananas.dialogs.TrackSelectionDialogFragment import com.nomadics9.ananas.dialogs.TrackSelectionDialogFragment
@ -45,6 +42,7 @@ import com.nomadics9.ananas.utils.PlayerGestureHelper
import com.nomadics9.ananas.utils.PreviewScrubListener import com.nomadics9.ananas.utils.PreviewScrubListener
import com.nomadics9.ananas.viewmodels.PlayerActivityViewModel import com.nomadics9.ananas.viewmodels.PlayerActivityViewModel
import com.nomadics9.ananas.viewmodels.PlayerEvents import com.nomadics9.ananas.viewmodels.PlayerEvents
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -54,7 +52,6 @@ var isControlsLocked: Boolean = false
@AndroidEntryPoint @AndroidEntryPoint
class PlayerActivity : BasePlayerActivity() { class PlayerActivity : BasePlayerActivity() {
@Inject @Inject
lateinit var appPreferences: AppPreferences lateinit var appPreferences: AppPreferences
@ -115,12 +112,13 @@ class PlayerActivity : BasePlayerActivity() {
configureInsets(lockedControls) configureInsets(lockedControls)
if (appPreferences.playerGestures) { if (appPreferences.playerGestures) {
playerGestureHelper = PlayerGestureHelper( playerGestureHelper =
appPreferences, PlayerGestureHelper(
this, appPreferences,
binding.playerView, this,
getSystemService(AUDIO_SERVICE) as AudioManager, binding.playerView,
) getSystemService(AUDIO_SERVICE) as AudioManager,
)
} }
binding.playerView.findViewById<View>(R.id.back_button).setOnClickListener { binding.playerView.findViewById<View>(R.id.back_button).setOnClickListener {
@ -155,7 +153,12 @@ class PlayerActivity : BasePlayerActivity() {
skipButton.text = skipButton.text =
getString(CoreR.string.skip_intro_button) getString(CoreR.string.skip_intro_button)
skipButton.isVisible = skipButton.isVisible =
!isInPictureInPictureMode && !buttonPressed && (showSkip == true || (binding.playerView.isControllerFullyVisible && currentSegment?.skip == true)) !isInPictureInPictureMode &&
!buttonPressed &&
(
showSkip == true ||
(binding.playerView.isControllerFullyVisible && currentSegment?.skip == true)
)
watchCreditsButton.isVisible = false watchCreditsButton.isVisible = false
} }
@ -167,7 +170,10 @@ class PlayerActivity : BasePlayerActivity() {
getString(CoreR.string.skip_credit_button_last) getString(CoreR.string.skip_credit_button_last)
} }
skipButton.isVisible = skipButton.isVisible =
!isInPictureInPictureMode && !buttonPressed && currentSegment?.skip == true && !binding.playerView.isControllerFullyVisible !isInPictureInPictureMode &&
!buttonPressed &&
currentSegment?.skip == true &&
!binding.playerView.isControllerFullyVisible
watchCreditsButton.isVisible = skipButton.isVisible watchCreditsButton.isVisible = skipButton.isVisible
} }
@ -181,12 +187,15 @@ class PlayerActivity : BasePlayerActivity() {
when (currentSegment?.type) { when (currentSegment?.type) {
"intro" -> { "intro" -> {
skipButton.isVisible = skipButton.isVisible =
!buttonPressed && (showSkip == true || (visibility == View.VISIBLE && currentSegment?.skip == true)) !buttonPressed &&
(showSkip == true || (visibility == View.VISIBLE && currentSegment?.skip == true))
} }
"credit" -> { "credit" -> {
skipButton.isVisible = skipButton.isVisible =
!buttonPressed && currentSegment?.skip == true && visibility == View.GONE !buttonPressed &&
currentSegment?.skip == true &&
visibility == View.GONE
watchCreditsButton.isVisible = skipButton.isVisible watchCreditsButton.isVisible = skipButton.isVisible
} }
} }
@ -268,7 +277,8 @@ class PlayerActivity : BasePlayerActivity() {
if (appPreferences.playerPipGesture) { if (appPreferences.playerPipGesture) {
try { try {
setPictureInPictureParams(pipParams(event.isPlaying)) setPictureInPictureParams(pipParams(event.isPlaying))
} catch (_: IllegalArgumentException) { } } catch (_: IllegalArgumentException) {
}
} }
} }
} }
@ -346,11 +356,12 @@ class PlayerActivity : BasePlayerActivity() {
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 =
imagePreview, PreviewScrubListener(
timeBar, imagePreview,
viewModel.player, timeBar,
) viewModel.player,
)
timeBar.addListener(previewScrubListener!!) timeBar.addListener(previewScrubListener!!)
} }
@ -381,34 +392,38 @@ class PlayerActivity : BasePlayerActivity() {
private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams { private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams {
val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height) val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height)
val aspectRatio = binding.playerView.player?.videoSize?.let { val aspectRatio =
Rational( binding.playerView.player?.videoSize?.let {
it.width.coerceAtMost((it.height * 2.39f).toInt()), Rational(
it.height.coerceAtMost((it.width * 2.39f).toInt()), it.width.coerceAtMost((it.height * 2.39f).toInt()),
) it.height.coerceAtMost((it.width * 2.39f).toInt()),
} )
}
val sourceRectHint = if (displayAspectRatio < aspectRatio!!) { val sourceRectHint =
val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt() if (displayAspectRatio < aspectRatio!!) {
Rect( val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt()
0, Rect(
space, 0,
binding.playerView.width, space,
(binding.playerView.width.toFloat() / aspectRatio.toFloat()).toInt() + space, binding.playerView.width,
) (binding.playerView.width.toFloat() / aspectRatio.toFloat()).toInt() + space,
} else { )
val space = ((binding.playerView.width - (binding.playerView.height.toFloat() * aspectRatio.toFloat())) / 2).toInt() } else {
Rect( val space = ((binding.playerView.width - (binding.playerView.height.toFloat() * aspectRatio.toFloat())) / 2).toInt()
space, Rect(
0, space,
(binding.playerView.height.toFloat() * aspectRatio.toFloat()).toInt() + space, 0,
binding.playerView.height, (binding.playerView.height.toFloat() * aspectRatio.toFloat()).toInt() + space,
) binding.playerView.height,
} )
}
val builder = PictureInPictureParams.Builder() val builder =
.setAspectRatio(aspectRatio) PictureInPictureParams
.setSourceRectHint(sourceRectHint) .Builder()
.setAspectRatio(aspectRatio)
.setSourceRectHint(sourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(enableAutoEnter) builder.setAutoEnterEnabled(enableAutoEnter)
@ -424,31 +439,52 @@ class PlayerActivity : BasePlayerActivity() {
try { try {
enterPictureInPictureMode(pipParams()) enterPictureInPictureMode(pipParams())
} catch (_: IllegalArgumentException) { } } catch (_: IllegalArgumentException) {
}
} }
private fun showQualitySelectionDialog() { private fun showQualitySelectionDialog() {
val height = viewModel.getOriginalHeight() val height = viewModel.getOriginalHeight()
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries).toList()
val qualityValues = resources.getStringArray(CoreR.array.quality_values).toList()
// Map entries to values
val qualityMap = qualityEntries.zip(qualityValues).toMap()
val qualities: List<String> =
when (height) {
0 -> qualityEntries
in 1001..1999 ->
listOf(
qualityEntries[0],
"${qualityEntries[1]} (1080p)",
qualityEntries[2],
qualityEntries[3],
qualityEntries[4],
qualityEntries[5],
)
in 2000..3000 ->
listOf(
qualityEntries[0],
"${qualityEntries[1]} (4K)",
qualityEntries[2],
qualityEntries[3],
qualityEntries[4],
qualityEntries[5],
)
else -> qualityEntries
}
val qualities = when (height) {
0 -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
in 1001..1999 -> arrayOf("Auto", "Original (1080p) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
in 2000..3000 -> arrayOf("Auto", "Original (4K) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
else -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
}
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle("Select Video Quality") .setTitle("Select Video Quality")
.setItems(qualities) { _, which -> .setItems(qualities.toTypedArray()) { _, which ->
val selectedQuality = qualities[which] val selectedQualityEntry = qualities[which]
viewModel.changeVideoQuality(selectedQuality) val selectedQualityValue =
} qualityMap.entries.find { it.key.contains(selectedQualityEntry.split(" ")[0]) }?.value ?: selectedQualityEntry
.show() viewModel.changeVideoQuality(selectedQualityValue)
}.show()
} }
override fun onPictureInPictureModeChanged( override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean, isInPictureInPictureMode: Boolean,
newConfig: Configuration, newConfig: Configuration,
@ -463,25 +499,29 @@ class PlayerActivity : BasePlayerActivity() {
playerGestureHelper?.updateZoomMode(false) playerGestureHelper?.updateZoomMode(false)
// Brightness mode Auto // Brightness mode Auto
window.attributes = window.attributes.apply { window.attributes =
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE window.attributes.apply {
} screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
} }
false -> { false -> {
binding.playerView.useController = true binding.playerView.useController = true
playerGestureHelper?.updateZoomMode(wasZoom) playerGestureHelper?.updateZoomMode(wasZoom)
// Override auto brightness // Override auto brightness
window.attributes = window.attributes.apply { window.attributes =
screenBrightness = if (appPreferences.playerBrightnessRemember) { window.attributes.apply {
appPreferences.playerBrightness screenBrightness =
} else { if (appPreferences.playerBrightnessRemember) {
Settings.System.getInt( appPreferences.playerBrightness
contentResolver, } else {
Settings.System.SCREEN_BRIGHTNESS, Settings.System
).toFloat() / 255 .getInt(
contentResolver,
Settings.System.SCREEN_BRIGHTNESS,
).toFloat() / 255
}
} }
}
} }
} }
} }

View file

@ -17,6 +17,7 @@ import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.FindroidSources import com.nomadics9.ananas.models.FindroidSources
import com.nomadics9.ananas.models.FindroidTrickplayInfo import com.nomadics9.ananas.models.FindroidTrickplayInfo
import com.nomadics9.ananas.models.UiText import com.nomadics9.ananas.models.UiText
import com.nomadics9.ananas.models.VideoQuality
import com.nomadics9.ananas.models.toFindroidEpisodeDto import com.nomadics9.ananas.models.toFindroidEpisodeDto
import com.nomadics9.ananas.models.toFindroidMediaStreamDto import com.nomadics9.ananas.models.toFindroidMediaStreamDto
import com.nomadics9.ananas.models.toFindroidMovieDto import com.nomadics9.ananas.models.toFindroidMovieDto
@ -50,17 +51,17 @@ class DownloaderImpl(
storageIndex: Int, storageIndex: Int,
): Pair<Long, UiText?> { ): Pair<Long, UiText?> {
try { try {
Timber.d("Downloading item: ${item.id} with sourceId: $sourceId") Timber.d("Downloading item: ${item.id} with sourceId: $sourceId")
val source = val source =
jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId } jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId }
val segments = jellyfinRepository.getSegmentsTimestamps(item.id) val segments = jellyfinRepository.getSegmentsTimestamps(item.id)
val trickplayInfo = if (item is FindroidSources) { val trickplayInfo =
item.trickplayInfo?.get(sourceId) if (item is FindroidSources) {
} else { item.trickplayInfo?.get(sourceId)
null } else {
} null
}
val storageLocation = context.getExternalFilesDirs(null)[storageIndex] val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
if (storageLocation == null || Environment.getExternalStorageState(storageLocation) != Environment.MEDIA_MOUNTED) { if (storageLocation == null || Environment.getExternalStorageState(storageLocation) != Environment.MEDIA_MOUNTED) {
return Pair(-1, UiText.StringResource(CoreR.string.storage_unavailable)) return Pair(-1, UiText.StringResource(CoreR.string.storage_unavailable))
@ -96,9 +97,13 @@ class DownloaderImpl(
return Pair( return Pair(
-1, -1,
if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource( if (e.message != null) {
CoreR.string.unknown_error UiText.DynamicString(e.message!!)
) } else {
UiText.StringResource(
CoreR.string.unknown_error,
)
},
) )
} }
} }
@ -110,7 +115,7 @@ class DownloaderImpl(
trickplayInfo: FindroidTrickplayInfo?, trickplayInfo: FindroidTrickplayInfo?,
segments: List<FindroidSegment>?, segments: List<FindroidSegment>?,
path: Uri, path: Uri,
quality: String quality: String,
): Pair<Long, UiText?> { ): Pair<Long, UiText?> {
val transcodingUrl = getTranscodedUrl(item.id, quality) val transcodingUrl = getTranscodedUrl(item.id, quality)
when (item) { when (item) {
@ -126,12 +131,14 @@ class DownloaderImpl(
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
} }
val request = DownloadManager.Request(transcodingUrl) val request =
.setTitle(item.name) DownloadManager
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .Request(transcodingUrl)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setTitle(item.name)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setDestinationUri(path) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId) database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null) return Pair(downloadId, null)
@ -139,7 +146,8 @@ class DownloaderImpl(
is FindroidEpisode -> { is FindroidEpisode -> {
database.insertShow( database.insertShow(
jellyfinRepository.getShow(item.seriesId) jellyfinRepository
.getShow(item.seriesId)
.toFindroidShowDto(appPreferences.currentServer!!), .toFindroidShowDto(appPreferences.currentServer!!),
) )
database.insertSeason( database.insertSeason(
@ -156,12 +164,14 @@ class DownloaderImpl(
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
} }
val request = DownloadManager.Request(transcodingUrl) val request =
.setTitle(item.name) DownloadManager
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .Request(transcodingUrl)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setTitle(item.name)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setDestinationUri(path) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId) database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null) return Pair(downloadId, null)
@ -190,12 +200,14 @@ class DownloaderImpl(
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
} }
val request = DownloadManager.Request(source.path.toUri()) val request =
.setTitle(item.name) DownloadManager
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .Request(source.path.toUri())
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setTitle(item.name)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setDestinationUri(path) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId) database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null) return Pair(downloadId, null)
@ -203,7 +215,8 @@ class DownloaderImpl(
is FindroidEpisode -> { is FindroidEpisode -> {
database.insertShow( database.insertShow(
jellyfinRepository.getShow(item.seriesId) jellyfinRepository
.getShow(item.seriesId)
.toFindroidShowDto(appPreferences.currentServer!!), .toFindroidShowDto(appPreferences.currentServer!!),
) )
database.insertSeason( database.insertSeason(
@ -219,12 +232,14 @@ class DownloaderImpl(
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
} }
val request = DownloadManager.Request(source.path.toUri()) val request =
.setTitle(item.name) DownloadManager
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .Request(source.path.toUri())
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setTitle(item.name)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setDestinationUri(path) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(path)
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId) database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null) return Pair(downloadId, null)
@ -233,15 +248,20 @@ class DownloaderImpl(
return Pair(-1, null) return Pair(-1, null)
} }
override suspend fun cancelDownload(
override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) { item: FindroidItem,
source: FindroidSource,
) {
if (source.downloadId != null) { if (source.downloadId != null) {
downloadManager.remove(source.downloadId!!) downloadManager.remove(source.downloadId!!)
} }
deleteItem(item, source) deleteItem(item, source)
} }
override suspend fun deleteItem(item: FindroidItem, source: FindroidSource) { override suspend fun deleteItem(
item: FindroidItem,
source: FindroidSource,
) {
when (item) { when (item) {
is FindroidMovie -> { is FindroidMovie -> {
database.deleteMovie(item.id) database.deleteMovie(item.id)
@ -283,15 +303,18 @@ class DownloaderImpl(
if (downloadId == null) { if (downloadId == null) {
return Pair(downloadStatus, progress) return Pair(downloadStatus, progress)
} }
val query = DownloadManager.Query() val query =
.setFilterById(downloadId) DownloadManager
.Query()
.setFilterById(downloadId)
val cursor = downloadManager.query(query) val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
downloadStatus = cursor.getInt( downloadStatus =
cursor.getColumnIndexOrThrow( cursor.getInt(
DownloadManager.COLUMN_STATUS, cursor.getColumnIndexOrThrow(
), DownloadManager.COLUMN_STATUS,
) ),
)
when (downloadStatus) { when (downloadStatus) {
DownloadManager.STATUS_RUNNING -> { DownloadManager.STATUS_RUNNING -> {
val totalBytes = val totalBytes =
@ -320,25 +343,28 @@ class DownloaderImpl(
val storageLocation = context.getExternalFilesDirs(null)[storageIndex] val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
for (mediaStream in source.mediaStreams.filter { it.isExternal }) { for (mediaStream in source.mediaStreams.filter { it.isExternal }) {
val id = UUID.randomUUID() val id = UUID.randomUUID()
val streamPath = Uri.fromFile( val streamPath =
File( Uri.fromFile(
storageLocation, File(
"downloads/${item.id}.${source.id}.$id.download" storageLocation,
"downloads/${item.id}.${source.id}.$id.download",
),
) )
)
database.insertMediaStream( database.insertMediaStream(
mediaStream.toFindroidMediaStreamDto( mediaStream.toFindroidMediaStreamDto(
id, id,
source.id, source.id,
streamPath.path.orEmpty() streamPath.path.orEmpty(),
) ),
) )
val request = DownloadManager.Request(Uri.parse(mediaStream.path)) val request =
.setTitle(mediaStream.title) DownloadManager
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .Request(Uri.parse(mediaStream.path))
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setTitle(mediaStream.title)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setDestinationUri(streamPath) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
.setDestinationUri(streamPath)
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setMediaStreamDownloadId(id, downloadId) database.setMediaStreamDownloadId(id, downloadId)
} }
@ -347,7 +373,7 @@ class DownloaderImpl(
private fun downloadEmbeddedMediaStreams( private fun downloadEmbeddedMediaStreams(
item: FindroidItem, item: FindroidItem,
source: FindroidSource, source: FindroidSource,
storageIndex: Int = 0 storageIndex: Int = 0,
) { ) {
val storageLocation = context.getExternalFilesDirs(null)[storageIndex] val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
val subtitleStreams = source.mediaStreams.filter { !it.isExternal && it.type == MediaStreamType.SUBTITLE && it.path != null } val subtitleStreams = source.mediaStreams.filter { !it.isExternal && it.type == MediaStreamType.SUBTITLE && it.path != null }
@ -357,50 +383,55 @@ class DownloaderImpl(
deliveryUrl = deliveryUrl.replace("Stream.srt", "Stream.vtt") deliveryUrl = deliveryUrl.replace("Stream.srt", "Stream.vtt")
} }
val id = UUID.randomUUID() val id = UUID.randomUUID()
val streamPath = Uri.fromFile( val streamPath =
File( Uri.fromFile(
storageLocation, File(
"downloads/${item.id}.${source.id}.$id.download" storageLocation,
"downloads/${item.id}.${source.id}.$id.download",
),
) )
)
database.insertMediaStream( database.insertMediaStream(
mediaStream.toFindroidMediaStreamDto( mediaStream.toFindroidMediaStreamDto(
id, id,
source.id, source.id,
streamPath.path.orEmpty() streamPath.path.orEmpty(),
) ),
) )
val request = DownloadManager.Request(Uri.parse(deliveryUrl)) val request =
.setTitle(mediaStream.title) DownloadManager
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .Request(Uri.parse(deliveryUrl))
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setTitle(mediaStream.title)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setDestinationUri(streamPath) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
.setDestinationUri(streamPath)
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setMediaStreamDownloadId(id, downloadId) database.setMediaStreamDownloadId(id, downloadId)
} }
} }
private suspend fun downloadTrickplayData( private suspend fun downloadTrickplayData(
itemId: UUID, itemId: UUID,
sourceId: String, sourceId: String,
trickplayInfo: FindroidTrickplayInfo, trickplayInfo: FindroidTrickplayInfo,
) { ) {
val maxIndex = ceil( val maxIndex =
trickplayInfo.thumbnailCount.toDouble() ceil(
.div(trickplayInfo.tileWidth * trickplayInfo.tileHeight) trickplayInfo.thumbnailCount
).toInt() .toDouble()
.div(trickplayInfo.tileWidth * trickplayInfo.tileHeight),
).toInt()
val byteArrays = mutableListOf<ByteArray>() val byteArrays = mutableListOf<ByteArray>()
for (i in 0..maxIndex) { for (i in 0..maxIndex) {
jellyfinRepository.getTrickplayData( jellyfinRepository
itemId, .getTrickplayData(
trickplayInfo.width, itemId,
i, trickplayInfo.width,
)?.let { byteArray -> i,
byteArrays.add(byteArray) )?.let { byteArray ->
} byteArrays.add(byteArray)
}
} }
saveTrickplayData(itemId, sourceId, trickplayInfo, byteArrays) saveTrickplayData(itemId, sourceId, trickplayInfo, byteArrays)
} }
@ -420,52 +451,46 @@ class DownloaderImpl(
} }
} }
private suspend fun getTranscodedUrl(itemId: UUID, quality: String): Uri? { private suspend fun getTranscodedUrl(
val maxBitrate = when (quality) { itemId: UUID,
"720p" -> 2000000 // 2 Mbps quality: String,
"480p" -> 1000000 // 1 Mbps ): Uri? {
"360p" -> 800000 // 800Kbps val videoQuality = VideoQuality.fromString(quality)!!
else -> 2000000 // Default to 2 Mbps if not specified
}
return try { return try {
val deviceProfile =
val deviceProfile = jellyfinRepository.buildDeviceProfile(maxBitrate,"mkv", EncodingContext.STATIC) jellyfinRepository.buildDeviceProfile(
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate) VideoQuality.getBitrate(videoQuality),
val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!! "mkv",
EncodingContext.STATIC,
)
val playbackInfo =
jellyfinRepository.getPostedPlaybackInfo(
itemId,
false,
deviceProfile,
VideoQuality.getBitrate(videoQuality),
)
val mediaSourceId =
playbackInfo.content.mediaSources
.firstOrNull()
?.id!!
val playSessionId = playbackInfo.content.playSessionId!! val playSessionId = playbackInfo.content.playSessionId!!
val deviceId = jellyfinRepository.getDeviceId() val deviceId = jellyfinRepository.getDeviceId()
val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, deviceId, mediaSourceId, playSessionId, maxBitrate, "ts") val downloadUrl =
jellyfinRepository.getVideoStreambyContainerUrl(
itemId,
deviceId,
mediaSourceId,
playSessionId,
VideoQuality.getBitrate(videoQuality),
VideoQuality.getQualityInt(videoQuality),
"mkv",
)
val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality) return downloadUrl.toUri()
Timber.d("Constructed Transcode URL: $transcodeUri")
transcodeUri
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
null null
} }
} }
private fun buildTranscodeUri(
transcodingUrl: String,
maxBitrate: Int,
quality: String
): Uri {
val resolution = when (quality) {
"720p" -> "720"
"480p" -> "480"
"360p" -> "360"
else -> "720"
}
return Uri.parse(transcodingUrl).buildUpon()
.appendQueryParameter("MaxVideoHeight", resolution)
.appendQueryParameter("MaxVideoBitRate", maxBitrate.toString())
.appendQueryParameter("subtitleMethod", "External")
//.appendQueryParameter("api_key", apiKey)
.build()
}
} }

View file

@ -26,13 +26,17 @@
<item>opensles</item> <item>opensles</item>
</string-array> </string-array>
<string-array name="quality_entries"> <string-array name="quality_entries">
<item>Auto</item>
<item>Original</item> <item>Original</item>
<item>1080p - 8Mbps</item>
<item>720p - 2Mbps</item> <item>720p - 2Mbps</item>
<item>480p - 1Mbps</item> <item>480p - 1Mbps</item>
<item>360p - 800Kbps</item> <item>360p - 800Kbps</item>
</string-array> </string-array>
<string-array name="quality_values"> <string-array name="quality_values">
<item>Auto</item>
<item>Original</item> <item>Original</item>
<item>1080p</item>
<item>720p</item> <item>720p</item>
<item>480p</item> <item>480p</item>
<item>360p</item> <item>360p</item>

View file

@ -0,0 +1,25 @@
package com.nomadics9.ananas.models
enum class VideoQuality(
val bitrate: Int,
val qualityString: String,
val qualityInt: Int,
) {
PAuto(1, "Auto", 1080),
POriginal(1000000000, "Original", 1080),
P1080(8000000, "1080p", 1080),
P720(2000000, "720p", 720),
P480(1000000, "480p", 480),
P360(700000, "360p", 360),
;
companion object {
fun fromString(quality: String): VideoQuality? = entries.find { it.qualityString == quality }
fun getBitrate(quality: VideoQuality): Int = quality.bitrate
fun getQualityString(quality: VideoQuality): String = quality.qualityString
fun getQualityInt(quality: VideoQuality): Int = quality.qualityInt
}
}

View file

@ -14,8 +14,6 @@ import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
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
import org.jellyfin.sdk.model.api.DeviceInfo
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -31,7 +29,9 @@ interface JellyfinRepository {
suspend fun getUserViews(): List<BaseItemDto> suspend fun getUserViews(): List<BaseItemDto>
suspend fun getItem(itemId: UUID): BaseItemDto suspend fun getItem(itemId: UUID): BaseItemDto
suspend fun getEpisode(itemId: UUID): FindroidEpisode suspend fun getEpisode(itemId: UUID): FindroidEpisode
suspend fun getMovie(itemId: UUID): FindroidMovie suspend fun getMovie(itemId: UUID): FindroidMovie
suspend fun getShow(itemId: UUID): FindroidShow suspend fun getShow(itemId: UUID): FindroidShow
@ -72,7 +72,10 @@ interface JellyfinRepository {
suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> suspend fun getLatestMedia(parentId: UUID): List<FindroidItem>
suspend fun getSeasons(seriesId: UUID, offline: Boolean = false): List<FindroidSeason> suspend fun getSeasons(
seriesId: UUID,
offline: Boolean = false,
): List<FindroidSeason>
suspend fun getNextUp(seriesId: UUID? = null): List<FindroidEpisode> suspend fun getNextUp(seriesId: UUID? = null): List<FindroidEpisode>
@ -85,21 +88,40 @@ interface JellyfinRepository {
offline: Boolean = false, offline: Boolean = false,
): List<FindroidEpisode> ): List<FindroidEpisode>
suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource> suspend fun getMediaSources(
itemId: UUID,
includePath: Boolean = false,
): List<FindroidSource>
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String suspend fun getStreamUrl(
itemId: UUID,
mediaSourceId: String,
playSessionId: String? = null,
): String
suspend fun getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>? suspend fun getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>?
suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? suspend fun getTrickplayData(
itemId: UUID,
width: Int,
index: Int,
): ByteArray?
suspend fun postCapabilities() suspend fun postCapabilities()
suspend fun postPlaybackStart(itemId: UUID) suspend fun postPlaybackStart(itemId: UUID)
suspend fun postPlaybackStop(itemId: UUID, positionTicks: Long, playedPercentage: Int) suspend fun postPlaybackStop(
itemId: UUID,
positionTicks: Long,
playedPercentage: Int,
)
suspend fun postPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) suspend fun postPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
)
suspend fun markAsFavorite(itemId: UUID) suspend fun markAsFavorite(itemId: UUID)
@ -121,15 +143,36 @@ interface JellyfinRepository {
suspend fun getDeviceId(): String suspend fun getDeviceId(): String
suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> suspend fun buildDeviceProfile(
maxBitrate: Int,
container: String,
context: EncodingContext,
): DeviceProfile
suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile suspend fun getVideoStreambyContainerUrl(
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
maxHeight: Int,
container: String,
): String
suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String suspend fun getTranscodedVideoStream(
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
): String
suspend fun getTranscodedVideoStream(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int): String suspend fun getPostedPlaybackInfo(
itemId: UUID,
suspend fun getPostedPlaybackInfo(itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse> enableDirectStream: Boolean,
deviceProfile: DeviceProfile,
maxBitrate: Int,
): Response<PlaybackInfoResponse>
suspend fun stopEncodingProcess(playSessionId: String) suspend fun stopEncodingProcess(playSessionId: String)
} }

View file

@ -17,6 +17,7 @@ import com.nomadics9.ananas.models.FindroidSegments
import com.nomadics9.ananas.models.FindroidShow import com.nomadics9.ananas.models.FindroidShow
import com.nomadics9.ananas.models.FindroidSource import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.SortBy import com.nomadics9.ananas.models.SortBy
import com.nomadics9.ananas.models.VideoQuality
import com.nomadics9.ananas.models.toFindroidCollection import com.nomadics9.ananas.models.toFindroidCollection
import com.nomadics9.ananas.models.toFindroidEpisode import com.nomadics9.ananas.models.toFindroidEpisode
import com.nomadics9.ananas.models.toFindroidItem import com.nomadics9.ananas.models.toFindroidItem
@ -36,7 +37,6 @@ import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
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
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
import org.jellyfin.sdk.model.api.DeviceOptionsDto import org.jellyfin.sdk.model.api.DeviceOptionsDto
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile import org.jellyfin.sdk.model.api.DirectPlayProfile
@ -57,7 +57,6 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodeReason
import org.jellyfin.sdk.model.api.TranscodeSeekInfo import org.jellyfin.sdk.model.api.TranscodeSeekInfo
import org.jellyfin.sdk.model.api.TranscodingProfile import org.jellyfin.sdk.model.api.TranscodingProfile
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
@ -71,55 +70,70 @@ class JellyfinRepositoryImpl(
private val database: ServerDatabaseDao, private val database: ServerDatabaseDao,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
) : JellyfinRepository { ) : JellyfinRepository {
override suspend fun getPublicSystemInfo(): PublicSystemInfo = withContext(Dispatchers.IO) { override suspend fun getPublicSystemInfo(): PublicSystemInfo =
jellyfinApi.systemApi.getPublicSystemInfo().content withContext(Dispatchers.IO) {
} jellyfinApi.systemApi.getPublicSystemInfo().content
}
override suspend fun getUserViews(): List<BaseItemDto> = withContext(Dispatchers.IO) { override suspend fun getUserViews(): List<BaseItemDto> =
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty() withContext(Dispatchers.IO) {
} jellyfinApi.viewsApi
.getUserViews(jellyfinApi.userId!!)
.content.items
.orEmpty()
}
override suspend fun getItem(itemId: UUID): BaseItemDto = withContext(Dispatchers.IO) { override suspend fun getItem(itemId: UUID): BaseItemDto =
jellyfinApi.userLibraryApi.getItem(itemId, jellyfinApi.userId!!).content withContext(Dispatchers.IO) {
} jellyfinApi.userLibraryApi.getItem(itemId, jellyfinApi.userId!!).content
}
override suspend fun getEpisode(itemId: UUID): FindroidEpisode = override suspend fun getEpisode(itemId: UUID): FindroidEpisode =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi.getItem( jellyfinApi.userLibraryApi
itemId, .getItem(
jellyfinApi.userId!!, itemId,
).content.toFindroidEpisode(this@JellyfinRepositoryImpl, database)!! jellyfinApi.userId!!,
).content
.toFindroidEpisode(this@JellyfinRepositoryImpl, database)!!
} }
override suspend fun getMovie(itemId: UUID): FindroidMovie = override suspend fun getMovie(itemId: UUID): FindroidMovie =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi.getItem( jellyfinApi.userLibraryApi
itemId, .getItem(
jellyfinApi.userId!!, itemId,
).content.toFindroidMovie(this@JellyfinRepositoryImpl, database) jellyfinApi.userId!!,
).content
.toFindroidMovie(this@JellyfinRepositoryImpl, database)
} }
override suspend fun getShow(itemId: UUID): FindroidShow = override suspend fun getShow(itemId: UUID): FindroidShow =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi.getItem( jellyfinApi.userLibraryApi
itemId, .getItem(
jellyfinApi.userId!!, itemId,
).content.toFindroidShow(this@JellyfinRepositoryImpl) jellyfinApi.userId!!,
).content
.toFindroidShow(this@JellyfinRepositoryImpl)
} }
override suspend fun getSeason(itemId: UUID): FindroidSeason = override suspend fun getSeason(itemId: UUID): FindroidSeason =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi.getItem( jellyfinApi.userLibraryApi
itemId, .getItem(
jellyfinApi.userId!!, itemId,
).content.toFindroidSeason(this@JellyfinRepositoryImpl) jellyfinApi.userId!!,
).content
.toFindroidSeason(this@JellyfinRepositoryImpl)
} }
override suspend fun getLibraries(): List<FindroidCollection> = override suspend fun getLibraries(): List<FindroidCollection> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi.getItems( jellyfinApi.itemsApi
jellyfinApi.userId!!, .getItems(
).content.items jellyfinApi.userId!!,
).content.items
.orEmpty() .orEmpty()
.mapNotNull { it.toFindroidCollection(this@JellyfinRepositoryImpl) } .mapNotNull { it.toFindroidCollection(this@JellyfinRepositoryImpl) }
} }
@ -134,16 +148,17 @@ class JellyfinRepositoryImpl(
limit: Int?, limit: Int?,
): List<FindroidItem> = ): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi.getItems( jellyfinApi.itemsApi
jellyfinApi.userId!!, .getItems(
parentId = parentId, jellyfinApi.userId!!,
includeItemTypes = includeTypes, parentId = parentId,
recursive = recursive, includeItemTypes = includeTypes,
sortBy = listOf(ItemSortBy.fromName(sortBy.sortString)), recursive = recursive,
sortOrder = listOf(sortOrder), sortBy = listOf(ItemSortBy.fromName(sortBy.sortString)),
startIndex = startIndex, sortOrder = listOf(sortOrder),
limit = limit, startIndex = startIndex,
).content.items limit = limit,
).content.items
.orEmpty() .orEmpty()
.mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) } .mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) }
} }
@ -154,13 +169,14 @@ class JellyfinRepositoryImpl(
recursive: Boolean, recursive: Boolean,
sortBy: SortBy, sortBy: SortBy,
sortOrder: SortOrder, sortOrder: SortOrder,
): Flow<PagingData<FindroidItem>> { ): Flow<PagingData<FindroidItem>> =
return Pager( Pager(
config = PagingConfig( config =
pageSize = 10, PagingConfig(
maxSize = 100, pageSize = 10,
enablePlaceholders = false, maxSize = 100,
), enablePlaceholders = false,
),
pagingSourceFactory = { pagingSourceFactory = {
ItemsPagingSource( ItemsPagingSource(
this, this,
@ -172,87 +188,102 @@ class JellyfinRepositoryImpl(
) )
}, },
).flow ).flow
}
override suspend fun getPersonItems( override suspend fun getPersonItems(
personIds: List<UUID>, personIds: List<UUID>,
includeTypes: List<BaseItemKind>?, includeTypes: List<BaseItemKind>?,
recursive: Boolean, recursive: Boolean,
): List<FindroidItem> = withContext(Dispatchers.IO) { ): List<FindroidItem> =
jellyfinApi.itemsApi.getItems( withContext(Dispatchers.IO) {
jellyfinApi.userId!!, jellyfinApi.itemsApi
personIds = personIds, .getItems(
includeItemTypes = includeTypes, jellyfinApi.userId!!,
recursive = recursive, personIds = personIds,
).content.items includeItemTypes = includeTypes,
.orEmpty() recursive = recursive,
.mapNotNull { ).content.items
it.toFindroidItem(this@JellyfinRepositoryImpl, database) .orEmpty()
} .mapNotNull {
} it.toFindroidItem(this@JellyfinRepositoryImpl, database)
}
}
override suspend fun getFavoriteItems(): List<FindroidItem> = override suspend fun getFavoriteItems(): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi.getItems( jellyfinApi.itemsApi
jellyfinApi.userId!!, .getItems(
filters = listOf(ItemFilter.IS_FAVORITE), jellyfinApi.userId!!,
includeItemTypes = listOf( filters = listOf(ItemFilter.IS_FAVORITE),
BaseItemKind.MOVIE, includeItemTypes =
BaseItemKind.SERIES, listOf(
BaseItemKind.EPISODE, BaseItemKind.MOVIE,
), BaseItemKind.SERIES,
recursive = true, BaseItemKind.EPISODE,
).content.items ),
recursive = true,
).content.items
.orEmpty() .orEmpty()
.mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) } .mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) }
} }
override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> = override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi.getItems( jellyfinApi.itemsApi
jellyfinApi.userId!!, .getItems(
searchTerm = searchQuery, jellyfinApi.userId!!,
includeItemTypes = listOf( searchTerm = searchQuery,
BaseItemKind.MOVIE, includeItemTypes =
BaseItemKind.SERIES, listOf(
BaseItemKind.EPISODE, BaseItemKind.MOVIE,
), BaseItemKind.SERIES,
recursive = true, BaseItemKind.EPISODE,
).content.items ),
recursive = true,
).content.items
.orEmpty() .orEmpty()
.mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) } .mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) }
} }
override suspend fun getResumeItems(): List<FindroidItem> { override suspend fun getResumeItems(): List<FindroidItem> {
val items = withContext(Dispatchers.IO) { val items =
jellyfinApi.itemsApi.getResumeItems( withContext(Dispatchers.IO) {
jellyfinApi.userId!!, jellyfinApi.itemsApi
limit = 12, .getResumeItems(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), jellyfinApi.userId!!,
).content.items.orEmpty() limit = 12,
} includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
).content.items
.orEmpty()
}
return items.mapNotNull { return items.mapNotNull {
it.toFindroidItem(this, database) it.toFindroidItem(this, database)
} }
} }
override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> { override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> {
val items = withContext(Dispatchers.IO) { val items =
jellyfinApi.userLibraryApi.getLatestMedia( withContext(Dispatchers.IO) {
jellyfinApi.userId!!, jellyfinApi.userLibraryApi
parentId = parentId, .getLatestMedia(
limit = 16, jellyfinApi.userId!!,
).content parentId = parentId,
} limit = 16,
).content
}
return items.mapNotNull { return items.mapNotNull {
it.toFindroidItem(this, database) it.toFindroidItem(this, database)
} }
} }
override suspend fun getSeasons(seriesId: UUID, offline: Boolean): List<FindroidSeason> = override suspend fun getSeasons(
seriesId: UUID,
offline: Boolean,
): List<FindroidSeason> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (!offline) { if (!offline) {
jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items jellyfinApi.showsApi
.getSeasons(seriesId, jellyfinApi.userId!!)
.content.items
.orEmpty() .orEmpty()
.map { it.toFindroidSeason(this@JellyfinRepositoryImpl) } .map { it.toFindroidSeason(this@JellyfinRepositoryImpl) }
} else { } else {
@ -262,12 +293,13 @@ class JellyfinRepositoryImpl(
override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> = override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.showsApi.getNextUp( jellyfinApi.showsApi
jellyfinApi.userId!!, .getNextUp(
limit = 24, jellyfinApi.userId!!,
seriesId = seriesId, limit = 24,
enableResumable = false, seriesId = seriesId,
).content.items enableResumable = false,
).content.items
.orEmpty() .orEmpty()
.mapNotNull { it.toFindroidEpisode(this@JellyfinRepositoryImpl) } .mapNotNull { it.toFindroidEpisode(this@JellyfinRepositoryImpl) }
} }
@ -282,14 +314,15 @@ class JellyfinRepositoryImpl(
): List<FindroidEpisode> = ): List<FindroidEpisode> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (!offline) { if (!offline) {
jellyfinApi.showsApi.getEpisodes( jellyfinApi.showsApi
seriesId, .getEpisodes(
jellyfinApi.userId!!, seriesId,
seasonId = seasonId, jellyfinApi.userId!!,
fields = fields, seasonId = seasonId,
startItemId = startItemId, fields = fields,
limit = limit, startItemId = startItemId,
).content.items limit = limit,
).content.items
.orEmpty() .orEmpty()
.mapNotNull { it.toFindroidEpisode(this@JellyfinRepositoryImpl, database) } .mapNotNull { it.toFindroidEpisode(this@JellyfinRepositoryImpl, database) }
} else { } else {
@ -297,39 +330,47 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List<FindroidSource> = override suspend fun getMediaSources(
itemId: UUID,
includePath: Boolean,
): List<FindroidSource> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val sources = mutableListOf<FindroidSource>() val sources = mutableListOf<FindroidSource>()
sources.addAll( sources.addAll(
jellyfinApi.mediaInfoApi.getPostedPlaybackInfo( jellyfinApi.mediaInfoApi
itemId, .getPostedPlaybackInfo(
PlaybackInfoDto(
userId = jellyfinApi.userId!!,
deviceProfile = DeviceProfile(
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
codecProfiles = emptyList(),
containerProfiles = emptyList(),
directPlayProfiles = listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = emptyList(),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
),
),
maxStreamingBitrate = 1_000_000_000,
),
).content.mediaSources.map {
it.toFindroidSource(
this@JellyfinRepositoryImpl,
itemId, itemId,
includePath, PlaybackInfoDto(
) userId = jellyfinApi.userId!!,
}, deviceProfile =
DeviceProfile(
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
codecProfiles = emptyList(),
containerProfiles = emptyList(),
directPlayProfiles =
listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO),
),
transcodingProfiles = emptyList(),
subtitleProfiles =
listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
),
),
maxStreamingBitrate = 1_000_000_000,
),
).content.mediaSources
.map {
it.toFindroidSource(
this@JellyfinRepositoryImpl,
itemId,
includePath,
)
},
) )
sources.addAll( sources.addAll(
database.getSources(itemId).map { it.toFindroidSource(database) }, database.getSources(itemId).map { it.toFindroidSource(database) },
@ -337,26 +378,32 @@ class JellyfinRepositoryImpl(
sources sources
} }
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String = override suspend fun getStreamUrl(
itemId: UUID,
mediaSourceId: String,
playSessionId: String?,
): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// val deviceId = getDeviceId()
try { try {
val url = if (playSessionId != null) { val url =
jellyfinApi.videosApi.getVideoStreamUrl( if (playSessionId != null) {
itemId, jellyfinApi.videosApi.getVideoStreamUrl(
static = true, itemId,
mediaSourceId = mediaSourceId, static = true,
playSessionId = playSessionId, mediaSourceId = mediaSourceId,
deviceId = getDeviceId(), playSessionId = playSessionId,
context = EncodingContext.STATIC // deviceId = deviceId,
) context = EncodingContext.STREAMING,
} else { )
jellyfinApi.videosApi.getVideoStreamUrl( } else {
itemId, jellyfinApi.videosApi.getVideoStreamUrl(
static = true, itemId,
mediaSourceId = mediaSourceId, static = true,
deviceId = getDeviceId(), mediaSourceId = mediaSourceId,
) // deviceId = deviceId,
} )
}
url url
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
@ -377,33 +424,36 @@ class JellyfinRepositoryImpl(
pathParameters["itemId"] = itemId pathParameters["itemId"] = itemId
try { try {
val segmentToConvert = jellyfinApi.api.get<FindroidSegments>( val segmentToConvert =
"/Episode/{itemId}/IntroSkipperSegments", jellyfinApi.api
pathParameters, .get<FindroidSegments>(
).content "/Episode/{itemId}/IntroSkipperSegments",
pathParameters,
).content
val segmentConverted = mutableListOf( val segmentConverted =
segmentToConvert.intro!!.let { mutableListOf(
FindroidSegment( segmentToConvert.intro!!.let {
type = "intro", FindroidSegment(
skip = true, type = "intro",
startTime = it.startTime, skip = true,
endTime = it.endTime, startTime = it.startTime,
showAt = it.showAt, endTime = it.endTime,
hideAt = it.hideAt, showAt = it.showAt,
) hideAt = it.hideAt,
}, )
segmentToConvert.credit!!.let { },
FindroidSegment( segmentToConvert.credit!!.let {
type = "credit", FindroidSegment(
skip = true, type = "credit",
startTime = it.startTime, skip = true,
endTime = it.endTime, startTime = it.startTime,
showAt = it.showAt, endTime = it.endTime,
hideAt = it.hideAt, showAt = it.showAt,
) hideAt = it.hideAt,
}, )
) },
)
Timber.tag("SegmentInfo").d("segmentToConvert: %s", segmentToConvert) Timber.tag("SegmentInfo").d("segmentToConvert: %s", segmentToConvert)
Timber.tag("SegmentInfo").d("segmentConverted: %s", segmentConverted) Timber.tag("SegmentInfo").d("segmentConverted: %s", segmentConverted)
@ -413,7 +463,11 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? = override suspend fun getTrickplayData(
itemId: UUID,
width: Int,
index: Int,
): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
try { try {
@ -421,9 +475,13 @@ class JellyfinRepositoryImpl(
if (sources != null) { if (sources != null) {
return@withContext File(sources.first(), index.toString()).readBytes() return@withContext File(sources.first(), index.toString()).readBytes()
} }
} catch (_: Exception) { } } catch (_: Exception) {
}
return@withContext jellyfinApi.trickplayApi.getTrickplayTileImage(itemId, width, index).content.toByteArray() return@withContext jellyfinApi.trickplayApi
.getTrickplayTileImage(itemId, width, index)
.content
.toByteArray()
} catch (e: Exception) { } catch (e: Exception) {
return@withContext null return@withContext null
} }
@ -434,21 +492,22 @@ class JellyfinRepositoryImpl(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.sessionApi.postCapabilities( jellyfinApi.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO), playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands = listOf( supportedCommands =
GeneralCommandType.VOLUME_UP, listOf(
GeneralCommandType.VOLUME_DOWN, GeneralCommandType.VOLUME_UP,
GeneralCommandType.TOGGLE_MUTE, GeneralCommandType.VOLUME_DOWN,
GeneralCommandType.SET_AUDIO_STREAM_INDEX, GeneralCommandType.TOGGLE_MUTE,
GeneralCommandType.SET_SUBTITLE_STREAM_INDEX, GeneralCommandType.SET_AUDIO_STREAM_INDEX,
GeneralCommandType.MUTE, GeneralCommandType.SET_SUBTITLE_STREAM_INDEX,
GeneralCommandType.UNMUTE, GeneralCommandType.MUTE,
GeneralCommandType.SET_VOLUME, GeneralCommandType.UNMUTE,
GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SET_VOLUME,
GeneralCommandType.PLAY, GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.PLAY_STATE, GeneralCommandType.PLAY,
GeneralCommandType.PLAY_NEXT, GeneralCommandType.PLAY_STATE,
GeneralCommandType.PLAY_MEDIA_SOURCE, GeneralCommandType.PLAY_NEXT,
), GeneralCommandType.PLAY_MEDIA_SOURCE,
),
supportsMediaControl = true, supportsMediaControl = true,
) )
} }
@ -570,186 +629,214 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getUserConfiguration(): UserConfiguration = withContext(Dispatchers.IO) { override suspend fun getUserConfiguration(): UserConfiguration =
jellyfinApi.userApi.getCurrentUser().content.configuration!! withContext(Dispatchers.IO) {
} jellyfinApi.userApi
.getCurrentUser()
.content.configuration!!
}
override suspend fun getDownloads(): List<FindroidItem> = override suspend fun getDownloads(): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val items = mutableListOf<FindroidItem>() val items = mutableListOf<FindroidItem>()
items.addAll( items.addAll(
database.getMoviesByServerId(appPreferences.currentServer!!) database
.getMoviesByServerId(appPreferences.currentServer!!)
.map { it.toFindroidMovie(database, jellyfinApi.userId!!) }, .map { it.toFindroidMovie(database, jellyfinApi.userId!!) },
) )
items.addAll( items.addAll(
database.getShowsByServerId(appPreferences.currentServer!!) database
.getShowsByServerId(appPreferences.currentServer!!)
.map { it.toFindroidShow(database, jellyfinApi.userId!!) }, .map { it.toFindroidShow(database, jellyfinApi.userId!!) },
) )
items items
} }
override fun getUserId(): UUID { override fun getUserId(): UUID = jellyfinApi.userId!!
return jellyfinApi.userId!!
}
override suspend fun buildDeviceProfile(
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> { maxBitrate: Int,
return when (transcodeResolution) { container: String,
1080 -> 8000000 to 384000 // Adjusted for 1080p context: EncodingContext,
720 -> 2000000 to 384000 // Adjusted for 720p ): DeviceProfile {
480 -> 1000000 to 384000 // Adjusted for 480p val deviceProfile =
360 -> 800000 to 128000 // Adjusted for 360p ClientCapabilitiesDto(
else -> 12000000 to 384000 supportedCommands = emptyList(),
} playableMediaTypes =
} listOf(
MediaType.VIDEO,
override suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile { MediaType.AUDIO,
val deviceProfile = ClientCapabilitiesDto( MediaType.UNKNOWN,
supportedCommands = emptyList(), ),
playableMediaTypes = emptyList(), supportsMediaControl = true,
supportsMediaControl = true, supportsPersistentIdentifier = true,
supportsPersistentIdentifier = true, deviceProfile =
deviceProfile = DeviceProfile( DeviceProfile(
name = "AnanasUser", name = "AnanasUser",
id = getUserId().toString(), id = getUserId().toString(),
maxStaticBitrate = maxBitrate, maxStaticBitrate = maxBitrate,
maxStreamingBitrate = maxBitrate, maxStreamingBitrate = maxBitrate,
codecProfiles = emptyList(), codecProfiles = emptyList(),
containerProfiles = listOf(), containerProfiles = listOf(),
directPlayProfiles = listOf( directPlayProfiles =
DirectPlayProfile(type = DlnaProfileType.VIDEO), listOf(
DirectPlayProfile(type = DlnaProfileType.AUDIO), DirectPlayProfile(type = DlnaProfileType.VIDEO),
), DirectPlayProfile(type = DlnaProfileType.AUDIO),
transcodingProfiles = listOf( ),
TranscodingProfile( transcodingProfiles =
container = container, listOf(
context = context, TranscodingProfile(
protocol = MediaStreamProtocol.HLS, container = container,
audioCodec = "aac,ac3,eac3", context = context,
videoCodec = "hevc,h264", protocol = MediaStreamProtocol.HLS,
type = DlnaProfileType.VIDEO, audioCodec = "aac,ac3,eac3",
conditions = listOf( videoCodec = "hevc,h264",
ProfileCondition( type = DlnaProfileType.VIDEO,
condition = ProfileConditionType.LESS_THAN_EQUAL, conditions =
property = ProfileConditionValue.VIDEO_BITRATE, listOf(
value = "8000000", ProfileCondition(
isRequired = true, condition = ProfileConditionType.LESS_THAN_EQUAL,
) property = ProfileConditionValue.VIDEO_BITRATE,
), value = "8000000",
copyTimestamps = true, isRequired = true,
enableSubtitlesInManifest = true, ),
transcodeSeekInfo = TranscodeSeekInfo.AUTO, ),
copyTimestamps = true,
enableSubtitlesInManifest = true,
transcodeSeekInfo = TranscodeSeekInfo.AUTO,
),
),
subtitleProfiles =
listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL),
),
), ),
),
subtitleProfiles = listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL)
),
) )
)
return deviceProfile.deviceProfile!! return deviceProfile.deviceProfile!!
} }
override suspend fun getPostedPlaybackInfo(
override suspend fun getPostedPlaybackInfo(itemId: UUID ,enableDirectStream: Boolean ,deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse> { itemId: UUID,
val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo( enableDirectStream: Boolean,
itemId = itemId, deviceProfile: DeviceProfile,
PlaybackInfoDto( maxBitrate: Int,
userId = jellyfinApi.userId!!, ): Response<PlaybackInfoResponse> {
enableTranscoding = true, val playbackInfo =
enableDirectPlay = false, jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
enableDirectStream = enableDirectStream, itemId = itemId,
autoOpenLiveStream = true, PlaybackInfoDto(
deviceProfile = deviceProfile, userId = jellyfinApi.userId!!,
allowAudioStreamCopy = true, enableTranscoding = true,
allowVideoStreamCopy = true, enableDirectPlay = false,
maxStreamingBitrate = maxBitrate, enableDirectStream = enableDirectStream,
autoOpenLiveStream = true,
deviceProfile = buildDeviceProfile(maxBitrate, "ts", EncodingContext.STREAMING),
allowAudioStreamCopy = true,
allowVideoStreamCopy = true,
maxStreamingBitrate = maxBitrate,
),
) )
)
return playbackInfo return playbackInfo
} }
override suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String { override suspend fun getVideoStreambyContainerUrl(
val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl( itemId: UUID,
itemId, deviceId: String,
static = false, mediaSourceId: String,
deviceId = deviceId, playSessionId: String,
mediaSourceId = mediaSourceId, videoBitrate: Int,
playSessionId = playSessionId, maxHeight: Int,
videoBitRate = videoBitrate, container: String,
audioBitRate = 384000, ): String {
videoCodec = "hevc", val url =
audioCodec = "aac,ac3,eac3", jellyfinApi.videosApi.getVideoStreamByContainerUrl(
container = container,
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL
)
return url
}
override suspend fun getTranscodedVideoStream(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int): String {
val isAuto = videoBitrate == 12000000
val url = if (!isAuto) {
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId, itemId,
static = false, static = false,
deviceId = deviceId, deviceId = deviceId,
mediaSourceId = mediaSourceId, mediaSourceId = mediaSourceId,
playSessionId = playSessionId, playSessionId = playSessionId,
videoBitRate = videoBitrate, videoBitRate = videoBitrate,
enableAdaptiveBitrateStreaming = false, maxHeight = maxHeight,
audioBitRate = 384000, audioBitRate = 128000,
videoCodec = "hevc", videoCodec = "hevc",
audioCodec = "aac,ac3,eac3", audioCodec = "aac",
container = container,
startTimeTicks = 0, startTimeTicks = 0,
copyTimestamps = true, copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL, subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
context = EncodingContext.STREAMING,
segmentContainer = "ts",
transcodeReasons = "ContainerBitrateExceedsLimit",
)
} else {
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId,
static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
enableAdaptiveBitrateStreaming = true,
videoCodec = "hevc",
audioCodec = "aac,ac3,eac3",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
context = EncodingContext.STREAMING,
segmentContainer = "ts",
transcodeReasons = "ContainerBitrateExceedsLimit",
) )
return url
}
override suspend fun getTranscodedVideoStream(
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
): String {
val isAuto = videoBitrate == VideoQuality.getBitrate(VideoQuality.PAuto)
val url: String
try {
url =
if (!isAuto) {
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId,
static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
videoBitRate = videoBitrate,
enableAdaptiveBitrateStreaming = false,
audioBitRate = 128000,
videoCodec = "hevc",
audioCodec = "aac",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
context = EncodingContext.STREAMING,
segmentContainer = "ts",
transcodeReasons = "ContainerBitrateExceedsLimit",
)
} else {
jellyfinApi.api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId,
static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
enableAdaptiveBitrateStreaming = true,
videoCodec = "hevc",
audioCodec = "aac",
startTimeTicks = 0,
copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
context = EncodingContext.STREAMING,
segmentContainer = "ts",
transcodeReasons = "ContainerBitrateExceedsLimit",
)
}
} catch (e: Exception) {
Timber.e(e)
throw e
} }
return url return url
} }
override suspend fun getDeviceId(): String = jellyfinApi.api.deviceInfo.id
override suspend fun getDeviceId(): String {
val devices = jellyfinApi.devicesApi.getDevices(getUserId())
return devices.content.items?.firstOrNull()?.id!!
}
override suspend fun stopEncodingProcess(playSessionId: String) { override suspend fun stopEncodingProcess(playSessionId: String) {
val deviceId = getDeviceId() val deviceId = getDeviceId()
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess( jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
deviceId = deviceId, deviceId = deviceId,
playSessionId = playSessionId playSessionId = playSessionId,
) )
} }
} }

View file

@ -1,7 +1,6 @@
package com.nomadics9.ananas.repository package com.nomadics9.ananas.repository
import android.content.Context import android.content.Context
import android.devicelock.DeviceId
import androidx.paging.PagingData import androidx.paging.PagingData
import com.nomadics9.ananas.AppPreferences import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi import com.nomadics9.ananas.api.JellyfinApi
@ -27,7 +26,6 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
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
import org.jellyfin.sdk.model.api.DeviceInfo
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -44,14 +42,9 @@ class JellyfinRepositoryOfflineImpl(
private val database: ServerDatabaseDao, private val database: ServerDatabaseDao,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
) : JellyfinRepository { ) : JellyfinRepository {
override suspend fun getPublicSystemInfo(): PublicSystemInfo = throw Exception("System info not available in offline mode")
override suspend fun getPublicSystemInfo(): PublicSystemInfo { override suspend fun getUserViews(): List<BaseItemDto> = emptyList()
throw Exception("System info not available in offline mode")
}
override suspend fun getUserViews(): List<BaseItemDto> {
return emptyList()
}
override suspend fun getItem(itemId: UUID): BaseItemDto { override suspend fun getItem(itemId: UUID): BaseItemDto {
TODO("Not yet implemented") TODO("Not yet implemented")
@ -115,38 +108,61 @@ class JellyfinRepositoryOfflineImpl(
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> { override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> =
return withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val movies = database.searchMovies(appPreferences.currentServer!!, searchQuery).map { it.toFindroidMovie(database, jellyfinApi.userId!!) } val movies =
val shows = database.searchShows(appPreferences.currentServer!!, searchQuery).map { it.toFindroidShow(database, jellyfinApi.userId!!) } database
val episodes = database.searchEpisodes(appPreferences.currentServer!!, searchQuery).map { it.toFindroidEpisode(database, jellyfinApi.userId!!) } .searchMovies(
appPreferences.currentServer!!,
searchQuery,
).map { it.toFindroidMovie(database, jellyfinApi.userId!!) }
val shows =
database
.searchShows(
appPreferences.currentServer!!,
searchQuery,
).map { it.toFindroidShow(database, jellyfinApi.userId!!) }
val episodes =
database.searchEpisodes(appPreferences.currentServer!!, searchQuery).map {
it.toFindroidEpisode(database, jellyfinApi.userId!!)
}
movies + shows + episodes movies + shows + episodes
} }
}
override suspend fun getResumeItems(): List<FindroidItem> { override suspend fun getResumeItems(): List<FindroidItem> =
return withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val movies = database.getMoviesByServerId(appPreferences.currentServer!!).map { it.toFindroidMovie(database, jellyfinApi.userId!!) }.filter { it.playbackPositionTicks > 0 } val movies =
val episodes = database.getEpisodesByServerId(appPreferences.currentServer!!).map { it.toFindroidEpisode(database, jellyfinApi.userId!!) }.filter { it.playbackPositionTicks > 0 } database
.getMoviesByServerId(appPreferences.currentServer!!)
.map {
it.toFindroidMovie(database, jellyfinApi.userId!!)
}.filter { it.playbackPositionTicks > 0 }
val episodes =
database
.getEpisodesByServerId(appPreferences.currentServer!!)
.map {
it.toFindroidEpisode(database, jellyfinApi.userId!!)
}.filter { it.playbackPositionTicks > 0 }
movies + episodes movies + episodes
} }
}
override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> { override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> = emptyList()
return emptyList()
}
override suspend fun getSeasons(seriesId: UUID, offline: Boolean): List<FindroidSeason> = override suspend fun getSeasons(
seriesId: UUID,
offline: Boolean,
): List<FindroidSeason> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) } database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) }
} }
override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> { override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> =
return withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val result = mutableListOf<FindroidEpisode>() val result = mutableListOf<FindroidEpisode>()
val shows = database.getShowsByServerId(appPreferences.currentServer!!).filter { val shows =
if (seriesId != null) it.id == seriesId else true database.getShowsByServerId(appPreferences.currentServer!!).filter {
} if (seriesId != null) it.id == seriesId else true
}
for (show in shows) { for (show in shows) {
val episodes = database.getEpisodesByShowId(show.id).map { it.toFindroidEpisode(database, jellyfinApi.userId!!) } val episodes = database.getEpisodesByShowId(show.id).map { it.toFindroidEpisode(database, jellyfinApi.userId!!) }
val indexOfLastPlayed = episodes.indexOfLast { it.played } val indexOfLastPlayed = episodes.indexOfLast { it.played }
@ -158,7 +174,6 @@ class JellyfinRepositoryOfflineImpl(
} }
result.filter { it.playbackPositionTicks == 0L } result.filter { it.playbackPositionTicks == 0L }
} }
}
override suspend fun getEpisodes( override suspend fun getEpisodes(
seriesId: UUID, seriesId: UUID,
@ -174,12 +189,19 @@ class JellyfinRepositoryOfflineImpl(
items items
} }
override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List<FindroidSource> = override suspend fun getMediaSources(
itemId: UUID,
includePath: Boolean,
): List<FindroidSource> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.getSources(itemId).map { it.toFindroidSource(database) } database.getSources(itemId).map { it.toFindroidSource(database) }
} }
override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String?): String { override suspend fun getStreamUrl(
itemId: UUID,
mediaSourceId: String,
playSessionId: String?,
): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -188,7 +210,11 @@ class JellyfinRepositoryOfflineImpl(
database.getSegments(itemId)?.toFindroidSegments() database.getSegments(itemId)?.toFindroidSegments()
} }
override suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? = override suspend fun getTrickplayData(
itemId: UUID,
width: Int,
index: Int,
): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val sources = File(context.filesDir, "trickplay/$itemId").listFiles() ?: return@withContext null val sources = File(context.filesDir, "trickplay/$itemId").listFiles() ?: return@withContext null
@ -202,7 +228,11 @@ class JellyfinRepositoryOfflineImpl(
override suspend fun postPlaybackStart(itemId: UUID) {} override suspend fun postPlaybackStart(itemId: UUID) {}
override suspend fun postPlaybackStop(itemId: UUID, positionTicks: Long, playedPercentage: Int) { override suspend fun postPlaybackStop(
itemId: UUID,
positionTicks: Long,
playedPercentage: Int,
) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
when { when {
playedPercentage < 10 -> { playedPercentage < 10 -> {
@ -262,35 +292,31 @@ class JellyfinRepositoryOfflineImpl(
} }
} }
override fun getBaseUrl(): String { override fun getBaseUrl(): String = ""
return ""
}
override suspend fun updateDeviceName(name: String) { override suspend fun updateDeviceName(name: String) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getUserConfiguration(): UserConfiguration? { override suspend fun getUserConfiguration(): UserConfiguration? = null
return null
}
override suspend fun getDownloads(): List<FindroidItem> = override suspend fun getDownloads(): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val items = mutableListOf<FindroidItem>() val items = mutableListOf<FindroidItem>()
items.addAll( items.addAll(
database.getMoviesByServerId(appPreferences.currentServer!!) database
.getMoviesByServerId(appPreferences.currentServer!!)
.map { it.toFindroidMovie(database, jellyfinApi.userId!!) }, .map { it.toFindroidMovie(database, jellyfinApi.userId!!) },
) )
items.addAll( items.addAll(
database.getShowsByServerId(appPreferences.currentServer!!) database
.getShowsByServerId(appPreferences.currentServer!!)
.map { it.toFindroidShow(database, jellyfinApi.userId!!) }, .map { it.toFindroidShow(database, jellyfinApi.userId!!) },
) )
items items
} }
override fun getUserId(): UUID { override fun getUserId(): UUID = jellyfinApi.userId!!
return jellyfinApi.userId!!
}
override suspend fun getDeviceId(): String { override suspend fun getDeviceId(): String {
TODO("Not yet implemented") TODO("Not yet implemented")
@ -299,7 +325,7 @@ class JellyfinRepositoryOfflineImpl(
override suspend fun buildDeviceProfile( override suspend fun buildDeviceProfile(
maxBitrate: Int, maxBitrate: Int,
container: String, container: String,
context: EncodingContext context: EncodingContext,
): DeviceProfile { ): DeviceProfile {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -310,7 +336,8 @@ class JellyfinRepositoryOfflineImpl(
mediaSourceId: String, mediaSourceId: String,
playSessionId: String, playSessionId: String,
videoBitrate: Int, videoBitrate: Int,
container: String maxHeight: Int,
container: String,
): String { ): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -320,7 +347,7 @@ class JellyfinRepositoryOfflineImpl(
deviceId: String, deviceId: String,
mediaSourceId: String, mediaSourceId: String,
playSessionId: String, playSessionId: String,
videoBitrate: Int videoBitrate: Int,
): String { ): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -329,7 +356,7 @@ class JellyfinRepositoryOfflineImpl(
itemId: UUID, itemId: UUID,
enableDirectStream: Boolean, enableDirectStream: Boolean,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
maxBitrate: Int maxBitrate: Int,
): Response<PlaybackInfoResponse> { ): Response<PlaybackInfoResponse> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -337,8 +364,4 @@ class JellyfinRepositoryOfflineImpl(
override suspend fun stopEncodingProcess(playSessionId: String) { override suspend fun stopEncodingProcess(playSessionId: String) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
TODO("Not yet implemented")
}
} }