From 4e8ee15d0a2ca52334dc7b198e5354a8c2f90a1c Mon Sep 17 00:00:00 2001 From: nomadics9 Date: Sat, 20 Jul 2024 06:31:53 +0300 Subject: [PATCH] bugfixes: getDeviceId() / code: New Enum VideoQuality --- .../com/nomadics9/ananas/PlayerActivity.kt | 190 +-- .../nomadics9/ananas/utils/DownloaderImpl.kt | 273 ++-- core/src/main/res/values/string_arrays.xml | 4 + .../nomadics9/ananas/models/VideoQuality.kt | 25 + .../ananas/repository/JellyfinRepository.kt | 71 +- .../repository/JellyfinRepositoryImpl.kt | 763 ++++++----- .../JellyfinRepositoryOfflineImpl.kt | 129 +- .../viewmodels/PlayerActivityViewModel.kt | 1167 +++++++++-------- 8 files changed, 1450 insertions(+), 1172 deletions(-) create mode 100644 data/src/main/java/com/nomadics9/ananas/models/VideoQuality.kt diff --git a/app/phone/src/main/java/com/nomadics9/ananas/PlayerActivity.kt b/app/phone/src/main/java/com/nomadics9/ananas/PlayerActivity.kt index 0c9f56ba..f2cead62 100644 --- a/app/phone/src/main/java/com/nomadics9/ananas/PlayerActivity.kt +++ b/app/phone/src/main/java/com/nomadics9/ananas/PlayerActivity.kt @@ -24,19 +24,16 @@ import android.widget.ImageView import android.widget.Space import android.widget.TextView import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.C -import androidx.media3.common.Player import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerView import androidx.navigation.navArgs import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint import com.nomadics9.ananas.databinding.ActivityPlayerBinding import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment 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.viewmodels.PlayerActivityViewModel import com.nomadics9.ananas.viewmodels.PlayerEvents +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -54,7 +52,6 @@ var isControlsLocked: Boolean = false @AndroidEntryPoint class PlayerActivity : BasePlayerActivity() { - @Inject lateinit var appPreferences: AppPreferences @@ -115,12 +112,13 @@ class PlayerActivity : BasePlayerActivity() { configureInsets(lockedControls) if (appPreferences.playerGestures) { - playerGestureHelper = PlayerGestureHelper( - appPreferences, - this, - binding.playerView, - getSystemService(AUDIO_SERVICE) as AudioManager, - ) + playerGestureHelper = + PlayerGestureHelper( + appPreferences, + this, + binding.playerView, + getSystemService(AUDIO_SERVICE) as AudioManager, + ) } binding.playerView.findViewById(R.id.back_button).setOnClickListener { @@ -155,7 +153,12 @@ class PlayerActivity : BasePlayerActivity() { skipButton.text = getString(CoreR.string.skip_intro_button) 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 } @@ -167,7 +170,10 @@ class PlayerActivity : BasePlayerActivity() { getString(CoreR.string.skip_credit_button_last) } skipButton.isVisible = - !isInPictureInPictureMode && !buttonPressed && currentSegment?.skip == true && !binding.playerView.isControllerFullyVisible + !isInPictureInPictureMode && + !buttonPressed && + currentSegment?.skip == true && + !binding.playerView.isControllerFullyVisible watchCreditsButton.isVisible = skipButton.isVisible } @@ -181,12 +187,15 @@ class PlayerActivity : BasePlayerActivity() { when (currentSegment?.type) { "intro" -> { skipButton.isVisible = - !buttonPressed && (showSkip == true || (visibility == View.VISIBLE && currentSegment?.skip == true)) + !buttonPressed && + (showSkip == true || (visibility == View.VISIBLE && currentSegment?.skip == true)) } "credit" -> { skipButton.isVisible = - !buttonPressed && currentSegment?.skip == true && visibility == View.GONE + !buttonPressed && + currentSegment?.skip == true && + visibility == View.GONE watchCreditsButton.isVisible = skipButton.isVisible } } @@ -268,7 +277,8 @@ class PlayerActivity : BasePlayerActivity() { if (appPreferences.playerPipGesture) { try { setPictureInPictureParams(pipParams(event.isPlaying)) - } catch (_: IllegalArgumentException) { } + } catch (_: IllegalArgumentException) { + } } } } @@ -346,11 +356,12 @@ class PlayerActivity : BasePlayerActivity() { if (appPreferences.playerTrickplay) { val imagePreview = binding.playerView.findViewById(R.id.image_preview) - previewScrubListener = PreviewScrubListener( - imagePreview, - timeBar, - viewModel.player, - ) + previewScrubListener = + PreviewScrubListener( + imagePreview, + timeBar, + viewModel.player, + ) timeBar.addListener(previewScrubListener!!) } @@ -381,34 +392,38 @@ class PlayerActivity : BasePlayerActivity() { private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams { val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height) - val aspectRatio = binding.playerView.player?.videoSize?.let { - Rational( - it.width.coerceAtMost((it.height * 2.39f).toInt()), - it.height.coerceAtMost((it.width * 2.39f).toInt()), - ) - } + val aspectRatio = + binding.playerView.player?.videoSize?.let { + Rational( + it.width.coerceAtMost((it.height * 2.39f).toInt()), + it.height.coerceAtMost((it.width * 2.39f).toInt()), + ) + } - val sourceRectHint = if (displayAspectRatio < aspectRatio!!) { - val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt() - Rect( - 0, - 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() - Rect( - space, - 0, - (binding.playerView.height.toFloat() * aspectRatio.toFloat()).toInt() + space, - binding.playerView.height, - ) - } + val sourceRectHint = + if (displayAspectRatio < aspectRatio!!) { + val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt() + Rect( + 0, + 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() + Rect( + space, + 0, + (binding.playerView.height.toFloat() * aspectRatio.toFloat()).toInt() + space, + binding.playerView.height, + ) + } - val builder = PictureInPictureParams.Builder() - .setAspectRatio(aspectRatio) - .setSourceRectHint(sourceRectHint) + val builder = + PictureInPictureParams + .Builder() + .setAspectRatio(aspectRatio) + .setSourceRectHint(sourceRectHint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(enableAutoEnter) @@ -424,31 +439,52 @@ class PlayerActivity : BasePlayerActivity() { try { enterPictureInPictureMode(pipParams()) - } catch (_: IllegalArgumentException) { } + } catch (_: IllegalArgumentException) { + } } 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 = + 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) .setTitle("Select Video Quality") - .setItems(qualities) { _, which -> - val selectedQuality = qualities[which] - viewModel.changeVideoQuality(selectedQuality) - } - .show() + .setItems(qualities.toTypedArray()) { _, which -> + val selectedQualityEntry = qualities[which] + val selectedQualityValue = + qualityMap.entries.find { it.key.contains(selectedQualityEntry.split(" ")[0]) }?.value ?: selectedQualityEntry + viewModel.changeVideoQuality(selectedQualityValue) + }.show() } - - - - override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration, @@ -463,25 +499,29 @@ class PlayerActivity : BasePlayerActivity() { playerGestureHelper?.updateZoomMode(false) // Brightness mode Auto - window.attributes = window.attributes.apply { - screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE - } + window.attributes = + window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } } false -> { binding.playerView.useController = true playerGestureHelper?.updateZoomMode(wasZoom) // Override auto brightness - window.attributes = window.attributes.apply { - screenBrightness = if (appPreferences.playerBrightnessRemember) { - appPreferences.playerBrightness - } else { - Settings.System.getInt( - contentResolver, - Settings.System.SCREEN_BRIGHTNESS, - ).toFloat() / 255 + window.attributes = + window.attributes.apply { + screenBrightness = + if (appPreferences.playerBrightnessRemember) { + appPreferences.playerBrightness + } else { + Settings.System + .getInt( + contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + ).toFloat() / 255 + } } - } } } } diff --git a/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt b/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt index 50f5efaa..c1f90fd1 100644 --- a/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt +++ b/core/src/main/java/com/nomadics9/ananas/utils/DownloaderImpl.kt @@ -17,6 +17,7 @@ import com.nomadics9.ananas.models.FindroidSource import com.nomadics9.ananas.models.FindroidSources import com.nomadics9.ananas.models.FindroidTrickplayInfo import com.nomadics9.ananas.models.UiText +import com.nomadics9.ananas.models.VideoQuality import com.nomadics9.ananas.models.toFindroidEpisodeDto import com.nomadics9.ananas.models.toFindroidMediaStreamDto import com.nomadics9.ananas.models.toFindroidMovieDto @@ -50,17 +51,17 @@ class DownloaderImpl( storageIndex: Int, ): Pair { try { - Timber.d("Downloading item: ${item.id} with sourceId: $sourceId") val source = jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId } val segments = jellyfinRepository.getSegmentsTimestamps(item.id) - val trickplayInfo = if (item is FindroidSources) { - item.trickplayInfo?.get(sourceId) - } else { - null - } + val trickplayInfo = + if (item is FindroidSources) { + item.trickplayInfo?.get(sourceId) + } else { + null + } val storageLocation = context.getExternalFilesDirs(null)[storageIndex] if (storageLocation == null || Environment.getExternalStorageState(storageLocation) != Environment.MEDIA_MOUNTED) { return Pair(-1, UiText.StringResource(CoreR.string.storage_unavailable)) @@ -96,9 +97,13 @@ class DownloaderImpl( return Pair( -1, - if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource( - CoreR.string.unknown_error - ) + if (e.message != null) { + UiText.DynamicString(e.message!!) + } else { + UiText.StringResource( + CoreR.string.unknown_error, + ) + }, ) } } @@ -110,7 +115,7 @@ class DownloaderImpl( trickplayInfo: FindroidTrickplayInfo?, segments: List?, path: Uri, - quality: String + quality: String, ): Pair { val transcodingUrl = getTranscodedUrl(item.id, quality) when (item) { @@ -126,12 +131,14 @@ class DownloaderImpl( if (segments != null) { database.insertSegments(segments.toFindroidSegmentsDto(item.id)) } - val request = DownloadManager.Request(transcodingUrl) - .setTitle(item.name) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationUri(path) + val request = + DownloadManager + .Request(transcodingUrl) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) val downloadId = downloadManager.enqueue(request) database.setSourceDownloadId(source.id, downloadId) return Pair(downloadId, null) @@ -139,7 +146,8 @@ class DownloaderImpl( is FindroidEpisode -> { database.insertShow( - jellyfinRepository.getShow(item.seriesId) + jellyfinRepository + .getShow(item.seriesId) .toFindroidShowDto(appPreferences.currentServer!!), ) database.insertSeason( @@ -156,12 +164,14 @@ class DownloaderImpl( if (segments != null) { database.insertSegments(segments.toFindroidSegmentsDto(item.id)) } - val request = DownloadManager.Request(transcodingUrl) - .setTitle(item.name) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationUri(path) + val request = + DownloadManager + .Request(transcodingUrl) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) val downloadId = downloadManager.enqueue(request) database.setSourceDownloadId(source.id, downloadId) return Pair(downloadId, null) @@ -190,12 +200,14 @@ class DownloaderImpl( if (segments != null) { database.insertSegments(segments.toFindroidSegmentsDto(item.id)) } - val request = DownloadManager.Request(source.path.toUri()) - .setTitle(item.name) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationUri(path) + val request = + DownloadManager + .Request(source.path.toUri()) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) val downloadId = downloadManager.enqueue(request) database.setSourceDownloadId(source.id, downloadId) return Pair(downloadId, null) @@ -203,7 +215,8 @@ class DownloaderImpl( is FindroidEpisode -> { database.insertShow( - jellyfinRepository.getShow(item.seriesId) + jellyfinRepository + .getShow(item.seriesId) .toFindroidShowDto(appPreferences.currentServer!!), ) database.insertSeason( @@ -219,12 +232,14 @@ class DownloaderImpl( if (segments != null) { database.insertSegments(segments.toFindroidSegmentsDto(item.id)) } - val request = DownloadManager.Request(source.path.toUri()) - .setTitle(item.name) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationUri(path) + val request = + DownloadManager + .Request(source.path.toUri()) + .setTitle(item.name) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(path) val downloadId = downloadManager.enqueue(request) database.setSourceDownloadId(source.id, downloadId) return Pair(downloadId, null) @@ -233,15 +248,20 @@ class DownloaderImpl( return Pair(-1, null) } - - override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) { + override suspend fun cancelDownload( + item: FindroidItem, + source: FindroidSource, + ) { if (source.downloadId != null) { downloadManager.remove(source.downloadId!!) } deleteItem(item, source) } - override suspend fun deleteItem(item: FindroidItem, source: FindroidSource) { + override suspend fun deleteItem( + item: FindroidItem, + source: FindroidSource, + ) { when (item) { is FindroidMovie -> { database.deleteMovie(item.id) @@ -283,15 +303,18 @@ class DownloaderImpl( if (downloadId == null) { return Pair(downloadStatus, progress) } - val query = DownloadManager.Query() - .setFilterById(downloadId) + val query = + DownloadManager + .Query() + .setFilterById(downloadId) val cursor = downloadManager.query(query) if (cursor.moveToFirst()) { - downloadStatus = cursor.getInt( - cursor.getColumnIndexOrThrow( - DownloadManager.COLUMN_STATUS, - ), - ) + downloadStatus = + cursor.getInt( + cursor.getColumnIndexOrThrow( + DownloadManager.COLUMN_STATUS, + ), + ) when (downloadStatus) { DownloadManager.STATUS_RUNNING -> { val totalBytes = @@ -320,25 +343,28 @@ class DownloaderImpl( val storageLocation = context.getExternalFilesDirs(null)[storageIndex] for (mediaStream in source.mediaStreams.filter { it.isExternal }) { val id = UUID.randomUUID() - val streamPath = Uri.fromFile( - File( - storageLocation, - "downloads/${item.id}.${source.id}.$id.download" + val streamPath = + Uri.fromFile( + File( + storageLocation, + "downloads/${item.id}.${source.id}.$id.download", + ), ) - ) database.insertMediaStream( mediaStream.toFindroidMediaStreamDto( id, source.id, - streamPath.path.orEmpty() - ) + streamPath.path.orEmpty(), + ), ) - val request = DownloadManager.Request(Uri.parse(mediaStream.path)) - .setTitle(mediaStream.title) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) - .setDestinationUri(streamPath) + val request = + DownloadManager + .Request(Uri.parse(mediaStream.path)) + .setTitle(mediaStream.title) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) + .setDestinationUri(streamPath) val downloadId = downloadManager.enqueue(request) database.setMediaStreamDownloadId(id, downloadId) } @@ -347,7 +373,7 @@ class DownloaderImpl( private fun downloadEmbeddedMediaStreams( item: FindroidItem, source: FindroidSource, - storageIndex: Int = 0 + storageIndex: Int = 0, ) { val storageLocation = context.getExternalFilesDirs(null)[storageIndex] 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") } val id = UUID.randomUUID() - val streamPath = Uri.fromFile( - File( - storageLocation, - "downloads/${item.id}.${source.id}.$id.download" + val streamPath = + Uri.fromFile( + File( + storageLocation, + "downloads/${item.id}.${source.id}.$id.download", + ), ) - ) database.insertMediaStream( mediaStream.toFindroidMediaStreamDto( id, source.id, - streamPath.path.orEmpty() - ) + streamPath.path.orEmpty(), + ), ) - val request = DownloadManager.Request(Uri.parse(deliveryUrl)) - .setTitle(mediaStream.title) - .setAllowedOverMetered(appPreferences.downloadOverMobileData) - .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) - .setDestinationUri(streamPath) + val request = + DownloadManager + .Request(Uri.parse(deliveryUrl)) + .setTitle(mediaStream.title) + .setAllowedOverMetered(appPreferences.downloadOverMobileData) + .setAllowedOverRoaming(appPreferences.downloadWhenRoaming) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) + .setDestinationUri(streamPath) val downloadId = downloadManager.enqueue(request) database.setMediaStreamDownloadId(id, downloadId) } } - private suspend fun downloadTrickplayData( itemId: UUID, sourceId: String, trickplayInfo: FindroidTrickplayInfo, ) { - val maxIndex = ceil( - trickplayInfo.thumbnailCount.toDouble() - .div(trickplayInfo.tileWidth * trickplayInfo.tileHeight) - ).toInt() + val maxIndex = + ceil( + trickplayInfo.thumbnailCount + .toDouble() + .div(trickplayInfo.tileWidth * trickplayInfo.tileHeight), + ).toInt() val byteArrays = mutableListOf() for (i in 0..maxIndex) { - jellyfinRepository.getTrickplayData( - itemId, - trickplayInfo.width, - i, - )?.let { byteArray -> - byteArrays.add(byteArray) - } + jellyfinRepository + .getTrickplayData( + itemId, + trickplayInfo.width, + i, + )?.let { byteArray -> + byteArrays.add(byteArray) + } } saveTrickplayData(itemId, sourceId, trickplayInfo, byteArrays) } @@ -420,52 +451,46 @@ class DownloaderImpl( } } - private suspend fun getTranscodedUrl(itemId: UUID, quality: String): Uri? { - val maxBitrate = when (quality) { - "720p" -> 2000000 // 2 Mbps - "480p" -> 1000000 // 1 Mbps - "360p" -> 800000 // 800Kbps - else -> 2000000 // Default to 2 Mbps if not specified - } - + private suspend fun getTranscodedUrl( + itemId: UUID, + quality: String, + ): Uri? { + val videoQuality = VideoQuality.fromString(quality)!! return try { - - val deviceProfile = jellyfinRepository.buildDeviceProfile(maxBitrate,"mkv", EncodingContext.STATIC) - val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,false,deviceProfile,maxBitrate) - val mediaSourceId = playbackInfo.content.mediaSources.firstOrNull()?.id!! + val deviceProfile = + jellyfinRepository.buildDeviceProfile( + VideoQuality.getBitrate(videoQuality), + "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 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) - Timber.d("Constructed Transcode URL: $transcodeUri") - transcodeUri + return downloadUrl.toUri() } catch (e: Exception) { Timber.e(e) 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() - } } - - - - diff --git a/core/src/main/res/values/string_arrays.xml b/core/src/main/res/values/string_arrays.xml index d198af5a..33721829 100644 --- a/core/src/main/res/values/string_arrays.xml +++ b/core/src/main/res/values/string_arrays.xml @@ -26,13 +26,17 @@ opensles + Auto Original + 1080p - 8Mbps 720p - 2Mbps 480p - 1Mbps 360p - 800Kbps + Auto Original + 1080p 720p 480p 360p diff --git a/data/src/main/java/com/nomadics9/ananas/models/VideoQuality.kt b/data/src/main/java/com/nomadics9/ananas/models/VideoQuality.kt new file mode 100644 index 00000000..823335cf --- /dev/null +++ b/data/src/main/java/com/nomadics9/ananas/models/VideoQuality.kt @@ -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 + } +} diff --git a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt index e86ab602..23adc8c8 100644 --- a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt +++ b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepository.kt @@ -14,8 +14,6 @@ import kotlinx.coroutines.flow.Flow import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto 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.EncodingContext import org.jellyfin.sdk.model.api.ItemFields @@ -31,7 +29,9 @@ interface JellyfinRepository { suspend fun getUserViews(): List suspend fun getItem(itemId: UUID): BaseItemDto + suspend fun getEpisode(itemId: UUID): FindroidEpisode + suspend fun getMovie(itemId: UUID): FindroidMovie suspend fun getShow(itemId: UUID): FindroidShow @@ -72,7 +72,10 @@ interface JellyfinRepository { suspend fun getLatestMedia(parentId: UUID): List - suspend fun getSeasons(seriesId: UUID, offline: Boolean = false): List + suspend fun getSeasons( + seriesId: UUID, + offline: Boolean = false, + ): List suspend fun getNextUp(seriesId: UUID? = null): List @@ -85,21 +88,40 @@ interface JellyfinRepository { offline: Boolean = false, ): List - suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List + suspend fun getMediaSources( + itemId: UUID, + includePath: Boolean = false, + ): List - 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? - 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 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) @@ -121,15 +143,36 @@ interface JellyfinRepository { suspend fun getDeviceId(): String - suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair + 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, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response + suspend fun getPostedPlaybackInfo( + itemId: UUID, + enableDirectStream: Boolean, + deviceProfile: DeviceProfile, + maxBitrate: Int, + ): Response suspend fun stopEncodingProcess(playSessionId: String) } diff --git a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt index 2daace11..52e1343d 100644 --- a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt +++ b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryImpl.kt @@ -17,6 +17,7 @@ import com.nomadics9.ananas.models.FindroidSegments import com.nomadics9.ananas.models.FindroidShow import com.nomadics9.ananas.models.FindroidSource import com.nomadics9.ananas.models.SortBy +import com.nomadics9.ananas.models.VideoQuality import com.nomadics9.ananas.models.toFindroidCollection import com.nomadics9.ananas.models.toFindroidEpisode 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.BaseItemKind 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.DeviceProfile 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.SubtitleDeliveryMethod 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.TranscodingProfile import org.jellyfin.sdk.model.api.UserConfiguration @@ -71,55 +70,70 @@ class JellyfinRepositoryImpl( private val database: ServerDatabaseDao, private val appPreferences: AppPreferences, ) : JellyfinRepository { - override suspend fun getPublicSystemInfo(): PublicSystemInfo = withContext(Dispatchers.IO) { - jellyfinApi.systemApi.getPublicSystemInfo().content - } + override suspend fun getPublicSystemInfo(): PublicSystemInfo = + withContext(Dispatchers.IO) { + jellyfinApi.systemApi.getPublicSystemInfo().content + } - override suspend fun getUserViews(): List = withContext(Dispatchers.IO) { - jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty() - } + override suspend fun getUserViews(): List = + withContext(Dispatchers.IO) { + jellyfinApi.viewsApi + .getUserViews(jellyfinApi.userId!!) + .content.items + .orEmpty() + } - override suspend fun getItem(itemId: UUID): BaseItemDto = withContext(Dispatchers.IO) { - jellyfinApi.userLibraryApi.getItem(itemId, jellyfinApi.userId!!).content - } + override suspend fun getItem(itemId: UUID): BaseItemDto = + withContext(Dispatchers.IO) { + jellyfinApi.userLibraryApi.getItem(itemId, jellyfinApi.userId!!).content + } override suspend fun getEpisode(itemId: UUID): FindroidEpisode = withContext(Dispatchers.IO) { - jellyfinApi.userLibraryApi.getItem( - itemId, - jellyfinApi.userId!!, - ).content.toFindroidEpisode(this@JellyfinRepositoryImpl, database)!! + jellyfinApi.userLibraryApi + .getItem( + itemId, + jellyfinApi.userId!!, + ).content + .toFindroidEpisode(this@JellyfinRepositoryImpl, database)!! } override suspend fun getMovie(itemId: UUID): FindroidMovie = withContext(Dispatchers.IO) { - jellyfinApi.userLibraryApi.getItem( - itemId, - jellyfinApi.userId!!, - ).content.toFindroidMovie(this@JellyfinRepositoryImpl, database) + jellyfinApi.userLibraryApi + .getItem( + itemId, + jellyfinApi.userId!!, + ).content + .toFindroidMovie(this@JellyfinRepositoryImpl, database) } override suspend fun getShow(itemId: UUID): FindroidShow = withContext(Dispatchers.IO) { - jellyfinApi.userLibraryApi.getItem( - itemId, - jellyfinApi.userId!!, - ).content.toFindroidShow(this@JellyfinRepositoryImpl) + jellyfinApi.userLibraryApi + .getItem( + itemId, + jellyfinApi.userId!!, + ).content + .toFindroidShow(this@JellyfinRepositoryImpl) } override suspend fun getSeason(itemId: UUID): FindroidSeason = withContext(Dispatchers.IO) { - jellyfinApi.userLibraryApi.getItem( - itemId, - jellyfinApi.userId!!, - ).content.toFindroidSeason(this@JellyfinRepositoryImpl) + jellyfinApi.userLibraryApi + .getItem( + itemId, + jellyfinApi.userId!!, + ).content + .toFindroidSeason(this@JellyfinRepositoryImpl) } override suspend fun getLibraries(): List = withContext(Dispatchers.IO) { - jellyfinApi.itemsApi.getItems( - jellyfinApi.userId!!, - ).content.items + jellyfinApi.itemsApi + .getItems( + jellyfinApi.userId!!, + ).content.items .orEmpty() .mapNotNull { it.toFindroidCollection(this@JellyfinRepositoryImpl) } } @@ -134,16 +148,17 @@ class JellyfinRepositoryImpl( limit: Int?, ): List = withContext(Dispatchers.IO) { - jellyfinApi.itemsApi.getItems( - jellyfinApi.userId!!, - parentId = parentId, - includeItemTypes = includeTypes, - recursive = recursive, - sortBy = listOf(ItemSortBy.fromName(sortBy.sortString)), - sortOrder = listOf(sortOrder), - startIndex = startIndex, - limit = limit, - ).content.items + jellyfinApi.itemsApi + .getItems( + jellyfinApi.userId!!, + parentId = parentId, + includeItemTypes = includeTypes, + recursive = recursive, + sortBy = listOf(ItemSortBy.fromName(sortBy.sortString)), + sortOrder = listOf(sortOrder), + startIndex = startIndex, + limit = limit, + ).content.items .orEmpty() .mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) } } @@ -154,13 +169,14 @@ class JellyfinRepositoryImpl( recursive: Boolean, sortBy: SortBy, sortOrder: SortOrder, - ): Flow> { - return Pager( - config = PagingConfig( - pageSize = 10, - maxSize = 100, - enablePlaceholders = false, - ), + ): Flow> = + Pager( + config = + PagingConfig( + pageSize = 10, + maxSize = 100, + enablePlaceholders = false, + ), pagingSourceFactory = { ItemsPagingSource( this, @@ -172,87 +188,102 @@ class JellyfinRepositoryImpl( ) }, ).flow - } override suspend fun getPersonItems( personIds: List, includeTypes: List?, recursive: Boolean, - ): List = withContext(Dispatchers.IO) { - jellyfinApi.itemsApi.getItems( - jellyfinApi.userId!!, - personIds = personIds, - includeItemTypes = includeTypes, - recursive = recursive, - ).content.items - .orEmpty() - .mapNotNull { - it.toFindroidItem(this@JellyfinRepositoryImpl, database) - } - } + ): List = + withContext(Dispatchers.IO) { + jellyfinApi.itemsApi + .getItems( + jellyfinApi.userId!!, + personIds = personIds, + includeItemTypes = includeTypes, + recursive = recursive, + ).content.items + .orEmpty() + .mapNotNull { + it.toFindroidItem(this@JellyfinRepositoryImpl, database) + } + } override suspend fun getFavoriteItems(): List = withContext(Dispatchers.IO) { - jellyfinApi.itemsApi.getItems( - jellyfinApi.userId!!, - filters = listOf(ItemFilter.IS_FAVORITE), - includeItemTypes = listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - BaseItemKind.EPISODE, - ), - recursive = true, - ).content.items + jellyfinApi.itemsApi + .getItems( + jellyfinApi.userId!!, + filters = listOf(ItemFilter.IS_FAVORITE), + includeItemTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + ), + recursive = true, + ).content.items .orEmpty() .mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) } } override suspend fun getSearchItems(searchQuery: String): List = withContext(Dispatchers.IO) { - jellyfinApi.itemsApi.getItems( - jellyfinApi.userId!!, - searchTerm = searchQuery, - includeItemTypes = listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - BaseItemKind.EPISODE, - ), - recursive = true, - ).content.items + jellyfinApi.itemsApi + .getItems( + jellyfinApi.userId!!, + searchTerm = searchQuery, + includeItemTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + ), + recursive = true, + ).content.items .orEmpty() .mapNotNull { it.toFindroidItem(this@JellyfinRepositoryImpl, database) } } override suspend fun getResumeItems(): List { - val items = withContext(Dispatchers.IO) { - jellyfinApi.itemsApi.getResumeItems( - jellyfinApi.userId!!, - limit = 12, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), - ).content.items.orEmpty() - } + val items = + withContext(Dispatchers.IO) { + jellyfinApi.itemsApi + .getResumeItems( + jellyfinApi.userId!!, + limit = 12, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), + ).content.items + .orEmpty() + } return items.mapNotNull { it.toFindroidItem(this, database) } } override suspend fun getLatestMedia(parentId: UUID): List { - val items = withContext(Dispatchers.IO) { - jellyfinApi.userLibraryApi.getLatestMedia( - jellyfinApi.userId!!, - parentId = parentId, - limit = 16, - ).content - } + val items = + withContext(Dispatchers.IO) { + jellyfinApi.userLibraryApi + .getLatestMedia( + jellyfinApi.userId!!, + parentId = parentId, + limit = 16, + ).content + } return items.mapNotNull { it.toFindroidItem(this, database) } } - override suspend fun getSeasons(seriesId: UUID, offline: Boolean): List = + override suspend fun getSeasons( + seriesId: UUID, + offline: Boolean, + ): List = withContext(Dispatchers.IO) { if (!offline) { - jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items + jellyfinApi.showsApi + .getSeasons(seriesId, jellyfinApi.userId!!) + .content.items .orEmpty() .map { it.toFindroidSeason(this@JellyfinRepositoryImpl) } } else { @@ -262,12 +293,13 @@ class JellyfinRepositoryImpl( override suspend fun getNextUp(seriesId: UUID?): List = withContext(Dispatchers.IO) { - jellyfinApi.showsApi.getNextUp( - jellyfinApi.userId!!, - limit = 24, - seriesId = seriesId, - enableResumable = false, - ).content.items + jellyfinApi.showsApi + .getNextUp( + jellyfinApi.userId!!, + limit = 24, + seriesId = seriesId, + enableResumable = false, + ).content.items .orEmpty() .mapNotNull { it.toFindroidEpisode(this@JellyfinRepositoryImpl) } } @@ -282,14 +314,15 @@ class JellyfinRepositoryImpl( ): List = withContext(Dispatchers.IO) { if (!offline) { - jellyfinApi.showsApi.getEpisodes( - seriesId, - jellyfinApi.userId!!, - seasonId = seasonId, - fields = fields, - startItemId = startItemId, - limit = limit, - ).content.items + jellyfinApi.showsApi + .getEpisodes( + seriesId, + jellyfinApi.userId!!, + seasonId = seasonId, + fields = fields, + startItemId = startItemId, + limit = limit, + ).content.items .orEmpty() .mapNotNull { it.toFindroidEpisode(this@JellyfinRepositoryImpl, database) } } else { @@ -297,39 +330,47 @@ class JellyfinRepositoryImpl( } } - override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List = + override suspend fun getMediaSources( + itemId: UUID, + includePath: Boolean, + ): List = withContext(Dispatchers.IO) { val sources = mutableListOf() sources.addAll( - jellyfinApi.mediaInfoApi.getPostedPlaybackInfo( - itemId, - 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, + jellyfinApi.mediaInfoApi + .getPostedPlaybackInfo( 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( database.getSources(itemId).map { it.toFindroidSource(database) }, @@ -337,26 +378,32 @@ class JellyfinRepositoryImpl( 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) { + // val deviceId = getDeviceId() try { - val url = if (playSessionId != null) { - jellyfinApi.videosApi.getVideoStreamUrl( - itemId, - static = true, - mediaSourceId = mediaSourceId, - playSessionId = playSessionId, - deviceId = getDeviceId(), - context = EncodingContext.STATIC - ) - } else { - jellyfinApi.videosApi.getVideoStreamUrl( - itemId, - static = true, - mediaSourceId = mediaSourceId, - deviceId = getDeviceId(), - ) - } + val url = + if (playSessionId != null) { + jellyfinApi.videosApi.getVideoStreamUrl( + itemId, + static = true, + mediaSourceId = mediaSourceId, + playSessionId = playSessionId, + // deviceId = deviceId, + context = EncodingContext.STREAMING, + ) + } else { + jellyfinApi.videosApi.getVideoStreamUrl( + itemId, + static = true, + mediaSourceId = mediaSourceId, + // deviceId = deviceId, + ) + } url } catch (e: Exception) { Timber.e(e) @@ -377,33 +424,36 @@ class JellyfinRepositoryImpl( pathParameters["itemId"] = itemId try { - val segmentToConvert = jellyfinApi.api.get( - "/Episode/{itemId}/IntroSkipperSegments", - pathParameters, - ).content + val segmentToConvert = + jellyfinApi.api + .get( + "/Episode/{itemId}/IntroSkipperSegments", + pathParameters, + ).content - val segmentConverted = mutableListOf( - segmentToConvert.intro!!.let { - FindroidSegment( - type = "intro", - skip = true, - startTime = it.startTime, - endTime = it.endTime, - showAt = it.showAt, - hideAt = it.hideAt, - ) - }, - segmentToConvert.credit!!.let { - FindroidSegment( - type = "credit", - skip = true, - startTime = it.startTime, - endTime = it.endTime, - showAt = it.showAt, - hideAt = it.hideAt, - ) - }, - ) + val segmentConverted = + mutableListOf( + segmentToConvert.intro!!.let { + FindroidSegment( + type = "intro", + skip = true, + startTime = it.startTime, + endTime = it.endTime, + showAt = it.showAt, + hideAt = it.hideAt, + ) + }, + segmentToConvert.credit!!.let { + FindroidSegment( + type = "credit", + skip = true, + startTime = it.startTime, + endTime = it.endTime, + showAt = it.showAt, + hideAt = it.hideAt, + ) + }, + ) Timber.tag("SegmentInfo").d("segmentToConvert: %s", segmentToConvert) 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) { try { try { @@ -421,9 +475,13 @@ class JellyfinRepositoryImpl( if (sources != null) { 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) { return@withContext null } @@ -434,21 +492,22 @@ class JellyfinRepositoryImpl( withContext(Dispatchers.IO) { jellyfinApi.sessionApi.postCapabilities( playableMediaTypes = listOf(MediaType.VIDEO), - supportedCommands = listOf( - GeneralCommandType.VOLUME_UP, - GeneralCommandType.VOLUME_DOWN, - GeneralCommandType.TOGGLE_MUTE, - GeneralCommandType.SET_AUDIO_STREAM_INDEX, - GeneralCommandType.SET_SUBTITLE_STREAM_INDEX, - GeneralCommandType.MUTE, - GeneralCommandType.UNMUTE, - GeneralCommandType.SET_VOLUME, - GeneralCommandType.DISPLAY_MESSAGE, - GeneralCommandType.PLAY, - GeneralCommandType.PLAY_STATE, - GeneralCommandType.PLAY_NEXT, - GeneralCommandType.PLAY_MEDIA_SOURCE, - ), + supportedCommands = + listOf( + GeneralCommandType.VOLUME_UP, + GeneralCommandType.VOLUME_DOWN, + GeneralCommandType.TOGGLE_MUTE, + GeneralCommandType.SET_AUDIO_STREAM_INDEX, + GeneralCommandType.SET_SUBTITLE_STREAM_INDEX, + GeneralCommandType.MUTE, + GeneralCommandType.UNMUTE, + GeneralCommandType.SET_VOLUME, + GeneralCommandType.DISPLAY_MESSAGE, + GeneralCommandType.PLAY, + GeneralCommandType.PLAY_STATE, + GeneralCommandType.PLAY_NEXT, + GeneralCommandType.PLAY_MEDIA_SOURCE, + ), supportsMediaControl = true, ) } @@ -570,186 +629,214 @@ class JellyfinRepositoryImpl( } } - override suspend fun getUserConfiguration(): UserConfiguration = withContext(Dispatchers.IO) { - jellyfinApi.userApi.getCurrentUser().content.configuration!! - } + override suspend fun getUserConfiguration(): UserConfiguration = + withContext(Dispatchers.IO) { + jellyfinApi.userApi + .getCurrentUser() + .content.configuration!! + } override suspend fun getDownloads(): List = withContext(Dispatchers.IO) { val items = mutableListOf() items.addAll( - database.getMoviesByServerId(appPreferences.currentServer!!) + database + .getMoviesByServerId(appPreferences.currentServer!!) .map { it.toFindroidMovie(database, jellyfinApi.userId!!) }, ) items.addAll( - database.getShowsByServerId(appPreferences.currentServer!!) + database + .getShowsByServerId(appPreferences.currentServer!!) .map { it.toFindroidShow(database, jellyfinApi.userId!!) }, ) items } - override fun getUserId(): UUID { - return jellyfinApi.userId!! - } + override fun getUserId(): UUID = jellyfinApi.userId!! - - override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair { - return when (transcodeResolution) { - 1080 -> 8000000 to 384000 // Adjusted for 1080p - 720 -> 2000000 to 384000 // Adjusted for 720p - 480 -> 1000000 to 384000 // Adjusted for 480p - 360 -> 800000 to 128000 // Adjusted for 360p - else -> 12000000 to 384000 - } - } - - override suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile { - val deviceProfile = ClientCapabilitiesDto( - supportedCommands = emptyList(), - playableMediaTypes = emptyList(), - supportsMediaControl = true, - supportsPersistentIdentifier = true, - deviceProfile = DeviceProfile( - name = "AnanasUser", - id = getUserId().toString(), - maxStaticBitrate = maxBitrate, - maxStreamingBitrate = maxBitrate, - codecProfiles = emptyList(), - containerProfiles = listOf(), - directPlayProfiles = listOf( - DirectPlayProfile(type = DlnaProfileType.VIDEO), - DirectPlayProfile(type = DlnaProfileType.AUDIO), - ), - transcodingProfiles = listOf( - TranscodingProfile( - container = container, - context = context, - protocol = MediaStreamProtocol.HLS, - audioCodec = "aac,ac3,eac3", - videoCodec = "hevc,h264", - type = DlnaProfileType.VIDEO, - conditions = listOf( - ProfileCondition( - condition = ProfileConditionType.LESS_THAN_EQUAL, - property = ProfileConditionValue.VIDEO_BITRATE, - value = "8000000", - isRequired = true, - ) - ), - copyTimestamps = true, - enableSubtitlesInManifest = true, - transcodeSeekInfo = TranscodeSeekInfo.AUTO, + override suspend fun buildDeviceProfile( + maxBitrate: Int, + container: String, + context: EncodingContext, + ): DeviceProfile { + val deviceProfile = + ClientCapabilitiesDto( + supportedCommands = emptyList(), + playableMediaTypes = + listOf( + MediaType.VIDEO, + MediaType.AUDIO, + MediaType.UNKNOWN, + ), + supportsMediaControl = true, + supportsPersistentIdentifier = true, + deviceProfile = + DeviceProfile( + name = "AnanasUser", + id = getUserId().toString(), + maxStaticBitrate = maxBitrate, + maxStreamingBitrate = maxBitrate, + codecProfiles = emptyList(), + containerProfiles = listOf(), + directPlayProfiles = + listOf( + DirectPlayProfile(type = DlnaProfileType.VIDEO), + DirectPlayProfile(type = DlnaProfileType.AUDIO), + ), + transcodingProfiles = + listOf( + TranscodingProfile( + container = container, + context = context, + protocol = MediaStreamProtocol.HLS, + audioCodec = "aac,ac3,eac3", + videoCodec = "hevc,h264", + type = DlnaProfileType.VIDEO, + conditions = + listOf( + ProfileCondition( + condition = ProfileConditionType.LESS_THAN_EQUAL, + property = ProfileConditionValue.VIDEO_BITRATE, + value = "8000000", + isRequired = true, + ), + ), + 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!! } - - override suspend fun getPostedPlaybackInfo(itemId: UUID ,enableDirectStream: Boolean ,deviceProfile: DeviceProfile ,maxBitrate: Int): Response { - val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo( - itemId = itemId, - PlaybackInfoDto( - userId = jellyfinApi.userId!!, - enableTranscoding = true, - enableDirectPlay = false, - enableDirectStream = enableDirectStream, - autoOpenLiveStream = true, - deviceProfile = deviceProfile, - allowAudioStreamCopy = true, - allowVideoStreamCopy = true, - maxStreamingBitrate = maxBitrate, + override suspend fun getPostedPlaybackInfo( + itemId: UUID, + enableDirectStream: Boolean, + deviceProfile: DeviceProfile, + maxBitrate: Int, + ): Response { + val playbackInfo = + jellyfinApi.mediaInfoApi.getPostedPlaybackInfo( + itemId = itemId, + PlaybackInfoDto( + userId = jellyfinApi.userId!!, + enableTranscoding = true, + enableDirectPlay = false, + enableDirectStream = enableDirectStream, + autoOpenLiveStream = true, + deviceProfile = buildDeviceProfile(maxBitrate, "ts", EncodingContext.STREAMING), + allowAudioStreamCopy = true, + allowVideoStreamCopy = true, + maxStreamingBitrate = maxBitrate, + ), ) - ) return playbackInfo } - override suspend fun getVideoStreambyContainerUrl(itemId: UUID, deviceId: String, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String { - val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl( - itemId, - static = false, - deviceId = deviceId, - mediaSourceId = mediaSourceId, - playSessionId = playSessionId, - videoBitRate = videoBitrate, - audioBitRate = 384000, - videoCodec = "hevc", - audioCodec = "aac,ac3,eac3", - 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( + override suspend fun getVideoStreambyContainerUrl( + itemId: UUID, + deviceId: String, + mediaSourceId: String, + playSessionId: String, + videoBitrate: Int, + maxHeight: Int, + container: String, + ): String { + val url = + jellyfinApi.videosApi.getVideoStreamByContainerUrl( itemId, static = false, deviceId = deviceId, mediaSourceId = mediaSourceId, playSessionId = playSessionId, videoBitRate = videoBitrate, - enableAdaptiveBitrateStreaming = false, - audioBitRate = 384000, + maxHeight = maxHeight, + audioBitRate = 128000, videoCodec = "hevc", - audioCodec = "aac,ac3,eac3", + audioCodec = "aac", + container = container, 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,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 } - - override suspend fun getDeviceId(): String { - val devices = jellyfinApi.devicesApi.getDevices(getUserId()) - return devices.content.items?.firstOrNull()?.id!! - } + override suspend fun getDeviceId(): String = jellyfinApi.api.deviceInfo.id override suspend fun stopEncodingProcess(playSessionId: String) { val deviceId = getDeviceId() jellyfinApi.api.hlsSegmentApi.stopEncodingProcess( deviceId = deviceId, - playSessionId = playSessionId + playSessionId = playSessionId, ) } - } - - diff --git a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt index 9b8a1fa5..a44f96ec 100644 --- a/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt +++ b/data/src/main/java/com/nomadics9/ananas/repository/JellyfinRepositoryOfflineImpl.kt @@ -1,7 +1,6 @@ package com.nomadics9.ananas.repository import android.content.Context -import android.devicelock.DeviceId import androidx.paging.PagingData import com.nomadics9.ananas.AppPreferences 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.model.api.BaseItemDto 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.EncodingContext import org.jellyfin.sdk.model.api.ItemFields @@ -44,14 +42,9 @@ class JellyfinRepositoryOfflineImpl( private val database: ServerDatabaseDao, private val appPreferences: AppPreferences, ) : JellyfinRepository { + override suspend fun getPublicSystemInfo(): PublicSystemInfo = throw Exception("System info not available in offline mode") - override suspend fun getPublicSystemInfo(): PublicSystemInfo { - throw Exception("System info not available in offline mode") - } - - override suspend fun getUserViews(): List { - return emptyList() - } + override suspend fun getUserViews(): List = emptyList() override suspend fun getItem(itemId: UUID): BaseItemDto { TODO("Not yet implemented") @@ -115,38 +108,61 @@ class JellyfinRepositoryOfflineImpl( TODO("Not yet implemented") } - override suspend fun getSearchItems(searchQuery: String): List { - return withContext(Dispatchers.IO) { - val movies = database.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!!) } + override suspend fun getSearchItems(searchQuery: String): List = + withContext(Dispatchers.IO) { + val movies = + database + .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 } - } - override suspend fun getResumeItems(): List { - return withContext(Dispatchers.IO) { - val movies = 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 } + override suspend fun getResumeItems(): List = + withContext(Dispatchers.IO) { + val movies = + 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 } - } - override suspend fun getLatestMedia(parentId: UUID): List { - return emptyList() - } + override suspend fun getLatestMedia(parentId: UUID): List = emptyList() - override suspend fun getSeasons(seriesId: UUID, offline: Boolean): List = + override suspend fun getSeasons( + seriesId: UUID, + offline: Boolean, + ): List = withContext(Dispatchers.IO) { database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) } } - override suspend fun getNextUp(seriesId: UUID?): List { - return withContext(Dispatchers.IO) { + override suspend fun getNextUp(seriesId: UUID?): List = + withContext(Dispatchers.IO) { val result = mutableListOf() - val shows = database.getShowsByServerId(appPreferences.currentServer!!).filter { - if (seriesId != null) it.id == seriesId else true - } + val shows = + database.getShowsByServerId(appPreferences.currentServer!!).filter { + if (seriesId != null) it.id == seriesId else true + } for (show in shows) { val episodes = database.getEpisodesByShowId(show.id).map { it.toFindroidEpisode(database, jellyfinApi.userId!!) } val indexOfLastPlayed = episodes.indexOfLast { it.played } @@ -158,7 +174,6 @@ class JellyfinRepositoryOfflineImpl( } result.filter { it.playbackPositionTicks == 0L } } - } override suspend fun getEpisodes( seriesId: UUID, @@ -174,12 +189,19 @@ class JellyfinRepositoryOfflineImpl( items } - override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List = + override suspend fun getMediaSources( + itemId: UUID, + includePath: Boolean, + ): List = withContext(Dispatchers.IO) { 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") } @@ -188,7 +210,11 @@ class JellyfinRepositoryOfflineImpl( 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) { try { 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 postPlaybackStop(itemId: UUID, positionTicks: Long, playedPercentage: Int) { + override suspend fun postPlaybackStop( + itemId: UUID, + positionTicks: Long, + playedPercentage: Int, + ) { withContext(Dispatchers.IO) { when { playedPercentage < 10 -> { @@ -262,35 +292,31 @@ class JellyfinRepositoryOfflineImpl( } } - override fun getBaseUrl(): String { - return "" - } + override fun getBaseUrl(): String = "" override suspend fun updateDeviceName(name: String) { TODO("Not yet implemented") } - override suspend fun getUserConfiguration(): UserConfiguration? { - return null - } + override suspend fun getUserConfiguration(): UserConfiguration? = null override suspend fun getDownloads(): List = withContext(Dispatchers.IO) { val items = mutableListOf() items.addAll( - database.getMoviesByServerId(appPreferences.currentServer!!) + database + .getMoviesByServerId(appPreferences.currentServer!!) .map { it.toFindroidMovie(database, jellyfinApi.userId!!) }, ) items.addAll( - database.getShowsByServerId(appPreferences.currentServer!!) + database + .getShowsByServerId(appPreferences.currentServer!!) .map { it.toFindroidShow(database, jellyfinApi.userId!!) }, ) items } - override fun getUserId(): UUID { - return jellyfinApi.userId!! - } + override fun getUserId(): UUID = jellyfinApi.userId!! override suspend fun getDeviceId(): String { TODO("Not yet implemented") @@ -299,7 +325,7 @@ class JellyfinRepositoryOfflineImpl( override suspend fun buildDeviceProfile( maxBitrate: Int, container: String, - context: EncodingContext + context: EncodingContext, ): DeviceProfile { TODO("Not yet implemented") } @@ -310,7 +336,8 @@ class JellyfinRepositoryOfflineImpl( mediaSourceId: String, playSessionId: String, videoBitrate: Int, - container: String + maxHeight: Int, + container: String, ): String { TODO("Not yet implemented") } @@ -320,7 +347,7 @@ class JellyfinRepositoryOfflineImpl( deviceId: String, mediaSourceId: String, playSessionId: String, - videoBitrate: Int + videoBitrate: Int, ): String { TODO("Not yet implemented") } @@ -329,7 +356,7 @@ class JellyfinRepositoryOfflineImpl( itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile, - maxBitrate: Int + maxBitrate: Int, ): Response { TODO("Not yet implemented") } @@ -337,8 +364,4 @@ class JellyfinRepositoryOfflineImpl( override suspend fun stopEncodingProcess(playSessionId: String) { TODO("Not yet implemented") } - - override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair { - TODO("Not yet implemented") - } } diff --git a/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt b/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt index 7cc37741..966b378f 100644 --- a/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt +++ b/player/video/src/main/java/com/nomadics9/ananas/viewmodels/PlayerActivityViewModel.kt @@ -27,6 +27,7 @@ import com.nomadics9.ananas.models.FindroidSegment import com.nomadics9.ananas.models.PlayerChapter import com.nomadics9.ananas.models.PlayerItem import com.nomadics9.ananas.models.Trickplay +import com.nomadics9.ananas.models.VideoQuality import com.nomadics9.ananas.mpv.MPVPlayer import com.nomadics9.ananas.player.video.R import com.nomadics9.ananas.repository.JellyfinRepository @@ -51,597 +52,627 @@ import kotlin.math.ceil @HiltViewModel class PlayerActivityViewModel -@Inject -constructor( - private val application: Application, - private val jellyfinRepository: JellyfinRepository, - private val appPreferences: AppPreferences, - private val jellyfinApi: JellyfinApi, - private val savedStateHandle: SavedStateHandle, -) : ViewModel(), Player.Listener { - val player: Player - private var originalHeight: Int = 0 + @Inject + constructor( + private val application: Application, + private val jellyfinRepository: JellyfinRepository, + private val appPreferences: AppPreferences, + private val jellyfinApi: JellyfinApi, + private val savedStateHandle: SavedStateHandle, + ) : ViewModel(), + Player.Listener { + val player: Player + private var originalHeight: Int = 0 - private val _uiState = MutableStateFlow( - UiState( - currentItemTitle = "", - currentSegment = null, - showSkip = false, - currentTrickplay = null, - currentChapters = null, - fileLoaded = false, - ), - ) - val uiState = _uiState.asStateFlow() - - private val eventsChannel = Channel() - val eventsChannelFlow = eventsChannel.receiveAsFlow() - - private val segments: MutableMap> = mutableMapOf() - - data class UiState( - val currentItemTitle: String, - val currentSegment: FindroidSegment?, - val showSkip: Boolean?, - val currentTrickplay: Trickplay?, - val currentChapters: List?, - val fileLoaded: Boolean, - ) - - private var items: Array = arrayOf() - - private val trackSelector = DefaultTrackSelector(application) - var playWhenReady = true - private var currentMediaItemIndex = savedStateHandle["mediaItemIndex"] ?: 0 - private var playbackPosition: Long = savedStateHandle["position"] ?: 0 - - var playbackSpeed: Float = 1f - - private val handler = Handler(Looper.getMainLooper()) - - init { - if (appPreferences.playerMpv) { - val trackSelectionParameters = TrackSelectionParameters.Builder(application) - .setPreferredAudioLanguage(appPreferences.preferredAudioLanguage) - .setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage) - .build() - player = MPVPlayer( - context = application, - requestAudioFocus = true, - trackSelectionParameters = trackSelectionParameters, - seekBackIncrement = appPreferences.playerSeekBackIncrement, - seekForwardIncrement = appPreferences.playerSeekForwardIncrement, - videoOutput = appPreferences.playerMpvVo, - audioOutput = appPreferences.playerMpvAo, - hwDec = appPreferences.playerMpvHwdec, + private val _uiState = + MutableStateFlow( + UiState( + currentItemTitle = "", + currentSegment = null, + showSkip = false, + currentTrickplay = null, + currentChapters = null, + fileLoaded = false, + ), ) - } else { - val renderersFactory = - DefaultRenderersFactory(application).setExtensionRendererMode( - DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON, + val uiState = _uiState.asStateFlow() + + private val eventsChannel = Channel() + val eventsChannelFlow = eventsChannel.receiveAsFlow() + + private val segments: MutableMap> = mutableMapOf() + + data class UiState( + val currentItemTitle: String, + val currentSegment: FindroidSegment?, + val showSkip: Boolean?, + val currentTrickplay: Trickplay?, + val currentChapters: List?, + val fileLoaded: Boolean, + ) + + private var items: Array = arrayOf() + + private val trackSelector = DefaultTrackSelector(application) + var playWhenReady = true + private var currentMediaItemIndex = savedStateHandle["mediaItemIndex"] ?: 0 + private var playbackPosition: Long = savedStateHandle["position"] ?: 0 + + var playbackSpeed: Float = 1f + + private val handler = Handler(Looper.getMainLooper()) + + init { + if (appPreferences.playerMpv) { + val trackSelectionParameters = + TrackSelectionParameters + .Builder(application) + .setPreferredAudioLanguage(appPreferences.preferredAudioLanguage) + .setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage) + .build() + player = + MPVPlayer( + context = application, + requestAudioFocus = true, + trackSelectionParameters = trackSelectionParameters, + seekBackIncrement = appPreferences.playerSeekBackIncrement, + seekForwardIncrement = appPreferences.playerSeekForwardIncrement, + videoOutput = appPreferences.playerMpvVo, + audioOutput = appPreferences.playerMpvAo, + hwDec = appPreferences.playerMpvHwdec, + ) + } else { + val renderersFactory = + DefaultRenderersFactory(application).setExtensionRendererMode( + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON, + ) + trackSelector.setParameters( + trackSelector + .buildUponParameters() + .setTunnelingEnabled(true) + .setPreferredAudioLanguage(appPreferences.preferredAudioLanguage) + .setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage), ) - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTunnelingEnabled(true) - .setPreferredAudioLanguage(appPreferences.preferredAudioLanguage) - .setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage), - ) - player = ExoPlayer.Builder(application, renderersFactory) - .setTrackSelector(trackSelector) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .setUsage(C.USAGE_MEDIA) - .build(), - /* handleAudioFocus = */ - true, - ) - .setSeekBackIncrementMs(appPreferences.playerSeekBackIncrement) - .setSeekForwardIncrementMs(appPreferences.playerSeekForwardIncrement) - .build() + player = + ExoPlayer + .Builder(application, renderersFactory) + .setTrackSelector(trackSelector) + .setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build(), + // handleAudioFocus = + true, + ).setSeekBackIncrementMs(appPreferences.playerSeekBackIncrement) + .setSeekForwardIncrementMs(appPreferences.playerSeekForwardIncrement) + .build() + } } - } - fun initializePlayer( - items: Array, - ) { - this.items = items - player.addListener(this) + fun initializePlayer(items: Array) { + this.items = items + player.addListener(this) - viewModelScope.launch { - val mediaItems = mutableListOf() - try { - for (item in items) { - val streamUrl = item.mediaSourceUri - val mediaSubtitles = item.externalSubtitles.map { externalSubtitle -> - MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri) - .setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) }) - .setMimeType(externalSubtitle.mimeType) - .setLanguage(externalSubtitle.language) - .build() - } + viewModelScope.launch { + val mediaItems = mutableListOf() + try { + for (item in items) { + val streamUrl = item.mediaSourceUri + val mediaSubtitles = + item.externalSubtitles.map { externalSubtitle -> + MediaItem.SubtitleConfiguration + .Builder(externalSubtitle.uri) + .setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) }) + .setMimeType(externalSubtitle.mimeType) + .setLanguage(externalSubtitle.language) + .build() + } - if (appPreferences.playerIntroSkipper) { - jellyfinRepository.getSegmentsTimestamps(item.itemId)?.let { segment -> - segments[item.itemId] = segment + if (appPreferences.playerIntroSkipper) { + jellyfinRepository.getSegmentsTimestamps(item.itemId)?.let { segment -> + segments[item.itemId] = segment + } + Timber.tag("SegmentInfo").d("Segments: %s", segments) } - Timber.tag("SegmentInfo").d("Segments: %s", segments) + + Timber.d("Stream url: $streamUrl") + val mediaItem = + MediaItem + .Builder() + .setMediaId(item.itemId.toString()) + .setUri(streamUrl) + .setMediaMetadata( + MediaMetadata + .Builder() + .setTitle(item.name) + .build(), + ).setSubtitleConfigurations(mediaSubtitles) + .build() + mediaItems.add(mediaItem) + } + } catch (e: Exception) { + Timber.e(e) + } + + val startPosition = + if (playbackPosition == 0L) { + items.getOrNull(currentMediaItemIndex)?.playbackPosition ?: C.TIME_UNSET + } else { + playbackPosition } - Timber.d("Stream url: $streamUrl") - val mediaItem = - MediaItem.Builder() - .setMediaId(item.itemId.toString()) - .setUri(streamUrl) + player.setMediaItems( + mediaItems, + currentMediaItemIndex, + startPosition, + ) + player.prepare() + player.play() + pollPosition(player) + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun releasePlayer() { + val mediaId = player.currentMediaItem?.mediaId + val position = player.currentPosition + val duration = player.duration + GlobalScope.launch { + delay(1000L) + try { + jellyfinRepository.postPlaybackStop( + UUID.fromString(mediaId), + position.times(10000), + position.div(duration.toFloat()).times(100).toInt(), + ) + } catch (e: Exception) { + Timber.e(e) + } + } + + _uiState.update { it.copy(currentTrickplay = null) } + playWhenReady = false + playbackPosition = 0L + currentMediaItemIndex = 0 + player.removeListener(this) + player.release() + } + + private fun pollPosition(player: Player) { + val playbackProgressRunnable = + object : Runnable { + override fun run() { + savedStateHandle["position"] = player.currentPosition + viewModelScope.launch { + if (player.currentMediaItem != null && player.currentMediaItem!!.mediaId.isNotEmpty()) { + val itemId = UUID.fromString(player.currentMediaItem!!.mediaId) + try { + jellyfinRepository.postPlaybackProgress( + itemId, + player.currentPosition.times(10000), + !player.isPlaying, + ) + } catch (e: Exception) { + Timber.e(e) + } + } + } + handler.postDelayed(this, 5000L) + } + } + val segmentCheckRunnable = + object : Runnable { + override fun run() { + val currentMediaItem = player.currentMediaItem + if (currentMediaItem != null && currentMediaItem.mediaId.isNotEmpty()) { + val itemId = UUID.fromString(currentMediaItem.mediaId) + val seconds = player.currentPosition / 1000.0 + + val currentSegment = + segments[itemId]?.find { segment -> seconds in segment.startTime.. + val itemTitle = + if (item.parentIndexNumber != null && item.indexNumber != null) { + if (item.indexNumberEnd == null) { + "S${item.parentIndexNumber}:E${item.indexNumber} - ${item.name}" + } else { + "S${item.parentIndexNumber}:E${item.indexNumber}-${item.indexNumberEnd} - ${item.name}" + } + } else { + item.name + } + _uiState.update { + it.copy( + currentItemTitle = itemTitle, + currentSegment = null, + currentChapters = item.chapters, + fileLoaded = false, + ) + } + + jellyfinRepository.postPlaybackStart(item.itemId) + + if (appPreferences.playerTrickplay) { + getTrickplay(item) + } + } + } catch (e: Exception) { + Timber.e(e) + } + } + } + + override fun onPlaybackStateChanged(state: Int) { + var stateString = "UNKNOWN_STATE -" + when (state) { + ExoPlayer.STATE_IDLE -> { + stateString = "ExoPlayer.STATE_IDLE -" + } + + ExoPlayer.STATE_BUFFERING -> { + stateString = "ExoPlayer.STATE_BUFFERING -" + } + + ExoPlayer.STATE_READY -> { + stateString = "ExoPlayer.STATE_READY -" + _uiState.update { it.copy(fileLoaded = true) } + } + + ExoPlayer.STATE_ENDED -> { + stateString = "ExoPlayer.STATE_ENDED -" + eventsChannel.trySend(PlayerEvents.NavigateBack) + } + } + Timber.d("Changed player state to $stateString") + } + + override fun onCleared() { + super.onCleared() + Timber.d("Clearing Player ViewModel") + handler.removeCallbacksAndMessages(null) + releasePlayer() + } + + fun switchToTrack( + trackType: @C.TrackType Int, + index: Int, + ) { + // Index -1 equals disable track + if (index == -1) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(trackType) + .setTrackTypeDisabled(trackType, true) + .build() + } else { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .setOverrideForType( + TrackSelectionOverride( + player.currentTracks.groups + .filter { it.type == trackType && it.isSupported }[index] + .mediaTrackGroup, + 0, + ), + ).setTrackTypeDisabled(trackType, false) + .build() + } + } + + fun selectSpeed(speed: Float) { + player.setPlaybackSpeed(speed) + playbackSpeed = speed + } + + private suspend fun getTrickplay(item: PlayerItem) { + val trickplayInfo = item.trickplayInfo ?: return + Timber.d("Trickplay Resolution: ${trickplayInfo.width}") + + withContext(Dispatchers.Default) { + val maxIndex = + ceil( + trickplayInfo.thumbnailCount + .toDouble() + .div(trickplayInfo.tileWidth * trickplayInfo.tileHeight), + ).toInt() + val bitmaps = mutableListOf() + + for (i in 0..maxIndex) { + jellyfinRepository + .getTrickplayData( + item.itemId, + trickplayInfo.width, + i, + )?.let { byteArray -> + val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + for (offsetY in 0..? = uiState.value.currentChapters + + /** + * Get the index of the current chapter + * + * @return the index of the current chapter + */ + private fun getCurrentChapterIndex(): Int? { + val chapters = getChapters() ?: return null + + for (i in chapters.indices.reversed()) { + if (chapters[i].startPosition < player.currentPosition) { + return i + } + } + + return null + } + + /** + * Get the index of the next chapter + * + * @return the index of the next chapter + */ + private fun getNextChapterIndex(): Int? { + val chapters = getChapters() ?: return null + val currentChapterIndex = getCurrentChapterIndex() ?: return null + + return minOf(chapters.size - 1, currentChapterIndex + 1) + } + + /** + * Get the index of the previous chapter. Only use this for seeking as it + * will return the current chapter when player position is more than 5 + * seconds past the start of the chapter + * + * @return the index of the previous chapter + */ + private fun getPreviousChapterIndex(): Int? { + val chapters = getChapters() ?: return null + val currentChapterIndex = getCurrentChapterIndex() ?: return null + + // Return current chapter when more than 5 seconds past chapter start + if (player.currentPosition > chapters[currentChapterIndex].startPosition + 5000L) { + return currentChapterIndex + } + + return maxOf(0, currentChapterIndex - 1) + } + + fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 } + + fun isLastChapter(): Boolean? = getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 } + + /** + * Seek to chapter + * + * @param chapterIndex the index of the chapter to seek to + * @return the [PlayerChapter] which has been sought to + */ + private fun seekToChapter(chapterIndex: Int): PlayerChapter? = + getChapters()?.getOrNull(chapterIndex)?.also { chapter -> + player.seekTo(chapter.startPosition) + } + + /** + * Seek to the next chapter + * + * @return the [PlayerChapter] which has been sought to + */ + fun seekToNextChapter(): PlayerChapter? = getNextChapterIndex()?.let { seekToChapter(it) } + + /** + * Seek to the previous chapter Will seek to start of current chapter if + * player position is more than 5 seconds past start of chapter + * + * @return the [PlayerChapter] which has been sought to + */ + fun seekToPreviousChapter(): PlayerChapter? = getPreviousChapterIndex()?.let { seekToChapter(it) } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying)) + } + + fun changeVideoQuality(quality: String) { + val mediaId = player.currentMediaItem?.mediaId ?: return + val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return + val currentPosition = player.currentPosition + + viewModelScope.launch { + val videoQuality = VideoQuality.fromString(quality)!! + try { + val deviceProfile = + jellyfinRepository.buildDeviceProfile( + VideoQuality.getBitrate(videoQuality), + "ts", + EncodingContext.STREAMING, + ) + + val playbackInfo = + jellyfinRepository.getPostedPlaybackInfo( + currentItem.itemId, + true, + deviceProfile, + VideoQuality.getBitrate(videoQuality), + ) + val playSessionId = playbackInfo.content.playSessionId + if (playSessionId != null) { + jellyfinRepository.stopEncodingProcess(playSessionId) + } + val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true) + + val externalSubtitles = + currentItem.externalSubtitles.map { externalSubtitle -> + MediaItem.SubtitleConfiguration + .Builder(externalSubtitle.uri) + .setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) }) + .setLanguage(externalSubtitle.language.ifBlank { "Unknown" }) + .setMimeType(externalSubtitle.mimeType) + .build() + } + + val embeddedSubtitles = + mediaSources[currentMediaItemIndex] + .mediaStreams + .filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal && it.path != null } + .map { mediaStream -> + val test = mediaStream.codec + Timber.d("Deliver: %s", test) + var deliveryUrl = mediaStream.path + Timber.d("Deliverurl: %s", deliveryUrl) + if (mediaStream.codec == "webvtt") { + deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt") + } + MediaItem.SubtitleConfiguration + .Builder(Uri.parse(deliveryUrl)) + .setMimeType( + when (mediaStream.codec) { + "subrip" -> MimeTypes.APPLICATION_SUBRIP + "webvtt" -> MimeTypes.TEXT_VTT + "ssa" -> MimeTypes.TEXT_SSA + "pgs" -> MimeTypes.APPLICATION_PGS + "ass" -> MimeTypes.TEXT_SSA // ASS is a subtitle format that is essentially an extension of SSA + "srt" -> MimeTypes.APPLICATION_SUBRIP // SRT is another common name for SubRip + "vtt" -> MimeTypes.TEXT_VTT // VTT is a common extension for WebVTT + "ttml" -> MimeTypes.APPLICATION_TTML // TTML (Timed Text Markup Language) + "dfxp" -> MimeTypes.APPLICATION_TTML // DFXP is a profile of TTML + "stl" -> MimeTypes.APPLICATION_TTML // EBU STL (Subtitling Data Exchange Format) + "sbv" -> MimeTypes.APPLICATION_SUBRIP // YouTube's SBV format is similar to SubRip + else -> MimeTypes.TEXT_UNKNOWN + }, + ).setLanguage(mediaStream.language.ifBlank { "Unknown" }) + .setLabel("Embedded") + .build() + }.toMutableList() + + val allSubtitles = embeddedSubtitles.apply { addAll(externalSubtitles) } + + val url = + if (VideoQuality.getQualityString(videoQuality) == "Original") { + jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId) + } else { + val mediaSourceId = mediaSources[currentMediaItemIndex].id + val deviceId = jellyfinApi.api.deviceInfo.id + Timber.d("deviceid = %s", deviceId) + val url = + jellyfinRepository.getTranscodedVideoStream( + currentItem.itemId, + deviceId, + mediaSourceId, + playSessionId!!, + VideoQuality.getBitrate(videoQuality), + ) + val uriBuilder = url.toUri().buildUpon() + val apiKey = jellyfinApi.api.accessToken + uriBuilder.appendQueryParameter("api_key", apiKey) + val newUri = uriBuilder.build() + newUri.toString() + } + + Timber.e("URI IS %s", url) + val mediaItemBuilder = + MediaItem + .Builder() + .setMediaId(currentItem.itemId.toString()) + .setUri(url) + .setSubtitleConfigurations(allSubtitles) .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(item.name) + MediaMetadata + .Builder() + .setTitle(currentItem.name) .build(), ) - .setSubtitleConfigurations(mediaSubtitles) - .build() - mediaItems.add(mediaItem) - } - } catch (e: Exception) { - Timber.e(e) - } + player.pause() + player.setMediaItem(mediaItemBuilder.build()) + player.prepare() + player.seekTo(currentPosition) + playWhenReady = true + player.play() - val startPosition = if (playbackPosition == 0L) { - items.getOrNull(currentMediaItemIndex)?.playbackPosition ?: C.TIME_UNSET - } else { - playbackPosition - } + val originalHeight = + mediaSources[currentMediaItemIndex] + .mediaStreams + .filter { it.type == MediaStreamType.VIDEO } + .map { mediaStream -> mediaStream.height } + .first() ?: 1080 - player.setMediaItems( - mediaItems, - currentMediaItemIndex, - startPosition, - ) - player.prepare() - player.play() - pollPosition(player) - } - } + // Store the original height + this@PlayerActivityViewModel.originalHeight = originalHeight - @OptIn(DelicateCoroutinesApi::class) - private fun releasePlayer() { - val mediaId = player.currentMediaItem?.mediaId - val position = player.currentPosition - val duration = player.duration - GlobalScope.launch { - delay(1000L) - try { - jellyfinRepository.postPlaybackStop( - UUID.fromString(mediaId), - position.times(10000), - position.div(duration.toFloat()).times(100).toInt(), - ) - } catch (e: Exception) { - Timber.e(e) - } - } - - _uiState.update { it.copy(currentTrickplay = null) } - playWhenReady = false - playbackPosition = 0L - currentMediaItemIndex = 0 - player.removeListener(this) - player.release() - } - - private fun pollPosition(player: Player) { - val playbackProgressRunnable = object : Runnable { - override fun run() { - savedStateHandle["position"] = player.currentPosition - viewModelScope.launch { - if (player.currentMediaItem != null && player.currentMediaItem!!.mediaId.isNotEmpty()) { - val itemId = UUID.fromString(player.currentMediaItem!!.mediaId) - try { - jellyfinRepository.postPlaybackProgress( - itemId, - player.currentPosition.times(10000), - !player.isPlaying, - ) - } catch (e: Exception) { - Timber.e(e) - } - } - } - handler.postDelayed(this, 5000L) - } - } - val segmentCheckRunnable = object : Runnable { - override fun run() { - val currentMediaItem = player.currentMediaItem - if (currentMediaItem != null && currentMediaItem.mediaId.isNotEmpty()) { - val itemId = UUID.fromString(currentMediaItem.mediaId) - val seconds = player.currentPosition / 1000.0 - - val currentSegment = - segments[itemId]?.find { segment -> seconds in segment.startTime.. - val itemTitle = - if (item.parentIndexNumber != null && item.indexNumber != null) { - if (item.indexNumberEnd == null) { - "S${item.parentIndexNumber}:E${item.indexNumber} - ${item.name}" - } else { - "S${item.parentIndexNumber}:E${item.indexNumber}-${item.indexNumberEnd} - ${item.name}" - } - } else { - item.name - } - _uiState.update { - it.copy( - currentItemTitle = itemTitle, - currentSegment = null, - currentChapters = item.chapters, - fileLoaded = false, - ) - } - - jellyfinRepository.postPlaybackStart(item.itemId) - - if (appPreferences.playerTrickplay) { - getTrickplay(item) - } - } - } catch (e: Exception) { - Timber.e(e) - } - } - } - - override fun onPlaybackStateChanged(state: Int) { - var stateString = "UNKNOWN_STATE -" - when (state) { - ExoPlayer.STATE_IDLE -> { - stateString = "ExoPlayer.STATE_IDLE -" - } - - ExoPlayer.STATE_BUFFERING -> { - stateString = "ExoPlayer.STATE_BUFFERING -" - } - - ExoPlayer.STATE_READY -> { - stateString = "ExoPlayer.STATE_READY -" - _uiState.update { it.copy(fileLoaded = true) } - } - - ExoPlayer.STATE_ENDED -> { - stateString = "ExoPlayer.STATE_ENDED -" - eventsChannel.trySend(PlayerEvents.NavigateBack) - } - } - Timber.d("Changed player state to $stateString") - } - - override fun onCleared() { - super.onCleared() - Timber.d("Clearing Player ViewModel") - handler.removeCallbacksAndMessages(null) - releasePlayer() - } - - fun switchToTrack(trackType: @C.TrackType Int, index: Int) { - // Index -1 equals disable track - if (index == -1) { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(trackType) - .setTrackTypeDisabled(trackType, true) - .build() - } else { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setOverrideForType( - TrackSelectionOverride( - player.currentTracks.groups.filter { it.type == trackType && it.isSupported }[index].mediaTrackGroup, - 0 - ), - ) - .setTrackTypeDisabled(trackType, false) - .build() - } - } - - fun selectSpeed(speed: Float) { - player.setPlaybackSpeed(speed) - playbackSpeed = speed - } - - private suspend fun getTrickplay(item: PlayerItem) { - val trickplayInfo = item.trickplayInfo ?: return - Timber.d("Trickplay Resolution: ${trickplayInfo.width}") - - withContext(Dispatchers.Default) { - val maxIndex = ceil( - trickplayInfo.thumbnailCount.toDouble() - .div(trickplayInfo.tileWidth * trickplayInfo.tileHeight) - ).toInt() - val bitmaps = mutableListOf() - - for (i in 0..maxIndex) { - jellyfinRepository.getTrickplayData( - item.itemId, - trickplayInfo.width, - i, - )?.let { byteArray -> - val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) - for (offsetY in 0..? { - return uiState.value.currentChapters - } - - /** - * Get the index of the current chapter - * - * @return the index of the current chapter - */ - private fun getCurrentChapterIndex(): Int? { - val chapters = getChapters() ?: return null - - for (i in chapters.indices.reversed()) { - if (chapters[i].startPosition < player.currentPosition) { - return i - } } - return null + fun getOriginalHeight(): Int = originalHeight } - /** - * Get the index of the next chapter - * - * @return the index of the next chapter - */ - private fun getNextChapterIndex(): Int? { - val chapters = getChapters() ?: return null - val currentChapterIndex = getCurrentChapterIndex() ?: return null - - return minOf(chapters.size - 1, currentChapterIndex + 1) - } - - /** - * Get the index of the previous chapter. Only use this for seeking as it - * will return the current chapter when player position is more than 5 - * seconds past the start of the chapter - * - * @return the index of the previous chapter - */ - private fun getPreviousChapterIndex(): Int? { - val chapters = getChapters() ?: return null - val currentChapterIndex = getCurrentChapterIndex() ?: return null - - // Return current chapter when more than 5 seconds past chapter start - if (player.currentPosition > chapters[currentChapterIndex].startPosition + 5000L) { - return currentChapterIndex - } - - return maxOf(0, currentChapterIndex - 1) - } - - fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 } - fun isLastChapter(): Boolean? = - getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 } - - /** - * Seek to chapter - * - * @param chapterIndex the index of the chapter to seek to - * @return the [PlayerChapter] which has been sought to - */ - private fun seekToChapter(chapterIndex: Int): PlayerChapter? { - return getChapters()?.getOrNull(chapterIndex)?.also { chapter -> - player.seekTo(chapter.startPosition) - } - } - - /** - * Seek to the next chapter - * - * @return the [PlayerChapter] which has been sought to - */ - fun seekToNextChapter(): PlayerChapter? { - return getNextChapterIndex()?.let { seekToChapter(it) } - } - - /** - * Seek to the previous chapter Will seek to start of current chapter if - * player position is more than 5 seconds past start of chapter - * - * @return the [PlayerChapter] which has been sought to - */ - fun seekToPreviousChapter(): PlayerChapter? { - return getPreviousChapterIndex()?.let { seekToChapter(it) } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - super.onIsPlayingChanged(isPlaying) - eventsChannel.trySend(PlayerEvents.IsPlayingChanged(isPlaying)) - } - - private fun getTranscodeResolutions(preferredQuality: String): Int { - return when (preferredQuality) { - "1080p" -> 1080 - "720p - 2Mbps" -> 720 - "480p - 1Mbps" -> 480 - "360p - 800kbps" -> 360 - "Auto" -> 1 - else -> 1080 - } - } - - fun changeVideoQuality(quality: String) { - val mediaId = player.currentMediaItem?.mediaId ?: return - val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return - val currentPosition = player.currentPosition - - viewModelScope.launch { - try { - val transcodingResolution = getTranscodeResolutions(quality) - val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate( - transcodingResolution - ) - val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "mkv", EncodingContext.STREAMING) - val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(currentItem.itemId,true,deviceProfile,videoBitRate) - val playSessionId = playbackInfo.content.playSessionId - if (playSessionId != null) { - jellyfinRepository.stopEncodingProcess(playSessionId) - } - val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true) - - - val externalSubtitles = currentItem.externalSubtitles.map { externalSubtitle -> - MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri) - .setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) }) - .setLanguage(externalSubtitle.language.ifBlank { "Unknown" }) - .setMimeType(externalSubtitle.mimeType) - .build() - } - - val embeddedSubtitles = mediaSources[currentMediaItemIndex].mediaStreams - .filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal && it.path != null } - .map { mediaStream -> - val test = mediaStream.codec - Timber.d("Deliver: %s", test) - var deliveryUrl = mediaStream.path - Timber.d("Deliverurl: %s", deliveryUrl) - if (mediaStream.codec == "webvtt") { - deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt")} - MediaItem.SubtitleConfiguration.Builder(Uri.parse(deliveryUrl)) - .setMimeType( - when (mediaStream.codec) { - "subrip" -> MimeTypes.APPLICATION_SUBRIP - "webvtt" -> MimeTypes.TEXT_VTT - "ssa" -> MimeTypes.TEXT_SSA - "pgs" -> MimeTypes.APPLICATION_PGS - "ass" -> MimeTypes.TEXT_SSA // ASS is a subtitle format that is essentially an extension of SSA - "srt" -> MimeTypes.APPLICATION_SUBRIP // SRT is another common name for SubRip - "vtt" -> MimeTypes.TEXT_VTT // VTT is a common extension for WebVTT - "ttml" -> MimeTypes.APPLICATION_TTML // TTML (Timed Text Markup Language) - "dfxp" -> MimeTypes.APPLICATION_TTML // DFXP is a profile of TTML - "stl" -> MimeTypes.APPLICATION_TTML // EBU STL (Subtitling Data Exchange Format) - "sbv" -> MimeTypes.APPLICATION_SUBRIP // YouTube's SBV format is similar to SubRip - else -> MimeTypes.TEXT_UNKNOWN - } - ) - .setLanguage(mediaStream.language.ifBlank { "Unknown" }) - .setLabel("Embedded") - .build() - } - .toMutableList() - - - val allSubtitles = embeddedSubtitles.apply { addAll(externalSubtitles) } - - val url = if (transcodingResolution == 1080){ - jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId) - } else { - val mediaSourceId = mediaSources[currentMediaItemIndex].id - val deviceId = jellyfinRepository.getDeviceId() - val url = jellyfinRepository.getTranscodedVideoStream(currentItem.itemId, deviceId ,mediaSourceId, playSessionId!!, videoBitRate) - val uriBuilder = url.toUri().buildUpon() - val apiKey = jellyfinApi.api.accessToken - uriBuilder.appendQueryParameter("api_key",apiKey ) - val newUri = uriBuilder.build() - newUri.toString() - } - - - - Timber.e("URI IS %s", url) - val mediaItemBuilder = MediaItem.Builder() - .setMediaId(currentItem.itemId.toString()) - .setUri(url) - .setSubtitleConfigurations(allSubtitles) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(currentItem.name) - .build(), - ) - - - player.pause() - player.setMediaItem(mediaItemBuilder.build()) - player.prepare() - player.seekTo(currentPosition) - playWhenReady = true - player.play() - - val originalHeight = mediaSources[currentMediaItemIndex].mediaStreams - .filter { it.type == MediaStreamType.VIDEO } - .map {mediaStream -> mediaStream.height}.first() ?: 1080 - - - // Store the original height - this@PlayerActivityViewModel.originalHeight = originalHeight - - //isQualityChangeInProgress = true - } catch (e: Exception) { - Timber.e(e) - } - } - } - - fun getOriginalHeight(): Int { - return originalHeight - } -} - - sealed interface PlayerEvents { data object NavigateBack : PlayerEvents - data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents + + data class IsPlayingChanged( + val isPlaying: Boolean, + ) : PlayerEvents }