diff --git a/.gitignore b/.gitignore
index 68cb2a92..93f35f6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
/.idea/discord.xml
/.idea/gradle.xml
/.idea/deploymentTargetDropDown.xml
+/.idea/misc.xml
.DS_Store
/build
/captures
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index b696582b..00000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 2f90d51f..305f889a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
# Findroid
@@ -21,12 +21,19 @@ Home | Library | Movie | Season | Episode
- Completely native interface
- Supported media items: movies, series, seasons, episodes
- Direct play only, (no transcoding)
-- Video codes: H.263, H.264, H.265, VP8, VP9, AV1
- - Support depends on Android device
-- Audio codes: Vorbis, Opus, FLAC, ALAC, PCM µ-law, PCM A-law, MP1, MP2, MP3, AMR-NB, AMR-WB, AAC, AC-3, E-AC-3, DTS, DTS-HD, TrueHD
- - Support provided by ExoPlayer FFmpeg extension
-- Subtitle codecs: SRT, VTT, SSA/ASS, PGSSUB
- - SSA/ASS has limited styling support see [this issue](https://github.com/google/ExoPlayer/issues/8435)
+- ExoPlayer
+ - Video codes: H.263, H.264, H.265, VP8, VP9, AV1
+ - Support depends on Android device
+ - Audio codes: Vorbis, Opus, FLAC, ALAC, PCM µ-law, PCM A-law, MP1, MP2, MP3, AMR-NB, AMR-WB, AAC, AC-3, E-AC-3, DTS, DTS-HD, TrueHD
+ - Support provided by ExoPlayer FFmpeg extension
+ - Subtitle codecs: SRT, VTT, SSA/ASS, PGSSUB
+ - SSA/ASS has limited styling support see [this issue](https://github.com/google/ExoPlayer/issues/8435)
+- **NEW** MPV Player
+ - Should play everything, including SSA/ASS subs with proper styling!
+ - Optionally force software decoding when hardware decoding has issues.
+ - Issues:
+ - Can only play one item at a time, doesn't transistion to the next episode
+
## Planned features
- Websocket connection (Syncplay)
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 9c56d103..00000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,113 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'kotlin-android'
- id 'kotlin-parcelize'
- id 'kotlin-kapt'
- id 'androidx.navigation.safeargs.kotlin'
- id 'dagger.hilt.android.plugin'
- id "com.mikepenz.aboutlibraries.plugin"
-}
-
-android {
- compileSdkVersion 31
- buildToolsVersion "31.0.0"
-
- defaultConfig {
- applicationId "dev.jdtech.jellyfin"
- minSdkVersion 24
- targetSdkVersion 31
- versionCode 3
- versionName "0.1.2"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
-
- buildTypes {
- release {
- minifyEnabled true
- shrinkResources true
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
-
- compileOptions {
- coreLibraryDesugaringEnabled true
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = '1.8'
- }
-
- buildFeatures {
- dataBinding true
- viewBinding true
- }
-}
-
-dependencies {
- implementation 'androidx.core:core-ktx:1.6.0'
- implementation 'androidx.core:core-splashscreen:1.0.0-alpha01'
- implementation 'androidx.appcompat:appcompat:1.3.1'
-
- // Material
- implementation 'com.google.android.material:material:1.4.0'
-
- // ConstraintLayout
- implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
-
- // Navigation
- def navigation_version = "2.3.5"
- implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
- implementation "androidx.navigation:navigation-ui-ktx:$navigation_version"
-
- // RecyclerView
- implementation "androidx.recyclerview:recyclerview:1.2.1"
- implementation "androidx.recyclerview:recyclerview-selection:1.1.0"
-
- // Room
- def room_version = "2.3.0"
- implementation "androidx.room:room-runtime:$room_version"
- kapt "androidx.room:room-compiler:$room_version"
- implementation "androidx.room:room-ktx:$room_version"
-
- // Preference
- def preference_version = "1.1.1"
- implementation "androidx.preference:preference-ktx:$preference_version"
-
- // Jellyfin
- def jellyfin_version = "1.0.2"
- implementation "org.jellyfin.sdk:jellyfin-platform-android:$jellyfin_version"
-
- // Glide
- def glide_version = "4.12.0"
- implementation "com.github.bumptech.glide:glide:$glide_version"
- kapt "com.github.bumptech.glide:compiler:$glide_version"
-
- // Hilt
- def hilt_version = "2.38.1"
- implementation "com.google.dagger:hilt-android:$hilt_version"
- kapt "com.google.dagger:hilt-compiler:$hilt_version"
-
- // ExoPlayer
- def exoplayer_version = "2.15.0"
- implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
- implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
- implementation files('libs/extension-ffmpeg-release.aar')
-
- // Timber
- def timber_version = "5.0.1"
- implementation "com.jakewharton.timber:timber:$timber_version"
-
- def about_libraries_version = "8.9.1"
- implementation "com.mikepenz:aboutlibraries-core:$about_libraries_version"
- implementation "com.mikepenz:aboutlibraries:$about_libraries_version"
-
- // Testing
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
-
- coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000..6b29a419
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,123 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ id("kotlin-parcelize")
+ id("kotlin-kapt")
+ id("androidx.navigation.safeargs.kotlin")
+ id("dagger.hilt.android.plugin")
+ id("com.mikepenz.aboutlibraries.plugin")
+}
+
+android {
+ compileSdk = 31
+ buildToolsVersion = "31.0.0"
+
+ defaultConfig {
+ applicationId = "dev.jdtech.jellyfin"
+ minSdk = 24
+ targetSdk = 31
+ versionCode = 4
+ versionName = "0.2.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("debug") {
+ applicationIdSuffix = ".debug"
+ }
+ create("staging") {
+ initWith(getByName("release"))
+ applicationIdSuffix = ".staging"
+ }
+ getByName("release") {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ buildFeatures {
+ dataBinding = true
+ viewBinding = true
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.6.0")
+ implementation("androidx.core:core-splashscreen:1.0.0-alpha01")
+ implementation("androidx.appcompat:appcompat:1.3.1")
+
+ // Material
+ implementation("com.google.android.material:material:1.4.0")
+
+ // ConstraintLayout
+ implementation("androidx.constraintlayout:constraintlayout:2.1.0")
+
+ // Navigation
+ val navigationVersion = "2.3.5"
+ implementation("androidx.navigation:navigation-fragment-ktx:$navigationVersion")
+ implementation("androidx.navigation:navigation-ui-ktx:$navigationVersion")
+
+ // RecyclerView
+ implementation("androidx.recyclerview:recyclerview:1.2.1")
+ implementation("androidx.recyclerview:recyclerview-selection:1.1.0")
+
+ // Room
+ val roomVersion = "2.3.0"
+ implementation("androidx.room:room-runtime:$roomVersion")
+ kapt("androidx.room:room-compiler:$roomVersion")
+ implementation("androidx.room:room-ktx:$roomVersion")
+
+ // Preference
+ val preferenceVersion = "1.1.1"
+ implementation("androidx.preference:preference-ktx:$preferenceVersion")
+
+ // Jellyfin
+ val jellyfinVersion = "1.0.3"
+ implementation("org.jellyfin.sdk:jellyfin-platform-android:$jellyfinVersion")
+
+ // Glide
+ val glideVersion = "4.12.0"
+ implementation("com.github.bumptech.glide:glide:$glideVersion")
+ kapt("com.github.bumptech.glide:compiler:$glideVersion")
+
+ // Hilt
+ val hiltVersion = "2.38.1"
+ implementation("com.google.dagger:hilt-android:$hiltVersion")
+ kapt("com.google.dagger:hilt-compiler:$hiltVersion")
+
+ // ExoPlayer
+ val exoplayerVersion = "2.15.0"
+ implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
+ implementation("com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion")
+ implementation(files("libs/extension-ffmpeg-release.aar"))
+
+ // MPV
+ implementation(files("libs/libmpv.aar"))
+
+ // Timber
+ val timberVersion = "5.0.1"
+ implementation("com.jakewharton.timber:timber:$timberVersion")
+
+ val aboutLibrariesVersion = "8.9.1"
+ implementation("com.mikepenz:aboutlibraries-core:$aboutLibrariesVersion")
+ implementation("com.mikepenz:aboutlibraries:$aboutLibrariesVersion")
+
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.3")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
+
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
+}
\ No newline at end of file
diff --git a/app/libs/libmpv.aar b/app/libs/libmpv.aar
new file mode 100755
index 00000000..5da30423
Binary files /dev/null and b/app/libs/libmpv.aar differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 6ea8b71f..36d19cd8 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
+# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
diff --git a/app/src/main/assets/mpv.conf b/app/src/main/assets/mpv.conf
new file mode 100755
index 00000000..87a2ff6e
--- /dev/null
+++ b/app/src/main/assets/mpv.conf
@@ -0,0 +1,11 @@
+### hwdec: try to use hardware decoding
+# hwdec=mediacodec-copy
+# hwdec-codecs="h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1"
+
+### tls: allow self signed certificate
+# tls-verify=no
+# tls-ca-file=""
+
+### sub: scale subtitles with video
+# sub-scale-with-window=no
+# sub-use-margins=no
diff --git a/app/src/main/assets/subfont.ttf b/app/src/main/assets/subfont.ttf
new file mode 100644
index 00000000..56d013f8
Binary files /dev/null and b/app/src/main/assets/subfont.ttf differ
diff --git a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
index ea3ad3ad..321223ad 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
@@ -1,34 +1,146 @@
package dev.jdtech.jellyfin
+import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.view.WindowManager
+import android.widget.ImageButton
+import android.widget.TextView
import androidx.activity.viewModels
+import androidx.core.view.updatePadding
import androidx.navigation.navArgs
-import com.google.android.exoplayer2.ui.StyledPlayerView
+import com.google.android.exoplayer2.C
+import com.google.android.exoplayer2.SimpleExoPlayer
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector
+import com.google.android.exoplayer2.ui.TrackSelectionDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
+import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
+import dev.jdtech.jellyfin.mpv.MPVPlayer
+import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import timber.log.Timber
@AndroidEntryPoint
class PlayerActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityPlayerBinding
private val viewModel: PlayerActivityViewModel by viewModels()
-
private val args: PlayerActivityArgs by navArgs()
- private lateinit var playerView: StyledPlayerView
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("Creating player activity")
- setContentView(R.layout.activity_player)
+ binding = ActivityPlayerBinding.inflate(layoutInflater)
+ setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- playerView = findViewById(R.id.video_view)
+ binding.playerView.player = viewModel.player
- viewModel.player.observe(this, {
- playerView.player = it
+ val playerControls = binding.playerView.findViewById(R.id.player_controls)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ binding.playerView.findViewById(R.id.player_controls)
+ .setOnApplyWindowInsetsListener { _, windowInsets ->
+ val cutout = windowInsets.displayCutout
+ playerControls.updatePadding(
+ left = cutout?.safeInsetLeft ?: 0,
+ top = cutout?.safeInsetTop ?: 0,
+ right = cutout?.safeInsetRight ?: 0,
+ bottom = cutout?.safeInsetBottom ?: 0,
+ )
+ return@setOnApplyWindowInsetsListener windowInsets
+ }
+ }
+
+ binding.playerView.findViewById(R.id.back_button).setOnClickListener {
+ onBackPressed()
+ }
+
+ val videoNameTextView = binding.playerView.findViewById(R.id.video_name)
+
+ viewModel.currentItemTitle.observe(this, { title ->
+ videoNameTextView.text = title
+ })
+
+ val audioButton = binding.playerView.findViewById(R.id.btn_audio_track)
+ val subtitleButton = binding.playerView.findViewById(R.id.btn_subtitle)
+
+ audioButton.isEnabled = false
+ audioButton.imageAlpha = 75
+
+ subtitleButton.isEnabled = false
+ subtitleButton.imageAlpha = 75
+
+ audioButton.setOnClickListener {
+ when (viewModel.player) {
+ is MPVPlayer -> {
+ TrackSelectionDialogFragment(TrackType.AUDIO, viewModel).show(
+ supportFragmentManager,
+ "trackselectiondialog"
+ )
+ }
+ is SimpleExoPlayer -> {
+ val mappedTrackInfo =
+ viewModel.trackSelector.currentMappedTrackInfo ?: return@setOnClickListener
+
+ var audioRenderer: Int? = null
+ for (i in 0 until mappedTrackInfo.rendererCount) {
+ if (isRendererType(mappedTrackInfo, i, C.TRACK_TYPE_AUDIO)) {
+ audioRenderer = i
+ }
+ }
+
+ if (audioRenderer == null) return@setOnClickListener
+
+ val trackSelectionDialogBuilder = TrackSelectionDialogBuilder(
+ this, resources.getString(R.string.select_audio_track),
+ viewModel.trackSelector, audioRenderer
+ )
+ val trackSelectionDialog = trackSelectionDialogBuilder.build()
+ trackSelectionDialog.show()
+ }
+ }
+ }
+
+ subtitleButton.setOnClickListener {
+ when (viewModel.player) {
+ is MPVPlayer -> {
+ TrackSelectionDialogFragment(TrackType.SUBTITLE, viewModel).show(
+ supportFragmentManager,
+ "trackselectiondialog"
+ )
+ }
+ is SimpleExoPlayer -> {
+ val mappedTrackInfo =
+ viewModel.trackSelector.currentMappedTrackInfo ?: return@setOnClickListener
+
+ var subtitleRenderer: Int? = null
+ for (i in 0 until mappedTrackInfo.rendererCount) {
+ if (isRendererType(mappedTrackInfo, i, C.TRACK_TYPE_TEXT)) {
+ subtitleRenderer = i
+ }
+ }
+
+ if (subtitleRenderer == null) return@setOnClickListener
+
+ val trackSelectionDialogBuilder = TrackSelectionDialogBuilder(
+ this, resources.getString(R.string.select_subtile_track),
+ viewModel.trackSelector, subtitleRenderer
+ )
+ val trackSelectionDialog = trackSelectionDialogBuilder.build()
+ trackSelectionDialog.show()
+ }
+ }
+ }
+
+ viewModel.fileLoaded.observe(this, {
+ if (it) {
+ audioButton.isEnabled = true
+ audioButton.imageAlpha = 255
+ subtitleButton.isEnabled = true
+ subtitleButton.imageAlpha = 255
+ }
})
viewModel.navigateBack.observe(this, {
@@ -37,21 +149,19 @@ class PlayerActivity : AppCompatActivity() {
}
})
- if (viewModel.player.value == null) {
- viewModel.initializePlayer(args.items)
- }
+ viewModel.initializePlayer(args.items)
hideSystemUI()
}
override fun onPause() {
super.onPause()
- viewModel.playWhenReady = viewModel.player.value?.playWhenReady == true
- playerView.player?.playWhenReady = false
+ viewModel.playWhenReady = viewModel.player.playWhenReady == true
+ viewModel.player.playWhenReady = false
}
override fun onResume() {
super.onResume()
- viewModel.player.value?.playWhenReady = viewModel.playWhenReady
+ viewModel.player.playWhenReady = viewModel.playWhenReady
hideSystemUI()
}
@@ -63,6 +173,23 @@ class PlayerActivity : AppCompatActivity() {
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ window.attributes.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ }
+
+ private fun isRendererType(
+ mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
+ rendererIndex: Int,
+ type: Int
+ ): Boolean {
+ val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
+ if (trackGroupArray.length == 0) {
+ return false
+ }
+ val trackType = mappedTrackInfo.getRendererType(rendererIndex)
+ return type == trackType
}
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt
new file mode 100644
index 00000000..be2edf9a
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt
@@ -0,0 +1,73 @@
+package dev.jdtech.jellyfin.dialogs
+
+import android.app.AlertDialog
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import dev.jdtech.jellyfin.mpv.TrackType
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+import java.lang.IllegalStateException
+
+class TrackSelectionDialogFragment(
+ private val type: String,
+ private val viewModel: PlayerActivityViewModel
+) : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val trackNames: List
+ when (type) {
+ TrackType.AUDIO -> {
+ trackNames = viewModel.currentAudioTracks.map { track ->
+ if (track.title.isEmpty()) {
+ "${track.lang} - ${track.codec}"
+ } else {
+ "${track.title} - ${track.lang} - ${track.codec}"
+ }
+ }
+ return activity?.let { activity ->
+ val builder = AlertDialog.Builder(activity)
+ builder.setTitle("Select audio track")
+ .setSingleChoiceItems(
+ trackNames.toTypedArray(),
+ viewModel.currentAudioTracks.indexOfFirst { it.selected }) { _, which ->
+ viewModel.switchToTrack(
+ TrackType.AUDIO,
+ viewModel.currentAudioTracks[which]
+ )
+ }
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+ TrackType.SUBTITLE -> {
+ trackNames = viewModel.currentSubtitleTracks.map { track ->
+ if (track.title.isEmpty()) {
+ "${track.lang} - ${track.codec}"
+ } else {
+ "${track.title} - ${track.lang} - ${track.codec}"
+ }
+ }
+ return activity?.let { activity ->
+ val builder = AlertDialog.Builder(activity)
+ builder.setTitle("Select subtitle track")
+ .setSingleChoiceItems(
+ trackNames.toTypedArray(),
+ viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { _, which ->
+ viewModel.switchToTrack(
+ TrackType.SUBTITLE,
+ viewModel.currentSubtitleTracks[which]
+ )
+ }
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+ else -> {
+ trackNames = listOf()
+ return activity?.let {
+ val builder = AlertDialog.Builder(it)
+ builder.setTitle("Select ? track")
+ .setMessage("Unknown track type")
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt
index e30ba480..99bd8191 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt
@@ -10,10 +10,6 @@ import dev.jdtech.jellyfin.R
class InitializingFragment : Fragment() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- }
-
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
index 4bb4ffd2..8ff29f13 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
@@ -75,7 +75,7 @@ class MediaInfoFragment : Fragment() {
} else {
binding.originalTitle.visibility = View.GONE
}
- if (item.trailerCount != null && item.trailerCount!! < 1) {
+ if (item.remoteTrailers.isNullOrEmpty()) {
binding.trailerButton.visibility = View.GONE
}
binding.communityRating.visibility = when (item.communityRating != null) {
@@ -147,6 +147,7 @@ class MediaInfoFragment : Fragment() {
}
binding.trailerButton.setOnClickListener {
+ if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url)
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
index 726d62a3..0df53cf7 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
@@ -6,6 +6,7 @@ import java.util.*
@Parcelize
data class PlayerItem(
+ val name: String?,
val itemId: UUID,
val mediaSourceId: String,
val playbackPosition: Long
diff --git a/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt b/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
new file mode 100644
index 00000000..b8a9a573
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
@@ -0,0 +1,1504 @@
+package dev.jdtech.jellyfin.mpv
+
+import `is`.xyz.libmpv.MPVLib
+import android.annotation.SuppressLint
+import android.app.Application
+import android.content.Context
+import android.content.res.AssetManager
+import android.media.AudioManager
+import android.os.Handler
+import android.os.Looper
+import android.os.Parcelable
+import android.view.Surface
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.view.TextureView
+import androidx.core.content.getSystemService
+import com.google.android.exoplayer2.*
+import com.google.android.exoplayer2.audio.AudioAttributes
+import com.google.android.exoplayer2.device.DeviceInfo
+import com.google.android.exoplayer2.metadata.Metadata
+import com.google.android.exoplayer2.source.MediaSource
+import com.google.android.exoplayer2.source.ProgressiveMediaSource
+import com.google.android.exoplayer2.source.TrackGroup
+import com.google.android.exoplayer2.source.TrackGroupArray
+import com.google.android.exoplayer2.text.Cue
+import com.google.android.exoplayer2.trackselection.TrackSelection
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray
+import com.google.android.exoplayer2.upstream.DataSource
+import com.google.android.exoplayer2.util.*
+import com.google.android.exoplayer2.video.VideoSize
+import kotlinx.parcelize.Parcelize
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.CopyOnWriteArraySet
+
+@Suppress("SpellCheckingInspection")
+class MPVPlayer(
+ context: Context,
+ requestAudioFocus: Boolean,
+ preferredLanguages: Map,
+ disableHardwareDecoding: Boolean
+) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener {
+
+ private val audioManager: AudioManager by lazy { context.getSystemService()!! }
+ private var audioFocusCallback: () -> Unit = {}
+ private var audioFocusRequest = AudioManager.AUDIOFOCUS_REQUEST_FAILED
+ private val handler = Handler(context.mainLooper)
+
+ init {
+ require(context is Application)
+ val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
+ if (!mpvDir.exists()) {
+ mpvDir.mkdirs()
+ }
+ arrayOf("mpv.conf", "subfont.ttf").forEach { fileName ->
+ val file = File(mpvDir, fileName)
+ Log.i("mpv", "File ${file.absolutePath}")
+ if (!file.exists()) {
+ context.assets.open(fileName, AssetManager.ACCESS_STREAMING).copyTo(FileOutputStream(file))
+ }
+ }
+ MPVLib.create(context)
+
+ // General
+ MPVLib.setOptionString("config-dir", mpvDir.path)
+ MPVLib.setOptionString("vo", "gpu")
+ MPVLib.setOptionString("gpu-context", "android")
+ MPVLib.setOptionString("ao", "audiotrack,opensles")
+
+ // Hardware video decoding
+ if (disableHardwareDecoding) {
+ MPVLib.setOptionString("hwdec", "no")
+ } else {
+ MPVLib.setOptionString("hwdec", "mediacodec-copy")
+ }
+ MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
+
+ // TLS
+ MPVLib.setOptionString("tls-verify", "no")
+
+ // Cache
+ MPVLib.setOptionString("cache", "yes")
+ MPVLib.setOptionString("cache-pause-initial", "yes")
+ MPVLib.setOptionString("demuxer-max-bytes", "32MiB")
+ MPVLib.setOptionString("demuxer-max-back-bytes", "32MiB")
+
+ // Subs
+ MPVLib.setOptionString("sub-scale-with-window", "no")
+ MPVLib.setOptionString("sub-use-margins", "no")
+
+ // Other options
+ MPVLib.setOptionString("force-window", "no")
+ MPVLib.setOptionString("keep-open", "always")
+ MPVLib.setOptionString("save-position-on-quit", "no")
+ MPVLib.setOptionString("sub-font-provider", "none")
+ MPVLib.setOptionString("ytdl", "no")
+
+ MPVLib.init()
+
+ for (preferredLanguage in preferredLanguages) {
+ when (preferredLanguage.key) {
+ TrackType.AUDIO -> {
+ MPVLib.setOptionString("alang", preferredLanguage.value)
+ }
+ TrackType.SUBTITLE -> {
+ MPVLib.setOptionString("slang", preferredLanguage.value)
+ }
+ }
+ }
+
+ MPVLib.addObserver(this)
+
+ // Observe properties
+ data class Property(val name: String, @MPVLib.Format val format: Int)
+ arrayOf(
+ Property("track-list", MPVLib.MPV_FORMAT_STRING),
+ Property("paused-for-cache", MPVLib.MPV_FORMAT_FLAG),
+ Property("eof-reached", MPVLib.MPV_FORMAT_FLAG),
+ Property("seekable", MPVLib.MPV_FORMAT_FLAG),
+ Property("time-pos", MPVLib.MPV_FORMAT_INT64),
+ Property("duration", MPVLib.MPV_FORMAT_INT64),
+ Property("demuxer-cache-time", MPVLib.MPV_FORMAT_INT64),
+ Property("speed", MPVLib.MPV_FORMAT_DOUBLE)
+ ).forEach { (name, format) ->
+ MPVLib.observeProperty(name, format)
+ }
+
+ if (requestAudioFocus) {
+ @Suppress("DEPRECATION")
+ audioFocusRequest = audioManager.requestAudioFocus(
+ /* listener= */ this,
+ /* streamType= */ AudioManager.STREAM_MUSIC,
+ /* durationHint= */ AudioManager.AUDIOFOCUS_GAIN
+ )
+ if (audioFocusRequest != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+ MPVLib.setPropertyBoolean("pause", true)
+ }
+ }
+ }
+
+ // Listeners and notification.
+ @Suppress("DEPRECATION")
+ private val listeners: ListenerSet = ListenerSet(
+ context.mainLooper,
+ Clock.DEFAULT
+ ) { listener: Player.EventListener, flags: FlagSet ->
+ listener.onEvents( /* player= */this, Player.Events(flags))
+ }
+ @Suppress("DEPRECATION")
+ private val videoListeners = CopyOnWriteArraySet()
+
+ // Internal state.
+ private var internalMediaItems: List? = null
+ private var internalMediaItem: MediaItem? = null
+ @Player.State
+ private var playbackState: Int = Player.STATE_IDLE
+ private var currentPlayWhenReady: Boolean = false
+ @Player.RepeatMode
+ private val repeatMode: Int = REPEAT_MODE_OFF
+ private var trackGroupArray: TrackGroupArray = TrackGroupArray.EMPTY
+ private var trackSelectionArray: TrackSelectionArray = TrackSelectionArray()
+ private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
+
+ // MPV Custom
+ private var isPlayerReady: Boolean = false
+ private var isSeekable: Boolean = false
+ private var currentPositionMs: Long? = null
+ private var currentDurationMs: Long? = null
+ private var currentCacheDurationMs: Long? = null
+ var currentTracks: List