Compare commits

..

No commits in common. "main" and "download-transcoding" have entirely different histories.

46 changed files with 1355 additions and 1992 deletions

View file

@ -25,7 +25,6 @@ jobs:
assemble: assemble:
name: Assemble name: Assemble
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
if: startsWith(github.event.head_commit.message, 'build:')
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -41,11 +40,11 @@ jobs:
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew assemble run: ./gradlew assemble
# Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged. # Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged.
- name: Upload artifact ananas-v0.10.3-0.14.2-libre-arm64-v8a.apk - name: Upload artifact ananas-v0.14.2-libre-arm64-v8a.apk
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: phone-libre-arm64-v8a.apk name: phone-libre-arm64-v8a.apk
path: ./app/phone/build/outputs/apk/libre/release/ananas-v0.10.3-0.14.2-libre-arm64-v8a.apk path: ./app/phone/build/outputs/apk/libre/release/ananas-v0.14.2-libre-arm64-v8a.apk
# - name: Upload artifact phone-libre-armeabi-v7a-debug.apk # - name: Upload artifact phone-libre-armeabi-v7a-debug.apk
# uses: actions/upload-artifact@v4 # uses: actions/upload-artifact@v4
# with: # with:

1
.gitignore vendored
View file

@ -41,4 +41,3 @@ fastlane/report.xml
fastlane/Preview.html fastlane/Preview.html
fastlane/screenshots fastlane/screenshots
fastlane/test_output fastlane/test_output
push.sh

View file

@ -11,7 +11,6 @@ Personal fork
- Supported media items: movies, series, seasons, episodes - Supported media items: movies, series, seasons, episodes
- Direct play and Transcoding - Direct play and Transcoding
- Offline playback / downloads - Offline playback / downloads
- Transcoding Downloads (Original - 720p - 480p - 360p)
- ExoPlayer - ExoPlayer
- Video codecs: H.263, H.264, H.265, VP8, VP9, AV1 - Video codecs: H.263, H.264, H.265, VP8, VP9, AV1
- Support depends on Android device - Support depends on Android device

View file

@ -1,87 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.nomadics9.ananas",
"variantName": "AnanasRelease",
"elements": [
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "armeabi-v7a"
}
],
"attributes": [],
"versionCode": 16,
"versionName": "0.10.6-0.14.2",
"outputFile": "ananas-v0.10.6-0.14.2-Ananas-armeabi-v7a.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 16,
"versionName": "0.10.6-0.14.2",
"outputFile": "ananas-v0.10.6-0.14.2-Ananas-arm64-v8a.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "x86_64"
}
],
"attributes": [],
"versionCode": 16,
"versionName": "0.10.6-0.14.2",
"outputFile": "ananas-v0.10.6-0.14.2-Ananas-x86_64.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "x86"
}
],
"attributes": [],
"versionCode": 16,
"versionName": "0.10.6-0.14.2",
"outputFile": "ananas-v0.10.6-0.14.2-Ananas-x86.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/ananas-v0.10.6-0.14.2-Ananas-armeabi-v7a.dm",
"baselineProfiles/1/ananas-v0.10.6-0.14.2-Ananas-arm64-v8a.dm",
"baselineProfiles/1/ananas-v0.10.6-0.14.2-Ananas-x86_64.dm",
"baselineProfiles/1/ananas-v0.10.6-0.14.2-Ananas-x86.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/ananas-v0.10.6-0.14.2-Ananas-armeabi-v7a.dm",
"baselineProfiles/0/ananas-v0.10.6-0.14.2-Ananas-arm64-v8a.dm",
"baselineProfiles/0/ananas-v0.10.6-0.14.2-Ananas-x86_64.dm",
"baselineProfiles/0/ananas-v0.10.6-0.14.2-Ananas-x86.dm"
]
}
],
"minSdkVersionForDexing": 28
}

View file

@ -23,10 +23,6 @@ android {
versionName = Versions.appName versionName = Versions.appName
testInstrumentationRunner = "com.nomadics9.ananas.HiltTestRunner" testInstrumentationRunner = "com.nomadics9.ananas.HiltTestRunner"
buildConfigField( "String", "DEFAULT_SERVER_ADDRESS", "\" \"")
buildConfigField( "String", "REQUEST_SERVER_ADDRESS", "\" \"")
buildConfigField("String", "FORGET_PASSWORD_ADDRESS", "\" \"")
buildConfigField("String", "UPDATE_ADDRESS", "\" \"")
} }
applicationVariants.all { applicationVariants.all {
@ -61,18 +57,10 @@ android {
flavorDimensions += "variant" flavorDimensions += "variant"
productFlavors { productFlavors {
create("libre") { register("libre") {
dimension = "variant" dimension = "variant"
isDefault = true isDefault = true
} }
create("Ananas") {
dimension = "variant"
isDefault = false
buildConfigField( "String", "DEFAULT_SERVER_ADDRESS", "\"https://askar.tv\"")
buildConfigField( "String", "REQUEST_SERVER_ADDRESS", "\"https://r.askar.tv\"")
buildConfigField("String", "FORGET_PASSWORD_ADDRESS", "\"https://user.askar.tv/my/account\"")
buildConfigField("String", "UPDATE_ADDRESS", "\"https://fs.nmd.mov/p/ananas.apk\"")
}
} }
splits { splits {
@ -134,7 +122,6 @@ dependencies {
implementation(libs.material) implementation(libs.material)
implementation(libs.media3.ffmpeg.decoder) implementation(libs.media3.ffmpeg.decoder)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.markwon)
coreLibraryDesugaring(libs.android.desugar.jdk) coreLibraryDesugaring(libs.android.desugar.jdk)

View file

@ -16,9 +16,9 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 11, "versionCode": 9,
"versionName": "0.10.1-0.14.2", "versionName": "0.14.2",
"outputFile": "ananas-v0.10.1-0.14.2-libre-armeabi-v7a.apk" "outputFile": "ananas-v0.14.2-libre-armeabi-v7a.apk"
}, },
{ {
"type": "ONE_OF_MANY", "type": "ONE_OF_MANY",
@ -29,22 +29,9 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 11, "versionCode": 9,
"versionName": "0.10.1-0.14.2", "versionName": "0.14.2",
"outputFile": "ananas-v0.10.1-0.14.2-libre-x86_64.apk" "outputFile": "ananas-v0.14.2-libre-x86_64.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 11,
"versionName": "0.10.1-0.14.2",
"outputFile": "ananas-v0.10.1-0.14.2-libre-arm64-v8a.apk"
}, },
{ {
"type": "ONE_OF_MANY", "type": "ONE_OF_MANY",
@ -55,9 +42,22 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 11, "versionCode": 9,
"versionName": "0.10.1-0.14.2", "versionName": "0.14.2",
"outputFile": "ananas-v0.10.1-0.14.2-libre-x86.apk" "outputFile": "ananas-v0.14.2-libre-x86.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 9,
"versionName": "0.14.2",
"outputFile": "ananas-v0.14.2-libre-arm64-v8a.apk"
} }
], ],
"elementType": "File", "elementType": "File",
@ -66,20 +66,20 @@
"minApi": 28, "minApi": 28,
"maxApi": 30, "maxApi": 30,
"baselineProfiles": [ "baselineProfiles": [
"baselineProfiles/1/ananas-v0.10.1-0.14.2-libre-armeabi-v7a.dm", "baselineProfiles/1/ananas-v0.14.2-libre-armeabi-v7a.dm",
"baselineProfiles/1/ananas-v0.10.1-0.14.2-libre-x86_64.dm", "baselineProfiles/1/ananas-v0.14.2-libre-x86_64.dm",
"baselineProfiles/1/ananas-v0.10.1-0.14.2-libre-arm64-v8a.dm", "baselineProfiles/1/ananas-v0.14.2-libre-x86.dm",
"baselineProfiles/1/ananas-v0.10.1-0.14.2-libre-x86.dm" "baselineProfiles/1/ananas-v0.14.2-libre-arm64-v8a.dm"
] ]
}, },
{ {
"minApi": 31, "minApi": 31,
"maxApi": 2147483647, "maxApi": 2147483647,
"baselineProfiles": [ "baselineProfiles": [
"baselineProfiles/0/ananas-v0.10.1-0.14.2-libre-armeabi-v7a.dm", "baselineProfiles/0/ananas-v0.14.2-libre-armeabi-v7a.dm",
"baselineProfiles/0/ananas-v0.10.1-0.14.2-libre-x86_64.dm", "baselineProfiles/0/ananas-v0.14.2-libre-x86_64.dm",
"baselineProfiles/0/ananas-v0.10.1-0.14.2-libre-arm64-v8a.dm", "baselineProfiles/0/ananas-v0.14.2-libre-x86.dm",
"baselineProfiles/0/ananas-v0.10.1-0.14.2-libre-x86.dm" "baselineProfiles/0/ananas-v0.14.2-libre-arm64-v8a.dm"
] ]
} }
], ],

View file

@ -24,16 +24,19 @@ import android.widget.ImageView
import android.widget.Space import android.widget.Space
import android.widget.TextView import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs import androidx.navigation.navArgs
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import com.nomadics9.ananas.databinding.ActivityPlayerBinding import com.nomadics9.ananas.databinding.ActivityPlayerBinding
import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment import com.nomadics9.ananas.dialogs.SpeedSelectionDialogFragment
import com.nomadics9.ananas.dialogs.TrackSelectionDialogFragment import com.nomadics9.ananas.dialogs.TrackSelectionDialogFragment
@ -42,17 +45,16 @@ import com.nomadics9.ananas.utils.PlayerGestureHelper
import com.nomadics9.ananas.utils.PreviewScrubListener import com.nomadics9.ananas.utils.PreviewScrubListener
import com.nomadics9.ananas.viewmodels.PlayerActivityViewModel import com.nomadics9.ananas.viewmodels.PlayerActivityViewModel
import com.nomadics9.ananas.viewmodels.PlayerEvents import com.nomadics9.ananas.viewmodels.PlayerEvents
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import com.nomadics9.ananas.core.R as CoreR import com.nomadics9.ananas.core.R as CoreR
import com.nomadics9.ananas.models.VideoQuality
var isControlsLocked: Boolean = false var isControlsLocked: Boolean = false
@AndroidEntryPoint @AndroidEntryPoint
class PlayerActivity : BasePlayerActivity() { class PlayerActivity : BasePlayerActivity() {
@Inject @Inject
lateinit var appPreferences: AppPreferences lateinit var appPreferences: AppPreferences
@ -87,10 +89,12 @@ class PlayerActivity : BasePlayerActivity() {
binding = ActivityPlayerBinding.inflate(layoutInflater) binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality) val changeQualityButton: ImageButton = findViewById(R.id.btnChangeQuality)
changeQualityButton.setOnClickListener { changeQualityButton.setOnClickListener {
showQualitySelectionDialog() showQualitySelectionDialog()
} }
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.playerView.player = viewModel.player binding.playerView.player = viewModel.player
@ -111,8 +115,7 @@ class PlayerActivity : BasePlayerActivity() {
configureInsets(lockedControls) configureInsets(lockedControls)
if (appPreferences.playerGestures) { if (appPreferences.playerGestures) {
playerGestureHelper = playerGestureHelper = PlayerGestureHelper(
PlayerGestureHelper(
appPreferences, appPreferences,
this, this,
binding.playerView, binding.playerView,
@ -152,12 +155,7 @@ class PlayerActivity : BasePlayerActivity() {
skipButton.text = skipButton.text =
getString(CoreR.string.skip_intro_button) getString(CoreR.string.skip_intro_button)
skipButton.isVisible = skipButton.isVisible =
!isInPictureInPictureMode && !isInPictureInPictureMode && !buttonPressed && (showSkip == true || (binding.playerView.isControllerFullyVisible && currentSegment?.skip == true))
!buttonPressed &&
(
showSkip == true ||
(binding.playerView.isControllerFullyVisible && currentSegment?.skip == true)
)
watchCreditsButton.isVisible = false watchCreditsButton.isVisible = false
} }
@ -169,10 +167,7 @@ class PlayerActivity : BasePlayerActivity() {
getString(CoreR.string.skip_credit_button_last) getString(CoreR.string.skip_credit_button_last)
} }
skipButton.isVisible = skipButton.isVisible =
!isInPictureInPictureMode && !isInPictureInPictureMode && !buttonPressed && currentSegment?.skip == true && !binding.playerView.isControllerFullyVisible
!buttonPressed &&
currentSegment?.skip == true &&
!binding.playerView.isControllerFullyVisible
watchCreditsButton.isVisible = skipButton.isVisible watchCreditsButton.isVisible = skipButton.isVisible
} }
@ -186,15 +181,12 @@ class PlayerActivity : BasePlayerActivity() {
when (currentSegment?.type) { when (currentSegment?.type) {
"intro" -> { "intro" -> {
skipButton.isVisible = skipButton.isVisible =
!buttonPressed && !buttonPressed && (showSkip == true || (visibility == View.VISIBLE && currentSegment?.skip == true))
(showSkip == true || (visibility == View.VISIBLE && currentSegment?.skip == true))
} }
"credit" -> { "credit" -> {
skipButton.isVisible = skipButton.isVisible =
!buttonPressed && !buttonPressed && currentSegment?.skip == true && visibility == View.GONE
currentSegment?.skip == true &&
visibility == View.GONE
watchCreditsButton.isVisible = skipButton.isVisible watchCreditsButton.isVisible = skipButton.isVisible
} }
} }
@ -276,8 +268,7 @@ class PlayerActivity : BasePlayerActivity() {
if (appPreferences.playerPipGesture) { if (appPreferences.playerPipGesture) {
try { try {
setPictureInPictureParams(pipParams(event.isPlaying)) setPictureInPictureParams(pipParams(event.isPlaying))
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) { }
}
} }
} }
} }
@ -390,16 +381,14 @@ class PlayerActivity : BasePlayerActivity() {
private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams { private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams {
val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height) val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height)
val aspectRatio = val aspectRatio = binding.playerView.player?.videoSize?.let {
binding.playerView.player?.videoSize?.let {
Rational( Rational(
it.width.coerceAtMost((it.height * 2.39f).toInt()), it.width.coerceAtMost((it.height * 2.39f).toInt()),
it.height.coerceAtMost((it.width * 2.39f).toInt()), it.height.coerceAtMost((it.width * 2.39f).toInt()),
) )
} }
val sourceRectHint = val sourceRectHint = if (displayAspectRatio < aspectRatio!!) {
if (displayAspectRatio < aspectRatio!!) {
val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt() val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect( Rect(
0, 0,
@ -417,9 +406,7 @@ class PlayerActivity : BasePlayerActivity() {
) )
} }
val builder = val builder = PictureInPictureParams.Builder()
PictureInPictureParams
.Builder()
.setAspectRatio(aspectRatio) .setAspectRatio(aspectRatio)
.setSourceRectHint(sourceRectHint) .setSourceRectHint(sourceRectHint)
@ -440,31 +427,28 @@ class PlayerActivity : BasePlayerActivity() {
} catch (_: IllegalArgumentException) { } } catch (_: IllegalArgumentException) { }
} }
private var selectedIndex = 1 // Default to "Original" (index 1)
private fun showQualitySelectionDialog() { private fun showQualitySelectionDialog() {
val originalResolution = viewModel.getOriginalResolution() ?: 0 val height = viewModel.getOriginalHeight()
val qualityEntries = resources.getStringArray(CoreR.array.quality_entries).toList()
val qualityValues = resources.getStringArray(CoreR.array.quality_values).toList()
val qualities = qualityEntries.toMutableList() val qualities = when (height) {
val closestQuality = VideoQuality.entries 0 -> arrayOf("Auto", "Original - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
.filter { it != VideoQuality.Auto && it != VideoQuality.Original } in 1001..1999 -> arrayOf("Auto", "Original (1080p) - Max", "720p - 2Mbps", "480p - 1Mbps", "360p - 800kbps")
.minByOrNull { kotlin.math.abs(it.height*it.width - originalResolution) } 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")
if (closestQuality != null) {
qualities[1] = "${qualities[1]} (${closestQuality})"
} }
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(CoreR.string.select_quality) .setTitle("Select Video Quality")
.setSingleChoiceItems(qualities.toTypedArray(), selectedIndex) { dialog, which -> .setItems(qualities) { _, which ->
selectedIndex = which val selectedQuality = qualities[which]
val selectedQualityValue = qualityValues[which] viewModel.changeVideoQuality(selectedQuality)
viewModel.changeVideoQuality(selectedQualityValue)
dialog.dismiss()
} }
.show() .show()
} }
override fun onPictureInPictureModeChanged( override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean, isInPictureInPictureMode: Boolean,
newConfig: Configuration, newConfig: Configuration,
@ -479,8 +463,7 @@ class PlayerActivity : BasePlayerActivity() {
playerGestureHelper?.updateZoomMode(false) playerGestureHelper?.updateZoomMode(false)
// Brightness mode Auto // Brightness mode Auto
window.attributes = window.attributes = window.attributes.apply {
window.attributes.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
} }
} }
@ -489,14 +472,11 @@ class PlayerActivity : BasePlayerActivity() {
playerGestureHelper?.updateZoomMode(wasZoom) playerGestureHelper?.updateZoomMode(wasZoom)
// Override auto brightness // Override auto brightness
window.attributes = window.attributes = window.attributes.apply {
window.attributes.apply { screenBrightness = if (appPreferences.playerBrightnessRemember) {
screenBrightness =
if (appPreferences.playerBrightnessRemember) {
appPreferences.playerBrightness appPreferences.playerBrightness
} else { } else {
Settings.System Settings.System.getInt(
.getInt(
contentResolver, contentResolver,
Settings.System.SCREEN_BRIGHTNESS, Settings.System.SCREEN_BRIGHTNESS,
).toFloat() / 255 ).toFloat() / 255

View file

@ -14,7 +14,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.nomadics9.ananas.BuildConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import com.nomadics9.ananas.adapters.DiscoveredServerListAdapter import com.nomadics9.ananas.adapters.DiscoveredServerListAdapter
import com.nomadics9.ananas.databinding.FragmentAddServerBinding import com.nomadics9.ananas.databinding.FragmentAddServerBinding
@ -90,12 +89,7 @@ class AddServerFragment : Fragment() {
} }
} }
} }
if (BuildConfig.FLAVOR == "Ananas") { connectToServer(DEFAULT_SERVER_ADDRESS)
fun connectToServerDirectly(serverAddress: String = BuildConfig.DEFAULT_SERVER_ADDRESS) {
viewModel.checkServer(serverAddress.removeSuffix("/"))
}
connectToServerDirectly()
}
return binding.root return binding.root
} }
@ -135,21 +129,18 @@ class AddServerFragment : Fragment() {
} }
} }
private fun connectToServer() {
val serverAddress = (binding.editTextServerAddress as AppCompatEditText).text.toString()
viewModel.checkServer(serverAddress.removeSuffix("/"))
}
// private fun connectToServer() { // private fun connectToServer() {
// val serverAddress = (binding.editTextServerAddress as AppCompatEditText).text.toString() // val serverAddress = (binding.editTextServerAddress as AppCompatEditText).text.toString()
// if (serverAddress.isNotBlank()) {
// viewModel.checkServer(serverAddress.removeSuffix("/")) // viewModel.checkServer(serverAddress.removeSuffix("/"))
// } else {
// viewModel.checkServer(BuildConfig.DEFAULT_SERVER_ADDRESS.removeSuffix("/"))
// }
// } // }
companion object {
private const val DEFAULT_SERVER_ADDRESS = "https://askar.tv"
}
private fun connectToServer(serverAddress: String = DEFAULT_SERVER_ADDRESS) {
viewModel.checkServer(serverAddress.removeSuffix("/"))
}
private fun navigateToLoginFragment() { private fun navigateToLoginFragment() {
findNavController().navigate(AddServerFragmentDirections.actionAddServerFragmentToLoginFragment()) findNavController().navigate(AddServerFragmentDirections.actionAddServerFragmentToLoginFragment())
} }

