Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Activity Result APIs for External player #327

Merged
merged 1 commit into from
Mar 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 49 additions & 45 deletions app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import android.content.Intent
import android.net.Uri
import android.webkit.JavascriptInterface
import android.widget.Toast
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.LifecycleOwner
import org.jellyfin.mobile.AppPreferences
import org.jellyfin.mobile.R
import org.jellyfin.mobile.fragment.WebViewFragment
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.isPackageInstalled
import org.jellyfin.mobile.utils.runOnUiThread
import org.jellyfin.mobile.utils.toast
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.json.JSONException
Expand All @@ -24,12 +25,36 @@ import org.koin.core.KoinComponent
import org.koin.core.inject
import timber.log.Timber

class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {
private val context: Context = fragment.requireContext()
class ExternalPlayer(
private val context: Context,
lifecycleOwner: LifecycleOwner,
registry: ActivityResultRegistry,
) : KoinComponent {

private val appPreferences: AppPreferences by inject()
private val webappFunctionChannel: WebappFunctionChannel by inject()

private val playerContract = registry.register("externalplayer", lifecycleOwner, ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val intent = result.data
when (val action = intent?.action) {
Constants.MPV_PLAYER_RESULT_ACTION -> handleMPVPlayer(resultCode, intent)
Constants.MX_PLAYER_RESULT_ACTION -> handleMXPlayer(resultCode, intent)
Constants.VLC_PLAYER_RESULT_ACTION -> handleVLCPlayer(resultCode, intent)
else -> {
if (action != null && resultCode != Activity.RESULT_CANCELED) {
Timber.d("Unknown action $action [resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_not_supported_yet, Toast.LENGTH_LONG)
} else {
Timber.d("Playback canceled: no player selected or player without action result")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_invalid_player, Toast.LENGTH_LONG)
}
}
}
}

@JavascriptInterface
fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER

Expand All @@ -39,7 +64,7 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {
val mediaSource = JellyfinMediaSource(JSONObject(args))
if (mediaSource.playMethod == "DirectStream") {
val playerIntent = Intent(Intent.ACTION_VIEW).apply {
if (fragment.isPackageInstalled(appPreferences.externalPlayerApp)) {
if (context.packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) {
component = getComponent(appPreferences.externalPlayerApp)
}
setDataAndType(mediaSource.uri, mediaSource.mimeType)
Expand All @@ -56,10 +81,10 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {
putExtra("subs.enable", arrayOf(Uri.parse(selectedTrack)))
}
}
fragment.startActivityForResult(playerIntent, Constants.HANDLE_EXTERNAL_PLAYER)
Timber.d("Starting playback [id: ${mediaSource.id}, title: ${mediaSource.title}, playMethod: ${mediaSource.playMethod}, mediaStartMs: ${mediaSource.mediaStartMs}]")
playerContract.launch(playerIntent)
Timber.d("Starting playback [id=${mediaSource.id}, title=${mediaSource.title}, playMethod=${mediaSource.playMethod}, mediaStartMs=${mediaSource.mediaStartMs}]")
} else {
Timber.d("Play Method '${mediaSource.playMethod}' not tested, ignoring...")
Timber.d("Play Method '${mediaSource.playMethod}' not tested, ignoring")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_invalid_play_method, Toast.LENGTH_LONG)
}
Expand All @@ -70,28 +95,7 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {

private fun notifyEvent(event: String, parameters: String = "") {
if (event in arrayOf(Constants.EVENT_CANCELED, Constants.EVENT_ENDED, Constants.EVENT_TIME_UPDATE) && parameters == parameters.filter { it.isDigit() }) {
fragment.runOnUiThread {
webappFunctionChannel.call("window.ExtPlayer.notify$event($parameters)")
}
}
}

fun handleActivityResult(resultCode: Int, data: Intent?) {
when (data?.action) {
Constants.MPV_PLAYER_RESULT_ACTION -> handleMPVPlayer(resultCode, data)
Constants.MX_PLAYER_RESULT_ACTION -> handleMXPlayer(resultCode, data)
Constants.VLC_PLAYER_RESULT_ACTION -> handleVLCPlayer(resultCode, data)
else -> {
if (data?.action != null && resultCode != Activity.RESULT_CANCELED) {
Timber.d("Unknown action [resultCode: $resultCode, action: ${data.action}]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_not_supported_yet, Toast.LENGTH_LONG)
} else {
Timber.d("Playback canceled [no player selected or player without action result]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_invalid_player, Toast.LENGTH_LONG)
}
}
webappFunctionChannel.call("window.ExtPlayer.notify$event($parameters)")
}
}

Expand All @@ -102,22 +106,22 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {
Activity.RESULT_OK -> {
val position = data.getIntExtra("position", 0)
if (position > 0) {
Timber.d("Playback stopped [player: $player, position: $position]")
Timber.d("Playback stopped [player=$player, position=$position]")
notifyEvent(Constants.EVENT_TIME_UPDATE, "$position")
notifyEvent(Constants.EVENT_ENDED)
} else {
Timber.d("Playback completed [player: $player]")
Timber.d("Playback completed [player=$player]")
notifyEvent(Constants.EVENT_TIME_UPDATE)
notifyEvent(Constants.EVENT_ENDED)
}
}
Activity.RESULT_CANCELED -> {
Timber.d("Playback stopped by unknown error [player: $player]")
Timber.d("Playback stopped by unknown error [player=$player]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
else -> {
Timber.d("Invalid state [player: $player, resultCode: $resultCode]")
Timber.d("Invalid state [player=$player, resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
Expand All @@ -131,41 +135,41 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {
Activity.RESULT_OK -> {
when (val endBy = data.getStringExtra("end_by")) {
"playback_completion" -> {
Timber.d("Playback completed [player: $player]")
Timber.d("Playback completed [player=$player]")
notifyEvent(Constants.EVENT_TIME_UPDATE)
notifyEvent(Constants.EVENT_ENDED)
}
"user" -> {
val position = data.getIntExtra("position", 0)
val duration = data.getIntExtra("duration", 0)
if (position > 0) {
Timber.d("Playback stopped [player: $player, position: $position, duration: $duration]")
Timber.d("Playback stopped [player=$player, position=$position, duration=$duration]")
notifyEvent(Constants.EVENT_TIME_UPDATE, "$position")
notifyEvent(Constants.EVENT_ENDED)
} else {
Timber.d("Invalid state [player: $player, position: $position, duration: $duration]")
Timber.d("Invalid state [player=$player, position=$position, duration=$duration]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
else -> {
Timber.d("Invalid state [player: $player, end_by: $endBy]")
Timber.d("Invalid state [player=$player, endBy=$endBy]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
Activity.RESULT_CANCELED -> {
Timber.d("Playback stopped by user [player: $player]")
Timber.d("Playback stopped by user [player=$player]")
notifyEvent(Constants.EVENT_CANCELED)
}
Activity.RESULT_FIRST_USER -> {
Timber.d("Playback stopped by unknown error [player: $player]")
Timber.d("Playback stopped by unknown error [player=$player]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
else -> {
Timber.d("Invalid state [player: $player, resultCode: $resultCode]")
Timber.d("Invalid state [player=$player, resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
Expand All @@ -180,23 +184,23 @@ class ExternalPlayer(private val fragment: WebViewFragment) : KoinComponent {
val extraPosition = data.getLongExtra("extra_position", 0L)
val extraDuration = data.getLongExtra("extra_duration", 0L)
if (extraPosition > 0L) {
Timber.d("Playback stopped [player: $player, extra_position: $extraPosition, extra_duration: $extraDuration]")
Timber.d("Playback stopped [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]")
notifyEvent(Constants.EVENT_TIME_UPDATE, "$extraPosition")
notifyEvent(Constants.EVENT_ENDED)
} else {
if (extraDuration == 0L && extraPosition == 0L) {
Timber.d("Playback completed [player: $player]")
Timber.d("Playback completed [player=$player]")
notifyEvent(Constants.EVENT_TIME_UPDATE)
notifyEvent(Constants.EVENT_ENDED)
} else {
Timber.d("Invalid state [player: $player, extra_position: $extraPosition, extra_duration: $extraDuration]")
Timber.d("Invalid state [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
}
}
else -> {
Timber.d("Playback failed [player: $player, resultCode: $resultCode]")
Timber.d("Playback failed [player=$player, resultCode=$resultCode]")
notifyEvent(Constants.EVENT_CANCELED)
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class WebViewFragment : Fragment(), NativePlayerHost {
val apiClient: ApiClient by inject()
private val serverController: ServerController by inject()
private val webappFunctionChannel: WebappFunctionChannel by inject()
private val externalPlayer by lazy { ExternalPlayer(this) }
private lateinit var externalPlayer: ExternalPlayer

private var serverId: Long = 0
private lateinit var instanceUrl: String
Expand All @@ -67,6 +67,9 @@ class WebViewFragment : Fragment(), NativePlayerHost {
val args = requireArguments()
serverId = requireNotNull(args.getLong(FRAGMENT_WEB_VIEW_EXTRA_SERVER_ID)) { "Server id has not been supplied!" }
instanceUrl = requireNotNull(args.getString(FRAGMENT_WEB_VIEW_EXTRA_URL)) { "Server url has not been supplied!" }

externalPlayer = ExternalPlayer(requireContext(), this, requireActivity().activityResultRegistry)

requireActivity().onBackPressedDispatcher.addCallback(this) {
if (!connected || !webappFunctionChannel.goBack()) {
isEnabled = false
Expand Down Expand Up @@ -276,11 +279,4 @@ class WebViewFragment : Fragment(), NativePlayerHost {
addToBackStack(null)
}.commit()
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == Constants.HANDLE_EXTERNAL_PLAYER) {
externalPlayer.handleActivityResult(resultCode, data)
}
}
}
15 changes: 13 additions & 2 deletions app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,24 @@ class SettingsFragment : Fragment() {
titleRes = R.string.pref_exoplayer_allow_background_audio
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
}

// Generate available external player options
val packageManager = requireContext().packageManager
val externalPlayerOptions = listOf(
SelectionItem(ExternalPlayerPackage.SYSTEM_DEFAULT, R.string.external_player_system_default, R.string.external_player_system_default_description),
SelectionItem(ExternalPlayerPackage.MPV_PLAYER, R.string.external_player_mpv, R.string.external_player_mpv_description),
SelectionItem(ExternalPlayerPackage.MX_PLAYER_FREE, R.string.external_player_mx_player_free, R.string.external_player_mx_player_free_description),
SelectionItem(ExternalPlayerPackage.MX_PLAYER_PRO, R.string.external_player_mx_player_pro, R.string.external_player_mx_player_pro_description),
SelectionItem(ExternalPlayerPackage.VLC_PLAYER, R.string.external_player_vlc_player, R.string.external_player_vlc_player_description),
).filter { isPackageInstalled(it.key) }.plus(SelectionItem(ExternalPlayerPackage.SYSTEM_DEFAULT, R.string.external_player_system_default, R.string.external_player_system_default_description))
if (!isPackageInstalled(appPreferences.externalPlayerApp)) appPreferences.externalPlayerApp = ExternalPlayerPackage.SYSTEM_DEFAULT
).filter { item ->
item.key == ExternalPlayerPackage.SYSTEM_DEFAULT || packageManager.isPackageInstalled(item.key)
}

// Revert if current selection isn't available
if (!packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) {
appPreferences.externalPlayerApp = ExternalPlayerPackage.SYSTEM_DEFAULT
}

externalPlayerChoicePreference = singleChoice(Constants.PREF_EXTERNAL_PLAYER_APP, externalPlayerOptions) {
titleRes = R.string.external_player_app
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER
Expand Down
3 changes: 0 additions & 3 deletions app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,6 @@ object Constants {
// Video player intent extras
const val EXTRA_MEDIA_SOURCE_ITEM = "org.jellyfin.mobile.MEDIA_SOURCE_ITEM"

// External player result code
const val HANDLE_EXTERNAL_PLAYER = 1

// External player result actions
const val MPV_PLAYER_RESULT_ACTION = "is.xyz.mpv.MPVActivity.result"
const val MX_PLAYER_RESULT_ACTION = "com.mxtech.intent.result.VIEW"
Expand Down
5 changes: 2 additions & 3 deletions app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import android.provider.Settings
import android.provider.Settings.System.ACCELEROMETER_ROTATION
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
Expand Down Expand Up @@ -121,8 +120,8 @@ private fun Context.downloadFile(request: DownloadManager.Request, @DownloadMeth

fun Activity.isAutoRotateOn() = Settings.System.getInt(contentResolver, ACCELEROMETER_ROTATION, 0) == 1

fun Fragment.isPackageInstalled(@ExternalPlayerPackage packageName: String) = try {
packageName.isNotEmpty() && requireContext().packageManager.getApplicationInfo(packageName, 0).enabled
fun PackageManager.isPackageInstalled(@ExternalPlayerPackage packageName: String) = try {
packageName.isNotEmpty() && getApplicationInfo(packageName, 0).enabled
} catch (e: PackageManager.NameNotFoundException) {
false
}
Expand Down