You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ytdl-android/app/src/main/java/com/deniscerri/ytdl/util/Extensions.kt

792 lines
29 KiB
Kotlin

package com.deniscerri.ytdl.util
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.OvalShape
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
import android.net.Uri
import android.os.Build
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.util.DisplayMetrics
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.view.animation.Interpolator
import android.widget.EditText
import android.widget.ImageView
import android.widget.ScrollView
import android.widget.TextView
import androidx.annotation.OptIn
import androidx.annotation.Px
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.text.HtmlCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStarted
import androidx.recyclerview.widget.RecyclerView
import com.deniscerri.ytdl.App
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.models.DownloadItem
import com.deniscerri.ytdl.database.models.observeSources.ObserveSourcesItem
import com.deniscerri.ytdl.database.repository.DownloadRepository
import com.deniscerri.ytdl.database.repository.ObserveSourcesRepository
import com.deniscerri.ytdl.database.repository.ObserveSourcesRepository.EveryCategory
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils
import com.google.android.material.badge.ExperimentalBadgeUtils
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.chip.Chip
import com.google.android.material.tabs.TabLayout
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.neoutils.highlight.core.Highlight
import com.neoutils.highlight.core.scheme.TextColorScheme
import com.neoutils.highlight.core.util.UiColor
import com.neoutils.highlight.view.extension.applyTo
import com.neoutils.highlight.view.extension.removeAllSpans
import com.neoutils.highlight.view.extension.toSpannedString
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.BlurTransformation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import me.zhanghai.android.fastscroll.FastScrollerBuilder
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpCookie
import java.util.Calendar
import java.util.Locale
import java.util.regex.Pattern
import kotlin.math.abs
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
object Extensions {
fun dp(resources: Resources, px: Float) : Int {
val metrics: DisplayMetrics = resources.displayMetrics
return (metrics.density * px).toInt()
}
private var textHighlightSchemes = listOf(
TextColorScheme(
regex = "([\"'])(?:\\\\1|.)*?\\1".toRegex(),
matcher = com.neoutils.highlight.core.util.Matcher.fully(UiColor.Hex("#FC8500"))),
TextColorScheme(
regex = "(yt-dlp)|(ffmpeg)|(deno)|(python)".toRegex(),
matcher = com.neoutils.highlight.core.util.Matcher.fully(UiColor.Hex("#77eb09"))),
TextColorScheme(
regex = "(https?://(?:www\\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\\.[^\\s]{2,}|www\\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\\.[^\\s]{2,}|https?://(?:www\\.|(?!www))[a-zA-Z0-9]+\\.[^\\s]{2,}|www\\.[a-zA-Z0-9]+\\.[^\\s]{2,})".toRegex(),
matcher = com.neoutils.highlight.core.util.Matcher.fully(UiColor.Hex("#b5942f"))),
TextColorScheme(
regex = "\\d+(\\.\\d)?%".toRegex(),
matcher = com.neoutils.highlight.core.util.Matcher.fully(UiColor.Hex("#43a564"))),
)
fun View.enableTextHighlight(){
if (this is EditText || this is TextView){
//init syntax highlighter
val highlight = Highlight(textHighlightSchemes)
val highlightWatcher = object : TextWatcher {
override fun beforeTextChanged(
p0: CharSequence?,
p1: Int,
p2: Int,
p3: Int
) = Unit
override fun onTextChanged(
p0: CharSequence?,
p1: Int,
p2: Int,
p3: Int
) = Unit
override fun afterTextChanged(p0: Editable?) {
p0?.apply {
kotlin.runCatching {
removeAllSpans()
highlight.applyTo(this)
}
}
}
}
if (this is EditText) {
this.addTextChangedListener(highlightWatcher)
this.setText(highlight.toSpannedString(this.text.toString()))
}else if (this is TextView) {
this.addTextChangedListener(highlightWatcher)
this.text = highlight.toSpannedString(this.text.toString())
}
}
}
fun EditText.setTextAndRecalculateWidth(t : String){
val scale = context.resources.displayMetrics.density
this.setText(t)
val widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
this.measure(widthMeasureSpec, heightMeasureSpec)
val requiredWidth: Int = this.measuredWidth
if (t.length < 5){
this.layoutParams.width = (70 * scale + 0.5F).toInt()
}else{
this.layoutParams.width = requiredWidth
}
this.requestLayout()
}
@SuppressLint("ClickableViewAccessibility")
fun RecyclerView.forceFastScrollMode()
{
overScrollMode = View.OVER_SCROLL_ALWAYS
scrollBarStyle = View.SCROLLBARS_INSIDE_INSET
isVerticalScrollBarEnabled = true
setOnTouchListener { view, event ->
if (event.x >= this.width - 30) {
view.parent.requestDisallowInterceptTouchEvent(true)
when (event.action and MotionEvent.ACTION_MASK) {
MotionEvent.ACTION_UP -> view.parent.requestDisallowInterceptTouchEvent(false)
}
}
false
}
}
fun RecyclerView.enableFastScroll(){
val drawable = ShapeDrawable(OvalShape())
drawable.paint.color = context.getColor(android.R.color.transparent)
FastScrollerBuilder(this)
.useMd2Style()
.setTrackDrawable(drawable)
.build()
}
fun ScrollView.enableFastScroll() {
val drawable = ShapeDrawable(OvalShape())
drawable.paint.color = context.getColor(android.R.color.transparent)
FastScrollerBuilder(this)
.useMd2Style()
.setTrackDrawable(drawable)
.build()
}
fun File.getMediaDuration(context: Context): Int {
return kotlin.runCatching {
if (!exists()) return 0
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, Uri.parse(absolutePath))
val duration = retriever.extractMetadata(METADATA_KEY_DURATION)
retriever.release()
duration?.toIntOrNull()?.div(1000) ?: 0
}.getOrElse { 0 }
}
fun Dialog.setFullScreen(
@Px cornerRadius: Int = 0,
skipCollapsed: Boolean = true
) {
check(this is BottomSheetDialog) {
"Dialog must be a BottomSheetBottomSheetDialog."
}
lifecycleScope.launch {
withStarted {
val bottomSheetLayout = findViewById<ViewGroup>(com.google.android.material.R.id.design_bottom_sheet) ?: return@withStarted
with(bottomSheetLayout) {
updateLayoutParams {
height = ViewGroup.LayoutParams.MATCH_PARENT
width = ViewGroup.LayoutParams.MATCH_PARENT
}
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height + cornerRadius,
cornerRadius.toFloat()
)
}
}
}
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.maxWidth = ViewGroup.LayoutParams.MATCH_PARENT
behavior.skipCollapsed = skipCollapsed
}
}
}
fun JSONObject.getIntByAny(vararg tags:String): Int {
tags.forEach {
runCatching {
return this.getInt(it)
}
}
return -1
}
fun JSONObject.getStringByAny(vararg tags:String): String {
tags.forEach {
runCatching {
val tmp = this.getString(it)
if (tmp != "null") {
return try {
Json.decodeFromString<List<String>>(tmp).distinct().joinToString(", ")
} catch (e: Exception) {
tmp
}
}
}
}
return ""
}
fun TextView.setCustomTextSize(newSize: Float){
this.setTextSize(TypedValue.COMPLEX_UNIT_SP, newSize)
}
fun Int.toStringDuration(locale: Locale): String {
var format = String.format(
locale,
"%02d:%02d:%02d",
this / 3600,
this % 3600 / 60,
this % 60
)
// 00:00:00
if (this < 600) format = format.substring(4) else if (this < 3600) format =
format.substring(3) else if (this < 36000) format = format.substring(1)
return format
}
fun View.popup(){
val animator = ValueAnimator.ofFloat( 0.75f, 1f)
animator.addUpdateListener { animation: ValueAnimator ->
val value = animation.animatedValue as Float
this.scaleX = value
this.scaleY = value
}
animator.interpolator = Extensions.CustomInterpolator()
animator.setDuration(300)
animator.start()
}
fun dpToPx(resources: Resources, dp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
resources.displayMetrics
).toInt()
}
@OptIn(ExperimentalBadgeUtils::class)
fun Chip.createBadge(context: Context, nr: Int) {
this.overlay.clear()
if (nr > 0) {
val badge = BadgeDrawable.create(context).apply {
number = nr
badgeTextColor = context.getColor(R.color.black)
backgroundColor = context.getColor(R.color.white)
verticalOffset = dpToPx(context.resources, 20f)
horizontalOffset =
if (nr < 10) dpToPx(context.resources, 16f)
else if (nr < 100) dpToPx(context.resources, 19f)
else dpToPx(context.resources, 22f)
}
BadgeUtils.attachBadgeDrawable(badge, this)
}
}
@OptIn(ExperimentalBadgeUtils::class)
fun MaterialToolbar.updateMenuItemBadge(menuItemId: Int, nr: Int) {
this.findViewById<View>(menuItemId).overlay.clear()
val badge = BadgeDrawable.create(context).apply {
number = nr
backgroundColor = ContextCompat.getColor(context, R.color.white)
badgeTextColor = ContextCompat.getColor(context, R.color.black)
verticalOffset = dpToPx(resources, 4f)
horizontalOffset = dpToPx(resources, 4f)
}
if (nr > 0) {
BadgeUtils.attachBadgeDrawable(badge, this, menuItemId)
} else {
BadgeUtils.detachBadgeDrawable(badge, this, menuItemId)
}
}
fun TabLayout.Tab.createBadge(nr: Int){
removeBadge()
if (nr > 0) {
orCreateBadge.apply {
number = nr
verticalOffset = 3
horizontalOffset =
if (nr < 10) dp(App.instance.resources, 7f)
else if (nr < 100) dp(App.instance.resources, 10f)
else dp(App.instance.resources, 20f)
}
}
}
fun String.appendLineToLog(line: String = ""): String {
val lines = this.lines().toMutableList()
if (line.isNotBlank()) {
var newline = ""
val newLines = line.lines().filter { !lines.contains(it) }
lines.addAll(newLines)
if (newLines.isNotEmpty()) {
newLines.last().apply {
//common text in yt-dlp and aria2c progress
if (this.contains("[download") || this.contains(") CN:")) {
newline = "\n${this}"
}
}
}
return lines.distinct().filterNot {
((it.contains("[download") || it.contains(") CN:")) && !FINISHING_PROGRESS_LINES_REGEX.matcher(it).find())
|| it.contains("does not pass filter (id")
}.joinToString("\n") + newline
}
return lines.filterNot {
((it.contains("[download") || it.contains(") CN:")) && !FINISHING_PROGRESS_LINES_REGEX.matcher(it).find())
|| it.contains("does not pass filter (id")
}.joinToString("\n")
}
fun ImageView.loadThumbnail(hideThumb: Boolean, imageURL: String){
if(!hideThumb){
if (imageURL.isNotEmpty()) {
Picasso.get()
.load(imageURL)
.resize(1280, 0)
.onlyScaleDown()
.into(this)
} else {
Picasso.get().load(R.color.black).into(this)
}
}
}
fun ImageView.loadBlurryThumbnail(context: Context, hideThumb: Boolean, imageURL: String) {
if(!hideThumb){
if (imageURL.isNotEmpty()) {
Picasso.get()
.load(imageURL)
.resize(1280, 0)
.transform(BlurTransformation(context, 1, 1))
.onlyScaleDown()
.into(this)
} else {
Picasso.get().load(R.color.black).into(this)
}
}
}
fun List<DownloadRepository.Status>.toListString() : List<String>{
return this.map { it.toString() }
}
fun List<String>.closestValue(value: String) = minBy { abs(value.toInt() - it.toInt()) }
class CustomInterpolator : Interpolator {
override fun getInterpolation(input: Float): Float {
// Adjust this curve as needed for desired animation feel
return (Math.pow((input - 1).toDouble(), 5.0) + 1).toFloat()
}
}
enum class Period {
HOUR, MINUTE, SECOND, MILLISECOND
}
fun Long.toTimePeriodsArray() : Map<Period, Int> {
var tmp = this
val millis = ((tmp % 1000) / 100).toInt()
tmp /= 1000
val hours = (tmp / 3600).toInt()
tmp %= 3600
val minutes = (tmp / 60).toInt()
tmp %= 60
val seconds = tmp.toInt()
return mapOf(
Period.HOUR to hours,
Period.MINUTE to minutes,
Period.SECOND to seconds,
Period.MILLISECOND to millis
)
}
fun Long.toStringTimeStamp(forceMillis: Boolean = false, showMillisIfNonZero : Boolean = false): String {
return this.milliseconds.toComponents { hours, minutes, seconds, nanoseconds ->
buildString {
if (hours > 0) {
append(hours)
append(':')
}
append(minutes.toString().padStart(if (hours > 0) 2 else 1, '0'))
append(':')
append(seconds.toString().padStart(2, '0'))
val millis = nanoseconds / 1_000_000
if (forceMillis || showMillisIfNonZero && millis > 0) {
append('.')
var millisString = millis.toString().padStart(3, '0')
if (showMillisIfNonZero) millisString = millisString.trimEnd('0')
append(millisString)
}
}
}
}
fun String.convertToTimestamp(): Long =
kotlin.runCatching { tryConvertToTimestamp() }.getOrNull() ?: 0L
private val timeRegex =
Regex("""^(?:(?:(\d+):)?(\d{1,2}):)?(\d+)(?:\.(\d+))?$""")
fun String.tryConvertToTimestamp(): Long? {
try {
val match = timeRegex.matchEntire(this.trim()) ?: return null
val hours = match.groups[1]?.value?.toInt() ?: 0
val minutes = match.groups[2]?.value?.toInt() ?: 0
val seconds = match.groups[3]!!.value.toInt()
val millis = match.groups[4]?.value
?.take(3)?.padEnd(3,'0')?.toInt() ?: 0
return (hours.hours + minutes.minutes + seconds.seconds + millis.milliseconds).inWholeMilliseconds
}catch (ex: Exception) {
return null
}
}
fun String.convertNetscapeToSetCookie(): String {
// Split the Netscape cookie string
val parts =
this.split("\t").dropLastWhile { it.isEmpty() }
.toTypedArray()
if (parts.isEmpty()) return ""
// Extract the individual components
val domain = parts[0].trim { it <= ' ' }
val isSecure =
java.lang.Boolean.parseBoolean(parts[3].trim { it <= ' ' })
val expiry = parts[4].trim { it <= ' ' }.toLong()
val name = parts[5].trim { it <= ' ' }
val value = parts[6].trim { it <= ' ' }
// Create the BasicClientCookie
val cookie = HttpCookie(name, value)
cookie.domain = domain
cookie.path = "/"
cookie.secure = isSecure
// Set expiry
if (expiry != 0L) {
cookie.maxAge = expiry
} else {
// For session cookies, set to null
cookie.maxAge = -1L
}
// Get the Set-Cookie header format
return cookie.toString()
}
fun ObserveSourcesItem.calculateNextTimeForObserving() : Long {
val item = this
val now = System.currentTimeMillis()
var everyNr = item.everyNr
Calendar.getInstance().apply {
timeInMillis = item.startsTime
if (item.everyCategory != EveryCategory.HOUR){
val hourMin = Calendar.getInstance()
hourMin.timeInMillis = item.everyTime
set(Calendar.HOUR_OF_DAY, hourMin.get(Calendar.HOUR_OF_DAY))
if (item.everyCategory != EveryCategory.MINUTE) {
set(Calendar.MINUTE, hourMin.get(Calendar.MINUTE))
}
}
while (timeInMillis < now){
when(item.everyCategory){
EveryCategory.MINUTE -> { add(Calendar.MINUTE, everyNr) }
EveryCategory.HOUR -> { add(Calendar.HOUR, everyNr) }
EveryCategory.DAY -> { add(Calendar.DAY_OF_MONTH, everyNr) }
EveryCategory.WEEK -> {
item.weeklyConfig?.apply {
if (this.weekDays.isEmpty()){
add(Calendar.DAY_OF_MONTH, 7 * everyNr)
}else{
var weekDayNr = get(Calendar.DAY_OF_WEEK) - 1
if (weekDayNr == 0) weekDayNr = 7
val followingWeekDay = this.weekDays.firstOrNull { it > weekDayNr }
if (followingWeekDay == null){
add(Calendar.DAY_OF_MONTH, this.weekDays.minBy { it } + (7 - weekDayNr))
everyNr--
}else{
add(Calendar.DAY_OF_MONTH, followingWeekDay - weekDayNr)
}
if (everyNr > 1){
add(Calendar.DAY_OF_MONTH, 7 * everyNr)
}
}
}
}
EveryCategory.MONTH -> {
add(Calendar.MONTH, everyNr)
item.monthlyConfig?.apply { set(Calendar.DAY_OF_MONTH, this.everyMonthDay) }
}
}
}
return timeInMillis
}
}
enum class ObserveSourceDisplayStatus { ACTIVE, PAUSED, FINISHED }
// Same condition the worker uses to decide it's done (ObserveSourceWorker.kt:216-218)
fun ObserveSourcesItem.hasReachedEnd(now: Long = System.currentTimeMillis()): Boolean {
return (endsAfterCount > 0 && runCount >= endsAfterCount) ||
(endsDate > 0 && now >= endsDate)
}
fun ObserveSourcesItem.displayStatus(): ObserveSourceDisplayStatus {
return when {
status == ObserveSourcesRepository.SourceStatus.ACTIVE -> ObserveSourceDisplayStatus.ACTIVE
hasReachedEnd() -> ObserveSourceDisplayStatus.FINISHED
else -> ObserveSourceDisplayStatus.PAUSED
}
}
fun ObserveSourcesItem.scheduleSummary(context: Context): String {
val nr = everyNr
return when (everyCategory) {
EveryCategory.MINUTE -> context.resources.getQuantityString(R.plurals.every_minutes, nr, nr)
EveryCategory.HOUR -> context.resources.getQuantityString(R.plurals.every_hours, nr, nr)
EveryCategory.DAY -> context.resources.getQuantityString(R.plurals.every_days, nr, nr)
EveryCategory.WEEK -> context.resources.getQuantityString(R.plurals.every_weeks, nr, nr)
EveryCategory.MONTH -> context.resources.getQuantityString(R.plurals.every_months, nr, nr)
}
}
fun Int.toBitmap(context: Context): Bitmap{
val drawable = ContextCompat.getDrawable(context, this)
val bitmap = Bitmap.createBitmap(
drawable!!.intrinsicWidth,
drawable.intrinsicHeight, Bitmap.Config.ARGB_8888
)
val paint = Paint()
val colorValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorActivatedHighlight, colorValue, true)
paint.setColorFilter(PorterDuffColorFilter(colorValue.data, PorterDuff.Mode.SRC_IN))
val canvas = Canvas(bitmap)
DrawableCompat.setTint(drawable, ContextCompat.getColor(context, R.color.icon_fg))
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
fun DownloadItem.setAsScheduling(timeInMillis: Long) {
status = DownloadRepository.Status.Scheduled.toString()
downloadStartTime = timeInMillis
}
private val YOUTUBE_URL_PATTERN = Pattern.compile("((^(https?)://)?(www.)?(m.)?youtu(.be)?)|(^(https?)://(www.)?piped.video)")
private val YOUTUBE_CHANNEL_URL_PATTERN = Pattern.compile("((^(https?)://)?(www.)?(m.)?youtu(.be)?(be.com))/@[a-zA-Z]+")
private val YOUTUBE_WATCH_VIDEOS_URL_PATTERN = Pattern.compile("((^(https?)://)?(www.)?(m.)?youtu(.be)?(be.com))/watch_videos\\?video_ids=.*")
private val SOUNDCLOUD_URL_PATTERN = Pattern.compile("""^(https?://)?(www\.|m\.)?soundcloud\.com/[\w\-.]+(/[\w\-.]+)*/?$""")
private val GENERAL_URL_PATTERN = Pattern.compile("(http|ftp|https)://([\\w_-]+(?:\\.[\\w_-]+)+)([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])")
fun TextWithSubtitle(title: String, subtitle: String) : Spanned {
return HtmlCompat.fromHtml("<b><big>" + title + "</big></b>" + "<br />" +
"<small>" + subtitle + "</small>" + "<br />", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
fun String.isYoutubeURL() : Boolean {
return YOUTUBE_URL_PATTERN.matcher(this).find()
}
fun String.isYoutubeChannelURL() : Boolean {
return YOUTUBE_CHANNEL_URL_PATTERN.matcher(this).find()
}
fun String.isYoutubeWatchVideosURL() : Boolean {
return YOUTUBE_WATCH_VIDEOS_URL_PATTERN.matcher(this).find()
}
fun String.isSoundCloudURL() : Boolean {
return SOUNDCLOUD_URL_PATTERN.matcher(this).find()
}
fun String.extractURL() : String {
val res = GENERAL_URL_PATTERN.matcher(this)
return if (res.find()) {
res.group()
} else {
this
}
}
fun String.isURL(): Boolean {
return GENERAL_URL_PATTERN.matcher(this).find()
}
fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
): Flow<R> = combine(
flow,
combine(flow2, flow3, ::Pair),
combine(flow4, flow5, ::Pair),
combine(flow6, flow7, ::Pair),
) { t1, t2, t3, t4 ->
transform(
t1,
t2.first,
t2.second,
t3.first,
t3.second,
t4.first,
t4.second
)
}
fun DownloadItem.needsDataUpdating() : Boolean {
return this.title.isBlank() || this.author.isBlank() || this.thumb.isBlank()
}
fun String.applyFilenameTemplateForCuts() : String {
return if(this.isBlank()) {
"%(section_title&{} - |)s%(title).170B"
}else {
if(this.startsWith("%(section_title&{} - |)s")) this
else "%(section_title&{} - |)s$this"
}
}
fun String.getIDFromYoutubeURL() : String? {
val regex = Regex(
"(?:youtube\\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?|(?:shorts)?)/|.*?[?&]v=)|youtu\\.be/)([^\"&?/\\s]{11})"
)
val match = regex.find(this)
return if (match != null){
match.groupValues[1]
}else {
null
}
}
fun readJsonValue(reader: JsonReader, skipKeys: Set<String> = setOf()): Any {
return when (reader.peek()) {
JsonToken.BEGIN_ARRAY -> {
val array = JSONArray()
reader.beginArray()
while (reader.hasNext()) {
array.put(readJsonValue(reader))
}
reader.endArray()
array
}
JsonToken.BEGIN_OBJECT -> {
val obj = JSONObject()
reader.beginObject()
while (reader.hasNext()) {
val name = reader.nextName()
if (name in skipKeys) {
reader.skipValue() // never parsed, never allocated
continue
}
obj.put(name, readJsonValue(reader))
}
reader.endObject()
obj
}
JsonToken.STRING -> reader.nextString()
JsonToken.NUMBER -> {
val raw = reader.nextString()
raw.toLongOrNull() ?: raw.toDoubleOrNull() ?: raw
}
JsonToken.BOOLEAN -> reader.nextBoolean()
JsonToken.NULL -> {
reader.nextNull()
JSONObject.NULL
}
else -> {
reader.skipValue()
JSONObject.NULL
}
}
}
fun String.hasPermission(context: Context) : Boolean {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_PERMISSIONS
)
}
return packageInfo.requestedPermissions?.contains(this) ?: false
}
}