View file

@ -172,11 +172,6 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}else if (!appPreferences.downloadQualityDefault) { }else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog() createPickQualityDialog()
} else { } else {
startDownload()
}
}
private fun startDownload(){
binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent) binding.itemActions.downloadButton.setIconResource(AndroidR.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true binding.itemActions.progressDownload.isVisible = true
@ -230,6 +225,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
createDownloadPreparingDialog() createDownloadPreparingDialog()
viewModel.download() viewModel.download()
} }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
dialog?.let { dialog?.let {
@ -413,8 +409,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
} }
private fun createPickQualityDialog() { private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(CoreR.array.download_quality_entries) val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(CoreR.array.download_quality_values) val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val quality = appPreferences.downloadQuality val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality) val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality var selectedQuality = quality
@ -428,7 +424,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
builder.setPositiveButton("Download") { dialog, _ -> builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality appPreferences.downloadQuality = selectedQuality
dialog.dismiss() dialog.dismiss()
startDownload() handleDownload()
} }
builder.setNegativeButton("Cancel") { dialog, _ -> builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss() dialog.dismiss()

View file

@ -1,7 +1,5 @@
package com.nomadics9.ananas.fragments package com.nomadics9.ananas.fragments
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.Html.fromHtml import android.text.Html.fromHtml
import android.view.LayoutInflater import android.view.LayoutInflater
@ -19,13 +17,11 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import com.nomadics9.ananas.AppPreferences import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.BuildConfig
import com.nomadics9.ananas.adapters.UserLoginListAdapter import com.nomadics9.ananas.adapters.UserLoginListAdapter
import com.nomadics9.ananas.database.ServerDatabaseDao import com.nomadics9.ananas.database.ServerDatabaseDao
import com.nomadics9.ananas.databinding.FragmentLoginBinding import com.nomadics9.ananas.databinding.FragmentLoginBinding
import com.nomadics9.ananas.viewmodels.LoginEvent import com.nomadics9.ananas.viewmodels.LoginEvent
import com.nomadics9.ananas.viewmodels.LoginViewModel import com.nomadics9.ananas.viewmodels.LoginViewModel
import io.noties.markwon.Markwon
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -82,17 +78,6 @@ class LoginFragment : Fragment() {
(binding.editTextPassword as AppCompatEditText).requestFocus() (binding.editTextPassword as AppCompatEditText).requestFocus()
} }
if (BuildConfig.FLAVOR == "Ananas") {
binding.buttonForgetPassword.setOnClickListener {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.FORGET_PASSWORD_ADDRESS))
startActivity(browserIntent)
}
binding.buttonForgetPassword.isVisible = true
} else {
binding.buttonForgetPassword.isVisible = false
}
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState -> viewModel.uiState.collect { uiState ->
@ -158,21 +143,7 @@ class LoginFragment : Fragment() {
binding.editTextPasswordLayout.isEnabled = true binding.editTextPasswordLayout.isEnabled = true
uiState.disclaimer?.let { disclaimer -> uiState.disclaimer?.let { disclaimer ->
if (BuildConfig.FLAVOR == "Ananas") { binding.loginDisclaimer.text = fromHtml(disclaimer, 0)
val lines = disclaimer.lines()
val lineToRemoveIndex = 3
val filteredLines = lines.toMutableList().apply {
if (size > lineToRemoveIndex) {
removeAt(lineToRemoveIndex)
}
}
val filteredDisclaimer = filteredLines.joinToString("\n")
val markwon = Markwon.create(requireContext())
markwon.setMarkdown(binding.loginDisclaimer, filteredDisclaimer)
} else {
val markwon = Markwon.create(requireContext())
markwon.setMarkdown(binding.loginDisclaimer, disclaimer)
}
} }
} }

View file

@ -209,11 +209,6 @@ class MovieFragment : Fragment() {
} else if (!appPreferences.downloadQualityDefault) { } else if (!appPreferences.downloadQualityDefault) {
createPickQualityDialog() createPickQualityDialog()
} else { } else {
startDownload()
}
}
private fun startDownload() {
binding.itemActions.downloadButton.setIconResource(android.R.color.transparent) binding.itemActions.downloadButton.setIconResource(android.R.color.transparent)
binding.itemActions.progressDownload.isIndeterminate = true binding.itemActions.progressDownload.isIndeterminate = true
binding.itemActions.progressDownload.isVisible = true binding.itemActions.progressDownload.isVisible = true
@ -267,6 +262,7 @@ class MovieFragment : Fragment() {
createDownloadPreparingDialog() createDownloadPreparingDialog()
viewModel.download() viewModel.download()
} }
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -506,8 +502,8 @@ class MovieFragment : Fragment() {
} }
private fun createPickQualityDialog() { private fun createPickQualityDialog() {
val qualityEntries = resources.getStringArray(CoreR.array.download_quality_entries) val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(CoreR.array.download_quality_values) val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val quality = appPreferences.downloadQuality val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality) val currentQualityIndex = qualityValues.indexOf(quality)
var selectedQuality = quality var selectedQuality = quality
@ -520,8 +516,8 @@ class MovieFragment : Fragment() {
} }
builder.setPositiveButton("Download") { dialog, _ -> builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality appPreferences.downloadQuality = selectedQuality
startDownload()
dialog.dismiss() dialog.dismiss()
handleDownload()
} }
builder.setNegativeButton("Cancel") { dialog, _ -> builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss() dialog.dismiss()

View file

@ -11,7 +11,6 @@ import android.webkit.WebViewClient
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.nomadics9.ananas.BuildConfig
import com.nomadics9.ananas.R import com.nomadics9.ananas.R
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -54,7 +53,7 @@ class RequestsWebViewFragment : Fragment() {
} }
// Load your URL here // Load your URL here
webView.loadUrl(BuildConfig.REQUEST_SERVER_ADDRESS) webView.loadUrl("https://r.askar.tv")
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {

View file

@ -226,8 +226,8 @@ class SeasonFragment : Fragment() {
} }
private fun createPickQualityDialog(onQualitySelected: () -> Unit) { private fun createPickQualityDialog(onQualitySelected: () -> Unit) {
val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.download_quality_entries) val qualityEntries = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_entries)
val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.download_quality_values) val qualityValues = resources.getStringArray(com.nomadics9.ananas.core.R.array.quality_values)
val quality = appPreferences.downloadQuality val quality = appPreferences.downloadQuality
val currentQualityIndex = qualityValues.indexOf(quality) val currentQualityIndex = qualityValues.indexOf(quality)
@ -240,8 +240,8 @@ class SeasonFragment : Fragment() {
} }
builder.setPositiveButton("Download") { dialog, _ -> builder.setPositiveButton("Download") { dialog, _ ->
appPreferences.downloadQuality = selectedQuality appPreferences.downloadQuality = selectedQuality
onQualitySelected()
dialog.dismiss() dialog.dismiss()
onQualitySelected()
} }
builder.setNegativeButton("Cancel") { dialog, _ -> builder.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss() dialog.dismiss()

View file

@ -3,23 +3,12 @@ package com.nomadics9.ananas.fragments
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.core.content.res.ResourcesCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.BuildConfig
import com.nomadics9.ananas.utils.restart
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import com.nomadics9.ananas.AppPreferences
import kotlinx.coroutines.launch import com.nomadics9.ananas.utils.restart
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject import javax.inject.Inject
import com.nomadics9.ananas.core.R as CoreR import com.nomadics9.ananas.core.R as CoreR
@ -28,10 +17,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
@Inject @Inject
lateinit var appPreferences: AppPreferences lateinit var appPreferences: AppPreferences
private val updateUrl = BuildConfig.UPDATE_ADDRESS
private var isUpdateAvailable: Boolean = false
private var newLastModifiedDate: Date? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(CoreR.xml.fragment_settings, rootKey) setPreferencesFromResource(CoreR.xml.fragment_settings, rootKey)
@ -42,21 +27,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
findPreference<Preference>("switchUser")?.setOnPreferenceClickListener { findPreference<Preference>("switchUser")?.setOnPreferenceClickListener {
val serverId = appPreferences.currentServer!! val serverId = appPreferences.currentServer!!
findNavController().navigate( findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToUsersFragment(serverId))
TwoPaneSettingsFragmentDirections.actionNavigationSettingsToUsersFragment(
serverId
)
)
true true
} }
findPreference<Preference>("switchAddress")?.setOnPreferenceClickListener { findPreference<Preference>("switchAddress")?.setOnPreferenceClickListener {
val serverId = appPreferences.currentServer!! val serverId = appPreferences.currentServer!!
findNavController().navigate( findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToServerAddressesFragment(serverId))
TwoPaneSettingsFragmentDirections.actionNavigationSettingsToServerAddressesFragment(
serverId
)
)
true true
} }
@ -74,102 +51,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
true true
} }
findPreference<Preference>("appInfo")?.setOnPreferenceClickListener { findPreference<Preference>("appInfo")?.setOnPreferenceClickListener {
if (isUpdateAvailable && newLastModifiedDate != null) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateUrl))
startActivity(intent)
storeDate(newLastModifiedDate!!)
true
} else {
findNavController().navigate(TwoPaneSettingsFragmentDirections.actionSettingsFragmentToAboutLibraries()) findNavController().navigate(TwoPaneSettingsFragmentDirections.actionSettingsFragmentToAboutLibraries())
false true
}
} }
findPreference<Preference>("requests")?.setOnPreferenceClickListener { findPreference<Preference>("requests")?.setOnPreferenceClickListener {
findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToRequestsWebFragment()) findNavController().navigate(TwoPaneSettingsFragmentDirections.actionNavigationSettingsToRequestsWebFragment())
true true
} }
// Check for updates when the settings screen is opened
checkForUpdates()
}
private fun checkForUpdates() {
lifecycleScope.launch {
val lastModifiedDate = fetchLastModifiedDate(updateUrl)
if (lastModifiedDate != null) {
Timber.d("Fetched Last-Modified date: $lastModifiedDate")
val storedDate = getStoredDate()
Timber.d("Stored date: $storedDate")
if (storedDate == Date(0L) || lastModifiedDate.after(storedDate)) {
Timber.d("Update available")
isUpdateAvailable = true
newLastModifiedDate = lastModifiedDate
showUpdateAvailable()
} else {
Timber.d("No update available")
isUpdateAvailable = false
}
} else {
Timber.d("Failed to fetch Last-Modified date")
isUpdateAvailable = false
}
}
}
private suspend fun fetchLastModifiedDate(urlString: String): Date? {
return withContext(Dispatchers.IO) {
var urlConnection: HttpURLConnection? = null
try {
val url = URL(urlString)
urlConnection = url.openConnection() as HttpURLConnection
urlConnection.requestMethod = "HEAD"
val lastModified = urlConnection.getHeaderField("Last-Modified")
if (lastModified != null) {
val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US)
dateFormat.parse(lastModified)
} else {
null
}
} catch (e: Exception) {
Timber.e(e, "Error fetching Last-Modified date")
null
} finally {
urlConnection?.disconnect()
}
}
}
private fun getStoredDate(): Date {
val sharedPreferences = preferenceManager.sharedPreferences
val storedDateString = sharedPreferences?.getString("stored_date", null)
Timber.d("Retrieved stored date string: $storedDateString")
return if (storedDateString != null) {
try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).parse(storedDateString) ?: Date(0)
} catch (e: Exception) {
Timber.e(e, "Error parsing stored date string")
Date(0)
}
} else {
Date(0)
}
}
private fun storeDate(date: Date) {
val dateString = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(date)
preferenceManager.sharedPreferences?.edit()?.putString("stored_date", dateString)?.apply()
Timber.d("Stored new date: $dateString")
}
private fun showUpdateAvailable() {
val appInfoPreference = findPreference<Preference>("appInfo")
appInfoPreference?.let {
it.summary = "Update available!"
it.icon = ResourcesCompat.getDrawable(resources, CoreR.drawable.ic_download, null) // Ensure this drawable exists
Timber.d("Update available UI shown")
}
} }
} }

