AquaStream/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt

630 lines
22 KiB
Kotlin

package com.lagradost.cloudstream3
import android.Manifest
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Resources
import android.os.Build
import android.util.DisplayMetrics
import android.util.Log
import android.view.Gravity
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.View.NO_ID
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import org.schabi.newpipe.extractor.NewPipe
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
enum class FocusDirection {
Start,
End,
Up,
Down,
}
object CommonActivity {
private var _activity: WeakReference<Activity>? = null
var activity
get() = _activity?.get()
private set(value) {
_activity = WeakReference(value)
}
@MainThread
fun setActivityInstance(newActivity: Activity?) {
activity = newActivity
}
@MainThread
fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession
}
val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
// screenWidth and screenHeight does always
// refer to the screen while in landscape mode
val screenWidth: Int
get() {
return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
val screenHeight: Int
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event<Pair<Int, Int>>()
val onDialogDismissedEvent = Event<Int>()
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var currentToast: Toast? = null
fun showToast(@StringRes message: Int, duration: Int? = null) {
val act = activity ?: return
act.runOnUiThread {
showToast(act, act.getString(message), duration)
}
}
fun showToast(message: String?, duration: Int? = null) {
val act = activity ?: return
act.runOnUiThread {
showToast(act, message, duration)
}
}
fun showToast(message: UiText?, duration: Int? = null) {
val act = activity ?: return
if (message == null) return
act.runOnUiThread {
showToast(act, message.asString(act), duration)
}
}
@MainThread
fun showToast(act: Activity?, text: UiText, duration: Int) {
if (act == null) return
text.asStringNull(act)?.let {
showToast(act, it, duration)
}
}
/** duration is Toast.LENGTH_SHORT if null*/
@MainThread
fun showToast(act: Activity?, @StringRes message: Int, duration: Int? = null) {
if (act == null) return
showToast(act, act.getString(message), duration)
}
const val TAG = "COMPACT"
/** duration is Toast.LENGTH_SHORT if null*/
@MainThread
fun showToast(act: Activity?, message: String?, duration: Int? = null) {
if (act == null || message == null) {
Log.w(TAG, "invalid showToast act = $act message = $message")
return
}
Log.i(TAG, "showToast = $message")
try {
currentToast?.cancel()
} catch (e: Exception) {
logError(e)
}
try {
val inflater =
act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val layout: View = inflater.inflate(
R.layout.toast,
act.findViewById<View>(R.id.toast_layout_root) as ViewGroup?
)
val text = layout.findViewById(R.id.text) as TextView
text.text = message.trim()
val toast = Toast(act)
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.duration = duration ?: Toast.LENGTH_SHORT
toast.view = layout
//https://github.com/PureWriter/ToastCompat
toast.show()
currentToast = toast
} catch (e: Exception) {
logError(e)
}
}
/**
* Not all languages can be fetched from locale with a code.
* This map allows sidestepping the default Locale(languageCode)
* when setting the app language.
**/
val appLanguageExceptions = hashMapOf(
"zh-rTW" to Locale.TRADITIONAL_CHINESE
)
fun setLocale(context: Context?, languageCode: String?) {
if (context == null || languageCode == null) return
val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
val resources: Resources = context.resources
val config = resources.configuration
Locale.setDefault(locale)
config.setLocale(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
context.createConfigurationContext(config)
resources.updateConfiguration(config, resources.displayMetrics)
}
fun Context.updateLocale() {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val localeCode = settingsManager.getString(getString(R.string.locale_key), null)
setLocale(this, localeCode)
}
fun init(act: Activity) {
setActivityInstance(act)
val componentActivity = activity as? ComponentActivity ?: return
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
//https://developer.android.com/guide/topics/ui/picture-in-picture
canShowPipMode =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
componentActivity.updateLocale()
componentActivity.updateTv()
NewPipe.init(DownloaderTestImpl.getInstance())
for (resumeApp in resumeApps) {
resumeApp.launcher =
componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
val pos = resumeApp.getPosition(data)
val dur = resumeApp.getDuration(data)
if (dur > 0L && pos > 0L)
DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur)
removeKey(resumeApp.lastId)
ResultFragment.updateUI()
}
}
}
// Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
componentActivity,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
val requestPermissionLauncher = componentActivity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission: $isGranted")
}
requestPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS
)
}
}
private fun Activity.enterPIPMode() {
if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
} catch (e: Exception) {
enterPictureInPictureMode()
}
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
enterPictureInPictureMode()
}
}
} catch (e: Exception) {
logError(e)
}
}
fun onUserLeaveHint(act: Activity?) {
if (canEnterPipMode && canShowPipMode) {
act?.enterPIPMode()
}
}
fun loadThemes(act: Activity?) {
if (act == null) return
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
"AmoledLight" -> R.style.AmoledModeLight
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme
else -> R.style.AppTheme
}
val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal
"DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
"Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite
"CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen
"GreenApple" -> R.style.OverlayPrimaryColorGreenApple
"Red" -> R.style.OverlayPrimaryColorRed
"Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
"Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
else -> R.style.OverlayPrimaryColorNormal
}
act.theme.applyStyle(currentTheme, true)
act.theme.applyStyle(currentOverlayTheme, true)
act.theme.applyStyle(
R.style.LoadedStyle,
true
) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW
}
/** because we want closes find, aka when multiple have the same id, we go to parent
until the correct one is found */
private fun localLook(from: View, id: Int): View? {
if (id == NO_ID) return null
var currentLook: View = from
// limit to 15 look depth
for (i in 0..15) {
currentLook.findViewById<View?>(id)?.let { return it }
currentLook = (currentLook.parent as? View) ?: break
}
return null
}
/*var currentLook: View = view
while (true) {
val tmpNext = currentLook.findViewById<View?>(nextId)
if (tmpNext != null) {
next = tmpNext
break
}
currentLook = currentLook.parent as? View ?: break
}*/
private fun View.hasContent() : Boolean {
return isShown && when(this) {
//is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
else -> true
}
}
/** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
fun continueGetNextFocus(
root: Any?,
view: View,
direction: FocusDirection,
nextId: Int,
depth: Int = 0
): View? {
if (nextId == NO_ID) return null
// do an initial search for the view, in case the localLook is too deep we can use this as
// an early break and backup view
var next =
when (root) {
is Activity -> root.findViewById(nextId)
is View -> root.rootView.findViewById<View?>(nextId)
else -> null
} ?: return null
next = localLook(view, nextId) ?: next
val shown = next.hasContent()
// if cant focus but visible then break and let android decide
// the exception if is the view is a parent and has children that wants focus
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
} ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
// if not shown then continue because we will "skip" over views to get to a replacement
if (!shown) {
// we don't want a while true loop, so we let android decide if we find a recursive view
if (next == view) return null
return getNextFocus(root, next, direction, depth + 1)
}
(when (next) {
is ChipGroup -> {
next.children.firstOrNull { it.isFocusable && it.isShown }
}
is NavigationRailView -> {
next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
}
else -> null
})?.let {
return it
}
// nothing wrong with the view found, return it
return next
}
/** recursively looks for a next focus up to a depth of 10,
* this is used to override the normal shit focus system
* because this application has a lot of invisible views that messes with some tv devices*/
fun getNextFocus(
root: Any?,
view: View?,
direction: FocusDirection,
depth: Int = 0
): View? {
// if input is invalid let android decide + depth test to not crash if loop is found
if (view == null || depth >= 10 || root == null) {
return null
}
var nextId = when (direction) {
FocusDirection.Start -> {
if (view.isRtl())
view.nextFocusRightId
else
view.nextFocusLeftId
}
FocusDirection.Up -> {
view.nextFocusUpId
}
FocusDirection.End -> {
if (view.isRtl())
view.nextFocusLeftId
else
view.nextFocusRightId
}
FocusDirection.Down -> {
view.nextFocusDownId
}
}
if (nextId == NO_ID) {
// if not specified then use forward id
nextId = view.nextFocusForwardId
// if view is still not found to next focus then return and let android decide
if (nextId == NO_ID)
return null
}
return continueGetNextFocus(root, view, direction, nextId, depth)
}
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
//println("Keycode: $keyCode")
//showToast(
// this,
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
// Tested keycodes on remote:
// KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
// KeyEvent.KEYCODE_MEDIA_REWIND
// KeyEvent.KEYCODE_MENU
// KeyEvent.KEYCODE_MEDIA_NEXT
// KeyEvent.KEYCODE_MEDIA_PREVIOUS
// KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
// 149 keycode_numpad 5
when (keyCode) {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward
}
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack
}
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
PlayerEventType.NextEpisode
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
PlayerEventType.PrevEpisode
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause
}
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play
}
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock
}
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide
}
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute
}
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors
}
// OpenSubtitles shortcut
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline
}
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed
}
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize
}
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp
}
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
PlayerEventType.SkipCurrentChapter
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
PlayerEventType.PlayPauseToggle
}
else -> null
}?.let { playerEvent ->
playerEventListener?.invoke(playerEvent)
}
//when (keyCode) {
// KeyEvent.KEYCODE_DPAD_CENTER -> {
// println("DPAD PRESSED")
// }
//}
}
/** overrides focus and custom key events */
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
if (act == null) return null
val currentFocus = act.currentFocus
event?.keyCode?.let { keyCode ->
if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let
val nextView = when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
act,
currentFocus,
FocusDirection.Start
)
KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
act,
currentFocus,
FocusDirection.End
)
KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
act,
currentFocus,
FocusDirection.Up
)
KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
act,
currentFocus,
FocusDirection.Down
)
else -> null
}
// println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
keyEventListener?.invoke(Pair(event, true))
return true
}
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) {
UIHelper.showInputMethod(act.currentFocus?.findFocus())
}
//println("Keycode: $keyCode")
//showToast(
// this,
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
}
// if someone else want to override the focus then don't handle the event as it is already
// consumed. used in video player
if (keyEventListener?.invoke(Pair(event, false)) == true) {
return true
}
return null
}
}