879 lines
33 KiB
Kotlin
879 lines
33 KiB
Kotlin
package com.lagradost.cloudstream3.utils
|
|
|
|
import android.animation.ObjectAnimator
|
|
import android.annotation.SuppressLint
|
|
import android.app.Activity
|
|
import android.app.Activity.RESULT_CANCELED
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.content.*
|
|
import android.content.pm.PackageManager
|
|
import android.database.Cursor
|
|
import android.media.AudioAttributes
|
|
import android.media.AudioFocusRequest
|
|
import android.media.AudioManager
|
|
import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID
|
|
import android.net.ConnectivityManager
|
|
import android.net.NetworkCapabilities
|
|
import android.net.Uri
|
|
import android.os.*
|
|
import android.provider.MediaStore
|
|
import android.text.Spanned
|
|
import android.util.Log
|
|
import android.view.View
|
|
import android.view.View.LAYOUT_DIRECTION_LTR
|
|
import android.view.View.LAYOUT_DIRECTION_RTL
|
|
import android.view.animation.DecelerateInterpolator
|
|
import android.widget.Toast
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.annotation.WorkerThread
|
|
import androidx.appcompat.app.AlertDialog
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.text.HtmlCompat
|
|
import androidx.core.text.toSpanned
|
|
import androidx.core.widget.ContentLoadingProgressBar
|
|
import androidx.fragment.app.Fragment
|
|
import androidx.fragment.app.FragmentActivity
|
|
import androidx.navigation.fragment.findNavController
|
|
import androidx.preference.PreferenceManager
|
|
import androidx.recyclerview.widget.DiffUtil
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.recyclerview.widget.RecyclerView
|
|
import androidx.tvprovider.media.tv.*
|
|
import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor
|
|
import androidx.viewpager2.widget.ViewPager2
|
|
import com.fasterxml.jackson.module.kotlin.readValue
|
|
import com.google.android.gms.cast.framework.CastContext
|
|
import com.google.android.gms.cast.framework.CastState
|
|
import com.google.android.gms.common.ConnectionResult
|
|
import com.google.android.gms.common.GoogleApiAvailability
|
|
import com.google.android.gms.common.wrappers.Wrappers
|
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
|
import com.lagradost.cloudstream3.*
|
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent
|
|
import com.lagradost.cloudstream3.mvvm.logError
|
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
|
import com.lagradost.cloudstream3.plugins.RepositoryManager
|
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching
|
|
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
|
|
import com.lagradost.cloudstream3.ui.WebviewFragment
|
|
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
|
import com.lagradost.cloudstream3.ui.settings.Globals
|
|
import com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment
|
|
import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel.Companion.downloadAll
|
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
|
import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir
|
|
import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load
|
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
import okhttp3.Cache
|
|
import java.io.*
|
|
import java.net.URL
|
|
import java.net.URLDecoder
|
|
|
|
object AppUtils {
|
|
fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) {
|
|
for (i in 0..maxViewTypeId)
|
|
recycledViewPool.setMaxRecycledViews(i, maxPoolSize)
|
|
}
|
|
|
|
fun RecyclerView.isRecyclerScrollable(): Boolean {
|
|
val layoutManager =
|
|
this.layoutManager as? LinearLayoutManager?
|
|
val adapter = adapter
|
|
return if (layoutManager == null || adapter == null) false else layoutManager.findLastCompletelyVisibleItemPosition() < adapter.itemCount - 7 // bit more than 1 to make it more seamless
|
|
}
|
|
|
|
fun View.isLtr() = this.layoutDirection == LAYOUT_DIRECTION_LTR
|
|
fun View.isRtl() = this.layoutDirection == LAYOUT_DIRECTION_RTL
|
|
|
|
fun BottomSheetDialog?.ownHide() {
|
|
this?.hide()
|
|
}
|
|
|
|
fun BottomSheetDialog?.ownShow() {
|
|
// the reason for this is because show has a shitty animation we don't want
|
|
this?.window?.setWindowAnimations(-1)
|
|
this?.show()
|
|
Handler(Looper.getMainLooper()).postDelayed({
|
|
this?.window?.setWindowAnimations(R.style.Animation_Design_BottomSheetDialog)
|
|
}, 200)
|
|
}
|
|
|
|
//fun Context.deleteFavorite(data: SearchResponse) {
|
|
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
// normalSafeApiCall {
|
|
// val existingId =
|
|
// getWatchNextProgramByVideoId(data.url, this).second ?: return@normalSafeApiCall
|
|
// contentResolver.delete(
|
|
//
|
|
// TvContractCompat.buildWatchNextProgramUri(existingId),
|
|
// null, null
|
|
// )
|
|
// }
|
|
//}
|
|
fun String?.html(): Spanned {
|
|
return getHtmlText(this ?: return "".toSpanned())
|
|
}
|
|
|
|
private fun getHtmlText(text: String): Spanned {
|
|
return try {
|
|
// I have no idea if this can throw any error, but I dont want to try
|
|
HtmlCompat.fromHtml(
|
|
text, HtmlCompat.FROM_HTML_MODE_LEGACY
|
|
)
|
|
} catch (e: Exception) {
|
|
logError(e)
|
|
text.toSpanned()
|
|
}
|
|
}
|
|
|
|
@SuppressLint("RestrictedApi")
|
|
private fun buildWatchNextProgramUri(
|
|
context: Context,
|
|
card: DataStoreHelper.ResumeWatchingResult,
|
|
resumeWatching: VideoDownloadHelper.ResumeWatching?
|
|
): WatchNextProgram {
|
|
val isSeries = card.type?.isMovieType() == false
|
|
val title = if (isSeries) {
|
|
context.getNameFull(card.name, card.episode, card.season)
|
|
} else {
|
|
card.name
|
|
}
|
|
|
|
val builder = WatchNextProgram.Builder()
|
|
.setEpisodeTitle(title)
|
|
.setType(
|
|
if (isSeries) {
|
|
TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
|
} else TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
|
)
|
|
.setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
|
.setTitle(title)
|
|
.setPosterArtUri(Uri.parse(card.posterUrl))
|
|
.setIntentUri(Uri.parse(card.id?.let {
|
|
"$appStringResumeWatching://$it"
|
|
} ?: card.url))
|
|
.setInternalProviderId(card.url)
|
|
.setLastEngagementTimeUtcMillis(
|
|
resumeWatching?.updateTime ?: System.currentTimeMillis()
|
|
)
|
|
|
|
card.watchPos?.let {
|
|
builder.setDurationMillis(it.duration.toInt())
|
|
builder.setLastPlaybackPositionMillis(it.position.toInt())
|
|
}
|
|
|
|
if (isSeries)
|
|
card.episode?.let {
|
|
builder.setEpisodeNumber(it)
|
|
}
|
|
|
|
return builder.build()
|
|
}
|
|
|
|
// https://stackoverflow.com/a/67441735/13746422
|
|
fun ViewPager2.reduceDragSensitivity(f: Int = 4) {
|
|
val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
|
|
recyclerViewField.isAccessible = true
|
|
val recyclerView = recyclerViewField.get(this) as RecyclerView
|
|
|
|
val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop")
|
|
touchSlopField.isAccessible = true
|
|
val touchSlop = touchSlopField.get(recyclerView) as Int
|
|
touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally
|
|
}
|
|
|
|
fun ContentLoadingProgressBar?.animateProgressTo(to: Int) {
|
|
if (this == null) return
|
|
val animation: ObjectAnimator = ObjectAnimator.ofInt(
|
|
this,
|
|
"progress",
|
|
this.progress,
|
|
to
|
|
)
|
|
animation.duration = 500
|
|
animation.setAutoCancel(true)
|
|
animation.interpolator = DecelerateInterpolator()
|
|
animation.start()
|
|
}
|
|
|
|
fun Context.createNotificationChannel(
|
|
channelId: String,
|
|
channelName: String,
|
|
description: String
|
|
) {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
|
val channel =
|
|
NotificationChannel(channelId, channelName, importance).apply {
|
|
this.description = description
|
|
}
|
|
|
|
// Register the channel with the system.
|
|
val notificationManager: NotificationManager =
|
|
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
notificationManager.createNotificationChannel(channel)
|
|
}
|
|
}
|
|
|
|
@SuppressLint("RestrictedApi")
|
|
fun getAllWatchNextPrograms(context: Context): Set<Long> {
|
|
val COLUMN_WATCH_NEXT_ID_INDEX = 0
|
|
val cursor = context.contentResolver.query(
|
|
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
|
WatchNextProgram.PROJECTION,
|
|
/* selection = */ null,
|
|
/* selectionArgs = */ null,
|
|
/* sortOrder = */ null
|
|
)
|
|
val set = mutableSetOf<Long>()
|
|
cursor?.use {
|
|
if (it.moveToFirst()) {
|
|
do {
|
|
set.add(cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX))
|
|
} while (it.moveToNext())
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
/**
|
|
* Find the Watch Next program for given id.
|
|
* Returns the first instance available.
|
|
*/
|
|
@SuppressLint("RestrictedApi")
|
|
// Suppress RestrictedApi due to https://issuetracker.google.com/138150076
|
|
fun findFirstWatchNextProgram(context: Context, predicate: (Cursor) -> Boolean):
|
|
Pair<WatchNextProgram?, Long?> {
|
|
val COLUMN_WATCH_NEXT_ID_INDEX = 0
|
|
// val COLUMN_WATCH_NEXT_INTERNAL_PROVIDER_ID_INDEX = 1
|
|
// val COLUMN_WATCH_NEXT_COLUMN_BROWSABLE_INDEX = 2
|
|
|
|
val cursor = context.contentResolver.query(
|
|
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
|
WatchNextProgram.PROJECTION,
|
|
/* selection = */ null,
|
|
/* selectionArgs = */ null,
|
|
/* sortOrder = */ null
|
|
)
|
|
cursor?.use {
|
|
if (it.moveToFirst()) {
|
|
do {
|
|
if (predicate(cursor)) {
|
|
return fromCursor(cursor) to cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX)
|
|
}
|
|
} while (it.moveToNext())
|
|
}
|
|
}
|
|
return null to null
|
|
}
|
|
|
|
/**
|
|
* Query the Watch Next list and find the program with given videoId.
|
|
* Return null if not found.
|
|
*/
|
|
|
|
@RequiresApi(Build.VERSION_CODES.O)
|
|
@SuppressLint("Range")
|
|
@Synchronized
|
|
private fun getWatchNextProgramByVideoId(
|
|
id: String,
|
|
context: Context
|
|
): Pair<WatchNextProgram?, Long?> {
|
|
return findFirstWatchNextProgram(context) { cursor ->
|
|
(cursor.getString(cursor.getColumnIndex(COLUMN_INTERNAL_PROVIDER_ID)) == id)
|
|
}
|
|
}
|
|
|
|
/** Prevents losing data when removing and adding simultaneously */
|
|
private val continueWatchingLock = Mutex()
|
|
|
|
// https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java
|
|
@SuppressLint("RestrictedApi")
|
|
@Throws
|
|
@WorkerThread
|
|
suspend fun Context.addProgramsToContinueWatching(data: List<DataStoreHelper.ResumeWatchingResult>) {
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
val context = this
|
|
continueWatchingLock.withLock {
|
|
// A way to get all last watched timestamps
|
|
val timeStampHashMap = HashMap<Int, VideoDownloadHelper.ResumeWatching>()
|
|
getAllResumeStateIds()?.forEach { id ->
|
|
val lastWatched = getLastWatched(id) ?: return@forEach
|
|
timeStampHashMap[lastWatched.parentId] = lastWatched
|
|
}
|
|
|
|
val currentProgramIds = data.mapNotNull { episodeInfo ->
|
|
try {
|
|
val customId = "${episodeInfo.id}|${episodeInfo.apiName}|${episodeInfo.url}"
|
|
val (program, id) = getWatchNextProgramByVideoId(customId, context)
|
|
val nextProgram = buildWatchNextProgramUri(
|
|
context,
|
|
episodeInfo,
|
|
timeStampHashMap[episodeInfo.id]
|
|
)
|
|
|
|
// If the program is already in the Watch Next row, update it
|
|
if (program != null && id != null) {
|
|
PreviewChannelHelper(context).updateWatchNextProgram(
|
|
nextProgram,
|
|
id,
|
|
)
|
|
id
|
|
} else {
|
|
PreviewChannelHelper(context)
|
|
.publishWatchNextProgram(nextProgram)
|
|
}
|
|
} catch (e: Exception) {
|
|
logError(e)
|
|
null
|
|
}
|
|
}.toSet()
|
|
|
|
val allOldPrograms = getAllWatchNextPrograms(context) - currentProgramIds
|
|
|
|
// Ensures synced watch next progress by deleting all old programs.
|
|
allOldPrograms.forEach {
|
|
context.contentResolver.delete(
|
|
TvContractCompat.buildWatchNextProgramUri(it),
|
|
null, null
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@SuppressLint("Range")
|
|
fun getVideoContentUri(context: Context, videoFilePath: String): Uri? {
|
|
val cursor = context.contentResolver.query(
|
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Video.Media._ID),
|
|
MediaStore.Video.Media.DATA + "=? ", arrayOf(videoFilePath), null
|
|
)
|
|
return if (cursor != null && cursor.moveToFirst()) {
|
|
val id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID))
|
|
cursor.close()
|
|
Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "" + id)
|
|
} else {
|
|
val values = ContentValues()
|
|
values.put(MediaStore.Video.Media.DATA, videoFilePath)
|
|
context.contentResolver.insert(
|
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values
|
|
)
|
|
}
|
|
}
|
|
|
|
fun Activity.loadRepository(url: String) {
|
|
ioSafe {
|
|
val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe
|
|
RepositoryManager.addRepository(
|
|
RepositoryData(
|
|
repo.name,
|
|
url
|
|
)
|
|
)
|
|
main {
|
|
showToast(
|
|
getString(R.string.player_loaded_subtitles, repo.name),
|
|
Toast.LENGTH_LONG
|
|
)
|
|
}
|
|
afterRepositoryLoadedEvent.invoke(true)
|
|
addRepositoryDialog(repo.name, false)
|
|
}
|
|
}
|
|
|
|
abstract class DiffAdapter<T>(
|
|
open val items: MutableList<T>,
|
|
val comparison: (first: T, second: T) -> Boolean = { first, second ->
|
|
first.hashCode() == second.hashCode()
|
|
}
|
|
) :
|
|
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
override fun getItemCount(): Int {
|
|
return items.size
|
|
}
|
|
|
|
fun updateList(newList: List<T>) {
|
|
val diffResult = DiffUtil.calculateDiff(
|
|
GenericDiffCallback(this.items, newList)
|
|
)
|
|
|
|
items.clear()
|
|
items.addAll(newList)
|
|
|
|
diffResult.dispatchUpdatesTo(this)
|
|
}
|
|
|
|
inner class GenericDiffCallback(
|
|
private val oldList: List<T>,
|
|
private val newList: List<T>
|
|
) :
|
|
DiffUtil.Callback() {
|
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
comparison(oldList[oldItemPosition], newList[newItemPosition])
|
|
|
|
override fun getOldListSize() = oldList.size
|
|
|
|
override fun getNewListSize() = newList.size
|
|
|
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
oldList[oldItemPosition] == newList[newItemPosition]
|
|
}
|
|
}
|
|
|
|
fun Activity.addRepositoryDialog(repositoryName: String, isExtensionsFragment: Boolean) {
|
|
val message = String.format(resources.getString(
|
|
R.string.download_all_plugins_from_repo), repositoryName)
|
|
val repos = RepositoryManager.getRepositories()
|
|
|
|
// navigate to newly added repository on pressing OK
|
|
fun openAddedRepo() {
|
|
|
|
// don't redirect if user is adding from add repo button
|
|
if (!isExtensionsFragment && repos.isNotEmpty()) {
|
|
normalSafeApiCall { navigate(
|
|
R.id.navigation_home_to_navigation_settings_plugins,
|
|
PluginsFragment.newInstance(
|
|
repositoryName,
|
|
repos.last().url,
|
|
false))
|
|
}
|
|
}
|
|
}
|
|
|
|
runOnUiThread {
|
|
val builder : AlertDialog.Builder = AlertDialog.Builder(this)
|
|
builder.apply {
|
|
setTitle(repositoryName)
|
|
setMessage(message)
|
|
setPositiveButton(R.string.ok) { _, _ ->
|
|
openAddedRepo()
|
|
}
|
|
setCancelable(false)
|
|
show().setDefaultFocus()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun Context.hasWebView(): Boolean {
|
|
return this.packageManager.hasSystemFeature("android.software.webview")
|
|
}
|
|
|
|
fun openWebView(fragment: Fragment?, url: String) {
|
|
if (fragment?.context?.hasWebView() == true)
|
|
normalSafeApiCall {
|
|
fragment
|
|
.findNavController()
|
|
.navigate(R.id.navigation_webview, WebviewFragment.newInstance(url))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
|
|
* */
|
|
fun Context.openBrowser(
|
|
url: String,
|
|
fallbackWebview: Boolean = false,
|
|
fragment: Fragment? = null,
|
|
) {
|
|
try {
|
|
val intent = Intent(Intent.ACTION_VIEW)
|
|
intent.data = Uri.parse(url)
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
|
|
|
// activityResultRegistry is used to fall back to webview if a browser is missing
|
|
// On older versions the startActivity just crashes, but on newer android versions
|
|
// You need to check the result to make sure it failed
|
|
val activityResultRegistry = fragment?.activity?.activityResultRegistry
|
|
if (activityResultRegistry != null) {
|
|
activityResultRegistry.register(
|
|
url,
|
|
ActivityResultContracts.StartActivityForResult()
|
|
) { result ->
|
|
if (result.resultCode == RESULT_CANCELED && fallbackWebview) {
|
|
openWebView(fragment, url)
|
|
}
|
|
}.launch(intent)
|
|
} else {
|
|
ContextCompat.startActivity(this, intent, null)
|
|
}
|
|
|
|
} catch (e: Exception) {
|
|
logError(e)
|
|
if (fallbackWebview) {
|
|
openWebView(fragment, url)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun Context.isNetworkAvailable(): Boolean {
|
|
val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
val activeNetworkInfo = manager.activeNetworkInfo
|
|
return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false
|
|
}
|
|
|
|
fun splitQuery(url: URL): Map<String, String> {
|
|
val queryPairs: MutableMap<String, String> = LinkedHashMap()
|
|
val query: String = url.query
|
|
val pairs = query.split("&").toTypedArray()
|
|
for (pair in pairs) {
|
|
val idx = pair.indexOf("=")
|
|
queryPairs[URLDecoder.decode(pair.substring(0, idx), "UTF-8")] =
|
|
URLDecoder.decode(pair.substring(idx + 1), "UTF-8")
|
|
}
|
|
return queryPairs
|
|
}
|
|
|
|
/** Any object as json string */
|
|
fun Any.toJson(): String {
|
|
if (this is String) return this
|
|
return mapper.writeValueAsString(this)
|
|
}
|
|
|
|
inline fun <reified T> parseJson(value: String): T {
|
|
return mapper.readValue(value)
|
|
}
|
|
|
|
inline fun <reified T> tryParseJson(value: String?): T? {
|
|
return try {
|
|
parseJson(value ?: return null)
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
/**| S1:E2 Hello World
|
|
* | Episode 2. Hello world
|
|
* | Hello World
|
|
* | Season 1 - Episode 2
|
|
* | Episode 2
|
|
* **/
|
|
fun Context.getNameFull(name: String?, episode: Int?, season: Int?): String {
|
|
val rEpisode = if (episode == 0) null else episode
|
|
val rSeason = if (season == 0) null else season
|
|
|
|
val seasonName = getString(R.string.season)
|
|
val episodeName = getString(R.string.episode)
|
|
val seasonNameShort = getString(R.string.season_short)
|
|
val episodeNameShort = getString(R.string.episode_short)
|
|
|
|
if (name != null) {
|
|
return if (rEpisode != null && rSeason != null) {
|
|
"$seasonNameShort${rSeason}:$episodeNameShort${rEpisode} $name"
|
|
} else if (rEpisode != null) {
|
|
"$episodeName $rEpisode. $name"
|
|
} else {
|
|
name
|
|
}
|
|
} else {
|
|
if (rEpisode != null && rSeason != null) {
|
|
return "$seasonName $rSeason - $episodeName $rEpisode"
|
|
} else if (rSeason == null) {
|
|
return "$episodeName $rEpisode"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
fun Activity?.loadCache() {
|
|
try {
|
|
cacheClass("android.net.NetworkCapabilities".load())
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
|
|
//private val viewModel: ResultViewModel by activityViewModels()
|
|
|
|
private fun getResultsId(): Int {
|
|
return if (Globals.isLayout(Globals.TV or Globals.EMULATOR)) {
|
|
R.id.global_to_navigation_results_tv
|
|
} else {
|
|
R.id.global_to_navigation_results_phone
|
|
}
|
|
}
|
|
|
|
fun loadResult(
|
|
url: String,
|
|
apiName: String,
|
|
startAction: Int = 0,
|
|
startValue: Int = 0
|
|
) {
|
|
(activity as FragmentActivity?)?.loadResult(url, apiName, startAction, startValue)
|
|
}
|
|
|
|
fun FragmentActivity.loadResult(
|
|
url: String,
|
|
apiName: String,
|
|
startAction: Int = 0,
|
|
startValue: Int = 0
|
|
) {
|
|
try {
|
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
|
Kitsu.isEnabled =
|
|
settingsManager.getBoolean(this.getString(R.string.show_kitsu_posters_key), true)
|
|
}catch (t : Throwable) {
|
|
logError(t)
|
|
}
|
|
|
|
this.runOnUiThread {
|
|
// viewModelStore.clear()
|
|
this.navigate(
|
|
getResultsId(),
|
|
ResultFragment.newInstance(url, apiName, startAction, startValue)
|
|
)
|
|
}
|
|
}
|
|
|
|
fun loadSearchResult(
|
|
card: SearchResponse,
|
|
startAction: Int = 0,
|
|
startValue: Int? = null,
|
|
) {
|
|
activity?.loadSearchResult(card, startAction, startValue)
|
|
}
|
|
|
|
fun Activity?.loadSearchResult(
|
|
card: SearchResponse,
|
|
startAction: Int = 0,
|
|
startValue: Int? = null,
|
|
) {
|
|
this?.runOnUiThread {
|
|
// viewModelStore.clear()
|
|
this.navigate(
|
|
getResultsId(),
|
|
ResultFragment.newInstance(card, startAction, startValue)
|
|
)
|
|
}
|
|
//(this as? AppCompatActivity?)?.loadResult(card.url, card.apiName, startAction, startValue)
|
|
}
|
|
|
|
fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) {
|
|
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
audioManager.requestAudioFocus(focusRequest)
|
|
} else {
|
|
val audioManager: AudioManager =
|
|
getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
audioManager.requestAudioFocus(
|
|
null,
|
|
AudioManager.STREAM_MUSIC,
|
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
|
|
)
|
|
}
|
|
}
|
|
|
|
private var currentAudioFocusRequest: AudioFocusRequest? = null
|
|
private var currentAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
|
|
var onAudioFocusEvent = Event<Boolean>()
|
|
|
|
private fun getAudioListener(): AudioManager.OnAudioFocusChangeListener? {
|
|
if (currentAudioFocusChangeListener != null) return currentAudioFocusChangeListener
|
|
currentAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener {
|
|
onAudioFocusEvent.invoke(
|
|
when (it) {
|
|
AudioManager.AUDIOFOCUS_GAIN -> false
|
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> false
|
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> false
|
|
else -> true
|
|
}
|
|
)
|
|
}
|
|
return currentAudioFocusChangeListener
|
|
}
|
|
|
|
fun Context.isCastApiAvailable(): Boolean {
|
|
val isCastApiAvailable =
|
|
GoogleApiAvailability.getInstance()
|
|
.isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
|
try {
|
|
applicationContext?.let { CastContext.getSharedInstance(it) }
|
|
} catch (e: Exception) {
|
|
println(e)
|
|
// track non-fatal
|
|
return false
|
|
}
|
|
return isCastApiAvailable
|
|
}
|
|
|
|
fun Context.isConnectedToChromecast(): Boolean {
|
|
if (isCastApiAvailable()) {
|
|
val castContext = CastContext.getSharedInstance(this)
|
|
if (castContext.castState == CastState.CONNECTED) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Sets the focus to the negative button when in TV and Emulator layout.
|
|
**/
|
|
fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) {
|
|
if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return
|
|
this.getButton(buttonFocus).run {
|
|
isFocusableInTouchMode = true
|
|
requestFocus()
|
|
}
|
|
}
|
|
|
|
// Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt
|
|
@SuppressLint("Range")
|
|
fun Context.getUri(data: Uri?): Uri? {
|
|
var uri = data
|
|
val ctx = this
|
|
if (data != null && data.scheme == "content") {
|
|
// Mail-based apps - download the stream to a temporary file and play it
|
|
if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) {
|
|
var inputStream: InputStream? = null
|
|
var os: OutputStream? = null
|
|
var cursor: Cursor? = null
|
|
try {
|
|
cursor = ctx.contentResolver.query(
|
|
data,
|
|
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null
|
|
)
|
|
if (cursor != null && cursor.moveToFirst()) {
|
|
val filename =
|
|
cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
|
|
.replace("/", "")
|
|
inputStream = ctx.contentResolver.openInputStream(data)
|
|
if (inputStream == null) return data
|
|
os =
|
|
FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename)
|
|
val buffer = ByteArray(1024)
|
|
var bytesRead = inputStream.read(buffer)
|
|
while (bytesRead >= 0) {
|
|
os.write(buffer, 0, bytesRead)
|
|
bytesRead = inputStream.read(buffer)
|
|
}
|
|
uri =
|
|
Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename))
|
|
}
|
|
} catch (e: Exception) {
|
|
return null
|
|
} finally {
|
|
inputStream?.close()
|
|
os?.close()
|
|
cursor?.close()
|
|
}
|
|
} else if (data.authority == "media") {
|
|
uri = this.contentResolver.query(
|
|
data,
|
|
arrayOf(MediaStore.Video.Media.DATA), null, null, null
|
|
)?.use {
|
|
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
|
|
if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex)))
|
|
?: data else data
|
|
}
|
|
//uri = MediaUtils.getContentMediaUri(data)
|
|
/*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) {
|
|
println("TV AUTHORITY")
|
|
//val medialibrary = Medialibrary.getInstance()
|
|
//val media = medialibrary.getMedia(data.lastPathSegment!!.toLong())
|
|
uri = null//media.uri*/
|
|
} else {
|
|
val inputPFD: ParcelFileDescriptor?
|
|
try {
|
|
inputPFD = ctx.contentResolver.openFileDescriptor(data, "r")
|
|
if (inputPFD == null) return data
|
|
uri = Uri.parse("fd://" + inputPFD.fd)
|
|
// Cursor returnCursor =
|
|
// getContentResolver().query(data, null, null, null, null);
|
|
// if (returnCursor != null) {
|
|
// if (returnCursor.getCount() > 0) {
|
|
// int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
|
// if (nameIndex > -1) {
|
|
// returnCursor.moveToFirst();
|
|
// title = returnCursor.getString(nameIndex);
|
|
// }
|
|
// }
|
|
// returnCursor.close();
|
|
// }
|
|
} catch (e: FileNotFoundException) {
|
|
Log.e("TAG", "${e.message} for $data", e)
|
|
return null
|
|
} catch (e: IllegalArgumentException) {
|
|
Log.e("TAG", "${e.message} for $data", e)
|
|
return null
|
|
} catch (e: IllegalStateException) {
|
|
Log.e("TAG", "${e.message} for $data", e)
|
|
return null
|
|
} catch (e: NullPointerException) {
|
|
Log.e("TAG", "${e.message} for $data", e)
|
|
return null
|
|
} catch (e: SecurityException) {
|
|
Log.e("TAG", "${e.message} for $data", e)
|
|
return null
|
|
}
|
|
}// Media or MMS URI
|
|
}
|
|
return uri
|
|
}
|
|
|
|
fun Context.isUsingMobileData(): Boolean {
|
|
val conManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
val networkInfo = conManager.allNetworks
|
|
return networkInfo.any {
|
|
conManager.getNetworkCapabilities(it)
|
|
?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
|
} &&
|
|
!networkInfo.any {
|
|
conManager.getNetworkCapabilities(it)
|
|
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
|
}
|
|
}
|
|
|
|
|
|
private fun Activity?.cacheClass(clazz: String?) {
|
|
clazz?.let { c ->
|
|
this?.cacheDir?.let {
|
|
Cache(
|
|
directory = File(it, c.toClassDir()),
|
|
maxSize = 20L * 1024L * 1024L // 20 MiB
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun Context.isAppInstalled(uri: String): Boolean {
|
|
val pm = Wrappers.packageManager(this)
|
|
|
|
return try {
|
|
pm.getPackageInfo(uri, 0) // PackageManager.GET_ACTIVITIES
|
|
true
|
|
} catch (e: PackageManager.NameNotFoundException) {
|
|
false
|
|
}
|
|
}
|
|
|
|
fun getFocusRequest(): AudioFocusRequest? {
|
|
if (currentAudioFocusRequest != null) return currentAudioFocusRequest
|
|
currentAudioFocusRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
|
|
setAudioAttributes(AudioAttributes.Builder().run {
|
|
setUsage(AudioAttributes.USAGE_MEDIA)
|
|
setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
|
build()
|
|
})
|
|
setAcceptsDelayedFocusGain(true)
|
|
getAudioListener()?.let {
|
|
setOnAudioFocusChangeListener(it)
|
|
}
|
|
build()
|
|
}
|
|
} else {
|
|
null
|
|
}
|
|
return currentAudioFocusRequest
|
|
}
|
|
}
|