Compare commits
No commits in common. "main" and "download-transcoding" have entirely different histories.
main
...
download-t
46 changed files with 1355 additions and 1992 deletions
5
.github/workflows/build.yaml
vendored
5
.github/workflows/build.yaml
vendored
|
@ -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
1
.gitignore
vendored
|
@ -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
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -30,7 +30,6 @@ android {
|
||||||
flavorDimensions += "variant"
|
flavorDimensions += "variant"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
register("libre")
|
register("libre")
|
||||||
create("Ananas")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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?
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
1
lint.xml
1
lint.xml
|
@ -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>
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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]
|
||||||
|
|
1
version
1
version
|
@ -1 +0,0 @@
|
||||||
0.10.6
|
|
Loading…
Reference in a new issue