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

Decouple JavaScript bridge components from Fragments #813

Merged
merged 3 commits into from
Sep 24, 2022
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
8 changes: 7 additions & 1 deletion app/src/main/java/org/jellyfin/mobile/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.cast.Chromecast
import org.jellyfin.mobile.player.cast.IChromecast
import org.jellyfin.mobile.player.ui.PlayerFragment
Expand All @@ -24,12 +25,14 @@ import org.jellyfin.mobile.utils.extensions.replaceFragment
import org.jellyfin.mobile.utils.isWebViewSupported
import org.jellyfin.mobile.webapp.RemotePlayerService
import org.jellyfin.mobile.webapp.WebViewFragment
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.fragment.android.setupKoinFragmentFactory
import org.koin.androidx.viewmodel.ext.android.viewModel

class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModel()
private val activityEventHandler: ActivityEventHandler = get()
Maxr1998 marked this conversation as resolved.
Show resolved Hide resolved
val mainViewModel: MainViewModel by viewModel()
val chromecast: IChromecast = Chromecast()
private val permissionRequestHelper: PermissionRequestHelper by inject()

Expand Down Expand Up @@ -75,6 +78,9 @@ class MainActivity : AppCompatActivity() {
return
}

// Subscribe to activity events
with(activityEventHandler) { subscribe() }

