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 banner](images/banner.svg) +![Findroid banner](images/findroid-banner.png) # 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 = emptyList() + private var initialCommands = mutableListOf>() + private var initialSeekTo: Long = 0L + + // mpv events + override fun eventProperty(property: String) { + // Nothing to do... + } + + override fun eventProperty(property: String, value: String) { + handler.post { + when (property) { + "track-list" -> { + val (tracks, newTrackGroupArray, newTrackSelectionArray) = getMPVTracks(value) + tracks.forEach { Log.i("mpv", "${it.ffIndex} ${it.type} ${it.codec}") } + currentTracks = tracks + if (isPlayerReady) { + if (newTrackGroupArray != trackGroupArray || newTrackSelectionArray != trackSelectionArray) { + trackGroupArray = newTrackGroupArray + trackSelectionArray = newTrackSelectionArray + listeners.sendEvent(Player.EVENT_TRACKS_CHANGED) { listener -> + listener.onTracksChanged(currentTrackGroups, currentTrackSelections) + } + } + } else { + trackGroupArray = newTrackGroupArray + trackSelectionArray = newTrackSelectionArray + } + } + } + } + } + + override fun eventProperty(property: String, value: Boolean) { + handler.post { + when (property) { + "eof-reached" -> { + if (value && isPlayerReady) { + setPlayerStateAndNotifyIfChanged( + playWhenReady = false, + playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM, + playbackState = Player.STATE_ENDED + ) + resetInternalState() + } + } + "paused-for-cache" -> { + if (isPlayerReady) { + if (value) { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + } else { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_READY) + } + } + } + "seekable" -> { + if (isSeekable != value) { + isSeekable = value + listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> + listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + } + } + } + } + } + } + + override fun eventProperty(property: String, value: Long) { + handler.post { + when (property) { + "time-pos" -> currentPositionMs = value * C.MILLIS_PER_SECOND + "duration" -> { + if (currentDurationMs != value * C.MILLIS_PER_SECOND) { + currentDurationMs = value * C.MILLIS_PER_SECOND + listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> + listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + } + } + } + "demuxer-cache-time" -> currentCacheDurationMs = value * C.MILLIS_PER_SECOND + } + } + } + + override fun eventProperty(property: String, value: Double) { + handler.post { + when (property) { + "speed" -> { + playbackParameters = getPlaybackParameters().withSpeed(value.toFloat()) + listeners.sendEvent(Player.EVENT_PLAYBACK_PARAMETERS_CHANGED) {listener -> + listener.onPlaybackParametersChanged(getPlaybackParameters()) + } + } + } + } + } + + @SuppressLint("SwitchIntDef") + override fun event(@MPVLib.Event eventId: Int) { + handler.post { + when (eventId) { + MPVLib.MPV_EVENT_START_FILE -> { + if (!isPlayerReady) { + for (command in initialCommands) { + MPVLib.command(command) + } + } + } + MPVLib.MPV_EVENT_SEEK -> { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + listeners.sendEvent(Player.EVENT_POSITION_DISCONTINUITY) { listener -> + @Suppress("DEPRECATION") + listener.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK) + } + } + MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { + if (!isPlayerReady) { + isPlayerReady = true + listeners.sendEvent(Player.EVENT_TRACKS_CHANGED) { listener -> + listener.onTracksChanged(currentTrackGroups, currentTrackSelections) + } + seekTo(C.TIME_UNSET) + if (playWhenReady) { + Log.d("mpv", "Starting playback...") + MPVLib.setPropertyBoolean("pause", false) + } + for (videoListener in videoListeners) { + videoListener.onRenderedFirstFrame() + } + } else { + if (playbackState == Player.STATE_BUFFERING && bufferedPosition > currentPosition) { + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_READY) + } + } + } + } + } + } + + override fun eventEndFile(@MPVLib.Reason reason: Int, @MPVLib.Error error: Int) { + // Nothing to do... + } + + private fun setPlayerStateAndNotifyIfChanged( + playWhenReady: Boolean = getPlayWhenReady(), + @Player.PlayWhenReadyChangeReason playWhenReadyChangeReason: Int = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + @Player.State playbackState: Int = getPlaybackState() + ) { + var playerStateChanged = false + val wasPlaying = isPlaying + if (playbackState != getPlaybackState()) { + this.playbackState = playbackState + listeners.queueEvent(Player.EVENT_PLAYBACK_STATE_CHANGED) { listener -> + listener.onPlaybackStateChanged(playbackState) + } + playerStateChanged = true + } + if (playWhenReady != getPlayWhenReady()) { + this.currentPlayWhenReady = playWhenReady + listeners.queueEvent(Player.EVENT_PLAY_WHEN_READY_CHANGED) { listener -> + listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason) + } + playerStateChanged = true + } + if (playerStateChanged) { + listeners.queueEvent( /* eventFlag= */ C.INDEX_UNSET) { listener -> + @Suppress("DEPRECATION") + listener.onPlayerStateChanged(playWhenReady, playbackState) + } + } + if (wasPlaying != isPlaying) { + listeners.queueEvent(Player.EVENT_IS_PLAYING_CHANGED) { listener -> + listener.onIsPlayingChanged(isPlaying) + } + } + listeners.flushEvents() + } + + /** + * Select a [Track] or disable a [TrackType] in the current player. + * + * @param trackType The [TrackType] + * @param isExternal If track is external or embed in media + * @param index Index to select or [C.INDEX_UNSET] to disable [TrackType] + * @return true if the track is or was already selected + */ + fun selectTrack(@TrackType trackType: String, isExternal: Boolean = false, index: Int): Boolean { + if (index != C.INDEX_UNSET) { + Log.i("mpv", "${currentTracks.size}") + currentTracks.firstOrNull { + it.type == trackType && (if (isExternal) it.title else "${it.ffIndex}") == "$index" + }.let { track -> + if (track != null) { + Log.i("mpv", "selected track ${track.ffIndex} ${track.type}") + if (!track.selected) { + MPVLib.setPropertyInt(trackType, track.id) + } + } else { + return false + } + } + } else { + if (currentTracks.indexOfFirst { it.type == trackType && it.selected } != C.INDEX_UNSET) { + MPVLib.setPropertyString(trackType, "no") + } + } + return true + } + + // Timeline wrapper + private val timeline: Timeline = object : Timeline() { + /** + * Returns the number of windows in the timeline. + */ + override fun getWindowCount(): Int { + return 1 + } + + /** + * Populates a [com.google.android.exoplayer2.Timeline.Window] with data for the window at the specified index. + * + * @param windowIndex The index of the window. + * @param window The [com.google.android.exoplayer2.Timeline.Window] to populate. Must not be null. + * @param defaultPositionProjectionUs A duration into the future that the populated window's + * default start position should be projected. + * @return The populated [com.google.android.exoplayer2.Timeline.Window], for convenience. + */ + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + val currentMediaItem = internalMediaItem ?: MediaItem.Builder().build() + return if (windowIndex == 0) window.set( + /* uid= */ 0, + /* mediaItem= */ currentMediaItem, + /* manifest= */ null, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + /* isSeekable= */ isSeekable, + /* isDynamic= */ !isSeekable, + /* liveConfiguration= */ currentMediaItem.liveConfiguration, + /* defaultPositionUs= */ C.TIME_UNSET, + /* durationUs= */ C.msToUs(currentDurationMs ?: C.TIME_UNSET), + /* firstPeriodIndex= */ windowIndex, + /* lastPeriodIndex= */ windowIndex, + /* positionInFirstPeriodUs= */ C.TIME_UNSET + ) else window + } + + /** + * Returns the number of periods in the timeline. + */ + override fun getPeriodCount(): Int { + return 1 + } + + /** + * Populates a [com.google.android.exoplayer2.Timeline.Period] with data for the period at the specified index. + * + * @param periodIndex The index of the period. + * @param period The [com.google.android.exoplayer2.Timeline.Period] to populate. Must not be null. + * @param setIds Whether [com.google.android.exoplayer2.Timeline.Period.id] and [com.google.android.exoplayer2.Timeline.Period.uid] should be populated. If false, + * the fields will be set to null. The caller should pass false for efficiency reasons unless + * the fields are required. + * @return The populated [com.google.android.exoplayer2.Timeline.Period], for convenience. + */ + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + return if (periodIndex == 0) period.set( + /* id= */ 0, + /* uid= */ 0, + /* windowIndex= */ periodIndex, + /* durationUs= */ C.msToUs(currentDurationMs ?: C.TIME_UNSET), + /* positionInWindowUs= */ 0 + ) else period + } + + /** + * Returns the index of the period identified by its unique [com.google.android.exoplayer2.Timeline.Period.uid], or [ ][C.INDEX_UNSET] if the period is not in the timeline. + * + * @param uid A unique identifier for a period. + * @return The index of the period, or [C.INDEX_UNSET] if the period was not found. + */ + override fun getIndexOfPeriod(uid: Any): Int { + return if (uid == 0) 0 else C.INDEX_UNSET + } + + /** + * Returns the unique id of the period identified by its index in the timeline. + * + * @param periodIndex The index of the period. + * @return The unique id of the period. + */ + override fun getUidOfPeriod(periodIndex: Int): Any { + return if (periodIndex == 0) 0 else C.INDEX_UNSET + } + } + + // OnAudioFocusChangeListener implementation. + + /** + * Called on the listener to notify it the audio focus for this listener has been changed. + * The focusChange value indicates whether the focus was gained, + * whether the focus was lost, and whether that loss is transient, or whether the new focus + * holder will hold it for an unknown amount of time. + * When losing focus, listeners can use the focus change information to decide what + * behavior to adopt when losing focus. A music player could for instance elect to lower + * the volume of its music stream (duck) for transient focus losses, and pause otherwise. + * @param focusChange the type of focus change, one of [AudioManager.AUDIOFOCUS_GAIN], + * [AudioManager.AUDIOFOCUS_LOSS], [AudioManager.AUDIOFOCUS_LOSS_TRANSIENT] + * and [AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK]. + */ + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + val oldAudioFocusCallback = audioFocusCallback + val wasPlaying = isPlaying + MPVLib.setPropertyBoolean("pause", true) + setPlayerStateAndNotifyIfChanged( + playWhenReady = false, + playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS + ) + audioFocusCallback = { + oldAudioFocusCallback() + if (wasPlaying) MPVLib.setPropertyBoolean("pause", false) + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + MPVLib.command(arrayOf("multiply", "volume", "$AUDIO_FOCUS_DUCKING")) + audioFocusCallback = { + MPVLib.command(arrayOf("multiply", "volume", "${1f/AUDIO_FOCUS_DUCKING}")) + } + } + AudioManager.AUDIOFOCUS_GAIN -> { + audioFocusCallback() + audioFocusCallback = {} + } + } + } + + // Player implementation. + + /** + * Returns the [Looper] associated with the application thread that's used to access the + * player and on which player events are received. + */ + override fun getApplicationLooper(): Looper { + return handler.looper + } + + /** + * Registers a listener to receive events from the player. The listener's methods will be called + * on the thread that was used to construct the player. However, if the thread used to construct + * the player does not have a [Looper], then the listener will be called on the main thread. + * + * @param listener The listener to register. + */ + @Suppress("DEPRECATION") + override fun addListener(listener: Player.EventListener) { + listeners.add(listener) + } + + /** + * Registers a listener to receive all events from the player. + * + * @param listener The listener to register. + */ + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + videoListeners.add(listener) + } + + /** + * Unregister a listener registered through [.addListener]. The listener will + * no longer receive events from the player. + * + * @param listener The listener to unregister. + */ + @Suppress("DEPRECATION") + override fun removeListener(listener: Player.EventListener) { + listeners.remove(listener) + } + + /** + * Unregister a listener registered through [.addListener]. The listener will no + * longer receive events. + * + * @param listener The listener to unregister. + */ + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + videoListeners.remove(listener) + } + + /** + * Clears the playlist and adds the specified [MediaItems][MediaItem]. + * + * @param mediaItems The new [MediaItems][MediaItem]. + * @param resetPosition Whether the playback position should be reset to the default position in + * the first [Timeline.Window]. If false, playback will start from the position defined + * by [.getCurrentWindowIndex] and [.getCurrentPosition]. + */ + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + internalMediaItems = mediaItems + } + + /** + * Clears the playlist and adds the specified [MediaItems][MediaItem]. + * + * @param mediaItems The new [MediaItems][MediaItem]. + * @param startWindowIndex The window index to start playback from. If [com.google.android.exoplayer2.C.INDEX_UNSET] is + * passed, the current position is not reset. + * @param startPositionMs The position in milliseconds to start playback from. If [ ][com.google.android.exoplayer2.C.TIME_UNSET] is passed, the default position of the given window is used. In any case, if + * `startWindowIndex` is set to [com.google.android.exoplayer2.C.INDEX_UNSET], this parameter is ignored and the + * position is not reset at all. + * @throws com.google.android.exoplayer2.IllegalSeekPositionException If the provided `startWindowIndex` is not within the + * bounds of the list of media items. + */ + override fun setMediaItems(mediaItems: MutableList, startWindowIndex: Int, startPositionMs: Long) { + internalMediaItems = mediaItems + initialSeekTo = startPositionMs / 1000 + } + + /** + * Adds a list of media items at the given index of the playlist. + * + * @param index The index at which to add the media items. If the index is larger than the size of + * the playlist, the media items are added to the end of the playlist. + * @param mediaItems The [MediaItems][MediaItem] to add. + */ + override fun addMediaItems(index: Int, mediaItems: MutableList) { + TODO("Not yet implemented") + } + + /** + * Moves the media item range to the new index. + * + * @param fromIndex The start of the range to move. + * @param toIndex The first item not to be included in the range (exclusive). + * @param newIndex The new index of the first media item of the range. If the new index is larger + * than the size of the remaining playlist after removing the range, the range is moved to the + * end of the playlist. + */ + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) { + TODO("Not yet implemented") + } + + /** + * Removes a range of media items from the playlist. + * + * @param fromIndex The index at which to start removing media items. + * @param toIndex The index of the first item to be kept (exclusive). If the index is larger than + * the size of the playlist, media items to the end of the playlist are removed. + */ + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + TODO("Not yet implemented") + } + + /** + * Returns the player's currently available [com.google.android.exoplayer2.Player.Commands]. + * + * + * The returned [com.google.android.exoplayer2.Player.Commands] are not updated when available commands change. Use [ ][com.google.android.exoplayer2.Player.Listener.onAvailableCommandsChanged] to get an update when the available commands + * change. + * + * + * Executing a command that is not available (for example, calling [.next] if [ ][.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM] is unavailable) will neither throw an exception nor generate + * a [.getPlayerError] player error}. + * + * + * [.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM] and [.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM] + * are unavailable if there is no such [MediaItem]. + * + * @return The currently available [com.google.android.exoplayer2.Player.Commands]. + * @see com.google.android.exoplayer2.Player.Listener.onAvailableCommandsChanged + */ + override fun getAvailableCommands(): Player.Commands { + return permanentAvailableCommands + } + + private fun resetInternalState() { + isPlayerReady = false + isSeekable = false + playbackState = Player.STATE_IDLE + currentPlayWhenReady = false + currentPositionMs = null + currentDurationMs = null + currentCacheDurationMs = null + trackGroupArray = TrackGroupArray.EMPTY + trackSelectionArray = TrackSelectionArray() + playbackParameters = PlaybackParameters.DEFAULT + initialCommands.clear() + //initialSeekTo = 0L + } + + /** Prepares the player. */ + override fun prepare() { + internalMediaItems?.firstOrNull { it.playbackProperties?.uri != null }?.let { mediaItem -> + internalMediaItem = mediaItem + resetInternalState() + mediaItem.playbackProperties?.subtitles?.forEach { subtitle -> + initialCommands.add(arrayOf( + /* command= */ "sub-add", + /* url= */ "${subtitle.uri}", + /* flags= */ "auto", + /* title= */ "${subtitle.label}", + /* lang= */ "${subtitle.language}" + )) + } + MPVLib.command(arrayOf("loadfile", "${mediaItem.playbackProperties?.uri}")) + MPVLib.setPropertyBoolean("pause", true) + listeners.sendEvent(Player.EVENT_TIMELINE_CHANGED) { listener -> + listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + } + listeners.sendEvent(Player.EVENT_MEDIA_ITEM_TRANSITION) { listener -> + listener.onMediaItemTransition(mediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + } + setPlayerStateAndNotifyIfChanged(playbackState = Player.STATE_BUFFERING) + } + } + + /** + * Returns the current [playback state][com.google.android.exoplayer2.Player.State] of the player. + * + * @return The current [playback state][com.google.android.exoplayer2.Player.State]. + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackStateChanged + */ + override fun getPlaybackState(): Int { + return playbackState + } + + /** + * Returns the reason why playback is suppressed even though [.getPlayWhenReady] is `true`, or [.PLAYBACK_SUPPRESSION_REASON_NONE] if playback is not suppressed. + * + * @return The current [playback suppression reason][com.google.android.exoplayer2.Player.PlaybackSuppressionReason]. + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackSuppressionReasonChanged + */ + override fun getPlaybackSuppressionReason(): Int { + return PLAYBACK_SUPPRESSION_REASON_NONE + } + + /** + * Returns the error that caused playback to fail. This is the same error that will have been + * reported via [com.google.android.exoplayer2.Player.Listener.onPlayerError] at the time of failure. It + * can be queried using this method until the player is re-prepared. + * + * + * Note that this method will always return `null` if [.getPlaybackState] is not + * [.STATE_IDLE]. + * + * @return The error, or `null`. + * @see com.google.android.exoplayer2.Player.Listener.onPlayerError + */ + override fun getPlayerError(): ExoPlaybackException? { + return null + } + + /** + * Sets whether playback should proceed when [.getPlaybackState] == [.STATE_READY]. + * + * + * If the player is already in the ready state then this method pauses and resumes playback. + * + * @param playWhenReady Whether playback should proceed when ready. + */ + override fun setPlayWhenReady(playWhenReady: Boolean) { + if (currentPlayWhenReady != playWhenReady) { + setPlayerStateAndNotifyIfChanged( + playWhenReady = playWhenReady, + playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST + ) + if (isPlayerReady) { + MPVLib.setPropertyBoolean("pause", !playWhenReady) + } + } + } + + /** + * Whether playback will proceed when [.getPlaybackState] == [.STATE_READY]. + * + * @return Whether playback will proceed when ready. + * @see com.google.android.exoplayer2.Player.Listener.onPlayWhenReadyChanged + */ + override fun getPlayWhenReady(): Boolean { + return currentPlayWhenReady + } + + /** + * Sets the [com.google.android.exoplayer2.Player.RepeatMode] to be used for playback. + * + * @param repeatMode The repeat mode. + */ + override fun setRepeatMode(repeatMode: Int) { + TODO("Not yet implemented") + } + + /** + * Returns the current [com.google.android.exoplayer2.Player.RepeatMode] used for playback. + * + * @return The current repeat mode. + * @see com.google.android.exoplayer2.Player.Listener.onRepeatModeChanged + */ + override fun getRepeatMode(): Int { + return repeatMode + } + + /** + * Sets whether shuffling of windows is enabled. + * + * @param shuffleModeEnabled Whether shuffling is enabled. + */ + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + TODO("Not yet implemented") + } + + /** + * Returns whether shuffling of windows is enabled. + * + * @see com.google.android.exoplayer2.Player.Listener.onShuffleModeEnabledChanged + */ + override fun getShuffleModeEnabled(): Boolean { + return false + } + + /** + * Whether the player is currently loading the source. + * + * @return Whether the player is currently loading the source. + * @see com.google.android.exoplayer2.Player.Listener.onIsLoadingChanged + */ + override fun isLoading(): Boolean { + return false + } + + /** + * Seeks to a position specified in milliseconds in the specified window. + * + * @param windowIndex The index of the window. + * @param positionMs The seek position in the specified window, or [com.google.android.exoplayer2.C.TIME_UNSET] to seek to + * the window's default position. + * @throws com.google.android.exoplayer2.IllegalSeekPositionException If the player has a non-empty timeline and the provided + * `windowIndex` is not within the bounds of the current timeline. + */ + override fun seekTo(windowIndex: Int, positionMs: Long) { + if (windowIndex == 0) { + val seekTo = if (positionMs != C.TIME_UNSET) positionMs / C.MILLIS_PER_SECOND else initialSeekTo + if (isPlayerReady) { + MPVLib.command(arrayOf("seek", "$seekTo", "absolute")) + initialSeekTo = 0L + } else { + initialSeekTo = seekTo + } + } + } + + override fun getSeekBackIncrement(): Long { + return 5000 + } + + override fun getSeekForwardIncrement(): Long { + return 15000 + } + + override fun getMaxSeekToPreviousPosition(): Int { + TODO("Not yet implemented") + } + + /** + * Attempts to set the playback parameters. Passing [PlaybackParameters.DEFAULT] resets the + * player to the default, which means there is no speed or pitch adjustment. + * + * + * Playback parameters changes may cause the player to buffer. [ ][com.google.android.exoplayer2.Player.Listener.onPlaybackParametersChanged] will be called whenever the currently + * active playback parameters change. + * + * @param playbackParameters The playback parameters. + */ + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + if (getPlaybackParameters().speed != playbackParameters.speed) { + MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble()) + } + } + + /** + * Returns the currently active playback parameters. + * + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackParametersChanged + */ + override fun getPlaybackParameters(): PlaybackParameters { + return playbackParameters + } + + override fun stop(reset: Boolean) { + MPVLib.command(arrayOf("stop", "keep-playlist")) + } + + /** + * Releases the player. This method must be called when the player is no longer required. The + * player must not be used after calling this method. + */ + override fun release() { + if (audioFocusRequest == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(this) + } + resetInternalState() + MPVLib.destroy() + } + + /** + * Returns the available track groups. + * + * @see com.google.android.exoplayer2.Player.Listener.onTracksChanged + */ + override fun getCurrentTrackGroups(): TrackGroupArray { + return trackGroupArray + } + + /** + * Returns the current track selections. + * + * + * A concrete implementation may include null elements if it has a fixed number of renderer + * components, wishes to report a TrackSelection for each of them, and has one or more renderer + * components that is not assigned any selected tracks. + * + * @see com.google.android.exoplayer2.Player.Listener.onTracksChanged + */ + override fun getCurrentTrackSelections(): TrackSelectionArray { + return trackSelectionArray + } + + /** + * Returns the current static metadata for the track selections. + * + * + * The returned `metadataList` is an immutable list of [Metadata] instances, where + * the elements correspond to the [current track selections][.getCurrentTrackSelections], + * or an empty list if there are no track selections or the selected tracks contain no static + * metadata. + * + * + * This metadata is considered static in that it comes from the tracks' declared Formats, + * rather than being timed (or dynamic) metadata, which is represented within a metadata track. + * + * @see com.google.android.exoplayer2.Player.Listener.onStaticMetadataChanged + */ + override fun getCurrentStaticMetadata(): List { + return emptyList() + } + + /** + * Returns the current combined [MediaMetadata], or [MediaMetadata.EMPTY] if not + * supported. + * + * + * This [MediaMetadata] is a combination of the [MediaItem.mediaMetadata] and the + * static and dynamic metadata sourced from [com.google.android.exoplayer2.Player.Listener.onStaticMetadataChanged] and + * [com.google.android.exoplayer2.metadata.MetadataOutput.onMetadata]. + */ + override fun getMediaMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun getPlaylistMetadata(): MediaMetadata { + TODO("Not yet implemented") + } + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + TODO("Not yet implemented") + } + + /** + * Returns the current [Timeline]. Never null, but may be empty. + * + * @see com.google.android.exoplayer2.Player.Listener.onTimelineChanged + */ + override fun getCurrentTimeline(): Timeline { + return timeline + } + + /** Returns the index of the period currently being played. */ + override fun getCurrentPeriodIndex(): Int { + return currentWindowIndex + } + + /** + * Returns the index of the current [window][Timeline.Window] in the [ ][.getCurrentTimeline], or the prospective window index if the [ ][.getCurrentTimeline] is empty. + */ + override fun getCurrentWindowIndex(): Int { + return timeline.getFirstWindowIndex(shuffleModeEnabled) + } + + /** + * Returns the duration of the current content window or ad in milliseconds, or [ ][com.google.android.exoplayer2.C.TIME_UNSET] if the duration is not known. + */ + override fun getDuration(): Long { + return timeline.getWindow(currentWindowIndex, window).durationMs + } + + /** + * Returns the playback position in the current content window or ad, in milliseconds, or the + * prospective position in milliseconds if the [current timeline][.getCurrentTimeline] is + * empty. + */ + override fun getCurrentPosition(): Long { + return currentPositionMs ?: C.TIME_UNSET + } + + /** + * Returns an estimate of the position in the current content window or ad up to which data is + * buffered, in milliseconds. + */ + override fun getBufferedPosition(): Long { + return currentCacheDurationMs ?: contentPosition + } + + /** + * Returns an estimate of the total buffered duration from the current position, in milliseconds. + * This includes pre-buffered data for subsequent ads and windows. + */ + override fun getTotalBufferedDuration(): Long { + return bufferedPosition + } + + /** Returns whether the player is currently playing an ad. */ + override fun isPlayingAd(): Boolean { + return false + } + + /** + * If [.isPlayingAd] returns true, returns the index of the ad group in the period + * currently being played. Returns [com.google.android.exoplayer2.C.INDEX_UNSET] otherwise. + */ + override fun getCurrentAdGroupIndex(): Int { + return C.INDEX_UNSET + } + + /** + * If [.isPlayingAd] returns true, returns the index of the ad in its ad group. Returns + * [com.google.android.exoplayer2.C.INDEX_UNSET] otherwise. + */ + override fun getCurrentAdIndexInAdGroup(): Int { + return C.INDEX_UNSET + } + + /** + * If [.isPlayingAd] returns `true`, returns the content position that will be + * played once all ads in the ad group have finished playing, in milliseconds. If there is no ad + * playing, the returned position is the same as that returned by [.getCurrentPosition]. + */ + override fun getContentPosition(): Long { + return currentPosition + } + + /** + * If [.isPlayingAd] returns `true`, returns an estimate of the content position in + * the current content window up to which data is buffered, in milliseconds. If there is no ad + * playing, the returned position is the same as that returned by [.getBufferedPosition]. + */ + override fun getContentBufferedPosition(): Long { + return bufferedPosition + } + + /** Returns the attributes for audio playback. */ + override fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.DEFAULT + } + + /** + * Sets the audio volume, with 0 being silence and 1 being unity gain (signal unchanged). + * + * @param audioVolume Linear output gain to apply to all audio channels. + */ + override fun setVolume(audioVolume: Float) { + TODO("Not yet implemented") + } + + /** + * Returns the audio volume, with 0 being silence and 1 being unity gain (signal unchanged). + * + * @return The linear gain applied to all audio channels. + */ + override fun getVolume(): Float { + TODO("Not yet implemented") + } + + /** + * Clears any [Surface], [SurfaceHolder], [SurfaceView] or [TextureView] + * currently set on the player. + */ + override fun clearVideoSurface() { + TODO("Not yet implemented") + } + + /** + * Clears the [Surface] onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param surface The surface to clear. + */ + override fun clearVideoSurface(surface: Surface?) { + TODO("Not yet implemented") + } + + /** + * Sets the [Surface] onto which video will be rendered. The caller is responsible for + * tracking the lifecycle of the surface, and must clear the surface by calling `setVideoSurface(null)` if the surface is destroyed. + * + * + * If the surface is held by a [SurfaceView], [TextureView] or [ ] then it's recommended to use [.setVideoSurfaceView], [ ][.setVideoTextureView] or [.setVideoSurfaceHolder] rather than + * this method, since passing the holder allows the player to track the lifecycle of the surface + * automatically. + * + * @param surface The [Surface]. + */ + override fun setVideoSurface(surface: Surface?) { + TODO("Not yet implemented") + } + + /** + * Sets the [SurfaceHolder] that holds the [Surface] onto which video will be + * rendered. The player will track the lifecycle of the surface automatically. + * + * @param surfaceHolder The surface holder. + */ + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + TODO("Not yet implemented") + } + + /** + * Clears the [SurfaceHolder] that holds the [Surface] onto which video is being + * rendered if it matches the one passed. Else does nothing. + * + * @param surfaceHolder The surface holder to clear. + */ + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + TODO("Not yet implemented") + } + + /** + * Sets the [SurfaceView] onto which video will be rendered. The player will track the + * lifecycle of the surface automatically. + * + * @param surfaceView The surface view. + */ + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + surfaceView?.holder?.addCallback(surfaceHolder) + } + + /** + * Clears the [SurfaceView] onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param surfaceView The texture view to clear. + */ + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + surfaceView?.holder?.removeCallback(surfaceHolder) + } + + /** + * Sets the [TextureView] onto which video will be rendered. The player will track the + * lifecycle of the surface automatically. + * + * @param textureView The texture view. + */ + override fun setVideoTextureView(textureView: TextureView?) { + TODO("Not yet implemented") + } + + /** + * Clears the [TextureView] onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param textureView The texture view to clear. + */ + override fun clearVideoTextureView(textureView: TextureView?) { + TODO("Not yet implemented") + } + + /** + * Gets the size of the video. + * + * + * The video's width and height are `0` if there is no video or its size has not been + * determined yet. + * + * @see com.google.android.exoplayer2.Player.Listener.onVideoSizeChanged + */ + override fun getVideoSize(): VideoSize { + return VideoSize.UNKNOWN + } + + /** Returns the current [Cues][Cue]. This list may be empty. */ + override fun getCurrentCues(): MutableList { + TODO("Not yet implemented") + } + + /** Gets the device information. */ + override fun getDeviceInfo(): DeviceInfo { + TODO("Not yet implemented") + } + + /** + * Gets the current volume of the device. + * + * + * For devices with [local playback][DeviceInfo.PLAYBACK_TYPE_LOCAL], the volume returned + * by this method varies according to the current [stream type][com.google.android.exoplayer2.C.StreamType]. The stream + * type is determined by [AudioAttributes.usage] which can be converted to stream type with + * [Util.getStreamTypeForAudioUsage]. + * + * + * For devices with [remote playback][DeviceInfo.PLAYBACK_TYPE_REMOTE], the volume of the + * remote device is returned. + */ + override fun getDeviceVolume(): Int { + TODO("Not yet implemented") + } + + /** Gets whether the device is muted or not. */ + override fun isDeviceMuted(): Boolean { + TODO("Not yet implemented") + } + + /** + * Sets the volume of the device. + * + * @param volume The volume to set. + */ + override fun setDeviceVolume(volume: Int) { + TODO("Not yet implemented") + } + + /** Increases the volume of the device. */ + override fun increaseDeviceVolume() { + TODO("Not yet implemented") + } + + /** Decreases the volume of the device. */ + override fun decreaseDeviceVolume() { + TODO("Not yet implemented") + } + + /** Sets the mute state of the device. */ + override fun setDeviceMuted(muted: Boolean) { + TODO("Not yet implemented") + } + + private class CurrentTrackSelection( + private val currentTrackGroup: TrackGroup, + private val index: Int + ) : TrackSelection { + /** + * Returns an integer specifying the type of the selection, or [.TYPE_UNSET] if not + * specified. + * + * + * Track selection types are specific to individual applications, but should be defined + * starting from [.TYPE_CUSTOM_BASE] to ensure they don't conflict with any types that may + * be added to the library in the future. + */ + override fun getType(): Int { + return TrackSelection.TYPE_UNSET + } + + /** Returns the [TrackGroup] to which the selected tracks belong. */ + override fun getTrackGroup(): TrackGroup { + return currentTrackGroup + } + + /** Returns the number of tracks in the selection. */ + override fun length(): Int { + return if (index != C.INDEX_UNSET) 1 else 0 + } + + /** + * Returns the format of the track at a given index in the selection. + * + * @param index The index in the selection. + * @return The format of the selected track. + */ + override fun getFormat(index: Int): Format { + return currentTrackGroup.getFormat(index) + } + + /** + * Returns the index in the track group of the track at a given index in the selection. + * + * @param index The index in the selection. + * @return The index of the selected track. + */ + override fun getIndexInTrackGroup(index: Int): Int { + return index + } + + /** + * Returns the index in the selection of the track with the specified format. The format is + * located by identity so, for example, `selection.indexOf(selection.getFormat(index)) == + * index` even if multiple selected tracks have formats that contain the same values. + * + * @param format The format. + * @return The index in the selection, or [C.INDEX_UNSET] if the track with the specified + * format is not part of the selection. + */ + override fun indexOf(format: Format): Int { + return currentTrackGroup.indexOf(format) + } + + /** + * Returns the index in the selection of the track with the specified index in the track group. + * + * @param indexInTrackGroup The index in the track group. + * @return The index in the selection, or [C.INDEX_UNSET] if the track with the specified + * index is not part of the selection. + */ + override fun indexOf(indexInTrackGroup: Int): Int { + return indexInTrackGroup + } + } + + companion object { + /** + * Fraction to which audio volume is ducked on loss of audio focus + */ + private const val AUDIO_FOCUS_DUCKING = 0.5f + + private val permanentAvailableCommands: Player.Commands = Player.Commands.Builder() + .addAll( + COMMAND_PLAY_PAUSE, + COMMAND_SEEK_IN_CURRENT_WINDOW, + COMMAND_PREPARE_STOP, + COMMAND_SET_SPEED_AND_PITCH, + COMMAND_GET_CURRENT_MEDIA_ITEM, + COMMAND_GET_MEDIA_ITEMS_METADATA, + COMMAND_CHANGE_MEDIA_ITEMS, + COMMAND_SET_VIDEO_SURFACE, + COMMAND_SEEK_FORWARD, + COMMAND_SEEK_BACK + ) + .build() + + private val surfaceHolder: SurfaceHolder.Callback = object : SurfaceHolder.Callback { + /** + * This is called immediately after the surface is first created. + * Implementations of this should start up whatever rendering code + * they desire. Note that only one thread can ever draw into + * a [Surface], so you should not draw into the Surface here + * if your normal rendering will be in another thread. + * + * @param holder The SurfaceHolder whose surface is being created. + */ + override fun surfaceCreated(holder: SurfaceHolder) { + MPVLib.attachSurface(holder.surface) + MPVLib.setOptionString("force-window", "yes") + MPVLib.setOptionString("vo", "gpu") + } + + /** + * This is called immediately after any structural changes (format or + * size) have been made to the surface. You should at this point update + * the imagery in the surface. This method is always called at least + * once, after [.surfaceCreated]. + * + * @param holder The SurfaceHolder whose surface has changed. + * @param format The new [android.graphics.PixelFormat] of the surface. + * @param width The new width of the surface. + * @param height The new height of the surface. + */ + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + MPVLib.setPropertyString("android-surface-size", "${width}x$height") + } + + /** + * This is called immediately before a surface is being destroyed. After + * returning from this call, you should no longer try to access this + * surface. If you have a rendering thread that directly accesses + * the surface, you must ensure that thread is no longer touching the + * Surface before returning from this function. + * + * @param holder The SurfaceHolder whose surface is being destroyed. + */ + override fun surfaceDestroyed(holder: SurfaceHolder) { + MPVLib.setOptionString("vo", "null") + MPVLib.setOptionString("force-window", "no") + MPVLib.detachSurface() + } + } + + @Parcelize + data class Track( + val id: Int, + @TrackType val type: String, + val mimeType: String = when (type) { + TrackType.VIDEO -> MimeTypes.BASE_TYPE_VIDEO + TrackType.AUDIO -> MimeTypes.BASE_TYPE_AUDIO + TrackType.SUBTITLE -> MimeTypes.BASE_TYPE_TEXT + else -> "" + }, + val title: String, + val lang: String, + val external: Boolean, + val selected: Boolean, + val externalFilename: String?, + val ffIndex: Int, + val codec: String, + val width: Int?, + val height: Int? + ) : Parcelable { + fun toFormat() : Format { + return Format.Builder() + .setId(id) + .setContainerMimeType("$mimeType/$codec") + .setSampleMimeType("$mimeType/$codec") + .setCodecs(codec) + .setWidth(width ?: Format.NO_VALUE) + .setHeight(height ?: Format.NO_VALUE) + .build() + } + companion object { + fun fromJSON(json: JSONObject): Track { + return Track( + id = json.optInt("id"), + type = json.optString("type"), + title = json.optString("title"), + lang = json.optString("lang"), + external = json.getBoolean("external"), + selected = json.getBoolean("selected"), + externalFilename = json.optString("external-filename"), + ffIndex = json.optInt("ff-index"), + codec = json.optString("codec"), + width = json.optInt("demux-w").takeIf { it > 0 }, + height = json.optInt("demux-h").takeIf { it > 0 } + ) + } + } + } + + private fun getMPVTracks(trackList: String) : Triple ,TrackGroupArray, TrackSelectionArray> { + val tracks = mutableListOf() + var trackGroupArray = TrackGroupArray.EMPTY + var trackSelectionArray = TrackSelectionArray() + + val trackListVideo = mutableListOf() + val trackListAudio = mutableListOf() + val trackListText = mutableListOf() + var indexCurrentVideo: Int = C.INDEX_UNSET + var indexCurrentAudio: Int = C.INDEX_UNSET + var indexCurrentText: Int = C.INDEX_UNSET + try { + val currentTrackList = JSONArray(trackList) + for (index in 0 until currentTrackList.length()) { + val currentTrack = Track.fromJSON(currentTrackList.getJSONObject(index)) + val currentFormat = currentTrack.toFormat() + when (currentTrack.type) { + TrackType.VIDEO -> { + tracks.add(currentTrack) + trackListVideo.add(currentFormat) + if (currentTrack.selected) { + indexCurrentVideo = trackListVideo.indexOf(currentFormat) + } + } + TrackType.AUDIO -> { + tracks.add(currentTrack) + trackListAudio.add(currentFormat) + if (currentTrack.selected) { + indexCurrentAudio = trackListAudio.indexOf(currentFormat) + } + } + TrackType.SUBTITLE -> { + tracks.add(currentTrack) + trackListText.add(currentFormat) + if (currentTrack.selected) { + indexCurrentText = trackListText.indexOf(currentFormat) + } + } + else -> continue + } + } + val trackGroups = mutableListOf() + val trackSelections = mutableListOf() + if (trackListVideo.isNotEmpty()) { + with(TrackGroup(*trackListVideo.toTypedArray())) { + trackGroups.add(this) + trackSelections.add(CurrentTrackSelection(this, indexCurrentVideo)) + } + } + if (trackListAudio.isNotEmpty()) { + with(TrackGroup(*trackListAudio.toTypedArray())) { + trackGroups.add(this) + trackSelections.add(CurrentTrackSelection(this, indexCurrentAudio)) + } + } + if (trackListText.isNotEmpty()) { + with(TrackGroup(*trackListText.toTypedArray())) { + trackGroups.add(this) + trackSelections.add(CurrentTrackSelection(this, indexCurrentText)) + } + } + if (trackGroups.isNotEmpty()) { + trackGroupArray = TrackGroupArray(*trackGroups.toTypedArray()) + trackSelectionArray = TrackSelectionArray(*trackSelections.toTypedArray()) + } + } catch (e: JSONException) {} + return Triple(tracks, trackGroupArray, trackSelectionArray) + } + + /** + * Merges multiple [subtitleSources] into a single [videoSource] + */ + fun mergeMediaSources( + videoSource: MediaSource, + subtitleSources: Array, + dataSource: DataSource.Factory + ): MediaSource { + return when { + subtitleSources.isEmpty() -> videoSource + else -> { + val subtitles = mutableListOf() + subtitleSources.forEach { subtitleSource -> + subtitleSource.mediaItem.playbackProperties?.subtitles?.forEach { subtitle -> + subtitles.add(subtitle) + } + } + ProgressiveMediaSource.Factory(dataSource) + .createMediaSource( + videoSource.mediaItem.buildUpon() + .setSubtitles(subtitles).build() + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/mpv/TrackType.java b/app/src/main/java/dev/jdtech/jellyfin/mpv/TrackType.java new file mode 100644 index 00000000..0122b18e --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/mpv/TrackType.java @@ -0,0 +1,14 @@ +package dev.jdtech.jellyfin.mpv; + +import androidx.annotation.StringDef; + +@StringDef({ + TrackType.VIDEO, + TrackType.AUDIO, + TrackType.SUBTITLE, +}) +public @interface TrackType { + String VIDEO = "video"; + String AUDIO = "audio"; + String SUBTITLE = "sub"; +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt index e09b18a8..33ff30b5 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt @@ -86,7 +86,7 @@ constructor( val intros = jellyfinRepository.getIntros(startEpisode.id) for (intro in intros) { if (intro.mediaSources.isNullOrEmpty()) continue - playerItems.add(PlayerItem(intro.id, intro.mediaSources?.get(0)?.id!!, 0)) + playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0)) introsCount += 1 } } @@ -102,6 +102,7 @@ constructor( if (episode.locationType == LocationType.VIRTUAL) continue playerItems.add( PlayerItem( + episode.name, episode.id, episode.mediaSources?.get(0)?.id!!, playbackPosition diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt index 4b0a5485..ff032f3b 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt @@ -202,7 +202,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { val intros = jellyfinRepository.getIntros(series.id) for (intro in intros) { if (intro.mediaSources.isNullOrEmpty()) continue - playerItems.add(PlayerItem(intro.id, intro.mediaSources?.get(0)?.id!!, 0)) + playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0)) introsCount += 1 } } @@ -211,6 +211,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { "Movie" -> { playerItems.add( PlayerItem( + series.name, series.id, series.mediaSources?.get(mediaSourceIndex ?: 0)?.id!!, playbackPosition @@ -231,6 +232,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { if (episode.locationType == LocationType.VIRTUAL) continue playerItems.add( PlayerItem( + episode.name, episode.id, episode.mediaSources?.get(0)?.id!!, 0 @@ -250,6 +252,7 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() { if (episode.locationType == LocationType.VIRTUAL) continue playerItems.add( PlayerItem( + episode.name, episode.id, episode.mediaSources?.get(0)?.id!!, 0 diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt index 53d9cfd7..e45e4a0c 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/PlayerActivityViewModel.kt @@ -12,6 +12,8 @@ import com.google.android.exoplayer2.* import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.mpv.MPVPlayer +import dev.jdtech.jellyfin.mpv.TrackType import dev.jdtech.jellyfin.repository.JellyfinRepository import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -23,38 +25,70 @@ import javax.inject.Inject class PlayerActivityViewModel @Inject constructor( - private val application: Application, + application: Application, private val jellyfinRepository: JellyfinRepository ) : ViewModel(), Player.Listener { - private var _player = MutableLiveData() - var player: LiveData = _player + val player: BasePlayer private val _navigateBack = MutableLiveData() val navigateBack: LiveData = _navigateBack + private val _currentItemTitle = MutableLiveData() + val currentItemTitle: LiveData = _currentItemTitle + + var currentAudioTracks: MutableList = mutableListOf() + var currentSubtitleTracks: MutableList = mutableListOf() + + private val _fileLoaded = MutableLiveData(false) + val fileLoaded: LiveData = _fileLoaded + + private var items: Array = arrayOf() + + val trackSelector = DefaultTrackSelector(application) var playWhenReady = true private var currentWindow = 0 private var playbackPosition: Long = 0 private val sp = PreferenceManager.getDefaultSharedPreferences(application) + init { + val useMpv = sp.getBoolean("mpv_player", false) + + val preferredAudioLanguage = sp.getString("audio_language", null) ?: "" + val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: "" + + if (useMpv) { + val preferredLanguages = mapOf( + TrackType.AUDIO to preferredAudioLanguage, + TrackType.SUBTITLE to preferredSubtitleLanguage + ) + player = MPVPlayer( + application, + false, + preferredLanguages, + sp.getBoolean("mpv_disable_hwdec", false) + ) + } else { + val renderersFactory = + DefaultRenderersFactory(application).setExtensionRendererMode( + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + ) + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTunnelingEnabled(true) + .setPreferredAudioLanguage(preferredAudioLanguage) + .setPreferredTextLanguage(preferredSubtitleLanguage) + ) + player = SimpleExoPlayer.Builder(application, renderersFactory) + .setTrackSelector(trackSelector) + .build() + } + } + fun initializePlayer( items: Array ) { - - val renderersFactory = - DefaultRenderersFactory(application).setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - val trackSelector = DefaultTrackSelector(application) - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTunnelingEnabled(true) - .setPreferredAudioLanguage(sp.getString("audio_language", null)) - .setPreferredTextLanguage(sp.getString("subtitle_language", null)) - ) - val player = SimpleExoPlayer.Builder(application, renderersFactory) - .setTrackSelector(trackSelector) - .build() - + this.items = items player.addListener(this) viewModelScope.launch { @@ -76,16 +110,15 @@ constructor( } player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition) - player.playWhenReady = playWhenReady player.prepare() - _player.value = player + player.play() } pollPosition(player) } private fun releasePlayer() { - _player.value?.let { player -> + player.let { player -> runBlocking { try { jellyfinRepository.postPlaybackStop( @@ -98,17 +131,14 @@ constructor( } } - if (player.value != null) { - playWhenReady = player.value!!.playWhenReady - playbackPosition = player.value!!.currentPosition - currentWindow = player.value!!.currentWindowIndex - player.value!!.removeListener(this) - player.value!!.release() - _player.value = null - } + playWhenReady = player.playWhenReady + playbackPosition = player.currentPosition + currentWindow = player.currentWindowIndex + player.removeListener(this) + player.release() } - private fun pollPosition(player: SimpleExoPlayer) { + private fun pollPosition(player: BasePlayer) { val handler = Handler(Looper.getMainLooper()) val runnable = object : Runnable { override fun run() { @@ -135,6 +165,11 @@ constructor( Timber.d("Playing MediaItem: ${mediaItem?.mediaId}") viewModelScope.launch { try { + for (item in items) { + if (item.itemId.toString() == player.currentMediaItem?.mediaId ?: "") { + _currentItemTitle.value = item.name + } + } jellyfinRepository.postPlaybackStart(UUID.fromString(mediaItem?.mediaId)) } catch (e: Exception) { Timber.e(e) @@ -153,6 +188,23 @@ constructor( } ExoPlayer.STATE_READY -> { stateString = "ExoPlayer.STATE_READY -" + currentAudioTracks.clear() + currentSubtitleTracks.clear() + when (player) { + is MPVPlayer -> { + player.currentTracks.forEach { + when (it.type) { + TrackType.AUDIO -> { + currentAudioTracks.add(it) + } + TrackType.SUBTITLE -> { + currentSubtitleTracks.add(it) + } + } + } + } + } + _fileLoaded.value = true } ExoPlayer.STATE_ENDED -> { stateString = "ExoPlayer.STATE_ENDED -" @@ -167,4 +219,10 @@ constructor( Timber.d("Clearing Player ViewModel") releasePlayer() } + + fun switchToTrack(trackType: String, track: MPVPlayer.Companion.Track) { + if (player is MPVPlayer) { + player.selectTrack(trackType, isExternal = false, index = track.ffIndex) + } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_left.xml b/app/src/main/res/drawable/ic_arrow_left.xml new file mode 100644 index 00000000..adccd74d --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_closed_caption.xml b/app/src/main/res/drawable/ic_closed_caption.xml new file mode 100644 index 00000000..cd15b88a --- /dev/null +++ b/app/src/main/res/drawable/ic_closed_caption.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml new file mode 100644 index 00000000..f241b31a --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..c7c41e07 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_rewind.xml b/app/src/main/res/drawable/ic_rewind.xml new file mode 100644 index 00000000..ff2df095 --- /dev/null +++ b/app/src/main/res/drawable/ic_rewind.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_skip_back.xml b/app/src/main/res/drawable/ic_skip_back.xml new file mode 100644 index 00000000..f12a32aa --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_back.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_skip_forward.xml b/app/src/main/res/drawable/ic_skip_forward.xml new file mode 100644 index 00000000..177c4488 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_forward.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_speaker.xml b/app/src/main/res/drawable/ic_speaker.xml new file mode 100644 index 00000000..e1f49ba4 --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/transparent_circle_background.xml b/app/src/main/res/drawable/transparent_circle_background.xml new file mode 100644 index 00000000..6d78ee5e --- /dev/null +++ b/app/src/main/res/drawable/transparent_circle_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 19680665..d8ae92df 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -1,16 +1,16 @@ - - + app:show_buffering="always" /> - + diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml new file mode 100644 index 00000000..20a57760 --- /dev/null +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml new file mode 100644 index 00000000..81415aa1 --- /dev/null +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,59 @@ + + Findroid + Aplicación nativa de terceros para Jellyfin + Emblema Jellyfin + Agregar servidor + Acceso + Seleccionar servidor + Dirección del servidor + Usuario + Contraseña + Conectar + Acceder + Icono del servidor + Quitar servidor + ¿Está seguro de quitar el servidor %1$s + Quitar + Cancelar + Inicio + Mis contenidos + Favoritos + Configuración + Ver todo + Error al cargar datos + Reintentar + Generos + Director + Escritores + Reparto y equipo + Temporadas + Reproducir contenido + Ver el trailer + Marcar como visto o No visto + Favorito + Indicador de episodio visto + %1$d. %2$s + S%1$d:E%2$d - %3$s + Siguiente + Continuar viendo + Poster serie + No tienes favoritos + Buscar + Búsqueda sin resultados + Idioma + Idioma de audio preferido + Idioma de subtitulo preferido + Iniciando… + Servidores + Administrar servidores + Apariencia + Tema + Error preparando elementos. + Ver detalles + Ver detalles + Acerca + Política de privacidad + Información de la App + Error desconocido + Últimos %1$s + \ No newline at end of file diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml new file mode 100644 index 00000000..8af190ce --- /dev/null +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -0,0 +1,59 @@ + + Findroid + Aplicación nativa de terceros para Jellyfin + Emblema Jellyfin + Agregar servidor + Acceso + Seleccionar servidor + Dirección del servidor + Usuario + Contraseña + Conectar + Acceder + Icono del servidor + Quitar servidor + ¿Está seguro de quitar el servidor %1$s + Quitar + Cancelar + Inicio + Mis contenidos + Favoritos + Configuración + Ver todo + Error al cargar datos + Reintentar + Generos + Director + Escritores + Reparto y equipo + Temporadas + Reproducir contenido + Ver el trailer + Marcar cono visto o No visto + Favorito + Indicador de episodio visto + %1$d. %2$s + S%1$d:E%2$d - %3$s + Siguiente + Continar viendo + Últimos %1$s + Poster serie + No tienes favoritos + Buscar + Búsqueda sin resultados + Idioma + Idioma de audio preferido + Idioma de subtitulo preferido + Iniciando… + Servidores + Administrar servidores + Apariencia + Tema + Error preparando elementos. + Ver detalles + Ver detalles + Acerca + Política de privacidad + Información de la App + Error desconocido + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 00000000..317d951b --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,59 @@ + + Findroid + Aplicación nativa de terceros para Jellyfin + Emblema Jellyfin + Agregar servidor + Acceso + Seleccionar servidor + Dirección del servidor + Usuario + Contraseña + Conectar + Acceder + Icono del servidor + Quitar servidor + ¿Está seguro de quitar el servidor %1$s + Quitar + Cancelar + Inicio + Mis medios + Favoritos + Ajustes + Ver todo + Error al cargar datos + Reintentar + Géneros + Director + Escritores + Reparto y equipo + Temporadas + Reproducir contenido + Ver el adelanto + Marcar como visto o No visto + Favorito + Indicador de episodio visto + %1$d. %2$s + S%1$d:E%2$d - %3$s + Siguiente + Continar viendo + Últimos %1$s + Poster serie + No tienes favoritos + Buscar + Búsqueda sin resultados + Idioma + Idioma de audio preferido + Idioma de subtitulo preferido + Iniciando… + Servidores + Administrar servidores + Apariencia + Tema + Error preparando elementos. + Ver detalles + Ver detalles + Acerca + Política de privacidad + Información de aplicación + Error desconocido + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f230cfa6..4e95ff86 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -15,4 +15,5 @@ #FFFFFFFF #EB5757 #F2C94C + #AA000000 \ No newline at end of file diff --git a/app/src/main/res/values/drawables.xml b/app/src/main/res/values/drawables.xml new file mode 100644 index 00000000..131b14cb --- /dev/null +++ b/app/src/main/res/values/drawables.xml @@ -0,0 +1,5 @@ + + + @drawable/ic_play + @drawable/ic_pause + \ No newline at end of file diff --git a/app/src/main/res/values/languages.xml b/app/src/main/res/values/languages.xml index 0a4c73bd..306f3e9c 100644 --- a/app/src/main/res/values/languages.xml +++ b/app/src/main/res/values/languages.xml @@ -190,189 +190,189 @@ null - ab - aa - af - ak - sq - am - ar - an - hy - as - av - ae - ay - az - bm - ba - eu - be - bn - bh - bi - nb - bs - br - bg - my - ca - km - ch - ce - ny - zh - cu - cv - kw - co - cr - hr - cs - da - dv - nl - dz - en - eo - et - ee - fo - fj - fi - fr - ff - gd - gl - lg - ka - de - el - gn - gu - ht - ha - he - hz - hi - ho - hu - is - io - ig - id - ia - ie - iu - ik - ga - it - ja - jv - kl - kn - kr - ks - kk - ki - rw - ky - kv - kg - ko - kj - ku - lo - la - lv - li - ln - lt - lu - lb - mk - mg - ms - ml - mt - gv - mi - mr - mh - mn - na - nv - nd - nr - ng - ne - se - no - nn - oc - oj - or - om - os - pi - pa - fa - pl - pt - ps - qu - ro - rm - rn - ru - sm - sg - sa - sc - sr - sn - ii - sd - si - sk - sl - so - st - es - su - sw - ss - sv - tl - ty - tg - ta - tt - te - th - bo - ti - to - ts - tn - tr - tk - tw - ug - uk - ur - uz - ve - vi - vo - wa - cy - fy - wo - xh - yi - yo - za - zu + abk + aar + afr + aka + sqi + amh + ara + arg + hye + asm + ava + ave + aym + aze + bam + bak + eus + bel + ben + bih + bis + nob + bos + bre + bul + bur + cat + khm + cha + che + nya + chi + chu + chv + cor + cos + cre + hrv + cze + dan + div + dut + dzo + eng + epo + est + ewe + fao + fij + fin + fre + ful + gla + glg + lug + geo + ger + gre + grn + guj + hat + hau + heb + her + hin + hmo + hun + ice + ido + ibo + ind + ina + ile + iku + ipk + gle + ita + jpn + jav + kal + kan + kau + kas + kaz + kik + kin + kir + kom + kon + kor + kua + kur + lao + lat + lav + lim + lin + lit + lub + ltz + mac + mlg + may + mal + mlt + glv + mao + mar + mah + mon + nau + nav + nde + nbl + ndo + nep + sme + nor + nno + oci + oji + ori + orm + oss + pli + pan + per + pol + por + pus + que + rum + roh + run + rus + smo + sag + san + srd + srp + sna + iii + snd + sin + slo + slv + som + sot + spa + sun + swa + ssw + swe + tgl + tah + tgk + tam + tat + tel + tha + tib + tir + ton + tso + tsn + tur + tuk + twi + uig + ukr + urd + uzb + ven + vie + vol + wln + wel + fry + wol + xho + yid + yor + zha + zul \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d9f9612..361d2e83 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,4 +56,10 @@ Privacy policy App info Unknown error + Select audio track + Select subtitle track + MPV Player + Use the experimental MPV Player to play videos. MPV has support for more video, audio and subtitle codecs. + Force software decoding + Disable hardware decoding and use software decoding. Can be useful if hardware decoding gives weird artifacts. \ No newline at end of file diff --git a/app/src/main/res/xml/fragment_settings.xml b/app/src/main/res/xml/fragment_settings.xml index 669c3e45..0d3bbc9c 100644 --- a/app/src/main/res/xml/fragment_settings.xml +++ b/app/src/main/res/xml/fragment_settings.xml @@ -39,6 +39,18 @@ app:useSimpleSummaryProvider="true" /> + + + + + ("clean") { + delete(rootProject.buildDir) +} \ No newline at end of file diff --git a/images/banner.svg b/images/banner.svg deleted file mode 100644 index a9ee7e30..00000000 --- a/images/banner.svg +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - banner-dark - - diff --git a/images/findroid-banner.png b/images/findroid-banner.png new file mode 100755 index 00000000..02f0799d Binary files /dev/null and b/images/findroid-banner.png differ diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index ceaac52f..00000000 --- a/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = "Jellyfin" -include ':app' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..15a801b1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +include(":app")