View file

@ -73,7 +73,6 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<!--TODO: Content Desc to Strings-->
<ImageButton <ImageButton
android:id="@+id/btnChangeQuality" android:id="@+id/btnChangeQuality"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -81,7 +80,7 @@
android:background="@drawable/transparent_circle_background" android:background="@drawable/transparent_circle_background"
android:contentDescription="Quality" android:contentDescription="Quality"
android:padding="16dp" android:padding="16dp"
android:src="@drawable/ic_monitor_play" android:src="@drawable/ic_quality"
android:layout_gravity="end" android:layout_gravity="end"
app:tint="@android:color/white" app:tint="@android:color/white"
/> />

View file

@ -141,24 +141,10 @@
android:visibility="invisible" /> android:visibility="invisible" />
</RelativeLayout> </RelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp">
<Button
android:id="@+id/button_forget_password"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/forget_password" />
</RelativeLayout>
<TextView <TextView
android:id="@+id/login_disclaimer" android:id="@+id/login_disclaimer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp"
android:layout_margin="24dp" android:layout_margin="24dp"
android:textSize="16sp" android:textSize="16sp"

View file

@ -1,8 +1,8 @@
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
object Versions { object Versions {
const val appCode = 16 const val appCode = 9
const val appName = "0.10.6-0.14.2" const val appName = "0.14.2"
const val compileSdk = 34 const val compileSdk = 34
const val buildTools = "34.0.0" const val buildTools = "34.0.0"

View file

@ -30,7 +30,6 @@ android {
flavorDimensions += "variant" flavorDimensions += "variant"
productFlavors { productFlavors {
register("libre") register("libre")
create("Ananas")
} }
compileOptions { compileOptions {

View file

@ -6,8 +6,10 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import android.text.format.Formatter import android.text.format.Formatter
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.nomadics9.ananas.AppPreferences import com.nomadics9.ananas.AppPreferences
import com.nomadics9.ananas.api.JellyfinApi
import com.nomadics9.ananas.database.ServerDatabaseDao import com.nomadics9.ananas.database.ServerDatabaseDao
import com.nomadics9.ananas.models.FindroidEpisode import com.nomadics9.ananas.models.FindroidEpisode
import com.nomadics9.ananas.models.FindroidItem import com.nomadics9.ananas.models.FindroidItem
@ -17,7 +19,6 @@ import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.FindroidSources import com.nomadics9.ananas.models.FindroidSources
import com.nomadics9.ananas.models.FindroidTrickplayInfo import com.nomadics9.ananas.models.FindroidTrickplayInfo
import com.nomadics9.ananas.models.UiText import com.nomadics9.ananas.models.UiText
import com.nomadics9.ananas.models.VideoQuality
import com.nomadics9.ananas.models.toFindroidEpisodeDto import com.nomadics9.ananas.models.toFindroidEpisodeDto
import com.nomadics9.ananas.models.toFindroidMediaStreamDto import com.nomadics9.ananas.models.toFindroidMediaStreamDto
import com.nomadics9.ananas.models.toFindroidMovieDto import com.nomadics9.ananas.models.toFindroidMovieDto
@ -28,10 +29,34 @@ import com.nomadics9.ananas.models.toFindroidSourceDto
import com.nomadics9.ananas.models.toFindroidTrickplayInfoDto import com.nomadics9.ananas.models.toFindroidTrickplayInfoDto
import com.nomadics9.ananas.models.toFindroidUserDataDto import com.nomadics9.ananas.models.toFindroidUserDataDto
import com.nomadics9.ananas.repository.JellyfinRepository import com.nomadics9.ananas.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
import org.jellyfin.sdk.model.api.TranscodingProfile
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.URL
import java.util.UUID import java.util.UUID
import kotlin.Exception import kotlin.Exception
import kotlin.math.ceil import kotlin.math.ceil
@ -51,13 +76,13 @@ class DownloaderImpl(
storageIndex: Int, storageIndex: Int,
): Pair<Long, UiText?> { ): Pair<Long, UiText?> {
try { try {
Timber.d("Downloading item: ${item.id} with sourceId: $sourceId") Timber.d("Downloading item: ${item.id} with sourceId: $sourceId")
val source = val source =
jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId } jellyfinRepository.getMediaSources(item.id, true).first { it.id == sourceId }
val segments = jellyfinRepository.getSegmentsTimestamps(item.id) val segments = jellyfinRepository.getSegmentsTimestamps(item.id)
val trickplayInfo = val trickplayInfo = if (item is FindroidSources) {
if (item is FindroidSources) {
item.trickplayInfo?.get(sourceId) item.trickplayInfo?.get(sourceId)
} else { } else {
null null
@ -79,8 +104,15 @@ class DownloaderImpl(
), ),
) )
} }
handleDownload(item, source, storageIndex, trickplayInfo, segments, path) val qualityPreference = appPreferences.downloadQuality!!
return Pair(-1, null) Timber.d("Quality preference: $qualityPreference")
return if (qualityPreference != "Original") {
Timber.d("Handling Transcoding download for item: ${item.id}")
handleTranscodeDownload(item, source, storageIndex, trickplayInfo, segments, path, qualityPreference)
} else {
Timber.d("Handling original download for item: ${item.id}")
downloadOriginalItem(item, source, storageIndex, trickplayInfo, segments, path)
}
} catch (e: Exception) { } catch (e: Exception) {
try { try {
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId } val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
@ -90,18 +122,79 @@ class DownloaderImpl(
return Pair( return Pair(
-1, -1,
if (e.message != null) { if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource(
UiText.DynamicString(e.message!!) CoreR.string.unknown_error
} else {
UiText.StringResource(
CoreR.string.unknown_error,
) )
},
) )
} }
} }
private suspend fun handleDownload( private suspend fun handleTranscodeDownload(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int,
trickplayInfo: FindroidTrickplayInfo?,
segments: List<FindroidSegment>?,
path: Uri,
quality: String
): Pair<Long, UiText?> {
val transcodingUrl = getTranscodedUrl(item.id, quality)
when (item) {
is FindroidMovie -> {
database.insertMovie(item.toFindroidMovieDto(appPreferences.currentServer!!))
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, source.id, trickplayInfo)
}
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 downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
is FindroidEpisode -> {
database.insertShow(
jellyfinRepository.getShow(item.seriesId)
.toFindroidShowDto(appPreferences.currentServer!!),
)
database.insertSeason(
jellyfinRepository.getSeason(item.seasonId).toFindroidSeasonDto(),
)
database.insertEpisode(item.toFindroidEpisodeDto(appPreferences.currentServer!!))
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
downloadExternalMediaStreams(item, source, storageIndex)
if (trickplayInfo != null) {
downloadTrickplayData(item.id, source.id, trickplayInfo)
}
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 downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null)
}
}
return Pair(-1, null)
}
private suspend fun downloadOriginalItem(
item: FindroidItem, item: FindroidItem,
source: FindroidSource, source: FindroidSource,
storageIndex: Int, storageIndex: Int,
@ -121,13 +214,7 @@ class DownloaderImpl(
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
} }
if (appPreferences.downloadQuality != VideoQuality.Original.toString()) { val request = DownloadManager.Request(source.path.toUri())
downloadEmbeddedMediaStreams(item, source, storageIndex)
val transcodingUrl =
getTranscodedUrl(item.id, appPreferences.downloadQuality!!)
val request =
DownloadManager
.Request(transcodingUrl)
.setTitle(item.name) .setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
@ -136,25 +223,11 @@ class DownloaderImpl(
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId) database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null) return Pair(downloadId, null)
} else {
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)
}
} }
is FindroidEpisode -> { is FindroidEpisode -> {
database.insertShow( database.insertShow(
jellyfinRepository jellyfinRepository.getShow(item.seriesId)
.getShow(item.seriesId)
.toFindroidShowDto(appPreferences.currentServer!!), .toFindroidShowDto(appPreferences.currentServer!!),
) )
database.insertSeason( database.insertSeason(
@ -170,13 +243,7 @@ class DownloaderImpl(
if (segments != null) { if (segments != null) {
database.insertSegments(segments.toFindroidSegmentsDto(item.id)) database.insertSegments(segments.toFindroidSegmentsDto(item.id))
} }
if (appPreferences.downloadQuality != VideoQuality.Original.toString()) { val request = DownloadManager.Request(source.path.toUri())
downloadEmbeddedMediaStreams(item, source, storageIndex)
val transcodingUrl =
getTranscodedUrl(item.id, appPreferences.downloadQuality!!)
val request =
DownloadManager
.Request(transcodingUrl)
.setTitle(item.name) .setTitle(item.name)
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
@ -185,38 +252,20 @@ class DownloaderImpl(
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
database.setSourceDownloadId(source.id, downloadId) database.setSourceDownloadId(source.id, downloadId)
return Pair(downloadId, null) return Pair(downloadId, null)
} else {
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)
}
} }
} }
return Pair(-1, null) return Pair(-1, null)
} }
override suspend fun cancelDownload(
item: FindroidItem, override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) {
source: FindroidSource,
) {
if (source.downloadId != null) { if (source.downloadId != null) {
downloadManager.remove(source.downloadId!!) downloadManager.remove(source.downloadId!!)
} }
deleteItem(item, source) deleteItem(item, source)
} }
override suspend fun deleteItem( override suspend fun deleteItem(item: FindroidItem, source: FindroidSource) {
item: FindroidItem,
source: FindroidSource,
) {
when (item) { when (item) {
is FindroidMovie -> { is FindroidMovie -> {
database.deleteMovie(item.id) database.deleteMovie(item.id)
@ -258,14 +307,11 @@ class DownloaderImpl(
if (downloadId == null) { if (downloadId == null) {
return Pair(downloadStatus, progress) return Pair(downloadStatus, progress)
} }
val query = val query = DownloadManager.Query()
DownloadManager
.Query()
.setFilterById(downloadId) .setFilterById(downloadId)
val cursor = downloadManager.query(query) val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
downloadStatus = downloadStatus = cursor.getInt(
cursor.getInt(
cursor.getColumnIndexOrThrow( cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_STATUS, DownloadManager.COLUMN_STATUS,
), ),
@ -298,23 +344,20 @@ class DownloaderImpl(
val storageLocation = context.getExternalFilesDirs(null)[storageIndex] val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
for (mediaStream in source.mediaStreams.filter { it.isExternal }) { for (mediaStream in source.mediaStreams.filter { it.isExternal }) {
val id = UUID.randomUUID() val id = UUID.randomUUID()
val streamPath = val streamPath = Uri.fromFile(
Uri.fromFile(
File( File(
storageLocation, storageLocation,
"downloads/${item.id}.${source.id}.$id.download", "downloads/${item.id}.${source.id}.$id.download"
), )
) )
database.insertMediaStream( database.insertMediaStream(
mediaStream.toFindroidMediaStreamDto( mediaStream.toFindroidMediaStreamDto(
id, id,
source.id, source.id,
streamPath.path.orEmpty(), streamPath.path.orEmpty()
),
) )
val request = )
DownloadManager val request = DownloadManager.Request(Uri.parse(mediaStream.path))
.Request(Uri.parse(mediaStream.path))
.setTitle(mediaStream.title) .setTitle(mediaStream.title)
.setAllowedOverMetered(appPreferences.downloadOverMobileData) .setAllowedOverMetered(appPreferences.downloadOverMobileData)
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming) .setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
@ -325,62 +368,18 @@ class DownloaderImpl(
} }
} }
private fun downloadEmbeddedMediaStreams(
item: FindroidItem,
source: FindroidSource,
storageIndex: Int = 0,
) {
val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
val subtitleStreams = source.mediaStreams.filter { !it.isExternal && it.type == MediaStreamType.SUBTITLE && it.path != null }
for (mediaStream in subtitleStreams) {
var deliveryUrl = mediaStream.path!!
if (mediaStream.codec == "webvtt") {
deliveryUrl = deliveryUrl.replace("Stream.srt", "Stream.vtt")
}
val id = UUID.randomUUID()
val streamPath =
Uri.fromFile(
File(
storageLocation,
"downloads/${item.id}.${source.id}.$id.download",
),
)
database.insertMediaStream(
mediaStream.toFindroidMediaStreamDto(
id,
source.id,
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 downloadId = downloadManager.enqueue(request)
database.setMediaStreamDownloadId(id, downloadId)
}
}
private suspend fun downloadTrickplayData( private suspend fun downloadTrickplayData(
itemId: UUID, itemId: UUID,
sourceId: String, sourceId: String,
trickplayInfo: FindroidTrickplayInfo, trickplayInfo: FindroidTrickplayInfo,
) { ) {
val maxIndex = val maxIndex = ceil(
ceil( trickplayInfo.thumbnailCount.toDouble()
trickplayInfo.thumbnailCount .div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)
.toDouble()
.div(trickplayInfo.tileWidth * trickplayInfo.tileHeight),
).toInt() ).toInt()
val byteArrays = mutableListOf<ByteArray>() val byteArrays = mutableListOf<ByteArray>()
for (i in 0..maxIndex) { for (i in 0..maxIndex) {
jellyfinRepository jellyfinRepository.getTrickplayData(
.getTrickplayData(
itemId, itemId,
trickplayInfo.width, trickplayInfo.width,
i, i,
@ -406,46 +405,51 @@ class DownloaderImpl(
} }
} }
private suspend fun getTranscodedUrl( private suspend fun getTranscodedUrl(itemId: UUID, quality: String): Uri? {
itemId: UUID, val maxBitrate = when (quality) {
quality: String, "720p" -> 2000000 // 2 Mbps
): Uri? { "480p" -> 1000000 // 1 Mbps
val videoQuality = VideoQuality.fromString(quality)!! "360p" -> 800000 // 800Kbps
return try { else -> 2000000 // Default to 2 Mbps if not specified
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,
VideoQuality.getBitrate(videoQuality),
"mkv",
VideoQuality.getHeight(videoQuality),
)
return downloadUrl.toUri() 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 playSessionId = playbackInfo.content.playSessionId!!
val downloadUrl = jellyfinRepository.getVideoStreambyContainerUrl(itemId, mediaSourceId, playSessionId, maxBitrate, "ts")
val transcodeUri = buildTranscodeUri(downloadUrl, maxBitrate, quality)
Timber.d("Constructed Transcode URL: $transcodeUri")
transcodeUri
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
null null
} }
} }
private fun buildTranscodeUri(
transcodingUrl: String,
maxBitrate: Int,
quality: String
): Uri {
val resolution = when (quality) {
"720p" -> "720"
"480p" -> "480"
"360p" -> "360"
else -> "720"
} }
return Uri.parse(transcodingUrl).buildUpon()
.appendQueryParameter("MaxVideoHeight", resolution)
.appendQueryParameter("MaxVideoBitRate", maxBitrate.toString())
.appendQueryParameter("subtitleMethod", "External")
//.appendQueryParameter("api_key", apiKey)
.build()
}
}

View file

@ -1,35 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M10,7.75a0.75,0.75 0,0 1,1.142 -0.638l3.664,2.249a0.75,0.75 0,0 1,0 1.278l-3.664,2.25a0.75,0.75 0,0 1,-1.142 -0.64z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M12,17v4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M8,21h8"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M4,3L20,3A2,2 0,0 1,22 5L22,15A2,2 0,0 1,20 17L4,17A2,2 0,0 1,2 15L2,5A2,2 0,0 1,4 3z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -1,20 +1,2 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources></resources>
<string name="add_server_error_outdated">اصدار الخادم قديم: %1$s. الرجاء تحديث الخادم</string>
<string name="add_server_error_not_jellyfin">ليس خادم جيلي فن:%1$s</string>
<string name="add_server_error_version">اصدار الخادم غير مدعوم: %1$s. الرجاء تحديث الخادم</string>
<string name="add_server_error_slow">رد الخادم بطيء: %1$s</string>
<string name="login">تسجيل دخول</string>
<string name="login_error_wrong_username_password">اسم المستخدم او الكلمه السريه غير صحيحه</string>
<string name="select_server">اختر الخادم</string>
<string name="edit_text_server_address_hint">عنوان الخادم</string>
<string name="edit_text_username_hint">اسم المستخدم</string>
<string name="edit_text_password_hint">الكلمه السريه</string>
<string name="button_connect">اتصل</string>
<string name="button_login">تسجيل دخول</string>
<string name="remove_server">حذف الخادم</string>
<string name="add_server">اضافه خادم</string>
<string name="add_server_error_not_found">الخادم غير موجود</string>
<string name="add_server_error_empty_address">عنوان الخادم فاضي</string>
<string name="add_server_error_no_id">الخادم غير معرف بالid , يبدو انه هناك خلل في الخادم</string>
</resources>

View file

@ -26,37 +26,15 @@
<item>opensles</item> <item>opensles</item>
</string-array> </string-array>
<string-array name="quality_entries"> <string-array name="quality_entries">
<item>@string/quality_auto</item> <item>Original</item>
<item>@string/quality_original</item> <item>720p - 2Mbps</item>
<item>@string/quality_1080p</item> <item>480p - 1Mbps</item>
<item>@string/quality_720p</item> <item>360p - 800Kbps</item>
<item>@string/quality_480p</item>
<item>@string/quality_360p</item>
</string-array> </string-array>
<string-array name="quality_values"> <string-array name="quality_values">
<item>Auto</item>
<item>Original</item> <item>Original</item>
<item>1080p</item>
<item>720p</item> <item>720p</item>
<item>480p</item> <item>480p</item>
<item>360p</item> <item>360p</item>
</string-array> </string-array>
<string-array name="download_quality_entries">
<item>@string/quality_original</item>
<item>@string/quality_1080p</item>
<item>@string/quality_720p</item>
<item>@string/quality_480p</item>
<item>@string/quality_360p</item>
</string-array>
<string-array name="download_quality_values">
<item>Original</item>
<item>1080p</item>
<item>720p</item>
<item>480p</item>
<item>360p</item>
</string-array>
<string-array name="codecs">
<item>h264</item>
<item>hevc</item>
</string-array>
</resources> </resources>

View file

@ -11,7 +11,6 @@
<string name="add_server_error_not_found">Server not found</string> <string name="add_server_error_not_found">Server not found</string>
<string name="add_server_error_no_id">Server has no id, something seems to be wrong with the server</string> <string name="add_server_error_no_id">Server has no id, something seems to be wrong with the server</string>
<string name="login">Login</string> <string name="login">Login</string>
<string name="forget_password">Forget Password?</string>
<string name="login_error_wrong_username_password">Wrong username or password</string> <string name="login_error_wrong_username_password">Wrong username or password</string>
<string name="select_server">Select server</string> <string name="select_server">Select server</string>
<string name="edit_text_server_address_hint">Server address</string> <string name="edit_text_server_address_hint">Server address</string>
@ -140,7 +139,6 @@
<string name="settings_request_timeout">Request timeout (ms)</string> <string name="settings_request_timeout">Request timeout (ms)</string>
<string name="settings_connect_timeout">Connect timeout (ms)</string> <string name="settings_connect_timeout">Connect timeout (ms)</string>
<string name="settings_socket_timeout">Socket timeout (ms)</string> <string name="settings_socket_timeout">Socket timeout (ms)</string>
<string name="settings_quality_codec">Transcoding codec</string>
<string name="users">Users</string> <string name="users">Users</string>
<string name="add_user">Add user</string> <string name="add_user">Add user</string>
<string name="pref_player_mpv_hwdec">Hardware decoding</string> <string name="pref_player_mpv_hwdec">Hardware decoding</string>
@ -195,15 +193,6 @@
<string name="unmark_as_played">Unmark as played</string> <string name="unmark_as_played">Unmark as played</string>
<string name="add_to_favorites">Add to favorites</string> <string name="add_to_favorites">Add to favorites</string>
<string name="remove_from_favorites">Remove from favorites</string> <string name="remove_from_favorites">Remove from favorites</string>
<string name="quality_default">Default to selected download quality</string>
<string name="download_quality">Download Quality</string>
<string name="select_quality">Select Video Quality</string>
<string name="quality_auto">Auto</string>
<string name="quality_original">Original</string>
<string name="quality_1080p">1080p - 8Mbps</string>
<string name="quality_720p">720p - 3Mbps</string>
<string name="quality_480p">480p - 1.5Mbps</string>
<string name="quality_360p">360p - 0.8Mbps</string>
<string name="alaskarTV_requests">AlaskarTV Requests</string> <string name="alaskarTV_requests">AlaskarTV Requests</string>
<string name="pref_player_trickplay_gesture">Trick Play in seek gesture</string> <string name="pref_player_trickplay_gesture">Trick Play in seek gesture</string>
<string name="pref_player_trickplay_gesture_summary">Requires \'Seek gesture\' and \'Trick Play\'</string> <string name="pref_player_trickplay_gesture_summary">Requires \'Seek gesture\' and \'Trick Play\'</string>

View file

@ -75,8 +75,7 @@
<Preference <Preference
app:key="appInfo" app:key="appInfo"
app:icon="@drawable/ic_logo" app:icon="@drawable/ic_logo"
app:title="@string/app_info" app:title="@string/app_info" />
app:summary="" />
</PreferenceCategory> </PreferenceCategory>

View file

@ -11,13 +11,15 @@
app:title="@string/download_roaming" /> app:title="@string/download_roaming" />
<ListPreference <ListPreference
android:key="pref_downloads_quality" android:key="pref_downloads_quality"
android:title="@string/download_quality" android:title="Download Quality"
android:defaultValue="@string/quality_original" android:defaultValue="Original"
android:entries="@array/download_quality_entries" android:entries="@array/quality_entries"
android:entryValues="@array/download_quality_values" android:entryValues="@array/quality_values"
android:summary="%s" /> android:summary="%s" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
app:key="pref_downloads_quality_default" app:key="pref_downloads_quality_default"
app:summary="@string/quality_default" /> app:summary="Default to picked Download Quality" />
</PreferenceScreen> </PreferenceScreen>

View file

@ -20,11 +20,4 @@
android:defaultValue="true" android:defaultValue="true"
app:key="pref_auto_offline" app:key="pref_auto_offline"
app:title="@string/turn_on_offline_mode_automatically" /> app:title="@string/turn_on_offline_mode_automatically" />
<ListPreference
app:defaultValue="hevc"
app:key="pref_network_codec"
app:title="@string/settings_quality_codec"
app:useSimpleSummaryProvider="true"
app:entries="@array/codecs"
app:entryValues="@array/codecs" />
</PreferenceScreen> </PreferenceScreen>

View file

@ -1,31 +0,0 @@
package com.nomadics9.ananas.models
enum class VideoQuality(
val bitrate: Int,
val height: Int,
val width: Int,
val isOriginalQuality: Boolean,
) {
Auto(10000000, 1080, 1920, false),
Original(1000000000, 1080, 1920, true),
P3840(12000000,3840, 2160, false), // Here for future proofing and to calculate original resolution only
P1080(8000000, 1080, 1920, false),
P720(3000000, 720, 1280, false),
P480(1500000, 480, 854, false),
P360(800000, 360, 640, false);
override fun toString(): String = when (this) {
Auto -> "Auto"
Original -> "Original"
P3840 -> "4K"
else -> "${height}p"
}
companion object {
fun fromString(quality: String): VideoQuality? = entries.find { it.toString() == quality }
fun getBitrate(quality: VideoQuality): Int = quality.bitrate
fun getHeight(quality: VideoQuality): Int = quality.height
fun getWidth(quality: VideoQuality): Int = quality.width
fun getIsOriginalQuality(quality: VideoQuality): Boolean = quality.isOriginalQuality
}
}

View file

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -29,9 +30,7 @@ interface JellyfinRepository {
suspend fun getUserViews(): List<BaseItemDto> suspend fun getUserViews(): List<BaseItemDto>
suspend fun getItem(itemId: UUID): BaseItemDto suspend fun getItem(itemId: UUID): BaseItemDto
suspend fun getEpisode(itemId: UUID): FindroidEpisode suspend fun getEpisode(itemId: UUID): FindroidEpisode
suspend fun getMovie(itemId: UUID): FindroidMovie suspend fun getMovie(itemId: UUID): FindroidMovie
suspend fun getShow(itemId: UUID): FindroidShow suspend fun getShow(itemId: UUID): FindroidShow
@ -72,10 +71,7 @@ interface JellyfinRepository {
suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> suspend fun getLatestMedia(parentId: UUID): List<FindroidItem>
suspend fun getSeasons( suspend fun getSeasons(seriesId: UUID, offline: Boolean = false): List<FindroidSeason>
seriesId: UUID,
offline: Boolean = false,
): List<FindroidSeason>
suspend fun getNextUp(seriesId: UUID? = null): List<FindroidEpisode> suspend fun getNextUp(seriesId: UUID? = null): List<FindroidEpisode>
@ -90,7 +86,7 @@ interface JellyfinRepository {
suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource> suspend fun getMediaSources(itemId: UUID, includePath: Boolean = false): List<FindroidSource>
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String, playSessionId: String? = null): String suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String
suspend fun getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>? suspend fun getSegmentsTimestamps(itemId: UUID): List<FindroidSegment>?
@ -124,38 +120,13 @@ interface JellyfinRepository {
suspend fun getDeviceId(): String suspend fun getDeviceId(): String
suspend fun buildDeviceProfile( suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int>
maxBitrate: Int,
container: String,
context: EncodingContext,
): DeviceProfile
suspend fun getVideoStreambyContainerUrl( suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
container: String,
maxHeight: Int,
): String
suspend fun getTranscodedVideoStream( suspend fun getVideoStreambyContainerUrl(itemId: UUID, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String
itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
): String
suspend fun getPostedPlaybackInfo( suspend fun getPostedPlaybackInfo(itemId: UUID, enableDirectStream: Boolean, deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse>
itemId: UUID,
enableDirectStream: Boolean,
deviceProfile: DeviceProfile,
maxBitrate: Int,
): Response<PlaybackInfoResponse>
suspend fun stopEncodingProcess(playSessionId: String) suspend fun stopEncodingProcess(playSessionId: String)
suspend fun getAccessToken(): String?
} }

View file

@ -17,7 +17,6 @@ import com.nomadics9.ananas.models.FindroidSegments
import com.nomadics9.ananas.models.FindroidShow import com.nomadics9.ananas.models.FindroidShow
import com.nomadics9.ananas.models.FindroidSource import com.nomadics9.ananas.models.FindroidSource
import com.nomadics9.ananas.models.SortBy import com.nomadics9.ananas.models.SortBy
import com.nomadics9.ananas.models.VideoQuality
import com.nomadics9.ananas.models.toFindroidCollection import com.nomadics9.ananas.models.toFindroidCollection
import com.nomadics9.ananas.models.toFindroidEpisode import com.nomadics9.ananas.models.toFindroidEpisode
import com.nomadics9.ananas.models.toFindroidItem import com.nomadics9.ananas.models.toFindroidItem
@ -31,12 +30,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
import org.jellyfin.sdk.api.client.extensions.get import org.jellyfin.sdk.api.client.extensions.get
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceInfoQueryResult
import org.jellyfin.sdk.model.api.DeviceOptionsDto import org.jellyfin.sdk.model.api.DeviceOptionsDto
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile import org.jellyfin.sdk.model.api.DirectPlayProfile
@ -70,68 +69,53 @@ class JellyfinRepositoryImpl(
private val database: ServerDatabaseDao, private val database: ServerDatabaseDao,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
) : JellyfinRepository { ) : JellyfinRepository {
override suspend fun getPublicSystemInfo(): PublicSystemInfo = override suspend fun getPublicSystemInfo(): PublicSystemInfo = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) {
jellyfinApi.systemApi.getPublicSystemInfo().content jellyfinApi.systemApi.getPublicSystemInfo().content
} }
override suspend fun getUserViews(): List<BaseItemDto> = override suspend fun getUserViews(): List<BaseItemDto> = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items.orEmpty()
jellyfinApi.viewsApi
.getUserViews(jellyfinApi.userId!!)
.content.items
.orEmpty()
} }
override suspend fun getItem(itemId: UUID): BaseItemDto = override suspend fun getItem(itemId: UUID): BaseItemDto = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi.getItem(itemId, jellyfinApi.userId!!).content jellyfinApi.userLibraryApi.getItem(itemId, jellyfinApi.userId!!).content
} }
override suspend fun getEpisode(itemId: UUID): FindroidEpisode = override suspend fun getEpisode(itemId: UUID): FindroidEpisode =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi jellyfinApi.userLibraryApi.getItem(
.getItem(
itemId, itemId,
jellyfinApi.userId!!, jellyfinApi.userId!!,
).content ).content.toFindroidEpisode(this@JellyfinRepositoryImpl, database)!!
.toFindroidEpisode(this@JellyfinRepositoryImpl, database)!!
} }
override suspend fun getMovie(itemId: UUID): FindroidMovie = override suspend fun getMovie(itemId: UUID): FindroidMovie =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi jellyfinApi.userLibraryApi.getItem(
.getItem(
itemId, itemId,
jellyfinApi.userId!!, jellyfinApi.userId!!,
).content ).content.toFindroidMovie(this@JellyfinRepositoryImpl, database)
.toFindroidMovie(this@JellyfinRepositoryImpl, database)
} }
override suspend fun getShow(itemId: UUID): FindroidShow = override suspend fun getShow(itemId: UUID): FindroidShow =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi jellyfinApi.userLibraryApi.getItem(
.getItem(
itemId, itemId,
jellyfinApi.userId!!, jellyfinApi.userId!!,
).content ).content.toFindroidShow(this@JellyfinRepositoryImpl)
.toFindroidShow(this@JellyfinRepositoryImpl)
} }
override suspend fun getSeason(itemId: UUID): FindroidSeason = override suspend fun getSeason(itemId: UUID): FindroidSeason =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.userLibraryApi jellyfinApi.userLibraryApi.getItem(
.getItem(
itemId, itemId,
jellyfinApi.userId!!, jellyfinApi.userId!!,
).content ).content.toFindroidSeason(this@JellyfinRepositoryImpl)
.toFindroidSeason(this@JellyfinRepositoryImpl)
} }
override suspend fun getLibraries(): List<FindroidCollection> = override suspend fun getLibraries(): List<FindroidCollection> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi jellyfinApi.itemsApi.getItems(
.getItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
).content.items ).content.items
.orEmpty() .orEmpty()
@ -148,8 +132,7 @@ class JellyfinRepositoryImpl(
limit: Int?, limit: Int?,
): List<FindroidItem> = ): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi jellyfinApi.itemsApi.getItems(
.getItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
parentId = parentId, parentId = parentId,
includeItemTypes = includeTypes, includeItemTypes = includeTypes,
@ -169,10 +152,9 @@ class JellyfinRepositoryImpl(
recursive: Boolean, recursive: Boolean,
sortBy: SortBy, sortBy: SortBy,
sortOrder: SortOrder, sortOrder: SortOrder,
): Flow<PagingData<FindroidItem>> = ): Flow<PagingData<FindroidItem>> {
Pager( return Pager(
config = config = PagingConfig(
PagingConfig(
pageSize = 10, pageSize = 10,
maxSize = 100, maxSize = 100,
enablePlaceholders = false, enablePlaceholders = false,
@ -188,15 +170,14 @@ class JellyfinRepositoryImpl(
) )
}, },
).flow ).flow
}
override suspend fun getPersonItems( override suspend fun getPersonItems(
personIds: List<UUID>, personIds: List<UUID>,
includeTypes: List<BaseItemKind>?, includeTypes: List<BaseItemKind>?,
recursive: Boolean, recursive: Boolean,
): List<FindroidItem> = ): List<FindroidItem> = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { jellyfinApi.itemsApi.getItems(
jellyfinApi.itemsApi
.getItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
personIds = personIds, personIds = personIds,
includeItemTypes = includeTypes, includeItemTypes = includeTypes,
@ -210,12 +191,10 @@ class JellyfinRepositoryImpl(
override suspend fun getFavoriteItems(): List<FindroidItem> = override suspend fun getFavoriteItems(): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi jellyfinApi.itemsApi.getItems(
.getItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
filters = listOf(ItemFilter.IS_FAVORITE), filters = listOf(ItemFilter.IS_FAVORITE),
includeItemTypes = includeItemTypes = listOf(
listOf(
BaseItemKind.MOVIE, BaseItemKind.MOVIE,
BaseItemKind.SERIES, BaseItemKind.SERIES,
BaseItemKind.EPISODE, BaseItemKind.EPISODE,
@ -228,12 +207,10 @@ class JellyfinRepositoryImpl(
override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> = override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.itemsApi jellyfinApi.itemsApi.getItems(
.getItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
searchTerm = searchQuery, searchTerm = searchQuery,
includeItemTypes = includeItemTypes = listOf(
listOf(
BaseItemKind.MOVIE, BaseItemKind.MOVIE,
BaseItemKind.SERIES, BaseItemKind.SERIES,
BaseItemKind.EPISODE, BaseItemKind.EPISODE,
@ -245,15 +222,12 @@ class JellyfinRepositoryImpl(
} }
override suspend fun getResumeItems(): List<FindroidItem> { override suspend fun getResumeItems(): List<FindroidItem> {
val items = val items = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { jellyfinApi.itemsApi.getResumeItems(
jellyfinApi.itemsApi
.getResumeItems(
jellyfinApi.userId!!, jellyfinApi.userId!!,
limit = 12, limit = 12,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
).content.items ).content.items.orEmpty()
.orEmpty()
} }
return items.mapNotNull { return items.mapNotNull {
it.toFindroidItem(this, database) it.toFindroidItem(this, database)
@ -261,10 +235,8 @@ class JellyfinRepositoryImpl(
} }
override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> { override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> {
val items = val items = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { jellyfinApi.userLibraryApi.getLatestMedia(
jellyfinApi.userLibraryApi
.getLatestMedia(
jellyfinApi.userId!!, jellyfinApi.userId!!,
parentId = parentId, parentId = parentId,
limit = 16, limit = 16,
@ -275,15 +247,10 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getSeasons( override suspend fun getSeasons(seriesId: UUID, offline: Boolean): List<FindroidSeason> =
seriesId: UUID,
offline: Boolean,
): List<FindroidSeason> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (!offline) { if (!offline) {
jellyfinApi.showsApi jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
.getSeasons(seriesId, jellyfinApi.userId!!)
.content.items
.orEmpty() .orEmpty()
.map { it.toFindroidSeason(this@JellyfinRepositoryImpl) } .map { it.toFindroidSeason(this@JellyfinRepositoryImpl) }
} else { } else {
@ -293,8 +260,7 @@ class JellyfinRepositoryImpl(
override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> = override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.showsApi jellyfinApi.showsApi.getNextUp(
.getNextUp(
jellyfinApi.userId!!, jellyfinApi.userId!!,
limit = 24, limit = 24,
seriesId = seriesId, seriesId = seriesId,
@ -314,8 +280,7 @@ class JellyfinRepositoryImpl(
): List<FindroidEpisode> = ): List<FindroidEpisode> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (!offline) { if (!offline) {
jellyfinApi.showsApi jellyfinApi.showsApi.getEpisodes(
.getEpisodes(
seriesId, seriesId,
jellyfinApi.userId!!, jellyfinApi.userId!!,
seasonId = seasonId, seasonId = seasonId,
@ -330,41 +295,33 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getMediaSources( override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List<FindroidSource> =
itemId: UUID,
includePath: Boolean,
): List<FindroidSource> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val sources = mutableListOf<FindroidSource>() val sources = mutableListOf<FindroidSource>()
sources.addAll( sources.addAll(
jellyfinApi.mediaInfoApi jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
.getPostedPlaybackInfo(
itemId, itemId,
PlaybackInfoDto( PlaybackInfoDto(
userId = jellyfinApi.userId!!, userId = jellyfinApi.userId!!,
deviceProfile = deviceProfile = DeviceProfile(
DeviceProfile(
name = "Direct play all", name = "Direct play all",
maxStaticBitrate = 1_000_000_000, maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000, maxStreamingBitrate = 1_000_000_000,
codecProfiles = emptyList(), codecProfiles = emptyList(),
containerProfiles = emptyList(), containerProfiles = emptyList(),
directPlayProfiles = directPlayProfiles = listOf(
listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO), DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO), DirectPlayProfile(type = DlnaProfileType.AUDIO),
), ),
transcodingProfiles = emptyList(), transcodingProfiles = emptyList(),
subtitleProfiles = subtitleProfiles = listOf(
listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
), ),
), ),
maxStreamingBitrate = 1_000_000_000, maxStreamingBitrate = 1_000_000_000,
), ),
).content.mediaSources ).content.mediaSources.map {
.map {
it.toFindroidSource( it.toFindroidSource(
this@JellyfinRepositoryImpl, this@JellyfinRepositoryImpl,
itemId, itemId,
@ -378,33 +335,14 @@ class JellyfinRepositoryImpl(
sources sources
} }
override suspend fun getStreamUrl( override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String =
itemId: UUID,
mediaSourceId: String,
playSessionId: String?,
): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val deviceId = getDeviceId()
try { try {
val url =
if (playSessionId != null) {
jellyfinApi.videosApi.getVideoStreamUrl( jellyfinApi.videosApi.getVideoStreamUrl(
itemId, itemId,
static = true, static = true,
mediaSourceId = mediaSourceId, mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
deviceId = deviceId,
context = EncodingContext.STREAMING,
) )
} else {
jellyfinApi.videosApi.getVideoStreamUrl(
itemId,
static = true,
mediaSourceId = mediaSourceId,
deviceId = deviceId,
)
}
url
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
"" ""
@ -424,15 +362,12 @@ class JellyfinRepositoryImpl(
pathParameters["itemId"] = itemId pathParameters["itemId"] = itemId
try { try {
val segmentToConvert = val segmentToConvert = jellyfinApi.api.get<FindroidSegments>(
jellyfinApi.api
.get<FindroidSegments>(
"/Episode/{itemId}/IntroSkipperSegments", "/Episode/{itemId}/IntroSkipperSegments",
pathParameters, pathParameters,
).content ).content
val segmentConverted = val segmentConverted = mutableListOf(
mutableListOf(
segmentToConvert.intro!!.let { segmentToConvert.intro!!.let {
FindroidSegment( FindroidSegment(
type = "intro", type = "intro",
@ -463,11 +398,7 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getTrickplayData( override suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? =
itemId: UUID,
width: Int,
index: Int,
): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
try { try {
@ -475,13 +406,9 @@ class JellyfinRepositoryImpl(
if (sources != null) { if (sources != null) {
return@withContext File(sources.first(), index.toString()).readBytes() return@withContext File(sources.first(), index.toString()).readBytes()
} }
} catch (_: Exception) { } catch (_: Exception) { }
}
return@withContext jellyfinApi.trickplayApi return@withContext jellyfinApi.trickplayApi.getTrickplayTileImage(itemId, width, index).content.toByteArray()
.getTrickplayTileImage(itemId, width, index)
.content
.toByteArray()
} catch (e: Exception) { } catch (e: Exception) {
return@withContext null return@withContext null
} }
@ -492,8 +419,7 @@ class JellyfinRepositoryImpl(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
jellyfinApi.sessionApi.postCapabilities( jellyfinApi.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO), playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands = supportedCommands = listOf(
listOf(
GeneralCommandType.VOLUME_UP, GeneralCommandType.VOLUME_UP,
GeneralCommandType.VOLUME_DOWN, GeneralCommandType.VOLUME_DOWN,
GeneralCommandType.TOGGLE_MUTE, GeneralCommandType.TOGGLE_MUTE,
@ -629,62 +555,57 @@ class JellyfinRepositoryImpl(
} }
} }
override suspend fun getUserConfiguration(): UserConfiguration = override suspend fun getUserConfiguration(): UserConfiguration = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { jellyfinApi.userApi.getCurrentUser().content.configuration!!
jellyfinApi.userApi
.getCurrentUser()
.content.configuration!!
} }
override suspend fun getDownloads(): List<FindroidItem> = override suspend fun getDownloads(): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val items = mutableListOf<FindroidItem>() val items = mutableListOf<FindroidItem>()
items.addAll( items.addAll(
database database.getMoviesByServerId(appPreferences.currentServer!!)
.getMoviesByServerId(appPreferences.currentServer!!)
.map { it.toFindroidMovie(database, jellyfinApi.userId!!) }, .map { it.toFindroidMovie(database, jellyfinApi.userId!!) },
) )
items.addAll( items.addAll(
database database.getShowsByServerId(appPreferences.currentServer!!)
.getShowsByServerId(appPreferences.currentServer!!)
.map { it.toFindroidShow(database, jellyfinApi.userId!!) }, .map { it.toFindroidShow(database, jellyfinApi.userId!!) },
) )
items items
} }
override fun getUserId(): UUID = jellyfinApi.userId!! override fun getUserId(): UUID {
return jellyfinApi.userId!!
}
override suspend fun buildDeviceProfile(
maxBitrate: Int, override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
container: String, return when (transcodeResolution) {
context: EncodingContext, 1080 -> 8000000 to 384000 // Adjusted for 1080p
): DeviceProfile { 720 -> 2000000 to 384000 // Adjusted for 720p
val deviceProfile = 480 -> 1000000 to 384000 // Adjusted for 480p
ClientCapabilitiesDto( 360 -> 800000 to 128000 // Adjusted for 360p
else -> 8000000 to 384000
}
}
override suspend fun buildDeviceProfile(maxBitrate: Int, container: String, context: EncodingContext): DeviceProfile {
val deviceProfile = ClientCapabilitiesDto(
supportedCommands = emptyList(), supportedCommands = emptyList(),
playableMediaTypes = playableMediaTypes = emptyList(),
listOf(
MediaType.VIDEO,
MediaType.AUDIO,
MediaType.UNKNOWN,
),
supportsMediaControl = true, supportsMediaControl = true,
supportsPersistentIdentifier = true, supportsPersistentIdentifier = true,
deviceProfile = deviceProfile = DeviceProfile(
DeviceProfile(
name = "AnanasUser", name = "AnanasUser",
id = getUserId().toString(), id = getUserId().toString(),
maxStaticBitrate = maxBitrate, maxStaticBitrate = maxBitrate,
maxStreamingBitrate = maxBitrate, maxStreamingBitrate = maxBitrate,
codecProfiles = emptyList(), codecProfiles = emptyList(),
containerProfiles = listOf(), containerProfiles = listOf(),
directPlayProfiles = directPlayProfiles = listOf(
listOf(
DirectPlayProfile(type = DlnaProfileType.VIDEO), DirectPlayProfile(type = DlnaProfileType.VIDEO),
DirectPlayProfile(type = DlnaProfileType.AUDIO), DirectPlayProfile(type = DlnaProfileType.AUDIO),
), ),
transcodingProfiles = transcodingProfiles = listOf(
listOf(
TranscodingProfile( TranscodingProfile(
container = container, container = container,
context = context, context = context,
@ -692,22 +613,20 @@ class JellyfinRepositoryImpl(
audioCodec = "aac,ac3,eac3", audioCodec = "aac,ac3,eac3",
videoCodec = "hevc,h264", videoCodec = "hevc,h264",
type = DlnaProfileType.VIDEO, type = DlnaProfileType.VIDEO,
conditions = conditions = listOf(
listOf(
ProfileCondition( ProfileCondition(
condition = ProfileConditionType.LESS_THAN_EQUAL, condition = ProfileConditionType.LESS_THAN_EQUAL,
property = ProfileConditionValue.VIDEO_BITRATE, property = ProfileConditionValue.VIDEO_BITRATE,
value = "8000000", value = "8000000",
isRequired = true, isRequired = true,
), )
), ),
copyTimestamps = true, copyTimestamps = true,
enableSubtitlesInManifest = true, enableSubtitlesInManifest = true,
transcodeSeekInfo = TranscodeSeekInfo.AUTO, transcodeSeekInfo = TranscodeSeekInfo.AUTO,
), ),
), ),
subtitleProfiles = subtitleProfiles = listOf(
listOf(
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL),
@ -715,21 +634,16 @@ class JellyfinRepositoryImpl(
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("pgs", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("dvb_teletext", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL), SubtitleProfile("dvd_subtitle", SubtitleDeliveryMethod.EXTERNAL)
),
), ),
) )
)
return deviceProfile.deviceProfile!! return deviceProfile.deviceProfile!!
} }
override suspend fun getPostedPlaybackInfo(
itemId: UUID, override suspend fun getPostedPlaybackInfo(itemId: UUID ,enableDirectStream: Boolean ,deviceProfile: DeviceProfile ,maxBitrate: Int): Response<PlaybackInfoResponse> {
enableDirectStream: Boolean, val playbackInfo = jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
deviceProfile: DeviceProfile,
maxBitrate: Int,
): Response<PlaybackInfoResponse> {
val playbackInfo =
jellyfinApi.mediaInfoApi.getPostedPlaybackInfo(
itemId = itemId, itemId = itemId,
PlaybackInfoDto( PlaybackInfoDto(
userId = jellyfinApi.userId!!, userId = jellyfinApi.userId!!,
@ -737,110 +651,44 @@ class JellyfinRepositoryImpl(
enableDirectPlay = false, enableDirectPlay = false,
enableDirectStream = enableDirectStream, enableDirectStream = enableDirectStream,
autoOpenLiveStream = true, autoOpenLiveStream = true,
deviceProfile = buildDeviceProfile(maxBitrate, "ts", EncodingContext.STREAMING), deviceProfile = deviceProfile,
allowAudioStreamCopy = true, allowAudioStreamCopy = true,
allowVideoStreamCopy = true, allowVideoStreamCopy = true,
maxStreamingBitrate = maxBitrate, maxStreamingBitrate = maxBitrate,
), )
) )
return playbackInfo return playbackInfo
} }
override suspend fun getVideoStreambyContainerUrl( override suspend fun getVideoStreambyContainerUrl(itemId: UUID, mediaSourceId: String, playSessionId: String, videoBitrate: Int, container: String): String {
itemId: UUID, val url = jellyfinApi.videosApi.getVideoStreamByContainerUrl(
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
container: String,
maxHeight: Int,
): String {
val url =
jellyfinApi.videosApi.getVideoStreamByContainerUrl(
itemId, itemId,
static = false, static = false,
deviceId = deviceId,
mediaSourceId = mediaSourceId, mediaSourceId = mediaSourceId,
playSessionId = playSessionId, playSessionId = playSessionId,
videoBitRate = videoBitrate, videoBitRate = videoBitrate,
maxHeight = maxHeight, audioBitRate = 384000,
audioBitRate = 328000, videoCodec = "hevc",
videoCodec = appPreferences.transcodeCodec,
audioCodec = "aac,ac3,eac3", audioCodec = "aac,ac3,eac3",
container = container, container = container,
startTimeTicks = 0, startTimeTicks = 0,
copyTimestamps = true, copyTimestamps = true,
subtitleMethod = SubtitleDeliveryMethod.EXTERNAL,
) )
return url return url
} }
override suspend fun getTranscodedVideoStream( override suspend fun getDeviceId(): String {
itemId: UUID, val deviceId = jellyfinApi.devicesApi.getDevices(getUserId())
deviceId: String, return deviceId.toString()
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
): String {
val isAuto = videoBitrate == VideoQuality.getBitrate(VideoQuality.Auto)
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 = 328000,
videoCodec = appPreferences.transcodeCodec,
audioCodec = "aac,ac3,eac3",
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 = appPreferences.transcodeCodec,
audioCodec = "aac,ac3,eac3",
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 = jellyfinApi.api.deviceInfo.id
override suspend fun stopEncodingProcess(playSessionId: String) { override suspend fun stopEncodingProcess(playSessionId: String) {
val deviceId = getDeviceId()
jellyfinApi.api.hlsSegmentApi.stopEncodingProcess( jellyfinApi.api.hlsSegmentApi.stopEncodingProcess(
deviceId = deviceId, deviceId = getDeviceId(),
playSessionId = playSessionId, playSessionId = playSessionId
) )
} }
override suspend fun getAccessToken(): String? {
return jellyfinApi.api.accessToken
}
} }

View file

@ -42,9 +42,14 @@ class JellyfinRepositoryOfflineImpl(
private val database: ServerDatabaseDao, private val database: ServerDatabaseDao,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
) : JellyfinRepository { ) : JellyfinRepository {
override suspend fun getPublicSystemInfo(): PublicSystemInfo = throw Exception("System info not available in offline mode")
override suspend fun getUserViews(): List<BaseItemDto> = emptyList() override suspend fun getPublicSystemInfo(): PublicSystemInfo {
throw Exception("System info not available in offline mode")
}
override suspend fun getUserViews(): List<BaseItemDto> {
return emptyList()
}
override suspend fun getItem(itemId: UUID): BaseItemDto { override suspend fun getItem(itemId: UUID): BaseItemDto {
TODO("Not yet implemented") TODO("Not yet implemented")
@ -108,59 +113,36 @@ class JellyfinRepositoryOfflineImpl(
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> = override suspend fun getSearchItems(searchQuery: String): List<FindroidItem> {
withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val movies = val movies = database.searchMovies(appPreferences.currentServer!!, searchQuery).map { it.toFindroidMovie(database, jellyfinApi.userId!!) }
database val shows = database.searchShows(appPreferences.currentServer!!, searchQuery).map { it.toFindroidShow(database, jellyfinApi.userId!!) }
.searchMovies( val episodes = database.searchEpisodes(appPreferences.currentServer!!, searchQuery).map { it.toFindroidEpisode(database, jellyfinApi.userId!!) }
appPreferences.currentServer!!,
searchQuery,
).map { it.toFindroidMovie(database, jellyfinApi.userId!!) }
val shows =
database
.searchShows(
appPreferences.currentServer!!,
searchQuery,
).map { it.toFindroidShow(database, jellyfinApi.userId!!) }
val episodes =
database.searchEpisodes(appPreferences.currentServer!!, searchQuery).map {
it.toFindroidEpisode(database, jellyfinApi.userId!!)
}
movies + shows + episodes movies + shows + episodes
} }
override suspend fun getResumeItems(): List<FindroidItem> =
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<FindroidItem> = emptyList() override suspend fun getResumeItems(): List<FindroidItem> {
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 }
movies + episodes
}
}
override suspend fun getSeasons( override suspend fun getLatestMedia(parentId: UUID): List<FindroidItem> {
seriesId: UUID, return emptyList()
offline: Boolean, }
): List<FindroidSeason> =
override suspend fun getSeasons(seriesId: UUID, offline: Boolean): List<FindroidSeason> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) } database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) }
} }
override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> = override suspend fun getNextUp(seriesId: UUID?): List<FindroidEpisode> {
withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val result = mutableListOf<FindroidEpisode>() val result = mutableListOf<FindroidEpisode>()
val shows = val shows = database.getShowsByServerId(appPreferences.currentServer!!).filter {
database.getShowsByServerId(appPreferences.currentServer!!).filter {
if (seriesId != null) it.id == seriesId else true if (seriesId != null) it.id == seriesId else true
} }
for (show in shows) { for (show in shows) {
@ -174,6 +156,7 @@ class JellyfinRepositoryOfflineImpl(
} }
result.filter { it.playbackPositionTicks == 0L } result.filter { it.playbackPositionTicks == 0L }
} }
}
override suspend fun getEpisodes( override suspend fun getEpisodes(
seriesId: UUID, seriesId: UUID,
@ -189,19 +172,12 @@ class JellyfinRepositoryOfflineImpl(
items items
} }
override suspend fun getMediaSources( override suspend fun getMediaSources(itemId: UUID, includePath: Boolean): List<FindroidSource> =
itemId: UUID,
includePath: Boolean,
): List<FindroidSource> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database.getSources(itemId).map { it.toFindroidSource(database) } database.getSources(itemId).map { it.toFindroidSource(database) }
} }
override suspend fun getStreamUrl( override suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String {
itemId: UUID,
mediaSourceId: String,
playSessionId: String?,
): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -210,11 +186,7 @@ class JellyfinRepositoryOfflineImpl(
database.getSegments(itemId)?.toFindroidSegments() database.getSegments(itemId)?.toFindroidSegments()
} }
override suspend fun getTrickplayData( override suspend fun getTrickplayData(itemId: UUID, width: Int, index: Int): ByteArray? =
itemId: UUID,
width: Int,
index: Int,
): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val sources = File(context.filesDir, "trickplay/$itemId").listFiles() ?: return@withContext null val sources = File(context.filesDir, "trickplay/$itemId").listFiles() ?: return@withContext null
@ -228,11 +200,7 @@ class JellyfinRepositoryOfflineImpl(
override suspend fun postPlaybackStart(itemId: UUID) {} override suspend fun postPlaybackStart(itemId: UUID) {}
override suspend fun postPlaybackStop( override suspend fun postPlaybackStop(itemId: UUID, positionTicks: Long, playedPercentage: Int) {
itemId: UUID,
positionTicks: Long,
playedPercentage: Int,
) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
when { when {
playedPercentage < 10 -> { playedPercentage < 10 -> {
@ -292,31 +260,35 @@ class JellyfinRepositoryOfflineImpl(
} }
} }
override fun getBaseUrl(): String = "" override fun getBaseUrl(): String {
return ""
}
override suspend fun updateDeviceName(name: String) { override suspend fun updateDeviceName(name: String) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getUserConfiguration(): UserConfiguration? = null override suspend fun getUserConfiguration(): UserConfiguration? {
return null
}
override suspend fun getDownloads(): List<FindroidItem> = override suspend fun getDownloads(): List<FindroidItem> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val items = mutableListOf<FindroidItem>() val items = mutableListOf<FindroidItem>()
items.addAll( items.addAll(
database database.getMoviesByServerId(appPreferences.currentServer!!)
.getMoviesByServerId(appPreferences.currentServer!!)
.map { it.toFindroidMovie(database, jellyfinApi.userId!!) }, .map { it.toFindroidMovie(database, jellyfinApi.userId!!) },
) )
items.addAll( items.addAll(
database database.getShowsByServerId(appPreferences.currentServer!!)
.getShowsByServerId(appPreferences.currentServer!!)
.map { it.toFindroidShow(database, jellyfinApi.userId!!) }, .map { it.toFindroidShow(database, jellyfinApi.userId!!) },
) )
items items
} }
override fun getUserId(): UUID = jellyfinApi.userId!! override fun getUserId(): UUID {
return jellyfinApi.userId!!
}
override suspend fun getDeviceId(): String { override suspend fun getDeviceId(): String {
TODO("Not yet implemented") TODO("Not yet implemented")
@ -325,29 +297,17 @@ class JellyfinRepositoryOfflineImpl(
override suspend fun buildDeviceProfile( override suspend fun buildDeviceProfile(
maxBitrate: Int, maxBitrate: Int,
container: String, container: String,
context: EncodingContext, context: EncodingContext
): DeviceProfile { ): DeviceProfile {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getVideoStreambyContainerUrl( override suspend fun getVideoStreambyContainerUrl(
itemId: UUID, itemId: UUID,
deviceId: String,
mediaSourceId: String,
playSessionId: String,
videoBitrate: Int,
container: String,
maxHeight: Int,
): String {
TODO("Not yet implemented")
}
override suspend fun getTranscodedVideoStream(
itemId: UUID,
deviceId: String,
mediaSourceId: String, mediaSourceId: String,
playSessionId: String, playSessionId: String,
videoBitrate: Int, videoBitrate: Int,
container: String
): String { ): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -356,7 +316,7 @@ class JellyfinRepositoryOfflineImpl(
itemId: UUID, itemId: UUID,
enableDirectStream: Boolean, enableDirectStream: Boolean,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
maxBitrate: Int, maxBitrate: Int
): Response<PlaybackInfoResponse> { ): Response<PlaybackInfoResponse> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -365,7 +325,7 @@ class JellyfinRepositoryOfflineImpl(
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun getAccessToken(): String? { override suspend fun getVideoTranscodeBitRate(transcodeResolution: Int): Pair<Int, Int> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }

View file

@ -38,7 +38,6 @@ libmpv = "0.3.0"
material = "1.12.0" material = "1.12.0"
media3-ffmpeg-decoder = "1.3.1+2" media3-ffmpeg-decoder = "1.3.1+2"
timber = "5.0.1" timber = "5.0.1"
markwon = "4.6.2"
[libraries] [libraries]
aboutlibraries-core = { group = "com.mikepenz", name = "aboutlibraries-core", version.ref = "aboutlibraries" } aboutlibraries-core = { group = "com.mikepenz", name = "aboutlibraries-core", version.ref = "aboutlibraries" }
@ -100,7 +99,6 @@ material = { group = "com.google.android.material", name = "material", version.r
media3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3-ffmpeg-decoder" } media3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3-ffmpeg-decoder" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
markwon = { group = "io.noties.markwon", name = "core", version.ref = "markwon" }
[plugins] [plugins]
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<lint> <lint>
<issue id="MissingTranslation" severity="ignore" /> <issue id="MissingTranslation" severity="ignore" />
</lint> </lint>

View file

@ -1,20 +0,0 @@
package com.nomadics9.ananas
import androidx.media3.common.MimeTypes
public fun setSubtitlesMimeTypes(codec: String): String {
return when (codec) {
"subrip" -> MimeTypes.APPLICATION_SUBRIP
"webvtt" -> MimeTypes.TEXT_VTT
"ssa" -> MimeTypes.TEXT_SSA
"pgs" -> MimeTypes.APPLICATION_PGS
"ass" -> MimeTypes.TEXT_SSA
"srt" -> MimeTypes.APPLICATION_SUBRIP
"vtt" -> MimeTypes.TEXT_VTT
"ttml" -> MimeTypes.APPLICATION_TTML
"dfxp" -> MimeTypes.APPLICATION_TTML
"stl" -> MimeTypes.APPLICATION_TTML
"sbv" -> MimeTypes.APPLICATION_SUBRIP
else -> MimeTypes.TEXT_UNKNOWN
}
}

View file

@ -6,7 +6,6 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -14,7 +13,6 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.TrackSelectionParameters
@ -27,11 +25,9 @@ import com.nomadics9.ananas.models.FindroidSegment
import com.nomadics9.ananas.models.PlayerChapter import com.nomadics9.ananas.models.PlayerChapter
import com.nomadics9.ananas.models.PlayerItem import com.nomadics9.ananas.models.PlayerItem
import com.nomadics9.ananas.models.Trickplay import com.nomadics9.ananas.models.Trickplay
import com.nomadics9.ananas.models.VideoQuality
import com.nomadics9.ananas.mpv.MPVPlayer import com.nomadics9.ananas.mpv.MPVPlayer
import com.nomadics9.ananas.player.video.R import com.nomadics9.ananas.player.video.R
import com.nomadics9.ananas.repository.JellyfinRepository import com.nomadics9.ananas.repository.JellyfinRepository
import com.nomadics9.ananas.setSubtitlesMimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -44,8 +40,22 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
import org.jellyfin.sdk.model.api.ClientCapabilitiesDto
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DirectPlayProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.ProfileCondition
import org.jellyfin.sdk.model.api.ProfileConditionType
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.TranscodeSeekInfo
import org.jellyfin.sdk.model.api.TranscodingProfile
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -59,13 +69,11 @@ class PlayerActivityViewModel
private val jellyfinRepository: JellyfinRepository, private val jellyfinRepository: JellyfinRepository,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences,
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel(), ) : ViewModel(), Player.Listener {
Player.Listener {
val player: Player val player: Player
private var originalResolution: Int? = null private var originalHeight: Int = 0
private val _uiState = private val _uiState = MutableStateFlow(
MutableStateFlow(
UiState( UiState(
currentItemTitle = "", currentItemTitle = "",
currentSegment = null, currentSegment = null,
@ -104,14 +112,11 @@ class PlayerActivityViewModel
init { init {
if (appPreferences.playerMpv) { if (appPreferences.playerMpv) {
val trackSelectionParameters = val trackSelectionParameters = TrackSelectionParameters.Builder(application)
TrackSelectionParameters
.Builder(application)
.setPreferredAudioLanguage(appPreferences.preferredAudioLanguage) .setPreferredAudioLanguage(appPreferences.preferredAudioLanguage)
.setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage) .setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage)
.build() .build()
player = player = MPVPlayer(
MPVPlayer(
context = application, context = application,
requestAudioFocus = true, requestAudioFocus = true,
trackSelectionParameters = trackSelectionParameters, trackSelectionParameters = trackSelectionParameters,
@ -127,31 +132,30 @@ class PlayerActivityViewModel
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON,
) )
trackSelector.setParameters( trackSelector.setParameters(
trackSelector trackSelector.buildUponParameters()
.buildUponParameters()
.setTunnelingEnabled(true) .setTunnelingEnabled(true)
.setPreferredAudioLanguage(appPreferences.preferredAudioLanguage) .setPreferredAudioLanguage(appPreferences.preferredAudioLanguage)
.setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage), .setPreferredTextLanguage(appPreferences.preferredSubtitleLanguage),
) )
player = player = ExoPlayer.Builder(application, renderersFactory)
ExoPlayer
.Builder(application, renderersFactory)
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes AudioAttributes.Builder()
.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.build(), .build(),
// handleAudioFocus = /* handleAudioFocus = */
true, true,
).setSeekBackIncrementMs(appPreferences.playerSeekBackIncrement) )
.setSeekBackIncrementMs(appPreferences.playerSeekBackIncrement)
.setSeekForwardIncrementMs(appPreferences.playerSeekForwardIncrement) .setSeekForwardIncrementMs(appPreferences.playerSeekForwardIncrement)
.build() .build()
} }
} }
fun initializePlayer(items: Array<PlayerItem>) { fun initializePlayer(
items: Array<PlayerItem>,
) {
this.items = items this.items = items
player.addListener(this) player.addListener(this)
@ -160,10 +164,8 @@ class PlayerActivityViewModel
try { try {
for (item in items) { for (item in items) {
val streamUrl = item.mediaSourceUri val streamUrl = item.mediaSourceUri
val mediaSubtitles = val mediaSubtitles = item.externalSubtitles.map { externalSubtitle ->
item.externalSubtitles.map { externalSubtitle -> MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
MediaItem.SubtitleConfiguration
.Builder(externalSubtitle.uri)
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) }) .setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
.setMimeType(externalSubtitle.mimeType) .setMimeType(externalSubtitle.mimeType)
.setLanguage(externalSubtitle.language) .setLanguage(externalSubtitle.language)
@ -179,40 +181,24 @@ class PlayerActivityViewModel
Timber.d("Stream url: $streamUrl") Timber.d("Stream url: $streamUrl")
val mediaItem = val mediaItem =
MediaItem MediaItem.Builder()
.Builder()
.setMediaId(item.itemId.toString()) .setMediaId(item.itemId.toString())
.setUri(streamUrl) .setUri(streamUrl)
.setMediaMetadata( .setMediaMetadata(
MediaMetadata MediaMetadata.Builder()
.Builder()
.setTitle(item.name) .setTitle(item.name)
.build(), .build(),
).setSubtitleConfigurations(mediaSubtitles) )
.setSubtitleConfigurations(mediaSubtitles)
.build() .build()
mediaItems.add(mediaItem) mediaItems.add(mediaItem)
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
val videoSize = player.videoSize
val initialHeight = videoSize.height
val initialWidth = videoSize.width
originalResolution = initialHeight * initialWidth
Timber.d("Initial video size: $initialWidth x $initialHeight")
player.removeListener(this)
}
}
})
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} }
val startPosition = val startPosition = if (playbackPosition == 0L) {
if (playbackPosition == 0L) {
items.getOrNull(currentMediaItemIndex)?.playbackPosition ?: C.TIME_UNSET items.getOrNull(currentMediaItemIndex)?.playbackPosition ?: C.TIME_UNSET
} else { } else {
playbackPosition playbackPosition
@ -256,8 +242,7 @@ class PlayerActivityViewModel
} }
private fun pollPosition(player: Player) { private fun pollPosition(player: Player) {
val playbackProgressRunnable = val playbackProgressRunnable = object : Runnable {
object : Runnable {
override fun run() { override fun run() {
savedStateHandle["position"] = player.currentPosition savedStateHandle["position"] = player.currentPosition
viewModelScope.launch { viewModelScope.launch {
@ -277,8 +262,7 @@ class PlayerActivityViewModel
handler.postDelayed(this, 5000L) handler.postDelayed(this, 5000L)
} }
} }
val segmentCheckRunnable = val segmentCheckRunnable = object : Runnable {
object : Runnable {
override fun run() { override fun run() {
val currentMediaItem = player.currentMediaItem val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null && currentMediaItem.mediaId.isNotEmpty()) { if (currentMediaItem != null && currentMediaItem.mediaId.isNotEmpty()) {
@ -303,16 +287,12 @@ class PlayerActivityViewModel
if (segments.isNotEmpty()) handler.post(segmentCheckRunnable) if (segments.isNotEmpty()) handler.post(segmentCheckRunnable)
} }
override fun onMediaItemTransition( override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
mediaItem: MediaItem?,
reason: Int,
) {
Timber.d("Playing MediaItem: ${mediaItem?.mediaId}") Timber.d("Playing MediaItem: ${mediaItem?.mediaId}")
savedStateHandle["mediaItemIndex"] = player.currentMediaItemIndex savedStateHandle["mediaItemIndex"] = player.currentMediaItemIndex
viewModelScope.launch { viewModelScope.launch {
try { try {
items items.first { it.itemId.toString() == player.currentMediaItem?.mediaId }
.first { it.itemId.toString() == player.currentMediaItem?.mediaId }
.let { item -> .let { item ->
val itemTitle = val itemTitle =
if (item.parentIndexNumber != null && item.indexNumber != null) { if (item.parentIndexNumber != null && item.indexNumber != null) {
@ -376,30 +356,24 @@ class PlayerActivityViewModel
releasePlayer() releasePlayer()
} }
fun switchToTrack( fun switchToTrack(trackType: @C.TrackType Int, index: Int) {
trackType: @C.TrackType Int,
index: Int,
) {
// Index -1 equals disable track // Index -1 equals disable track
if (index == -1) { if (index == -1) {
player.trackSelectionParameters = player.trackSelectionParameters = player.trackSelectionParameters
player.trackSelectionParameters
.buildUpon() .buildUpon()
.clearOverridesOfType(trackType) .clearOverridesOfType(trackType)
.setTrackTypeDisabled(trackType, true) .setTrackTypeDisabled(trackType, true)
.build() .build()
} else { } else {
player.trackSelectionParameters = player.trackSelectionParameters = player.trackSelectionParameters
player.trackSelectionParameters
.buildUpon() .buildUpon()
.setOverrideForType( .setOverrideForType(
TrackSelectionOverride( TrackSelectionOverride(
player.currentTracks.groups player.currentTracks.groups.filter { it.type == trackType && it.isSupported }[index].mediaTrackGroup,
.filter { it.type == trackType && it.isSupported }[index] 0
.mediaTrackGroup,
0,
), ),
).setTrackTypeDisabled(trackType, false) )
.setTrackTypeDisabled(trackType, false)
.build() .build()
} }
} }
@ -414,17 +388,14 @@ class PlayerActivityViewModel
Timber.d("Trickplay Resolution: ${trickplayInfo.width}") Timber.d("Trickplay Resolution: ${trickplayInfo.width}")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val maxIndex = val maxIndex = ceil(
ceil( trickplayInfo.thumbnailCount.toDouble()
trickplayInfo.thumbnailCount .div(trickplayInfo.tileWidth * trickplayInfo.tileHeight)
.toDouble()
.div(trickplayInfo.tileWidth * trickplayInfo.tileHeight),
).toInt() ).toInt()
val bitmaps = mutableListOf<Bitmap>() val bitmaps = mutableListOf<Bitmap>()
for (i in 0..maxIndex) { for (i in 0..maxIndex) {
jellyfinRepository jellyfinRepository.getTrickplayData(
.getTrickplayData(
item.itemId, item.itemId,
trickplayInfo.width, trickplayInfo.width,
i, i,
@ -432,13 +403,12 @@ class PlayerActivityViewModel
val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) val fullBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
for (offsetY in 0..<trickplayInfo.height * trickplayInfo.tileHeight step trickplayInfo.height) { for (offsetY in 0..<trickplayInfo.height * trickplayInfo.tileHeight step trickplayInfo.height) {
for (offsetX in 0..<trickplayInfo.width * trickplayInfo.tileWidth step trickplayInfo.width) { for (offsetX in 0..<trickplayInfo.width * trickplayInfo.tileWidth step trickplayInfo.width) {
val bitmap = val bitmap = Bitmap.createBitmap(
Bitmap.createBitmap(
fullBitmap, fullBitmap,
offsetX, offsetX,
offsetY, offsetY,
trickplayInfo.width, trickplayInfo.width,
trickplayInfo.height, trickplayInfo.height
) )
bitmaps.add(bitmap) bitmaps.add(bitmap)
} }
@ -447,11 +417,10 @@ class PlayerActivityViewModel
} }
_uiState.update { _uiState.update {
it.copy( it.copy(
currentTrickplay = currentTrickplay = Trickplay(
Trickplay(
trickplayInfo.interval, trickplayInfo.interval,
bitmaps, bitmaps
), )
) )
} }
} }
@ -462,7 +431,9 @@ class PlayerActivityViewModel
* *
* @return list of [PlayerChapter] * @return list of [PlayerChapter]
*/ */
private fun getChapters(): List<PlayerChapter>? = uiState.value.currentChapters private fun getChapters(): List<PlayerChapter>? {
return uiState.value.currentChapters
}
/** /**
* Get the index of the current chapter * Get the index of the current chapter
@ -513,8 +484,8 @@ class PlayerActivityViewModel
} }
fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 } fun isFirstChapter(): Boolean? = getChapters()?.let { getCurrentChapterIndex() == 0 }
fun isLastChapter(): Boolean? =
fun isLastChapter(): Boolean? = getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 } getChapters()?.let { chapters -> getCurrentChapterIndex() == chapters.size - 1 }
/** /**
* Seek to chapter * Seek to chapter
@ -522,17 +493,20 @@ class PlayerActivityViewModel
* @param chapterIndex the index of the chapter to seek to * @param chapterIndex the index of the chapter to seek to
* @return the [PlayerChapter] which has been sought to * @return the [PlayerChapter] which has been sought to
*/ */
private fun seekToChapter(chapterIndex: Int): PlayerChapter? = private fun seekToChapter(chapterIndex: Int): PlayerChapter? {
getChapters()?.getOrNull(chapterIndex)?.also { chapter -> return getChapters()?.getOrNull(chapterIndex)?.also { chapter ->
player.seekTo(chapter.startPosition) player.seekTo(chapter.startPosition)
} }
}
/** /**
* Seek to the next chapter * Seek to the next chapter
* *
* @return the [PlayerChapter] which has been sought to * @return the [PlayerChapter] which has been sought to
*/ */
fun seekToNextChapter(): PlayerChapter? = getNextChapterIndex()?.let { seekToChapter(it) } fun seekToNextChapter(): PlayerChapter? {
return getNextChapterIndex()?.let { seekToChapter(it) }
}
/** /**
* Seek to the previous chapter Will seek to start of current chapter if * Seek to the previous chapter Will seek to start of current chapter if
@ -540,98 +514,171 @@ class PlayerActivityViewModel
* *
* @return the [PlayerChapter] which has been sought to * @return the [PlayerChapter] which has been sought to
*/ */
fun seekToPreviousChapter(): PlayerChapter? = getPreviousChapterIndex()?.let { seekToChapter(it) } fun seekToPreviousChapter(): PlayerChapter? {
return getPreviousChapterIndex()?.let { seekToChapter(it) }
}
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
eventsChannel.trySend(PlayerEvents.IsPlayingChanged(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 -> 1
}
}
fun changeVideoQuality(quality: String) { fun changeVideoQuality(quality: String) {
val mediaId = player.currentMediaItem?.mediaId ?: return val mediaId = player.currentMediaItem?.mediaId ?: return
val itemId = UUID.fromString(mediaId)
val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return val currentItem = items.firstOrNull { it.itemId.toString() == mediaId } ?: return
val currentPosition = player.currentPosition val currentPosition = player.currentPosition
viewModelScope.launch { viewModelScope.launch {
try { try {
val videoQuality = VideoQuality.fromString(quality)!! val transcodingResolution = getTranscodeResolutions(quality)
val deviceProfile = jellyfinRepository.buildDeviceProfile(VideoQuality.getBitrate(videoQuality), "mkv", EncodingContext.STREAMING) val (videoBitRate, audioBitRate) = jellyfinRepository.getVideoTranscodeBitRate(
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(currentItem.itemId,true,deviceProfile,VideoQuality.getBitrate(videoQuality)) transcodingResolution
)
val deviceProfile = jellyfinRepository.buildDeviceProfile(videoBitRate, "ts", EncodingContext.STREAMING)
val playbackInfo = jellyfinRepository.getPostedPlaybackInfo(itemId,true,deviceProfile,videoBitRate)
val playSessionId = playbackInfo.content.playSessionId val playSessionId = playbackInfo.content.playSessionId
if (playSessionId != null) { if (playSessionId != null) {
jellyfinRepository.stopEncodingProcess(playSessionId) jellyfinRepository.stopEncodingProcess(playSessionId)
} }
val mediaSources = jellyfinRepository.getMediaSources(currentItem.itemId, true) val mediaSource = playbackInfo.content.mediaSources.firstOrNull()
if (mediaSource == null) {
// TODO: can maybe tidy the sub stuff up Timber.e("Media source is null")
val externalSubtitles = currentItem.externalSubtitles.map { externalSubtitle -> } else {
Timber.d("Media source found: $mediaSource")
}
val transcodingUrl = mediaSource!!.transcodingUrl
val mediaSubtitles = currentItem.externalSubtitles.map { externalSubtitle ->
MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri) MediaItem.SubtitleConfiguration.Builder(externalSubtitle.uri)
.setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) }) .setLabel(externalSubtitle.title.ifBlank { application.getString(R.string.external) })
.setLanguage(externalSubtitle.language.ifBlank { "Unknown" })
.setMimeType(externalSubtitle.mimeType) .setMimeType(externalSubtitle.mimeType)
.build() .build()
} }
val embeddedSubtitles = mediaSources[currentMediaItemIndex].mediaStreams // TODO: Embedded sub support
.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal && it.path != null } // val embeddedSubtitles = mediaSource?.mediaStreams
.map { mediaStream -> // ?.filter { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
var deliveryUrl = mediaStream.path // ?.map { mediaStream ->
Timber.d("Deliverurl: %s", deliveryUrl) // MediaItem.SubtitleConfiguration.Builder(Uri.parse(mediaStream.deliveryUrl!!))
// Not sure if still needed // .setMimeType(
if (mediaStream.codec == "webvtt") { // when (mediaStream.codec) {
deliveryUrl = deliveryUrl?.replace("Stream.srt", "Stream.vtt")} // "subrip" -> MimeTypes.APPLICATION_SUBRIP
MediaItem.SubtitleConfiguration.Builder(Uri.parse(deliveryUrl)) // "webvtt" -> MimeTypes.APPLICATION_SUBRIP
.setMimeType(setSubtitlesMimeTypes(mediaStream.codec)) // "ass" -> MimeTypes.TEXT_SSA
.setLanguage(mediaStream.language.ifBlank { "Unknown" }) // else -> MimeTypes.TEXT_UNKNOWN
.setLabel("Embedded") // }
// )
// .setLanguage(mediaStream.language ?: "und")
// .setLabel(mediaStream.title ?: "Embedded Subtitle")
// .build()
// }
// ?.toMutableList() ?: mutableListOf()
// val allSubtitles = embeddedSubtitles.apply { addAll(mediaSubtitles) }
val baseUrl = jellyfinRepository.getBaseUrl()
val cleanBaseUrl = baseUrl.removePrefix("http://").removePrefix("https://")
val staticUrl = jellyfinRepository.getStreamUrl(itemId, currentItem.mediaSourceId)
val uri =
Uri.parse(transcodingUrl).buildUpon()
.scheme("https")
.authority(cleanBaseUrl)
.build() .build()
}
.toMutableList()
fun Uri.Builder.setOrReplaceQueryParameter(
name: String,
value: String
): Uri.Builder {
val currentQueryParams = this.build().queryParameterNames
val allSubtitles = // Create a new builder for the URI
if (VideoQuality.getIsOriginalQuality(videoQuality)) { val newBuilder = Uri.parse(this.build().toString()).buildUpon()
externalSubtitles
// Track if the parameter was replaced
var parameterReplaced = false
// Re-add all parameters
currentQueryParams.forEach { param ->
val paramValue = this.build().getQueryParameter(param)
if (param == name) {
// Replace the parameter value
parameterReplaced = true
newBuilder.appendQueryParameter(name, value)
} else { } else {
embeddedSubtitles.apply { addAll(externalSubtitles) } // Append the existing parameter
newBuilder.appendQueryParameter(param, paramValue)
}
} }
val url = if (VideoQuality.getIsOriginalQuality(videoQuality)){ // Append the new parameter only if it wasn't replaced
jellyfinRepository.getStreamUrl(currentItem.itemId, currentItem.mediaSourceId, playSessionId) if (!parameterReplaced) {
} else { newBuilder.appendQueryParameter(name, value)
val mediaSourceId = mediaSources[currentMediaItemIndex].id }
val deviceId = jellyfinRepository.getDeviceId()
val url = jellyfinRepository.getTranscodedVideoStream(currentItem.itemId, deviceId ,mediaSourceId, playSessionId!!, VideoQuality.getBitrate(videoQuality)) return newBuilder
val uriBuilder = url.toUri().buildUpon() }
val apiKey = jellyfinRepository.getAccessToken()
uriBuilder.appendQueryParameter("api_key",apiKey ) val uriBuilder = uri.buildUpon()
//.setOrReplaceQueryParameter("PlaySessionId", playSessionId!!)
if (transcodingResolution == 1) {
uriBuilder.setOrReplaceQueryParameter("EnableAdaptiveBitrateStreaming", "true")
uriBuilder.setOrReplaceQueryParameter("Static", "false")
uriBuilder.appendQueryParameter("MaxVideoHeight","1080" )
} else if (transcodingResolution == 720 || transcodingResolution == 480 || transcodingResolution == 360) {
uriBuilder.setOrReplaceQueryParameter(
"MaxVideoBitRate",
videoBitRate.toString()
)
uriBuilder.setOrReplaceQueryParameter("VideoBitrate", videoBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("AudioBitrate", audioBitRate.toString())
uriBuilder.setOrReplaceQueryParameter("Static", "false")
uriBuilder.appendQueryParameter("PlaySessionId", playSessionId)
uriBuilder.appendQueryParameter(
"MaxVideoHeight",
transcodingResolution.toString()
)
uriBuilder.appendQueryParameter("subtitleMethod", "External")
}
val newUri = uriBuilder.build() val newUri = uriBuilder.build()
newUri.toString() Timber.e("URI IS %s", newUri)
}
Timber.e("URI IS %s", url)
val mediaItemBuilder = MediaItem.Builder() val mediaItemBuilder = MediaItem.Builder()
.setMediaId(currentItem.itemId.toString()) .setMediaId(currentItem.itemId.toString())
.setUri(url) if (transcodingResolution == 1080) {
.setSubtitleConfigurations(allSubtitles) mediaItemBuilder.setUri(staticUrl)
} else {
mediaItemBuilder.setUri(newUri)
}
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(currentItem.name) .setTitle(currentItem.name)
.build(), .build(),
) )
.setSubtitleConfigurations(mediaSubtitles)
player.pause()
player.setMediaItem(mediaItemBuilder.build()) player.setMediaItem(mediaItemBuilder.build())
player.prepare() player.prepare()
player.seekTo(currentPosition) player.seekTo(currentPosition)
playWhenReady = true
player.play() player.play()
val originalHeight = mediaSource.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }?.height ?: -1
// Store the original height
this@PlayerActivityViewModel.originalHeight = originalHeight
//isQualityChangeInProgress = true //isQualityChangeInProgress = true
} catch (e: Exception) { } catch (e: Exception) {
@ -640,16 +687,13 @@ class PlayerActivityViewModel
} }
} }
fun getOriginalResolution(): Int? { fun getOriginalHeight(): Int {
return originalResolution return originalHeight
} }
} }
sealed interface PlayerEvents { sealed interface PlayerEvents {
data object NavigateBack : PlayerEvents data object NavigateBack : PlayerEvents
data class IsPlayingChanged(val isPlaying: Boolean) : PlayerEvents
data class IsPlayingChanged(
val isPlaying: Boolean,
) : PlayerEvents
} }

View file

@ -18,7 +18,6 @@ import com.nomadics9.ananas.models.PlayerChapter
import com.nomadics9.ananas.models.PlayerItem import com.nomadics9.ananas.models.PlayerItem
import com.nomadics9.ananas.models.TrickplayInfo import com.nomadics9.ananas.models.TrickplayInfo
import com.nomadics9.ananas.repository.JellyfinRepository import com.nomadics9.ananas.repository.JellyfinRepository
import com.nomadics9.ananas.setSubtitlesMimeTypes
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -137,22 +136,7 @@ class PlayerViewModel @Inject internal constructor(
} else { } else {
mediaSources[mediaSourceIndex] mediaSources[mediaSourceIndex]
} }
// Embedded Sub externally for offline playback val externalSubtitles = mediaSource.mediaStreams
val externalSubtitles = if (mediaSource.type.toString() == "LOCAL" ) {
mediaSource.mediaStreams
.filter { mediaStream ->
mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
}
.map { mediaStream ->
ExternalSubtitle(
mediaStream.title,
mediaStream.language,
Uri.parse(mediaStream.path!!),
setSubtitlesMimeTypes(mediaStream.codec),
)
}
}else {
mediaSource.mediaStreams
.filter { mediaStream -> .filter { mediaStream ->
mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank() mediaStream.isExternal && mediaStream.type == MediaStreamType.SUBTITLE && !mediaStream.path.isNullOrBlank()
} }
@ -161,10 +145,14 @@ class PlayerViewModel @Inject internal constructor(
mediaStream.title, mediaStream.title,
mediaStream.language, mediaStream.language,
Uri.parse(mediaStream.path!!), Uri.parse(mediaStream.path!!),
setSubtitlesMimeTypes(mediaStream.codec) when (mediaStream.codec) {
"subrip" -> MimeTypes.APPLICATION_SUBRIP
"webvtt" -> MimeTypes.APPLICATION_SUBRIP
"ass" -> MimeTypes.TEXT_SSA
else -> MimeTypes.TEXT_UNKNOWN
},
) )
} }
}
val trickplayInfo = when (this) { val trickplayInfo = when (this) {
is FindroidSources -> { is FindroidSources -> {
this.trickplayInfo?.get(mediaSource.id)?.let { this.trickplayInfo?.get(mediaSource.id)?.let {

View file

@ -121,11 +121,6 @@ constructor(
Constants.NETWORK_DEFAULT_SOCKET_TIMEOUT.toString(), Constants.NETWORK_DEFAULT_SOCKET_TIMEOUT.toString(),
)!!.toLongOrNull() ?: Constants.NETWORK_DEFAULT_SOCKET_TIMEOUT )!!.toLongOrNull() ?: Constants.NETWORK_DEFAULT_SOCKET_TIMEOUT
val transcodeCodec get() = sharedPreferences.getString(
Constants.PREF_NETWORK_CODEC,
Constants.NETWORK_DEFAULT_CODEC,
)
// Cache // Cache
val imageCache get() = sharedPreferences.getBoolean( val imageCache get() = sharedPreferences.getBoolean(
Constants.PREF_IMAGE_CACHE, Constants.PREF_IMAGE_CACHE,
@ -155,10 +150,9 @@ constructor(
val downloadQualityDefault get() = sharedPreferences.getBoolean( val downloadQualityDefault get() = sharedPreferences.getBoolean(
Constants.PREF_DOWNLOADS_QUALITY_DEFAULT, Constants.PREF_DOWNLOADS_QUALITY_DEFAULT,
false, false
) )
// Sorting // Sorting
var sortBy: String var sortBy: String
get() = sharedPreferences.getString( get() = sharedPreferences.getString(

View file

@ -43,7 +43,6 @@ object Constants {
const val PREF_NETWORK_REQUEST_TIMEOUT = "pref_network_request_timeout" const val PREF_NETWORK_REQUEST_TIMEOUT = "pref_network_request_timeout"
const val PREF_NETWORK_CONNECT_TIMEOUT = "pref_network_connect_timeout" const val PREF_NETWORK_CONNECT_TIMEOUT = "pref_network_connect_timeout"
const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout" const val PREF_NETWORK_SOCKET_TIMEOUT = "pref_network_socket_timeout"
const val PREF_NETWORK_CODEC = "pref_network_codec"
const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data" const val PREF_DOWNLOADS_MOBILE_DATA = "pref_downloads_mobile_data"
const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming" const val PREF_DOWNLOADS_ROAMING = "pref_downloads_roaming"
const val PREF_DOWNLOADS_QUALITY = "pref_downloads_quality" const val PREF_DOWNLOADS_QUALITY = "pref_downloads_quality"
@ -64,7 +63,6 @@ object Constants {
const val NETWORK_DEFAULT_REQUEST_TIMEOUT = 30_000L const val NETWORK_DEFAULT_REQUEST_TIMEOUT = 30_000L
const val NETWORK_DEFAULT_CONNECT_TIMEOUT = 6_000L const val NETWORK_DEFAULT_CONNECT_TIMEOUT = 6_000L
const val NETWORK_DEFAULT_SOCKET_TIMEOUT = 10_000L const val NETWORK_DEFAULT_SOCKET_TIMEOUT = 10_000L
const val NETWORK_DEFAULT_CODEC = "h264"
// sorting // sorting
// This values must correspond to a SortString from [SortBy] // This values must correspond to a SortString from [SortBy]

View file

@ -1 +0,0 @@
0.10.6