// Load UI
lifecycleScope.launchWhenStarted {
mainViewModel.serverState.collect { state ->
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class MainViewModel(
val serverEntity = apiClientController.loadSavedServer()
_serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset
}

/**
* Temporarily unset the selected server to be able to connect to a different one
*/
fun resetServer() {
_serverState.value = ServerState.Unset
}
}

sealed class ServerState {
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import org.chromium.net.CronetProvider
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayerEvent
Expand Down Expand Up @@ -47,13 +49,19 @@ val applicationModule = module {
single { OkHttpClient() }
single { ImageLoader(androidApplication()) }
single { PermissionRequestHelper() }
single { WebappFunctionChannel() }
single { RemoteVolumeProvider(get()) }
single(named(PLAYER_EVENT_CHANNEL)) { Channel<PlayerEvent>() }

// Controllers
single { ApiClientController(get(), get(), get(), get(), get()) }

// Event handlers and channels
single { ActivityEventHandler(get()) }
single { WebappFunctionChannel() }

// Bridge interfaces
single { NativePlayer(get(), get(), get(named(PLAYER_EVENT_CHANNEL))) }

// ViewModels
viewModel { MainViewModel(get(), get()) }

Expand Down
83 changes: 21 additions & 62 deletions app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
package org.jellyfin.mobile.bridge

import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.media.session.PlaybackState
import android.net.Uri
import android.webkit.JavascriptInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.jellyfin.mobile.R
import org.jellyfin.mobile.settings.SettingsFragment
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM
import org.jellyfin.mobile.utils.Constants.EXTRA_ARTIST
Expand All @@ -24,16 +20,8 @@ import org.jellyfin.mobile.utils.Constants.EXTRA_ITEM_ID
import org.jellyfin.mobile.utils.Constants.EXTRA_PLAYER_ACTION
import org.jellyfin.mobile.utils.Constants.EXTRA_POSITION
import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE
import org.jellyfin.mobile.utils.extensions.addFragment
import org.jellyfin.mobile.utils.extensions.disableFullscreen
import org.jellyfin.mobile.utils.extensions.enableFullscreen
import org.jellyfin.mobile.utils.extensions.requireMainActivity
import org.jellyfin.mobile.utils.requestDownload
import org.jellyfin.mobile.utils.runOnUiThread
import org.jellyfin.mobile.webapp.RemotePlayerService
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebViewFragment
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.json.JSONArray
Expand All @@ -44,9 +32,9 @@ import org.koin.core.component.get
import org.koin.core.component.inject
import timber.log.Timber

class NativeInterface(private val fragment: WebViewFragment) : KoinComponent {
private val context: Context = fragment.requireContext()
private val webappFunctionChannel: WebappFunctionChannel by inject()
@Suppress("unused")
class NativeInterface(private val context: Context) : KoinComponent {
private val activityEventHandler: ActivityEventHandler = get()
private val remoteVolumeProvider: RemoteVolumeProvider by inject()

@SuppressLint("HardwareIds")
Expand All @@ -72,38 +60,20 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent {

@JavascriptInterface
fun enableFullscreen(): Boolean {
fragment.runOnUiThread {
fragment.activity?.apply {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
enableFullscreen()
window.setBackgroundDrawable(null)
}
}
emitEvent(ActivityEvent.ChangeFullscreen(true))
return true
}

@JavascriptInterface
fun disableFullscreen(): Boolean {
fragment.runOnUiThread {
fragment.activity?.apply {
// Reset screen orientation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
disableFullscreen(true)
// Reset window background color
window.setBackgroundDrawableResource(R.color.theme_background)
}
}
emitEvent(ActivityEvent.ChangeFullscreen(false))
return true
}

@JavascriptInterface
fun openUrl(uri: String): Boolean = try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
context.startActivity(intent)
true
} catch (e: ActivityNotFoundException) {
Timber.e("openIntent: %s", e.message)
false
fun openUrl(uri: String): Boolean {
emitEvent(ActivityEvent.OpenUrl(uri))
return true
}

@JavascriptInterface
Expand Down Expand Up @@ -161,44 +131,33 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent {
Timber.e("Download failed: %s", e.message)
return false
}
runBlocking(Dispatchers.Main) {
fragment.requestDownload(Uri.parse(url), title, filename)
}

emitEvent(ActivityEvent.DownloadFile(Uri.parse(url), title, filename))
return true
}

@JavascriptInterface
fun openClientSettings() {
fragment.runOnUiThread {
fragment.parentFragmentManager.addFragment<SettingsFragment>()
}
emitEvent(ActivityEvent.OpenSettings)
}

@JavascriptInterface
fun openServerSelection() {
fragment.onSelectServer()
emitEvent(ActivityEvent.SelectServer)
}

@JavascriptInterface
fun exitApp() {
val activity = fragment.requireMainActivity()
if (activity.serviceBinder?.isPlaying == true) {
activity.moveTaskToBack(false)
} else {
activity.finish()
}
emitEvent(ActivityEvent.ExitApp)
}

@JavascriptInterface
fun execCast(action: String, args: String) {
fragment.requireMainActivity().chromecast.execute(
action,
JSONArray(args),
object : JavascriptCallback() {
override fun callback(keep: Boolean, err: String?, result: String?) {
webappFunctionChannel.call("""window.NativeShell.castCallback("$action", $keep, $err, $result);""")
}
},
)
emitEvent(ActivityEvent.CastMessage(action, JSONArray(args)))
}

@Suppress("NOTHING_TO_INLINE")
private inline fun emitEvent(event: ActivityEvent) {
activityEventHandler.emit(event)
}
}
18 changes: 9 additions & 9 deletions app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,28 @@ package org.jellyfin.mobile.bridge
import android.webkit.JavascriptInterface
import kotlinx.coroutines.channels.Channel
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named

class NativePlayer(private val host: NativePlayerHost) : KoinComponent {

private val appPreferences: AppPreferences by inject()
private val playerEventChannel: Channel<PlayerEvent> by inject(named(PLAYER_EVENT_CHANNEL))
@Suppress("unused")
class NativePlayer(
private val appPreferences: AppPreferences,
private val activityEventHandler: ActivityEventHandler,
private val playerEventChannel: Channel<PlayerEvent>,
) {

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

@JavascriptInterface
fun loadPlayer(args: String) {
PlayOptions.fromJson(JSONObject(args))?.let { options ->
host.loadNativePlayer(options)
activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(options))
}
}

Expand Down

This file was deleted.

16 changes: 16 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.jellyfin.mobile.events

import android.net.Uri
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.json.JSONArray

sealed class ActivityEvent {
class ChangeFullscreen(val isFullscreen: Boolean) : ActivityEvent()
class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent()
class OpenUrl(val uri: String) : ActivityEvent()
class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent()
class CastMessage(val action: String, val args: JSONArray) : ActivityEvent()
object OpenSettings : ActivityEvent()
object SelectServer : ActivityEvent()
object ExitApp : ActivityEvent()
}
108 changes: 108 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.jellyfin.mobile.events

import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.bridge.JavascriptCallback
import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.settings.SettingsFragment
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.addFragment
import org.jellyfin.mobile.utils.extensions.disableFullscreen
import org.jellyfin.mobile.utils.extensions.enableFullscreen
import org.jellyfin.mobile.utils.requestDownload
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import timber.log.Timber

class ActivityEventHandler(
private val webappFunctionChannel: WebappFunctionChannel,
) {
private val eventsFlow = MutableSharedFlow<ActivityEvent>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

fun MainActivity.subscribe() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
eventsFlow.collect { event ->
handleEvent(event)
}
}
}
}

private suspend fun MainActivity.handleEvent(event: ActivityEvent) {
when (event) {
is ActivityEvent.ChangeFullscreen -> {
if (event.isFullscreen) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
enableFullscreen()
window.setBackgroundDrawable(null)
} else {
// Reset screen orientation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
disableFullscreen(true)
// Reset window background color
window.setBackgroundDrawableResource(R.color.theme_background)
}
}
is ActivityEvent.LaunchNativePlayer -> {
val args = Bundle().apply {
putParcelable(Constants.EXTRA_MEDIA_PLAY_OPTIONS, event.playOptions)
}
supportFragmentManager.addFragment<PlayerFragment>(args)
}
is ActivityEvent.OpenUrl -> {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.uri))
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e("openIntent: %s", e.message)
}
}
is ActivityEvent.DownloadFile -> {
with(event) { requestDownload(uri, title, filename) }
}
is ActivityEvent.CastMessage -> {
val action = event.action
chromecast.execute(
action,
event.args,
object : JavascriptCallback() {
override fun callback(keep: Boolean, err: String?, result: String?) {
webappFunctionChannel.call("""window.NativeShell.castCallback("$action", $keep, $err, $result);""")
}
},
)
}
ActivityEvent.OpenSettings -> {
supportFragmentManager.addFragment<SettingsFragment>()
}
ActivityEvent.SelectServer -> {
mainViewModel.resetServer()
}
ActivityEvent.ExitApp -> {
if (serviceBinder?.isPlaying == true) {
moveTaskToBack(false)
} else {
finish()
}
}
}
}

fun emit(event: ActivityEvent) {
eventsFlow.tryEmit(event)
}
}
Loading