diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
new file mode 100644
index 00000000..7f7fd14c
--- /dev/null
+++ b/app/CMakeLists.txt
@@ -0,0 +1,6 @@
+# Set this to the minimum version your project supports.
+cmake_minimum_required(VERSION 3.18)
+project(CrashHandler)
+find_library(log-lib log)
+add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
+target_link_libraries(native-lib ${log-lib})
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3c12652a..333fbfb8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -32,6 +32,13 @@ android {
enable = true
}
+ // disable this for now
+ //externalNativeBuild {
+ // cmake {
+ // path("CMakeLists.txt")
+ // }
+ //}
+
signingConfigs {
create("prerelease") {
if (prereleaseStoreFile != null) {
@@ -49,10 +56,10 @@ android {
defaultConfig {
applicationId = "com.lagradost.cloudstream3"
minSdk = 21
- targetSdk = 33
+ targetSdk = 29
versionCode = 59
- versionName = "4.1.3"
+ versionName = "4.1.8"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
@@ -165,7 +172,7 @@ dependencies {
androidTestImplementation("androidx.test:core")
//implementation("io.karn:khttp-android:0.1.2") //okhttp instead
-// implementation("org.jsoup:jsoup:1.13.1")
+ // implementation("org.jsoup:jsoup:1.13.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
implementation("androidx.preference:preference-ktx:1.2.0")
@@ -181,22 +188,22 @@ dependencies {
// implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
// Media 3
- implementation("androidx.media3:media3-common:1.1.0")
- implementation("androidx.media3:media3-exoplayer:1.1.0")
- implementation("androidx.media3:media3-datasource-okhttp:1.1.0")
- implementation("androidx.media3:media3-ui:1.1.0")
- implementation("androidx.media3:media3-session:1.1.0")
- implementation("androidx.media3:media3-cast:1.1.0")
- implementation("androidx.media3:media3-exoplayer-hls:1.1.0")
- implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
+ implementation("androidx.media3:media3-common:1.1.1")
+ implementation("androidx.media3:media3-exoplayer:1.1.1")
+ implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
+ implementation("androidx.media3:media3-ui:1.1.1")
+ implementation("androidx.media3:media3-session:1.1.1")
+ implementation("androidx.media3:media3-cast:1.1.1")
+ implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
+ implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
// Custom ffmpeg extension for audio codecs
implementation("com.github.recloudstream:media-ffmpeg:1.1.0")
//implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
// Bug reports
- implementation("ch.acra:acra-core:5.8.4")
- implementation("ch.acra:acra-toast:5.8.4")
+ implementation("ch.acra:acra-core:5.11.0")
+ implementation("ch.acra:acra-toast:5.11.0")
compileOnly("com.google.auto.service:auto-service-annotations:1.0")
//either for java sources:
@@ -220,18 +227,18 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.8.1")
// Networking
-// implementation("com.squareup.okhttp3:okhttp:4.9.2")
-// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
+ // implementation("com.squareup.okhttp3:okhttp:4.9.2")
+ // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
implementation("com.github.Blatzar:NiceHttp:0.4.3")
// To fix SSL fuckery on android 9
implementation("org.conscrypt:conscrypt-android:2.2.1")
// Util to skip the URI file fuckery 🙏
- implementation("com.github.tachiyomiorg:unifile:17bec43")
+ implementation("com.github.LagradOst:SafeFile:0.0.2")
// API because cba maintaining it myself
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
- implementation("com.github.discord:OverlappingPanels:0.1.3")
+ implementation("com.github.discord:OverlappingPanels:0.1.5")
// debugImplementation because LeakCanary should only run in debug builds.
//debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
@@ -243,11 +250,9 @@ dependencies {
// used for subtitle decoding https://github.com/albfernandez/juniversalchardet
implementation("com.github.albfernandez:juniversalchardet:2.4.0")
- // slow af yt
- //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT")
-
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204
- implementation("com.github.TeamNewPipe:NewPipeExtractor:8495ad619e")
+ // this should be updated frequently to avoid trailer fu*kery
+ implementation("com.github.TeamNewPipe:NewPipeExtractor:1f08d28")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
// Library/extensions searching with Levenshtein distance
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 563c82f8..15767d7b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,7 +15,7 @@
-
+
+#include
+#include
+
+#define TAG "CloudStream Crash Handler"
+volatile sig_atomic_t gSignalStatus = 0;
+void handleNativeCrash(int signal) {
+ gSignalStatus = signal;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
+ #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
+ REGISTER_SIGNAL(SIGSEGV)
+ #undef REGISTER_SIGNAL
+}
+
+//extern "C" JNIEXPORT void JNICALL
+//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
+// int *p = nullptr;
+// *p = 0;
+//}
+
+extern "C" JNIEXPORT int JNICALL
+Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
+ //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
+ return gSignalStatus;
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index 069287b0..5f3162b4 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -43,9 +43,9 @@ class CustomReportSender : ReportSender {
override fun send(context: Context, errorContent: CrashReportData) {
println("Sending report")
val url =
- "https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse"
+ "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
val data = mapOf(
- "entry.753293084" to errorContent.toJSON()
+ "entry.1993829403" to errorContent.toJSON()
)
thread { // to not run it on main thread
@@ -104,12 +104,17 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
}
class AcraApplication : Application() {
+
override fun onCreate() {
super.onCreate()
- Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) {
+ //NativeCrashHandler.initCrashHandler()
+ ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
- })
+ }.also {
+ exceptionHandler = it
+ Thread.setDefaultUncaughtExceptionHandler(it)
+ }
}
override fun attachBaseContext(base: Context?) {
@@ -121,10 +126,10 @@ class AcraApplication : Application() {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
- reportContent = arrayOf(
+ reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
- ReportField.STACK_TRACE
+ ReportField.STACK_TRACE,
)
// removed this due to bug when starting the app, moved it to when it actually crashes
@@ -137,6 +142,8 @@ class AcraApplication : Application() {
}
companion object {
+ var exceptionHandler: ExceptionHandler? = null
+
/** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? = this as? Activity
?: (this as? ContextWrapper)?.baseContext?.getActivity()
@@ -211,6 +218,5 @@ class AcraApplication : Application() {
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
-
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
index 379a91e4..0a2db2bd 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
@@ -50,7 +50,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
companion object {
private const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
private var instance: DownloaderTestImpl? = null
/**
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
index 7790f047..80332445 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
@@ -29,7 +29,7 @@ import java.util.*
import kotlin.math.absoluteValue
const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
//val baseHeader = mapOf("User-Agent" to USER_AGENT)
val mapper = JsonMapper.builder().addModule(KotlinModule())
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index a8160d33..fbad4fce 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -144,6 +144,7 @@ import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ResponseParser
+import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
@@ -279,6 +280,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
companion object {
const val TAG = "MAINACT"
var lastError: String? = null
+
/**
* Setting this will automatically enter the query in the search
* next time the search fragment is opened.
@@ -286,7 +288,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
*
* This is a very bad solution but I was unable to find a better one.
**/
- private var nextSearchQuery: String? = null
+ var nextSearchQuery: String? = null
/**
* Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread
@@ -362,9 +364,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
loadRepository(url)
return true
} else if (safeURI(str)?.scheme == appStringSearch) {
+ val query = str.substringAfter("$appStringSearch://")
nextSearchQuery =
- URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8")
-
+ try {
+ URLDecoder.decode(query, "UTF-8")
+ } catch (t: Throwable) {
+ logError(t)
+ query
+ }
// Use both navigation views to support both layouts.
// It might be better to use the QuickSearch.
activity?.findViewById(R.id.nav_view)?.selectedItemId =
@@ -854,7 +861,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
RecyclerView::class.java.declaredMethods.firstOrNull {
it.name == "scrollStep"
}?.also { it.isAccessible = true }
- } catch (t : Throwable) {
+ } catch (t: Throwable) {
null
}
}
@@ -901,11 +908,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (dx > 0) dx else 0
}
- if(!NO_MOVE_LIST) {
+ if (!NO_MOVE_LIST) {
parent.smoothScrollBy(rdx, 0)
- }else {
+ } else {
val smoothScroll = reflectedScroll
- if(smoothScroll == null) {
+ if (smoothScroll == null) {
parent.smoothScrollBy(rdx, 0)
} else {
try {
@@ -915,12 +922,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
val out = IntArray(2)
smoothScroll.invoke(parent, rdx, 0, out)
val scrolledX = out[0]
- if(abs(scrolledX) <= 0) { // newFocus.measuredWidth*2
+ if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2
smoothScroll.invoke(parent, -rdx, 0, out)
parent.smoothScrollBy(scrolledX, 0)
if (NO_MOVE_LIST) targetDx = scrolledX
}
- } catch (t : Throwable) {
+ } catch (t: Throwable) {
parent.smoothScrollBy(rdx, 0)
}
}
@@ -1126,10 +1133,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
snackbar.show()
}
}
-
}
}
+ ioSafe { SafeFile.check(this@MainActivity) }
if (PluginManager.checkSafeModeFile()) {
normalSafeApiCall {
@@ -1315,7 +1322,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) {
bundle?.apply {
this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery)
- nextSearchQuery = null
}
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt
new file mode 100644
index 00000000..7be90440
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt
@@ -0,0 +1,53 @@
+package com.lagradost.cloudstream3
+
+import com.lagradost.cloudstream3.MainActivity.Companion.lastError
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.plugins.PluginManager.checkSafeModeFile
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+object NativeCrashHandler {
+ // external fun triggerNativeCrash()
+ /*private external fun initNativeCrashHandler()
+ private external fun getSignalStatus(): Int
+
+ private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch {
+
+ //launch {
+ // delay(10000)
+ // triggerNativeCrash()
+ //}
+
+ while (true) {
+ delay(10_000)
+ val signal = getSignalStatus()
+ // Signal is initialized to zero
+ if (signal == 0) continue
+
+ // Do not crash in safe mode!
+ if (lastError != null) continue
+ if (checkSafeModeFile()) continue
+
+ AcraApplication.exceptionHandler?.uncaughtException(
+ Thread.currentThread(),
+ RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n")
+ )
+ }
+ }
+
+ fun initCrashHandler() {
+ try {
+ System.loadLibrary("native-lib")
+ initNativeCrashHandler()
+ } catch (t: Throwable) {
+ // Make debug crash.
+ if (BuildConfig.DEBUG) throw t
+ logError(t)
+ return
+ }
+
+ initSignalPolling()
+ }*/
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt
index 6a2f399d..42f6eddb 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt
@@ -1,13 +1,11 @@
package com.lagradost.cloudstream3.extractors
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.utils.Qualities
+import com.lagradost.cloudstream3.USER_AGENT
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
-import android.util.Log
+import com.lagradost.cloudstream3.utils.Qualities
import java.net.URLDecoder
open class Cda: ExtractorApi() {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
index 93a280ed..e746b286 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
@@ -10,24 +10,39 @@ open class Mp4Upload : ExtractorApi() {
override var name = "Mp4Upload"
override var mainUrl = "https://www.mp4upload.com"
private val srcRegex = Regex("""player\.src\("(.*?)"""")
- override val requiresReferer = true
+ private val srcRegex2 = Regex("""player\.src\([\w\W]*src: "(.*?)"""")
+ override val requiresReferer = true
+ private val idMatch = Regex("""mp4upload\.com/(embed-|)([A-Za-z0-9]*)""")
override suspend fun getUrl(url: String, referer: String?): List? {
- with(app.get(url)) {
- getAndUnpack(this.text).let { unpackedText ->
- val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
- srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
- return listOf(
- ExtractorLink(
- name,
- name,
- link,
- url,
- quality ?: Qualities.Unknown.value,
- )
- )
- }
- }
+ val realUrl = idMatch.find(url)?.groupValues?.get(2)?.let { id ->
+ "$mainUrl/embed-$id.html"
+ } ?: url
+ val response = app.get(realUrl)
+ val unpackedText = getAndUnpack(response.text)
+ val quality =
+ unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
+ srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
+ return listOf(
+ ExtractorLink(
+ name,
+ name,
+ link,
+ url,
+ quality ?: Qualities.Unknown.value,
+ )
+ )
+ }
+ srcRegex2.find(unpackedText)?.groupValues?.get(1)?.let { link ->
+ return listOf(
+ ExtractorLink(
+ name,
+ name,
+ link,
+ url,
+ quality ?: Qualities.Unknown.value,
+ )
+ )
}
return null
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt
index b686f7d8..0154b4e8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Rabbitstream.kt
@@ -36,7 +36,6 @@ open class Rabbitstream : ExtractorApi() {
override val requiresReferer = false
open val embed = "ajax/embed-4"
open val key = "https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt"
- private var rawKey: String? = null
override suspend fun getUrl(
url: String,
@@ -82,9 +81,10 @@ open class Rabbitstream : ExtractorApi() {
)
}
+
}
- private suspend fun getRawKey(): String = rawKey ?: app.get(key).text.also { rawKey = it }
+ private suspend fun getRawKey(): String = app.get(key).text
private fun extractRealKey(originalString: String?, stops: String): Pair {
val table = parseJson>>(stops)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt
index b4774cf8..1d7b5a83 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt
@@ -23,7 +23,7 @@ data class VisualDownloadChildCached(
val data: VideoDownloadHelper.DownloadEpisodeCached,
)
-data class DownloadClickEvent(val action: Int, val data: EasyDownloadButton.IMinimumData)
+data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached)
class DownloadChildAdapter(
var cardList: List,
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt
deleted file mode 100644
index 77878432..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt
+++ /dev/null
@@ -1,264 +0,0 @@
-package com.lagradost.cloudstream3.ui.download
-
-import android.animation.ObjectAnimator
-import android.text.format.Formatter.formatShortFileSize
-import android.view.View
-import android.view.animation.DecelerateInterpolator
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.core.view.isGone
-import androidx.core.view.isVisible
-import androidx.core.widget.ContentLoadingProgressBar
-import com.google.android.material.button.MaterialButton
-import com.lagradost.cloudstream3.R
-import com.lagradost.cloudstream3.utils.Coroutines
-import com.lagradost.cloudstream3.utils.IDisposable
-import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
-import com.lagradost.cloudstream3.utils.VideoDownloadManager
-
-class EasyDownloadButton : IDisposable {
- interface IMinimumData {
- val id: Int
- }
-
- private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null
- private var _imageChangeCallback: ((Pair) -> Unit)? = null
-
- override fun dispose() {
- try {
- _clickCallback = null
- _imageChangeCallback = null
- downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it }
- downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it }
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- private var downloadProgressEventListener: ((Triple) -> Unit)? = null
- private var downloadStatusEventListener: ((Pair) -> Unit)? =
- null
-
- fun setUpMaterialButton(
- setupCurrentBytes: Long?,
- setupTotalBytes: Long?,
- progressBar: ContentLoadingProgressBar,
- downloadButton: MaterialButton,
- textView: TextView?,
- data: IMinimumData,
- clickCallback: (DownloadClickEvent) -> Unit,
- ) {
- setUpDownloadButton(
- setupCurrentBytes,
- setupTotalBytes,
- progressBar,
- textView,
- data,
- downloadButton,
- {
- downloadButton.setIconResource(it.first)
- downloadButton.text = it.second
- },
- clickCallback
- )
- }
-
- fun setUpMoreButton(
- setupCurrentBytes: Long?,
- setupTotalBytes: Long?,
- progressBar: ContentLoadingProgressBar,
- downloadImage: ImageView,
- textView: TextView?,
- textViewProgress: TextView?,
- clickableView: View,
- isTextPercentage: Boolean,
- data: IMinimumData,
- clickCallback: (DownloadClickEvent) -> Unit,
- ) {
- setUpDownloadButton(
- setupCurrentBytes,
- setupTotalBytes,
- progressBar,
- textViewProgress,
- data,
- clickableView,
- { (image, text) ->
- downloadImage.isVisible = textViewProgress?.isGone ?: true
- downloadImage.setImageResource(image)
- textView?.text = text
- },
- clickCallback, isTextPercentage
- )
- }
-
- fun setUpButton(
- setupCurrentBytes: Long?,
- setupTotalBytes: Long?,
- progressBar: ContentLoadingProgressBar,
- downloadImage: ImageView,
- textView: TextView?,
- data: IMinimumData,
- clickCallback: (DownloadClickEvent) -> Unit,
- ) {
- setUpDownloadButton(
- setupCurrentBytes,
- setupTotalBytes,
- progressBar,
- textView,
- data,
- downloadImage,
- {
- downloadImage.setImageResource(it.first)
- },
- clickCallback
- )
- }
-
- private fun setUpDownloadButton(
- setupCurrentBytes: Long?,
- setupTotalBytes: Long?,
- progressBar: ContentLoadingProgressBar,
- textView: TextView?,
- data: IMinimumData,
- downloadView: View,
- downloadImageChangeCallback: (Pair) -> Unit,
- clickCallback: (DownloadClickEvent) -> Unit,
- isTextPercentage: Boolean = false
- ) {
- _clickCallback = clickCallback
- _imageChangeCallback = downloadImageChangeCallback
- var lastState: VideoDownloadManager.DownloadType? = null
- var currentBytes = setupCurrentBytes ?: 0
- var totalBytes = setupTotalBytes ?: 0
- var needImageUpdate = true
-
- fun changeDownloadImage(state: VideoDownloadManager.DownloadType) {
- lastState = state
- if (currentBytes <= 0) needImageUpdate = true
- val img = if (currentBytes > 0) {
- when (state) {
- VideoDownloadManager.DownloadType.IsPaused -> Pair(
- R.drawable.ic_baseline_play_arrow_24,
- R.string.download_paused
- )
- VideoDownloadManager.DownloadType.IsDownloading -> Pair(
- R.drawable.netflix_pause,
- R.string.downloading
- )
- else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded)
- }
- } else {
- Pair(R.drawable.netflix_download, R.string.download)
- }
- _imageChangeCallback?.invoke(
- Pair(
- img.first,
- downloadView.context.getString(img.second)
- )
- )
- }
-
- fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) {
- currentBytes = setCurrentBytes
- totalBytes = setTotalBytes
-
- if (currentBytes == 0L) {
- changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped)
- textView?.visibility = View.GONE
- progressBar.visibility = View.GONE
- } else {
- if (lastState == VideoDownloadManager.DownloadType.IsStopped) {
- changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
- }
- textView?.visibility = View.VISIBLE
- progressBar.visibility = View.VISIBLE
- val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes)
- val totalMbString = formatShortFileSize(textView?.context, setTotalBytes)
-
- textView?.text =
- if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
- textView?.context?.getString(R.string.download_size_format)
- ?.format(currentMbString, totalMbString)
-
- progressBar.let { bar ->
- bar.max = (setTotalBytes / 1000).toInt()
-
- if (animate) {
- val animation: ObjectAnimator = ObjectAnimator.ofInt(
- bar,
- "progress",
- bar.progress,
- (setCurrentBytes / 1000).toInt()
- )
- animation.duration = 500
- animation.setAutoCancel(true)
- animation.interpolator = DecelerateInterpolator()
- animation.start()
- } else {
- bar.progress = (setCurrentBytes / 1000).toInt()
- }
- }
- }
- }
-
- fixDownloadedBytes(currentBytes, totalBytes, false)
- changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
-
- downloadProgressEventListener = { downloadData: Triple ->
- if (data.id == downloadData.first) {
- if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME
- Coroutines.runOnMainThread {
- fixDownloadedBytes(downloadData.second, downloadData.third, true)
- changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
- }
- }
- }
- }
-
- downloadStatusEventListener =
- { downloadData: Pair ->
- if (data.id == downloadData.first) {
- if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME
- Coroutines.runOnMainThread {
- changeDownloadImage(downloadData.second)
- }
- }
- }
- }
-
- downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it }
- downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it }
-
- downloadView.setOnClickListener {
- if (currentBytes <= 0 || totalBytes <= 0) {
- _clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
- } else {
- val list = arrayListOf(
- Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
- Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file),
- )
-
- // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
- if ((currentBytes * 100 / totalBytes) < 98) {
- list.add(
- if (lastState == VideoDownloadManager.DownloadType.IsDownloading)
- Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
- else
- Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download)
- )
- }
-
- it.popupMenuNoIcons(
- list
- ) {
- _clickCallback?.invoke(DownloadClickEvent(itemId, data))
- }
- }
- }
-
- downloadView.setOnLongClickListener {
- clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
- return@setOnLongClickListener true
- }
- }
-}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt
index 05f630a0..b43f1aac 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt
@@ -22,7 +22,7 @@ data class DownloadMetadata(
val progressPercentage: Long
get() = if (downloadedLength < 1024) 0 else maxOf(
0,
- minOf(100, (downloadedLength * 100L) / totalLength)
+ minOf(100, (downloadedLength * 100L) / (totalLength + 1))
)
}
@@ -101,38 +101,41 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
isZeroBytes = downloadedBytes == 0L
- val steps = 10000L
- progressBar.max = steps.toInt()
- // div by zero error and 1 byte off is ok impo
- val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt()
+ progressBar.post {
+ val steps = 10000L
+ progressBar.max = steps.toInt()
+ // div by zero error and 1 byte off is ok impo
- val animation = ProgressBarAnimation(
- progressBar,
- progressBar.progress.toFloat(),
- progress.toFloat()
- ).apply {
- fillAfter = true
- duration =
- if (progress > progressBar.progress) // we don't want to animate backward changes in progress
- 100
- else
- 0L
- }
+ val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt()
- if (isZeroBytes) {
- progressText?.isVisible = false
- } else {
- progressText?.apply {
- val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes)
- val totalMbString = Formatter.formatShortFileSize(context, totalBytes)
- text =
- //if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
- context?.getString(R.string.download_size_format)
- ?.format(currentMbString, totalMbString)
+ val animation = ProgressBarAnimation(
+ progressBar,
+ progressBar.progress.toFloat(),
+ progress.toFloat()
+ ).apply {
+ fillAfter = true
+ duration =
+ if (progress > progressBar.progress) // we don't want to animate backward changes in progress
+ 100
+ else
+ 0L
}
- }
- progressBar.startAnimation(animation)
+ if (isZeroBytes) {
+ progressText?.isVisible = false
+ } else {
+ progressText?.apply {
+ val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes)
+ val totalMbString = Formatter.formatShortFileSize(context, totalBytes)
+ text =
+ //if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
+ context?.getString(R.string.download_size_format)
+ ?.format(currentMbString, totalMbString)
+ }
+ }
+
+ progressBar.startAnimation(animation)
+ }
}
fun downloadStatusEvent(data: Pair) {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt
index bb2ba7b1..d97a4b88 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt
@@ -21,14 +21,17 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
}
override fun setStatus(status: DownloadStatusTell?) {
- super.setStatus(status)
- val txt = when (status) {
- DownloadStatusTell.IsPaused -> R.string.download_paused
- DownloadStatusTell.IsDownloading -> R.string.downloading
- DownloadStatusTell.IsDone -> R.string.downloaded
- else -> R.string.download
+ mainText?.post {
+ val txt = when (status) {
+ DownloadStatusTell.IsPaused -> R.string.download_paused
+ DownloadStatusTell.IsDownloading -> R.string.downloading
+ DownloadStatusTell.IsDone -> R.string.downloaded
+ else -> R.string.download
+ }
+ mainText?.setText(txt)
}
- mainText?.setText(txt)
+ super.setStatus(status)
+
}
override fun setDefaultClickListener(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt
index 0b7a7fea..d20fcf93 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt
@@ -174,7 +174,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
currentMetaData.apply {
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
- if ((downloadedLength * 100 / totalLength) < 98) {
+ if (progressPercentage < 98) {
list.add(
if (status == VideoDownloadManager.DownloadType.IsDownloading)
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
@@ -248,33 +248,34 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
//progressBar.isVisible =
// status != null && status != DownloadStatusTell.Complete && status != DownloadStatusTell.Error
//progressBarBackground.isVisible = status != null && status != DownloadStatusTell.Complete
- val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
+ progressBarBackground.post {
+ val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
+ if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
+ val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
+ progressBarBackground.startAnimation(animation)
+ } else {
+ progressBarBackground.clearAnimation()
+ }
- if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
- val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
- progressBarBackground.startAnimation(animation)
- } else {
- progressBarBackground.clearAnimation()
+ val progressDrawable =
+ if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline
+
+ progressBarBackground.background =
+ ContextCompat.getDrawable(context, progressDrawable)
+
+ val drawable = getDrawableFromStatus(status)
+ statusView.setImageDrawable(drawable)
+ val isDrawable = drawable != null
+
+ statusView.isVisible = isDrawable
+ val hide = hideWhenIcon && isDrawable
+ if (hide) {
+ progressBar.clearAnimation()
+ progressBarBackground.clearAnimation()
+ }
+ progressBarBackground.isGone = hide
+ progressBar.isGone = hide
}
-
- val progressDrawable =
- if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline
-
- progressBarBackground.background =
- ContextCompat.getDrawable(context, progressDrawable)
-
- val drawable = getDrawableFromStatus(status)
- statusView.setImageDrawable(drawable)
- val isDrawable = drawable != null
-
- statusView.isVisible = isDrawable
- val hide = hideWhenIcon && isDrawable
- if (hide) {
- progressBar.clearAnimation()
- progressBarBackground.clearAnimation()
- }
- progressBarBackground.isGone = hide
- progressBar.isGone = hide
}
override fun resetView() {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt
index fa0b6dfb..b84c619e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt
@@ -658,12 +658,14 @@ class HomeFragment : Fragment() {
return@observeNullable
}
- bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = {
+ val (items, delete) = item
+
+ bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = {
homeViewModel.expandAndReturn(it)
}, dismissCallback = {
homeViewModel.popup(null)
bottomSheetDialog = null
- })
+ }, deleteCallback = delete)
}
homeViewModel.reloadStored()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt
index 943f784a..1d8e1399 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt
@@ -246,7 +246,7 @@ class HomeParentItemAdapterPreview(
private val previewViewpagerText: ViewGroup =
itemView.findViewById(R.id.home_preview_viewpager_text)
- // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview)
+ // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview)
private var resumeHolder: View = itemView.findViewById(R.id.home_watch_holder)
private var resumeRecyclerView: RecyclerView =
itemView.findViewById(R.id.home_watch_child_recyclerview)
@@ -257,7 +257,7 @@ class HomeParentItemAdapterPreview(
private var homeAccount: View? =
itemView.findViewById(R.id.home_preview_switch_account)
- private var topPadding : View? = itemView.findViewById(R.id.home_padding)
+ private var topPadding: View? = itemView.findViewById(R.id.home_padding)
private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding)
@@ -283,7 +283,11 @@ class HomeParentItemAdapterPreview(
item.plot ?: ""
homePreviewText.text = item.name
- populateChips(homePreviewTags,item.tags ?: emptyList(), R.style.ChipFilledSemiTransparent)
+ populateChips(
+ homePreviewTags,
+ item.tags ?: emptyList(),
+ R.style.ChipFilledSemiTransparent
+ )
homePreviewTags.isGone =
item.tags.isNullOrEmpty()
@@ -413,7 +417,7 @@ class HomeParentItemAdapterPreview(
Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH),
)
- private val toggleListHolder : ChipGroup? = itemView.findViewById(R.id.home_type_holder)
+ private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder)
init {
previewViewpager.setPageTransformer(HomeScrollTransformer())
@@ -422,8 +426,14 @@ class HomeParentItemAdapterPreview(
resumeRecyclerView.adapter = resumeAdapter
bookmarkRecyclerView.adapter = bookmarkAdapter
- resumeRecyclerView.setLinearListLayout(nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF)
- bookmarkRecyclerView.setLinearListLayout(nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF)
+ resumeRecyclerView.setLinearListLayout(
+ nextLeft = R.id.nav_rail_view,
+ nextRight = FOCUS_SELF
+ )
+ bookmarkRecyclerView.setLinearListLayout(
+ nextLeft = R.id.nav_rail_view,
+ nextRight = FOCUS_SELF
+ )
fixPaddingStatusbarMargin(topPadding)
@@ -539,7 +549,7 @@ class HomeParentItemAdapterPreview(
resumeAdapter.updateList(resumeWatching)
if (binding is FragmentHomeHeadBinding) {
- binding.homeBookmarkParentItemTitle.setOnClickListener {
+ binding.homeWatchParentItemTitle.setOnClickListener {
viewModel.popup(
HomeViewModel.ExpandableHomepageList(
HomePageList(
@@ -547,7 +557,10 @@ class HomeParentItemAdapterPreview(
resumeWatching,
false
), 1, false
- )
+ ),
+ deleteCallback = {
+ viewModel.deleteResumeWatching()
+ }
)
}
}
@@ -572,10 +585,12 @@ class HomeParentItemAdapterPreview(
list,
false
), 1, false
- )
+ ), deleteCallback = {
+ viewModel.deleteBookmarks(list)
+ }
)
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt
index e8cf8863..b27223ec 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt
@@ -41,6 +41,8 @@ import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllBookmarkedData
+import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
@@ -92,6 +94,21 @@ class HomeViewModel : ViewModel() {
}
}
+ fun deleteResumeWatching() {
+ deleteAllResumeStateIds()
+ loadResumeWatching()
+ }
+
+ fun deleteBookmarks(list: List) {
+ list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) }
+ loadStoredData()
+ }
+
+ fun deleteBookmarks() {
+ deleteAllBookmarkedData()
+ loadStoredData()
+ }
+
var repo: APIRepository? = null
private val _apiName = MutableLiveData()
@@ -394,11 +411,14 @@ class HomeViewModel : ViewModel() {
}
- private val _popup = MutableLiveData(null)
- val popup: LiveData = _popup
+ private val _popup = MutableLiveData Unit)?>?>(null)
+ val popup: LiveData Unit)?>?> = _popup
- fun popup(list: ExpandableHomepageList?) {
- _popup.postValue(list)
+ fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) {
+ if (list == null)
+ _popup.postValue(null)
+ else
+ _popup.postValue(list to deleteCallback)
}
private fun bookmarksUpdated(unused: Boolean) {
@@ -436,8 +456,7 @@ class HomeViewModel : ViewModel() {
// do nothing
}
- fun reloadStored() {
- loadResumeWatching()
+ fun loadStoredData() {
val list = EnumSet.noneOf(WatchType::class.java)
getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let {
list.addAll(it)
@@ -445,6 +464,11 @@ class HomeViewModel : ViewModel() {
loadStoredData(list)
}
+ fun reloadStored() {
+ loadResumeWatching()
+ loadStoredData()
+ }
+
fun click(load: LoadClickCallback) {
loadResult(load.response.url, load.response.apiName, load.action)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt
index 6f40e145..d181e175 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt
@@ -1,16 +1,17 @@
package com.lagradost.cloudstream3.ui.player
+import android.content.ContentUris
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
-import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.UIHelper.navigate
+import com.lagradost.safefile.SafeFile
const val DTAG = "PlayerActivity"
@@ -50,14 +51,17 @@ class DownloadedPlayerActivity : AppCompatActivity() {
}
private fun playUri(uri: Uri) {
- val name = UniFile.fromUri(this, uri).name
+ val name = SafeFile.fromUri(this, uri)?.name()
this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(
listOf(
ExtractorUri(
uri = uri,
- name = name ?: getString(R.string.downloaded_file)
+ name = name ?: getString(R.string.downloaded_file),
+ // well not the same as a normal id, but we take it as users may want to
+ // play downloaded files and save the location
+ id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode()
)
)
)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index 9739b627..0f3c189d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -2,9 +2,11 @@ package com.lagradost.cloudstream3.ui.player
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
+import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.ColorStateList
+import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Color
import android.media.AudioManager
@@ -16,6 +18,7 @@ import android.util.DisplayMetrics
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
+import android.view.Surface
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
@@ -56,6 +59,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.Vector2
import kotlin.math.*
+
const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking
const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage
const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage
@@ -292,6 +296,36 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.getCurrentPreferredSubtitle() == null
}
+ open fun lockOrientation(activity: Activity) {
+ val display =
+ (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
+ val rotation = display.rotation
+ val currentOrientation = activity.resources.configuration.orientation
+ var orientation = 0
+ when (currentOrientation) {
+ Configuration.ORIENTATION_LANDSCAPE -> orientation =
+ if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
+
+ Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ //Configuration.ORIENTATION_PORTRAIT -> orientation =
+ // if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
+ }
+ activity.requestedOrientation = orientation
+ }
+
+ private fun updateOrientation() {
+ activity?.apply {
+ if(lockRotation) {
+ if(isLocked) {
+ lockOrientation(this)
+ }
+ else {
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ }
+ }
+ }
+ }
+
protected fun enterFullscreen() {
if (isFullScreenPlayer) {
activity?.hideSystemUI()
@@ -301,8 +335,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
activity?.window?.attributes = params
}
}
- if (lockRotation)
- activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ updateOrientation()
}
protected fun exitFullscreen() {
@@ -561,6 +594,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
isLocked = !isLocked
+ updateOrientation()
+
if (isLocked && isShowing) {
playerBinding?.playerHolder?.postDelayed({
if (isLocked && isShowing) {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index e0d50cc3..2b9304b6 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -21,13 +21,11 @@ import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.media3.common.Format.NO_VALUE
import androidx.media3.common.MimeTypes
-import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
-import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
import com.lagradost.cloudstream3.mvvm.*
@@ -52,6 +50,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx
+import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.Job
import java.util.*
import kotlin.math.abs
@@ -135,7 +134,7 @@ class GeneratorPlayer : FullScreenPlayer() {
return durPos.position
}
- var currentVerifyLink: Job? = null
+ private var currentVerifyLink: Job? = null
private fun loadExtractorJob(extractorLink: ExtractorLink?) {
currentVerifyLink?.cancel()
@@ -520,15 +519,16 @@ class GeneratorPlayer : FullScreenPlayer() {
if (uri == null) return@normalSafeApiCall
val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall
// RW perms for the path
- val flags =
+ ctx.contentResolver.takePersistableUriPermission(
+ uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
- ctx.contentResolver.takePersistableUriPermission(uri, flags)
-
- val file = UniFile.fromUri(ctx, uri)
- println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}")
+ val file = SafeFile.fromUri(ctx, uri)
+ val fileName = file?.name()
+ println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName")
// DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES
- val name = file.name ?: uri.toString()
+ val name = fileName ?: uri.toString()
val subtitleData = SubtitleData(
name,
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt
index 633ee762..a932a57c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt
@@ -23,7 +23,6 @@ import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.ViewModelProvider
-import androidx.recyclerview.widget.RecyclerView
import com.discord.panels.OverlappingPanelsLayout
import com.discord.panels.PanelsChildGestureRegionObserver
import com.google.android.gms.cast.framework.CastButtonFactory
@@ -62,8 +61,6 @@ import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppUtils.html
import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.isLtr
-import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.utils.AppUtils.loadCache
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
import com.lagradost.cloudstream3.utils.ExtractorLink
@@ -81,8 +78,13 @@ import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-open class ResultFragmentPhone : FullScreenPlayer(),
- PanelsChildGestureRegionObserver.GestureRegionsListener {
+open class ResultFragmentPhone : FullScreenPlayer() {
+ private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener {
+ override fun onGestureRegionsUpdate(gestureRegions: List) {
+ binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions)
+ }
+ }
+
protected lateinit var viewModel: ResultViewModel2
protected lateinit var syncModel: SyncViewModel
@@ -211,14 +213,17 @@ open class ResultFragmentPhone : FullScreenPlayer(),
}
override fun onDestroyView() {
+
//somehow this still leaks and I dont know why????
// todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt
PanelsChildGestureRegionObserver.Provider.get().let { obs ->
resultBinding?.resultCastItems?.let {
obs.unregister(it)
}
- obs.removeGestureRegionsUpdateListener(this)
+
+ obs.removeGestureRegionsUpdateListener(gestureRegionsListener)
}
+
updateUIEvent -= ::updateUI
binding = null
resultBinding = null
@@ -287,6 +292,8 @@ open class ResultFragmentPhone : FullScreenPlayer(),
it.colorFromAttribute(R.attr.primaryBlackBackground)
}
super.onResume()
+ PanelsChildGestureRegionObserver.Provider.get()
+ .addGestureRegionsUpdateListener(gestureRegionsListener)
}
override fun onStop() {
@@ -323,7 +330,16 @@ open class ResultFragmentPhone : FullScreenPlayer(),
setUrl(storedData.url)
syncModel.addFromUrl(storedData.url)
val api = APIHolder.getApiFromNameNull(storedData.apiName)
- PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this)
+
+ PanelsChildGestureRegionObserver.Provider.get().apply {
+ resultBinding?.resultCastItems?.let {
+ register(it)
+ }
+ addGestureRegionsUpdateListener(gestureRegionsListener)
+ }
+
+
+
// ===== ===== =====
resultBinding?.apply {
@@ -374,9 +390,8 @@ open class ResultFragmentPhone : FullScreenPlayer(),
DownloadButtonSetup.handleDownloadClick(downloadClickEvent)
}
)
- resultCastItems.let {
- PanelsChildGestureRegionObserver.Provider.get().register(it)
- }
+
+
resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val dy = scrollY - oldScrollY
if (dy > 0) { //check for scroll down
@@ -1055,11 +1070,7 @@ open class ResultFragmentPhone : FullScreenPlayer(),
override fun onPause() {
super.onPause()
- PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this)
- }
-
- override fun onGestureRegionsUpdate(gestureRegions: List) {
- binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions)
+ PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(gestureRegionsListener)
}
private fun setRecommendations(rec: List?, validApiName: String?) {
@@ -1114,4 +1125,4 @@ open class ResultFragmentPhone : FullScreenPlayer(),
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt
index 1f663e31..91e97dfc 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt
@@ -44,6 +44,8 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed {
private fun fixPlayerSize() {
playerWidthHeight?.let { (w, h) ->
+ if(w <= 0 || h <= 0) return@let
+
val orientation = context?.resources?.configuration?.orientation ?: return
val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
@@ -118,9 +120,6 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed {
override fun onTracksInfoChanged() {}
override fun exitedPipMode() {}
-
- override fun onGestureRegionsUpdate(gestureRegions: List) {}
-
private fun updateFullscreen(fullscreen: Boolean) {
isFullScreenPlayer = fullscreen
lockRotation = fullscreen
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
index 011d133d..82d9a8fe 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
@@ -591,12 +591,10 @@ class ResultViewModel2 : ViewModel() {
link,
"$fileName ${link.name}",
folder,
- if (link.url.contains(".srt")) ".srt" else "vtt",
+ if (link.url.contains(".srt")) "srt" else "vtt",
false,
- null
- ) {
- // no notification
- }
+ null, createNotificationCallback = {}
+ )
}
}
@@ -721,7 +719,7 @@ class ResultViewModel2 : ViewModel() {
)
)
}
- .map { ExtractorSubtitleLink(it.name, it.url, "") }
+ .map { ExtractorSubtitleLink(it.name, it.url, "") }.take(3)
.forEach { link ->
val fileName = VideoDownloadManager.getFileName(context, meta)
downloadSubtitle(context, link, fileName, folder)
@@ -1707,7 +1705,7 @@ class ResultViewModel2 : ViewModel() {
else -> {
if (response.type.isLiveStream())
R.string.play_livestream_button
- else if (response.type.isMovieType()) // this wont break compatibility as you only need to override isMovieType
+ else if (response.isMovie()) // this wont break compatibility as you only need to override isMovieType
R.string.play_movie_button
else null
}
@@ -2340,4 +2338,4 @@ class ResultViewModel2 : ViewModel() {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt
index 63213eb9..bdf82377 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt
@@ -84,7 +84,7 @@ class SearchFragment : Fragment() {
fun newInstance(query: String): Bundle {
return Bundle().apply {
- putString(SEARCH_QUERY, query)
+ if(query.isNotBlank()) putString(SEARCH_QUERY, query)
}
}
}
@@ -211,7 +211,7 @@ class SearchFragment : Fragment() {
reloadRepos()
binding?.apply {
- val adapter: RecyclerView.Adapter? =
+ val adapter: RecyclerView.Adapter =
SearchAdapter(
ArrayList(),
searchAutofitResults,
@@ -530,11 +530,18 @@ class SearchFragment : Fragment() {
searchMasterRecycler.layoutManager = GridLayoutManager(context, 1)
// Automatically search the specified query, this allows the app search to launch from intent
- arguments?.getString(SEARCH_QUERY)?.let { query ->
+ var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY)
+ if(sq.isNullOrBlank()) {
+ sq = MainActivity.nextSearchQuery
+ }
+
+ sq?.let { query ->
if (query.isBlank()) return@let
mainSearch.setQuery(query, true)
// Clear the query as to not make it request the same query every time the page is opened
- arguments?.putString(SEARCH_QUERY, null)
+ arguments?.remove(SEARCH_QUERY)
+ savedInstanceState?.remove(SEARCH_QUERY)
+ MainActivity.nextSearchQuery = null
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt
index d76eba1e..ae5f0aab 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt
@@ -3,9 +3,7 @@ package com.lagradost.cloudstream3.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
-import android.os.Build
import android.os.Bundle
-import android.os.Environment
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -13,7 +11,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.fasterxml.jackson.annotation.JsonProperty
-import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
@@ -41,7 +38,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
-import java.io.File
+import com.lagradost.safefile.SafeFile
fun getCurrentLocale(context: Context): String {
val res = context.resources
@@ -57,6 +54,8 @@ fun getCurrentLocale(context: Context): String {
// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto
val appLanguages = arrayListOf(
/* begin language list */
+ Triple("", "ajp", "ajp"),
+ Triple("", "አማርኛ", "am"),
Triple("", "العربية", "ar"),
Triple("", "ars", "ars"),
Triple("", "български", "bg"),
@@ -69,6 +68,7 @@ val appLanguages = arrayListOf(
Triple("", "Esperanto", "eo"),
Triple("", "español", "es"),
Triple("", "فارسی", "fa"),
+ Triple("", "fil", "fil"),
Triple("", "français", "fr"),
Triple("", "galego", "gl"),
Triple("", "हिन्दी", "hi"),
@@ -84,6 +84,7 @@ val appLanguages = arrayListOf(
Triple("", "македонски", "mk"),
Triple("", "മലയാളം", "ml"),
Triple("", "bahasa Melayu", "ms"),
+ Triple("", "ဗမာစာ", "my"),
Triple("", "Nederlands", "nl"),
Triple("", "norsk nynorsk", "nn"),
Triple("", "norsk bokmål", "no"),
@@ -97,6 +98,7 @@ val appLanguages = arrayListOf(
Triple("", "Soomaaliga", "so"),
Triple("", "svenska", "sv"),
Triple("", "தமிழ்", "ta"),
+ Triple("", "ትግርኛ", "ti"),
Triple("", "Tagalog", "tl"),
Triple("", "Türkçe", "tr"),
Triple("", "українська", "uk"),
@@ -137,8 +139,9 @@ class SettingsGeneral : PreferenceFragmentCompat() {
context.contentResolver.takePersistableUriPermission(uri, flags)
- val file = UniFile.fromUri(context, uri)
- println("Selected URI path: $uri - Full path: ${file.filePath}")
+ val file = SafeFile.fromUri(context, uri)
+ val filePath = file?.filePath()
+ println("Selected URI path: $uri - Full path: $filePath")
// Stores the real URI using download_path_key
// Important that the URI is stored instead of filepath due to permissions.
@@ -147,7 +150,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
// From URI -> File path
// File path here is purely for cosmetic purposes in settings
- (file.filePath ?: uri.toString()).let {
+ (filePath ?: uri.toString()).let {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putString(getString(R.string.download_path_pref), it).apply()
}
@@ -304,25 +307,23 @@ class SettingsGeneral : PreferenceFragmentCompat() {
}
return@setOnPreferenceClickListener true
}
+
fun getDownloadDirs(): List {
return normalSafeApiCall {
- val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath
+ context?.let { ctx ->
+ val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath()
- // app_name_download_path = Cloudstream and does not change depending on release.
- // DOES NOT WORK ON SCOPED STORAGE.
- val secondaryDir =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) null else Environment.getExternalStorageDirectory().absolutePath +
- File.separator + resources.getString(R.string.app_name_download_path)
- val first = listOf(defaultDir, secondaryDir)
- (try {
- val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second }
+ val first = listOf(defaultDir)
+ (try {
+ val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second }
- (first +
- requireContext().getExternalFilesDirs("").mapNotNull { it.path } +
- currentDir)
- } catch (e: Exception) {
- first
- }).filterNotNull().distinct()
+ (first +
+ ctx.getExternalFilesDirs("").mapNotNull { it.path } +
+ currentDir)
+ } catch (e: Exception) {
+ first
+ }).filterNotNull().distinct()
+ }
} ?: emptyList()
}
@@ -337,7 +338,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
val currentDir =
settingsManager.getString(getString(R.string.download_path_pref), null)
- ?: VideoDownloadManager.getDownloadDir().toString()
+ ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() }
activity?.showBottomDialog(
dirs + listOf("Custom"),
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt
index c304629a..62e46c08 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt
@@ -116,13 +116,14 @@ class SettingsUpdates : PreferenceFragmentCompat() {
null,
"txt",
false
- ).fileStream
- fileStream?.writer()?.write(text)
- } catch (e: Exception) {
- logError(e)
+ ).openNew()
+ fileStream.writer().write(text)
+ dialog.dismissSafe(activity)
+ } catch (t: Throwable) {
+ logError(t)
+ showToast(t.message)
} finally {
fileStream?.closeQuietly()
- dialog.dismissSafe(activity)
}
}
binding.closeBtt.setOnClickListener {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt
index 5bd0cd15..96593769 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt
@@ -1,11 +1,8 @@
package com.lagradost.cloudstream3.utils
import android.annotation.SuppressLint
-import android.content.ContentValues
import android.content.Context
import android.net.Uri
-import android.os.Build
-import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@@ -28,6 +25,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_T
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_UNIXTIME_KEY
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_USER_KEY
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi.Companion.OPEN_SUBTITLES_USER_KEY
+import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
@@ -36,9 +34,9 @@ import com.lagradost.cloudstream3.utils.DataStore.mapper
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir
-import java.io.IOException
+import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream
+import okhttp3.internal.closeQuietly
+import java.io.OutputStream
import java.io.PrintWriter
import java.lang.System.currentTimeMillis
import java.text.SimpleDateFormat
@@ -146,59 +144,25 @@ object BackupUtils {
}
@SuppressLint("SimpleDateFormat")
- fun FragmentActivity.backup() {
+ fun FragmentActivity.backup() = ioSafe {
+ var fileStream: OutputStream? = null
+ var printStream: PrintWriter? = null
try {
if (!checkWrite()) {
- showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG)
+ showToast(R.string.backup_failed, Toast.LENGTH_LONG)
requestRW()
- return
+ return@ioSafe
}
- val subDir = getBasePath().first
val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis()))
- val ext = "json"
+ val ext = "txt"
val displayName = "CS3_Backup_${date}"
val backupFile = getBackup()
+ val stream = setupStream(this@backup, displayName, null, ext, false)
- val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
- && subDir?.isDownloadDir() == true
- ) {
- val cr = this.contentResolver
- val contentUri =
- MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI
- //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
-
- val newFile = ContentValues().apply {
- put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
- put(MediaStore.MediaColumns.TITLE, displayName)
- // While it a json file we store as txt because not
- // all file managers support mimetype json
- put(MediaStore.MediaColumns.MIME_TYPE, "text/plain")
- //put(MediaStore.MediaColumns.RELATIVE_PATH, folder)
- }
-
- val newFileUri = cr.insert(
- contentUri,
- newFile
- ) ?: throw IOException("Error creating file uri")
- cr.openOutputStream(newFileUri, "w")
- ?: throw IOException("Error opening stream")
- } else {
- val fileName = "$displayName.$ext"
- val rFile = subDir?.findFile(fileName)
- if (rFile?.exists() == true) {
- rFile.delete()
- }
- val file =
- subDir?.createFile(fileName)
- ?: throw IOException("Error creating file")
- if (!file.exists()) throw IOException("File does not exist")
- file.openOutputStream()
- }
-
- val printStream = PrintWriter(steam)
+ fileStream = stream.openNew()
+ printStream = PrintWriter(fileStream)
printStream.print(mapper.writeValueAsString(backupFile))
- printStream.close()
showToast(
R.string.backup_success,
@@ -208,12 +172,15 @@ object BackupUtils {
logError(e)
try {
showToast(
- getString(R.string.backup_failed_error_format).format(e.toString()),
+ txt(R.string.backup_failed_error_format, e.toString()),
Toast.LENGTH_LONG
)
} catch (e: Exception) {
logError(e)
}
+ } finally {
+ printStream?.closeQuietly()
+ fileStream?.closeQuietly()
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
index 991651dc..2eb2ab01 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
@@ -353,6 +353,12 @@ object DataStoreHelper {
removeKeys(folder2)
}
+ fun deleteBookmarkedData(id : Int?) {
+ if (id == null) return
+ removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString())
+ removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString())
+ }
+
fun getAllResumeStateIds(): List? {
val folder = "$currentAccount/$RESULT_RESUME_WATCHING"
return getKeys(folder)?.mapNotNull {
@@ -519,12 +525,10 @@ object DataStoreHelper {
fun setResultWatchState(id: Int?, status: Int) {
if (id == null) return
- val folder = "$currentAccount/$RESULT_WATCH_STATE"
if (status == WatchType.NONE.internalId) {
- removeKey(folder, id.toString())
- removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString())
+ deleteBookmarkedData(id)
} else {
- setKey(folder, id.toString(), status)
+ setKey("$currentAccount/$RESULT_WATCH_STATE", id.toString(), status)
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt
index c1eb649b..421e4420 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt
@@ -7,6 +7,7 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO
@@ -15,6 +16,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck
import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode
import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume
import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent
+import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage
import kotlinx.coroutines.delay
const val DOWNLOAD_CHECK = "DownloadCheck"
@@ -25,28 +27,32 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo
override suspend fun doWork(): Result {
val key = workerParams.inputData.getString("key")
try {
- println("KEY $key")
if (key == DOWNLOAD_CHECK) {
- downloadCheck(applicationContext, ::handleNotification)?.let {
- awaitDownload(it)
- }
+ downloadCheck(applicationContext, ::handleNotification)
} else if (key != null) {
- val info = applicationContext.getKey(WORK_KEY_INFO, key)
+ val info =
+ applicationContext.getKey(WORK_KEY_INFO, key)
val pkg =
- applicationContext.getKey(WORK_KEY_PACKAGE, key)
- if (info != null) {
- downloadEpisode(
- applicationContext,
- info.source,
- info.folder,
- info.ep,
- info.links,
- ::handleNotification
+ applicationContext.getKey(
+ WORK_KEY_PACKAGE,
+ key
)
- awaitDownload(info.ep.id)
+
+ if (info != null) {
+ getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg ->
+ downloadFromResume(applicationContext, dpkg, ::handleNotification)
+ } ?: run {
+ downloadEpisode(
+ applicationContext,
+ info.source,
+ info.folder,
+ info.ep,
+ info.links,
+ ::handleNotification
+ )
+ }
} else if (pkg != null) {
downloadFromResume(applicationContext, pkg, ::handleNotification)
- awaitDownload(pkg.item.ep.id)
}
removeKeys(key)
}
@@ -73,6 +79,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo
VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> {
isDone = true
}
+
else -> Unit
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt
index 6c5117b4..11dfa441 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt
@@ -1,17 +1,17 @@
package com.lagradost.cloudstream3.utils
+import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.mvvm.logError
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.math.pow
-
+/** backwards api surface */
class M3u8Helper {
companion object {
- private val generator = M3u8Helper()
suspend fun generateM3u8(
source: String,
streamUrl: String,
@@ -20,34 +20,59 @@ class M3u8Helper {
headers: Map = mapOf(),
name: String = source
): List {
- return generator.m3u8Generation(
- M3u8Stream(
- streamUrl = streamUrl,
- quality = quality,
- headers = headers,
- ), null
- )
- .map { stream ->
- ExtractorLink(
- source,
- name = name,
- stream.streamUrl,
- referer,
- stream.quality ?: Qualities.Unknown.value,
- true,
- stream.headers,
- )
- }
+ return M3u8Helper2.generateM3u8(source, streamUrl, referer, quality, headers, name)
}
}
+
+ data class M3u8Stream(
+ val streamUrl: String,
+ val quality: Int? = null,
+ val headers: Map = mapOf()
+ )
+
+ suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List {
+ return M3u8Helper2.m3u8Generation(m3u8, returnThis)
+ }
+}
+
+object M3u8Helper2 {
+ suspend fun generateM3u8(
+ source: String,
+ streamUrl: String,
+ referer: String,
+ quality: Int? = null,
+ headers: Map = mapOf(),
+ name: String = source
+ ): List {
+ return m3u8Generation(
+ M3u8Helper.M3u8Stream(
+ streamUrl = streamUrl,
+ quality = quality,
+ headers = headers,
+ ), null
+ )
+ .map { stream ->
+ ExtractorLink(
+ source,
+ name = name,
+ stream.streamUrl,
+ referer,
+ stream.quality ?: Qualities.Unknown.value,
+ true,
+ stream.headers,
+ )
+ }
+ }
+
private val ENCRYPTION_DETECTION_REGEX = Regex("#EXT-X-KEY:METHOD=([^,]+),")
private val ENCRYPTION_URL_IV_REGEX =
Regex("#EXT-X-KEY:METHOD=([^,]+),URI=\"([^\"]+)\"(?:,IV=(.*))?")
private val QUALITY_REGEX =
Regex("""#EXT-X-STREAM-INF:(?:(?:.*?(?:RESOLUTION=\d+x(\d+)).*?\s+(.*))|(?:.*?\s+(.*)))""")
private val TS_EXTENSION_REGEX =
- Regex("""(.*\.ts.*|.*\.jpg.*)""") //.jpg here 'case vizcloud uses .jpg instead of .ts
+ Regex("""#EXTINF:.*\n(.+?\n)""") // fuck it we ball, who cares about the type anyways
+ //Regex("""(.*\.(ts|jpg|html).*)""") //.jpg here 'case vizcloud uses .jpg instead of .ts
private fun absoluteExtensionDetermination(url: String): String? {
val split = url.split("/")
@@ -64,21 +89,17 @@ class M3u8Helper {
}
}
- private val defaultIvGen = sequence {
- var initial = 1
+ private fun defaultIv(index: Int) : ByteArray {
+ return toBytes16Big(index+1)
+ }
- while (true) {
- yield(toBytes16Big(initial))
- ++initial
- }
- }.iterator()
-
- private fun getDecrypter(
+ fun getDecrypted(
secretKey: ByteArray,
data: ByteArray,
- iv: ByteArray = "".toByteArray()
+ iv: ByteArray = byteArrayOf(),
+ index : Int,
): ByteArray {
- val ivKey = if (iv.isEmpty()) defaultIvGen.next() else iv
+ val ivKey = if (iv.isEmpty()) defaultIv(index) else iv
val c = Cipher.getInstance("AES/CBC/PKCS5Padding")
val skSpec = SecretKeySpec(secretKey, "AES")
val ivSpec = IvParameterSpec(ivKey)
@@ -91,13 +112,8 @@ class M3u8Helper {
return st != null && (st.value.isNotEmpty() || st.destructured.component1() != "NONE")
}
- data class M3u8Stream(
- val streamUrl: String,
- val quality: Int? = null,
- val headers: Map = mapOf()
- )
- private fun selectBest(qualities: List): M3u8Stream? {
+ private fun selectBest(qualities: List): M3u8Helper.M3u8Stream? {
val result = qualities.sortedBy {
if (it.quality != null && it.quality <= 1080) it.quality else 0
}.filter {
@@ -113,19 +129,16 @@ class M3u8Helper {
}
private fun isNotCompleteUrl(url: String): Boolean {
- return !url.contains("https://") && !url.contains("http://")
+ return !url.startsWith("https://") && !url.startsWith("http://")
}
- suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List {
-// return listOf(m3u8)
- val list = mutableListOf()
+ suspend fun m3u8Generation(m3u8: M3u8Helper.M3u8Stream, returnThis: Boolean? = true): List {
+ val list = mutableListOf()
val m3u8Parent = getParentLink(m3u8.streamUrl)
val response = app.get(m3u8.streamUrl, headers = m3u8.headers, verify = false).text
-// var hasAnyContent = false
for (match in QUALITY_REGEX.findAll(response)) {
-// hasAnyContent = true
var (quality, m3u8Link, m3u8Link2) = match.destructured
if (m3u8Link.isEmpty()) m3u8Link = m3u8Link2
if (absoluteExtensionDetermination(m3u8Link) == "m3u8") {
@@ -136,21 +149,21 @@ class M3u8Helper {
println(m3u8.streamUrl)
}
list += m3u8Generation(
- M3u8Stream(
+ M3u8Helper.M3u8Stream(
m3u8Link,
quality.toIntOrNull(),
m3u8.headers
), false
)
}
- list += M3u8Stream(
+ list += M3u8Helper.M3u8Stream(
m3u8Link,
quality.toIntOrNull(),
m3u8.headers
)
}
if (returnThis != false) {
- list += M3u8Stream(
+ list += M3u8Helper.M3u8Stream(
m3u8.streamUrl,
Qualities.Unknown.value,
m3u8.headers
@@ -160,113 +173,134 @@ class M3u8Helper {
return list
}
+ data class LazyHlsDownloadData(
+ private val encryptionData: ByteArray,
+ private val encryptionIv: ByteArray,
+ private val isEncrypted: Boolean,
+ private val allTsLinks: List,
+ private val relativeUrl: String,
+ private val headers: Map,
+ ) {
+ val size get() = allTsLinks.size
- data class HlsDownloadData(
- val bytes: ByteArray,
- val currentIndex: Int,
- val totalTs: Int,
- val errored: Boolean = false
- )
+ suspend fun resolveLinkWhileSafe(
+ index: Int,
+ tries: Int = 3,
+ failDelay: Long = 3000,
+ condition : (() -> Boolean)
+ ): ByteArray? {
+ for (i in 0 until tries) {
+ if(!condition()) return null
- suspend fun hlsYield(
- qualities: List,
- startIndex: Int = 0
- ): Iterator {
- if (qualities.isEmpty()) return listOf(
- HlsDownloadData(
- byteArrayOf(),
- 1,
- 1,
- true
- )
- ).iterator()
-
- var selected = selectBest(qualities)
- if (selected == null) {
- selected = qualities[0]
+ try {
+ val out = resolveLink(index)
+ return if(condition()) out else null
+ } catch (e: IllegalArgumentException) {
+ return null
+ } catch (e : CancellationException) {
+ return null
+ } catch (t: Throwable) {
+ delay(failDelay)
+ }
+ }
+ return null
}
+
+ suspend fun resolveLinkSafe(
+ index: Int,
+ tries: Int = 3,
+ failDelay: Long = 3000
+ ): ByteArray? {
+ for (i in 0 until tries) {
+ try {
+ return resolveLink(index)
+ } catch (e: IllegalArgumentException) {
+ return null
+ } catch (e : CancellationException) {
+ return null
+ } catch (t: Throwable) {
+ delay(failDelay)
+ }
+ }
+ return null
+ }
+
+ @Throws
+ suspend fun resolveLink(index: Int): ByteArray {
+ if (index < 0 || index >= size) throw IllegalArgumentException("index must be in the bounds of the ts")
+ val url = allTsLinks[index]
+
+ val tsResponse = app.get(url, headers = headers, verify = false)
+ val tsData = tsResponse.body.bytes()
+ if (tsData.isEmpty()) throw ErrorLoadingException("no data")
+
+ return if (isEncrypted) {
+ getDecrypted(encryptionData, tsData, encryptionIv, index)
+ } else {
+ tsData
+ }
+ }
+ }
+
+ @Throws
+ suspend fun hslLazy(
+ qualities: List
+ ): LazyHlsDownloadData {
+ if (qualities.isEmpty()) throw IllegalArgumentException("qualities must be non empty")
+ val selected = selectBest(qualities) ?: qualities.first()
val headers = selected.headers
-
val streams = qualities.map { m3u8Generation(it, false) }.flatten()
- //val sslVerification = if (headers.containsKey("ssl_verification")) headers["ssl_verification"].toBoolean() else true
-
+ // this selects the best quality of the qualities offered,
+ // due to the recursive nature of m3u8, we only go 2 depth
val secondSelection = selectBest(streams.ifEmpty { listOf(selected) })
- if (secondSelection != null) {
- val m3u8Response =
- runBlocking {
- app.get(
- secondSelection.streamUrl,
- headers = headers,
- verify = false
- ).text
- }
+ ?: throw IllegalArgumentException("qualities has no streams")
- var encryptionUri: String?
- var encryptionIv = byteArrayOf()
- var encryptionData = byteArrayOf()
+ val m3u8Response =
+ app.get(
+ secondSelection.streamUrl,
+ headers = headers,
+ verify = false
+ ).text
- val encryptionState = isEncrypted(m3u8Response)
+ // encryption, this is because crunchy uses it
+ var encryptionIv = byteArrayOf()
+ var encryptionData = byteArrayOf()
- if (encryptionState) {
- val match =
- ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.destructured // its safe to assume that its not going to be null
- encryptionUri = match.component2()
+ val encryptionState = isEncrypted(m3u8Response)
- if (isNotCompleteUrl(encryptionUri)) {
- encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri"
- }
+ if (encryptionState) {
+ // its safe to assume that its not going to be null
+ val match =
+ ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.groupValues
- encryptionIv = match.component3().toByteArray()
- val encryptionKeyResponse =
- runBlocking { app.get(encryptionUri, headers = headers, verify = false) }
- encryptionData = encryptionKeyResponse.body?.bytes() ?: byteArrayOf()
+ var encryptionUri = match[2]
+
+ if (isNotCompleteUrl(encryptionUri)) {
+ encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri"
}
- val allTs = TS_EXTENSION_REGEX.findAll(m3u8Response)
- val allTsList = allTs.toList()
- val totalTs = allTsList.size
- if (totalTs == 0) {
- return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator()
- }
- var lastYield = 0
-
- val relativeUrl = getParentLink(secondSelection.streamUrl)
- var retries = 0
- val tsByteGen = sequence {
- loop@ for ((index, ts) in allTs.withIndex()) {
- val url = if (
- isNotCompleteUrl(ts.destructured.component1())
- ) "$relativeUrl/${ts.destructured.component1()}" else ts.destructured.component1()
- val c = index + 1 + startIndex
-
- while (lastYield != c) {
- try {
- val tsResponse =
- runBlocking { app.get(url, headers = headers, verify = false) }
- var tsData = tsResponse.body?.bytes() ?: byteArrayOf()
-
- if (encryptionState) {
- tsData = getDecrypter(encryptionData, tsData, encryptionIv)
- yield(HlsDownloadData(tsData, c, totalTs))
- lastYield = c
- break
- }
- yield(HlsDownloadData(tsData, c, totalTs))
- lastYield = c
- } catch (e: Exception) {
- logError(e)
- if (retries == 3) {
- yield(HlsDownloadData(byteArrayOf(), c, totalTs, true))
- break@loop
- }
- ++retries
- Thread.sleep(2_000)
- }
- }
- }
- }
- return tsByteGen.iterator()
+ encryptionIv = match[3].toByteArray()
+ val encryptionKeyResponse = app.get(encryptionUri, headers = headers, verify = false)
+ encryptionData = encryptionKeyResponse.body.bytes()
}
- return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator()
+ val relativeUrl = getParentLink(secondSelection.streamUrl)
+ val allTsList = TS_EXTENSION_REGEX.findAll(m3u8Response + "\n").map { ts ->
+ val value = ts.groupValues[1]
+ if (isNotCompleteUrl(value)) {
+ "$relativeUrl/${value}"
+ } else {
+ value
+ }
+ }.toList()
+ if (allTsList.isEmpty()) throw IllegalArgumentException("ts must be non empty")
+
+ return LazyHlsDownloadData(
+ encryptionData = encryptionData,
+ encryptionIv = encryptionIv,
+ isEncrypted = encryptionState,
+ allTsLinks = allTsList,
+ relativeUrl = relativeUrl,
+ headers = headers
+ )
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt
index a76cc115..d1614bc1 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt
@@ -2,20 +2,18 @@ package com.lagradost.cloudstream3.utils
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.TvType
-import com.lagradost.cloudstream3.ui.download.EasyDownloadButton
-
object VideoDownloadHelper {
data class DownloadEpisodeCached(
@JsonProperty("name") val name: String?,
@JsonProperty("poster") val poster: String?,
@JsonProperty("episode") val episode: Int,
@JsonProperty("season") val season: Int?,
- @JsonProperty("id") override val id: Int,
+ @JsonProperty("id") val id: Int,
@JsonProperty("parentId") val parentId: Int,
@JsonProperty("rating") val rating: Int?,
@JsonProperty("description") val description: String?,
@JsonProperty("cacheTime") val cacheTime: Long,
- ) : EasyDownloadButton.IMinimumData
+ )
data class DownloadHeaderCached(
@JsonProperty("apiName") val apiName: String,
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt
index c138ea75..6425ba66 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt
@@ -8,15 +8,10 @@ import android.content.*
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
-import android.os.Environment
-import android.provider.MediaStore
import androidx.annotation.DrawableRes
-import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.MutableLiveData
import androidx.preference.PreferenceManager
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
@@ -24,13 +19,14 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty
-import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.services.VideoDownloadService
@@ -39,23 +35,26 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import com.lagradost.safefile.MediaFileContentType
+import com.lagradost.safefile.SafeFile
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.internal.closeQuietly
-import java.io.BufferedInputStream
+import java.io.Closeable
import java.io.File
-import java.io.InputStream
+import java.io.IOException
import java.io.OutputStream
-import java.lang.Thread.sleep
-import java.net.URI
import java.net.URL
-import java.net.URLConnection
import java.util.*
-import kotlin.math.roundToInt
const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general"
const val DOWNLOAD_CHANNEL_NAME = "Downloads"
@@ -63,37 +62,35 @@ const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel"
object VideoDownloadManager {
var maxConcurrentDownloads = 3
+ var maxConcurrentConnections = 3
private var currentDownloads = mutableListOf()
private const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
- @DrawableRes
- const val imgDone = R.drawable.rddone
+ @get:DrawableRes
+ val imgDone get() = R.drawable.rddone
- @DrawableRes
- const val imgDownloading = R.drawable.rdload
+ @get:DrawableRes
+ val imgDownloading get() = R.drawable.rdload
- @DrawableRes
- const val imgPaused = R.drawable.rdpause
+ @get:DrawableRes
+ val imgPaused get() = R.drawable.rdpause
- @DrawableRes
- const val imgStopped = R.drawable.rderror
+ @get:DrawableRes
+ val imgStopped get() = R.drawable.rderror
- @DrawableRes
- const val imgError = R.drawable.rderror
+ @get:DrawableRes
+ val imgError get() = R.drawable.rderror
- @DrawableRes
- const val pressToPauseIcon = R.drawable.ic_baseline_pause_24
+ @get:DrawableRes
+ val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24
- @DrawableRes
- const val pressToResumeIcon = R.drawable.ic_baseline_play_arrow_24
+ @get:DrawableRes
+ val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24
- @DrawableRes
- const val pressToStopIcon = R.drawable.exo_icon_stop
-
- private var updateCount : Int = 0
- private val downloadDataUpdateCount = MutableLiveData()
+ @get:DrawableRes
+ val pressToStopIcon get() = R.drawable.baseline_stop_24
enum class DownloadType {
IsPaused,
@@ -162,24 +159,33 @@ object VideoDownloadManager {
@JsonProperty("pkg") val pkg: DownloadResumePackage,
)
- private const val SUCCESS_DOWNLOAD_DONE = 1
- private const val SUCCESS_STREAM = 3
- private const val SUCCESS_STOPPED = 2
+ data class DownloadStatus(
+ /** if you should retry with the same args and hope for a better result */
+ val retrySame: Boolean,
+ /** if you should try the next mirror */
+ val tryNext: Boolean,
+ /** if the result is what the user intended */
+ val success: Boolean,
+ )
- // will not download the next one, but is still classified as an error
- private const val ERROR_DELETING_FILE = 3
- private const val ERROR_CREATE_FILE = -2
- private const val ERROR_UNKNOWN = -10
+ /** Invalid input, just skip to the next one as the same args will give the same error */
+ private val DOWNLOAD_INVALID_INPUT =
+ DownloadStatus(retrySame = false, tryNext = true, success = false)
- //private const val ERROR_OPEN_FILE = -3
- private const val ERROR_TOO_SMALL_CONNECTION = -4
+ /** no need to try any other mirror as we have downloaded the file */
+ private val DOWNLOAD_SUCCESS =
+ DownloadStatus(retrySame = false, tryNext = false, success = true)
- //private const val ERROR_WRONG_CONTENT = -5
- private const val ERROR_CONNECTION_ERROR = -6
+ /** the user pressed stop, so no need to download anything else */
+ private val DOWNLOAD_STOPPED =
+ DownloadStatus(retrySame = false, tryNext = false, success = true)
- //private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7
- //private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8
- private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9
+ /** the process failed due to some reason, so we retry and also try the next mirror */
+ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false)
+
+ /** bad config, skip all mirrors as every call to download will have the same bad config */
+ private val DOWNLOAD_BAD_CONFIG =
+ DownloadStatus(retrySame = false, tryNext = false, success = false)
private const val KEY_RESUME_PACKAGES = "download_resume"
const val KEY_DOWNLOAD_INFO = "download_info"
@@ -211,15 +217,15 @@ object VideoDownloadManager {
}
}
- /** Will return IsDone if not found or error */
- fun getDownloadState(id: Int): DownloadType {
- return try {
- downloadStatus[id] ?: DownloadType.IsDone
- } catch (e: Exception) {
- logError(e)
- DownloadType.IsDone
- }
- }
+ ///** Will return IsDone if not found or error */
+ //fun getDownloadState(id: Int): DownloadType {
+ // return try {
+ // downloadStatus[id] ?: DownloadType.IsDone
+ // } catch (e: Exception) {
+ // logError(e)
+ // DownloadType.IsDone
+ // }
+ //}
private val cachedBitmaps = hashMapOf()
fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? {
@@ -258,8 +264,8 @@ object VideoDownloadManager {
notificationCallback: (Int, Notification) -> Unit,
hlsProgress: Long? = null,
hlsTotal: Long? = null,
-
- ): Notification? {
+ bytesPerSecond: Long
+ ): Notification? {
try {
if (total <= 0) return null// crash, invalid data
@@ -303,6 +309,8 @@ object VideoDownloadManager {
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false)
+ } else if (state == DownloadType.IsPending) {
+ builder.setProgress(0, 0, true)
}
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
@@ -329,27 +337,52 @@ object VideoDownloadManager {
val totalMbString: String
val suffix: String
+ val mbFormat = "%.1f MB"
+
if (hlsProgress != null && hlsTotal != null) {
progressPercentage = hlsProgress.toLong() * 100 / hlsTotal
progressMbString = hlsProgress.toString()
totalMbString = hlsTotal.toString()
- suffix = " - %.1f MB".format(progress / 1000000f)
+ suffix = " - $mbFormat".format(progress / 1000000f)
} else {
progressPercentage = progress * 100 / total
- progressMbString = "%.1f MB".format(progress / 1000000f)
- totalMbString = "%.1f MB".format(total / 1000000f)
+ progressMbString = mbFormat.format(progress / 1000000f)
+ totalMbString = mbFormat.format(total / 1000000f)
suffix = ""
}
+ val mbPerSecondString =
+ if (state == DownloadType.IsDownloading) {
+ " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f)
+ } else ""
+
val bigText =
- if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
- (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix"
- } else if (state == DownloadType.IsFailed) {
- downloadFormat.format(context.getString(R.string.download_failed), rowTwo)
- } else if (state == DownloadType.IsDone) {
- downloadFormat.format(context.getString(R.string.download_done), rowTwo)
- } else {
- downloadFormat.format(context.getString(R.string.download_canceled), rowTwo)
+ when (state) {
+ DownloadType.IsDownloading, DownloadType.IsPaused -> {
+ (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString"
+ }
+
+ DownloadType.IsPending -> {
+ (if (linkName == null) "" else "$linkName\n") + rowTwo
+ }
+
+ DownloadType.IsFailed -> {
+ downloadFormat.format(
+ context.getString(R.string.download_failed),
+ rowTwo
+ )
+ }
+
+ DownloadType.IsDone -> {
+ downloadFormat.format(context.getString(R.string.download_done), rowTwo)
+ }
+
+ DownloadType.IsStopped -> {
+ downloadFormat.format(
+ context.getString(R.string.download_canceled),
+ rowTwo
+ )
+ }
}
val bodyStyle = NotificationCompat.BigTextStyle()
@@ -357,14 +390,28 @@ object VideoDownloadManager {
builder.setStyle(bodyStyle)
} else {
val txt =
- if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
- rowTwo
- } else if (state == DownloadType.IsFailed) {
- downloadFormat.format(context.getString(R.string.download_failed), rowTwo)
- } else if (state == DownloadType.IsDone) {
- downloadFormat.format(context.getString(R.string.download_done), rowTwo)
- } else {
- downloadFormat.format(context.getString(R.string.download_canceled), rowTwo)
+ when (state) {
+ DownloadType.IsDownloading, DownloadType.IsPaused, DownloadType.IsPending -> {
+ rowTwo
+ }
+
+ DownloadType.IsFailed -> {
+ downloadFormat.format(
+ context.getString(R.string.download_failed),
+ rowTwo
+ )
+ }
+
+ DownloadType.IsDone -> {
+ downloadFormat.format(context.getString(R.string.download_done), rowTwo)
+ }
+
+ DownloadType.IsStopped -> {
+ downloadFormat.format(
+ context.getString(R.string.download_canceled),
+ rowTwo
+ )
+ }
}
builder.setContentText(txt)
@@ -447,54 +494,6 @@ object VideoDownloadManager {
return tempName.replace(" ", " ").trim(' ')
}
- @RequiresApi(Build.VERSION_CODES.Q)
- private fun ContentResolver.getExistingFolderStartName(relativePath: String): List>? {
- try {
- val projection = arrayOf(
- MediaStore.MediaColumns._ID,
- MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only)
- //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only)
- )
-
- val selection =
- "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath'"
-
- val result = this.query(
- MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
- projection, selection, null, null
- )
- val list = ArrayList>()
-
- result.use { c ->
- if (c != null && c.count >= 1) {
- c.moveToFirst()
- while (true) {
- val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
- val name =
- c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
- val uri = ContentUris.withAppendedId(
- MediaStore.Downloads.EXTERNAL_CONTENT_URI, id
- )
- list.add(Pair(name, uri))
- if (c.isLast) {
- break
- }
- c.moveToNext()
- }
-
- /*
- val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
- val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/
-
- }
- }
- return list
- } catch (e: Exception) {
- logError(e)
- return null
- }
- }
-
/**
* Used for getting video player subs.
* @return List of pairs for the files in this format:
@@ -505,76 +504,13 @@ object VideoDownloadManager {
basePath: String?
): List>? {
val base = basePathToFile(context, basePath)
- val folder = base?.gotoDir(relativePath, false)
+ val folder = base?.gotoDirectory(relativePath, false) ?: return null
+ if (folder.isDirectory() != false) return null
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) {
- return context.contentResolver?.getExistingFolderStartName(relativePath)
- } else {
-// val normalPath =
-// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace(
-// '/',
-// File.separatorChar
-// )
-// val folder = File(normalPath)
- if (folder?.isDirectory == true) {
- return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) }
- }
- }
- return null
-// }
+ return folder.listFiles()
+ ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) }
}
- @RequiresApi(Build.VERSION_CODES.Q)
- private fun ContentResolver.getExistingDownloadUriOrNullQ(
- relativePath: String,
- displayName: String
- ): Uri? {
- try {
- val projection = arrayOf(
- MediaStore.MediaColumns._ID,
- //MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only)
- //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only)
- )
-
- val selection =
- "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath' AND " + "${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'"
-
- val result = this.query(
- MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
- projection, selection, null, null
- )
-
- result.use { c ->
- if (c != null && c.count >= 1) {
- c.moveToFirst().let {
- val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
- /*
- val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
- val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/
-
- return ContentUris.withAppendedId(
- MediaStore.Downloads.EXTERNAL_CONTENT_URI, id
- )
- }
- }
- }
- return null
- } catch (e: Exception) {
- logError(e)
- return null
- }
- }
-
- @RequiresApi(Build.VERSION_CODES.Q)
- fun ContentResolver.getFileLength(fileUri: Uri): Long? {
- return try {
- this.openFileDescriptor(fileUri, "r")
- .use { it?.statSize ?: 0 }
- } catch (e: Exception) {
- logError(e)
- null
- }
- }
data class CreateNotificationMetadata(
val type: DownloadType,
@@ -582,19 +518,35 @@ object VideoDownloadManager {
val bytesTotal: Long,
val hlsProgress: Long? = null,
val hlsTotal: Long? = null,
+ val bytesPerSecond: Long
)
data class StreamData(
- val errorCode: Int,
- val resume: Boolean? = null,
- val fileLength: Long? = null,
- val fileStream: OutputStream? = null,
- )
+ private val fileLength: Long,
+ val file: SafeFile,
+ //val fileStream: OutputStream,
+ ) {
+ @Throws(IOException::class)
+ fun open(): OutputStream {
+ return file.openOutputStreamOrThrow(resume)
+ }
- /**
- * Sets up the appropriate file and creates a data stream from the file.
- * Used for initializing downloads.
- * */
+ @Throws(IOException::class)
+ fun openNew(): OutputStream {
+ return file.openOutputStreamOrThrow(false)
+ }
+
+ fun delete(): Boolean {
+ return file.delete()
+ }
+
+ val resume: Boolean get() = fileLength > 0L
+ val startAt: Long get() = if (resume) fileLength else 0L
+ val exists: Boolean get() = file.exists() == true
+ }
+
+
+ @Throws(IOException::class)
fun setupStream(
context: Context,
name: String,
@@ -602,408 +554,896 @@ object VideoDownloadManager {
extension: String,
tryResume: Boolean,
): StreamData {
- val displayName = getDisplayName(name, extension)
- val fileStream: OutputStream
- val fileLength: Long
- var resume = tryResume
- val baseFile = context.getBasePath()
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.first?.isDownloadDir() == true) {
- val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
-
- val currentExistingFile =
- cr.getExistingDownloadUriOrNullQ(
- folder ?: "",
- displayName
- ) // CURRENT FILE WITH THE SAME PATH
-
- fileLength =
- if (currentExistingFile == null || !resume) 0 else (cr.getFileLength(
- currentExistingFile
- )
- ?: 0)// IF NOT RESUME THEN 0, OTHERWISE THE CURRENT FILE SIZE
-
- if (!resume && currentExistingFile != null) { // DELETE FILE IF FILE EXITS AND NOT RESUME
- val rowsDeleted = context.contentResolver.delete(currentExistingFile, null, null)
- if (rowsDeleted < 1) {
- println("ERROR DELETING FILE!!!")
- }
- }
-
- var appendFile = false
- val newFileUri = if (resume && currentExistingFile != null) {
- appendFile = true
- currentExistingFile
- } else {
- val contentUri =
- MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI
- //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
- val currentMimeType = when (extension) {
-
- // Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents
- // downloading to /Downloads yet it works with null
-
- "vtt" -> null // "text/vtt"
- "mp4" -> "video/mp4"
- "srt" -> null // "application/x-subrip"//"text/plain"
- else -> null
- }
- val newFile = ContentValues().apply {
- put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
- put(MediaStore.MediaColumns.TITLE, name)
- if (currentMimeType != null)
- put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType)
- put(MediaStore.MediaColumns.RELATIVE_PATH, folder)
- }
-
- cr.insert(
- contentUri,
- newFile
- ) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
- }
-
- fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else ""))
- ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
- } else {
- val subDir = baseFile.first?.gotoDir(folder)
- val rFile = subDir?.findFile(displayName)
- if (rFile?.exists() != true) {
- fileLength = 0
- if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE)
- } else {
- if (resume) {
- fileLength = rFile.size()
- } else {
- fileLength = 0
- if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE)
- if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE)
- }
- }
- fileStream = (subDir.findFile(displayName)
- ?: subDir.createFile(displayName))!!.openOutputStream()
-// fileStream = FileOutputStream(rFile, false)
- if (fileLength == 0L) resume = false
- }
- return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream)
+ return setupStream(
+ context.getBasePath().first ?: getDefaultDir(context) ?: throw IOException("Bad config"),
+ name,
+ folder,
+ extension,
+ tryResume
+ )
}
- fun downloadThing(
- context: Context,
- link: IDownloadableMinimum,
+ /**
+ * Sets up the appropriate file and creates a data stream from the file.
+ * Used for initializing downloads.
+ * */
+ @Throws(IOException::class)
+ fun setupStream(
+ baseFile: SafeFile,
name: String,
folder: String?,
extension: String,
tryResume: Boolean,
- parentId: Int?,
- createNotificationCallback: (CreateNotificationMetadata) -> Unit,
- ): Int {
- if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) {
- return ERROR_UNKNOWN
- }
-
- val basePath = context.getBasePath()
-
+ ): StreamData {
val displayName = getDisplayName(name, extension)
- val relativePath =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath(
- folder
- ) else folder
- fun deleteFile(): Int {
- return delete(context, name, relativePath, extension, parentId, basePath.first)
- }
+ val subDir = baseFile.gotoDirectoryOrThrow(folder)
+ val foundFile = subDir.findFile(displayName)
- val stream = setupStream(context, name, relativePath, extension, tryResume)
- if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode
-
- val resume = stream.resume!!
- val fileStream = stream.fileStream!!
- val fileLength = stream.fileLength!!
-
- // CONNECT
- val connection: URLConnection =
- URL(link.url.replace(" ", "%20")).openConnection() // IDK OLD PHONES BE WACK
-
- // SET CONNECTION SETTINGS
- connection.connectTimeout = 10000
- connection.setRequestProperty("Accept-Encoding", "identity")
- connection.setRequestProperty("user-agent", USER_AGENT)
- if (link.referer.isNotEmpty()) connection.setRequestProperty("referer", link.referer)
-
- // extra stuff
- connection.setRequestProperty(
- "sec-ch-ua",
- "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\""
- )
-
- connection.setRequestProperty("sec-ch-ua-mobile", "?0")
- connection.setRequestProperty("accept", "*/*")
- // dataSource.setRequestProperty("Sec-Fetch-Site", "none") //same-site
- connection.setRequestProperty("sec-fetch-user", "?1")
- connection.setRequestProperty("sec-fetch-mode", "navigate")
- connection.setRequestProperty("sec-fetch-dest", "video")
- link.headers.entries.forEach {
- connection.setRequestProperty(it.key, it.value)
- }
-
- if (resume)
- connection.setRequestProperty("Range", "bytes=${fileLength}-")
- val resumeLength = (if (resume) fileLength else 0)
-
- // ON CONNECTION
- connection.connect()
-
- val contentLength = try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // fuck android
- connection.contentLengthLong
+ val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) {
+ subDir.createFileOrThrow(displayName) to 0L
+ } else {
+ if (tryResume) {
+ foundFile to foundFile.lengthOrThrow()
} else {
- connection.getHeaderField("content-length").toLongOrNull()
- ?: connection.contentLength.toLong()
+ foundFile.deleteOrThrow()
+ subDir.createFileOrThrow(displayName) to 0L
}
- } catch (e: Exception) {
- logError(e)
- 0L
- }
- val bytesTotal = contentLength + resumeLength
-
- if (extension == "mp4" && bytesTotal < 5000000) return ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG
-
- parentId?.let {
- setKey(
- KEY_DOWNLOAD_INFO,
- it.toString(),
- DownloadedFileInfo(
- bytesTotal,
- relativePath ?: "",
- displayName,
- basePath = basePath.second
- )
- )
}
- // Could use connection.contentType for mime types when creating the file,
- // however file is already created and players don't go of file type
+ return StreamData(fileLength, file)
+ }
- // https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header
- // might receive application/octet-stream
- /*if (!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) {
- return ERROR_WRONG_CONTENT // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE
- }*/
+ /** This class handles the notifications, as well as the relevant key */
+ data class DownloadMetaData(
+ private val id: Int?,
+ var bytesDownloaded: Long = 0,
+ var bytesWritten: Long = 0,
- // READ DATA FROM CONNECTION
- val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream)
- val buffer = ByteArray(1024)
- var count: Int
- var bytesDownloaded = resumeLength
+ var totalBytes: Long? = null,
- var isPaused = false
- var isStopped = false
- var isDone = false
- var isFailed = false
+ // notification metadata
+ private var lastUpdatedMs: Long = 0,
+ private var lastDownloadedBytes: Long = 0,
+ private val createNotificationCallback: (CreateNotificationMetadata) -> Unit,
- // TO NOT REUSE CODE
- fun updateNotification() {
- val type = when {
- isDone -> DownloadType.IsDone
- isStopped -> DownloadType.IsStopped
- isFailed -> DownloadType.IsFailed
- isPaused -> DownloadType.IsPaused
- else -> DownloadType.IsDownloading
- }
+ private var internalType: DownloadType = DownloadType.IsPending,
- parentId?.let { id ->
- try {
- downloadStatus[id] = type
- downloadStatusEvent.invoke(Pair(id, type))
- downloadProgressEvent.invoke(Triple(id, bytesDownloaded, bytesTotal))
- } catch (e: Exception) {
- // IDK MIGHT ERROR
- }
- }
+ // how many segments that we have downloaded
+ var hlsProgress: Int = 0,
+ // how many segments that exist
+ var hlsTotal: Int? = null,
+ // this is how many segments that has been written to the file
+ // will always be <= hlsProgress as we may keep some in a buffer
+ var hlsWrittenProgress: Int = 0,
- createNotificationCallback.invoke(
- CreateNotificationMetadata(
- type,
- bytesDownloaded,
- bytesTotal
- )
- )
- /*createNotification(
- context,
- source,
- link.name,
- ep,
- type,
- bytesDownloaded,
- bytesTotal
- )*/
+ // this is used for copy with metadata on how much we have downloaded for setKey
+ private var downloadFileInfoTemplate: DownloadedFileInfo? = null
+ ) : Closeable {
+ fun setResumeLength(length: Long) {
+ bytesDownloaded = length
+ bytesWritten = length
+ lastDownloadedBytes = length
}
- val downloadEventListener = { event: Pair ->
- if (event.first == parentId) {
+ val approxTotalBytes: Long
+ get() = totalBytes ?: hlsTotal?.let { total ->
+ (bytesDownloaded * (total / hlsProgress.toFloat())).toLong()
+ } ?: bytesDownloaded
+
+ private val isHLS get() = hlsTotal != null
+
+ private var stopListener: (() -> Unit)? = null
+
+ /** on cancel button pressed or failed invoke this once and only once */
+ fun setOnStop(callback: (() -> Unit)) {
+ stopListener = callback
+ }
+
+ fun removeStopListener() {
+ stopListener = null
+ }
+
+ private val downloadEventListener = { event: Pair ->
+ if (event.first == id) {
when (event.second) {
DownloadActionType.Pause -> {
- isPaused = true; updateNotification()
+ type = DownloadType.IsPaused
}
+
DownloadActionType.Stop -> {
- isStopped = true; updateNotification()
+ type = DownloadType.IsStopped
removeKey(KEY_RESUME_PACKAGES, event.first.toString())
saveQueue()
+ stopListener?.invoke()
+ stopListener = null
}
+
DownloadActionType.Resume -> {
- isPaused = false; updateNotification()
+ type = DownloadType.IsDownloading
}
}
}
}
- if (parentId != null)
- downloadEvent += downloadEventListener
-
- // UPDATE DOWNLOAD NOTIFICATION
- val notificationCoroutine = main {
- while (true) {
- if (!isPaused) {
- updateNotification()
- }
- for (i in 1..10) {
- delay(100)
- }
+ private fun updateFileInfo() {
+ if (id == null) return
+ downloadFileInfoTemplate?.let { template ->
+ setKey(
+ KEY_DOWNLOAD_INFO,
+ id.toString(),
+ template.copy(
+ totalBytes = approxTotalBytes,
+ extraInfo = if (isHLS) hlsWrittenProgress.toString() else null
+ )
+ )
}
}
- // THE REAL READ
- try {
- while (true) {
- count = connectionInputStream.read(buffer)
- if (count < 0) break
- bytesDownloaded += count
- // downloadProgressEvent.invoke(Pair(id, bytesDownloaded)) // Updates too much for any UI to keep up with
- while (isPaused) {
- sleep(100)
- if (isStopped) {
- break
- }
- }
- if (isStopped) {
- break
- }
- fileStream.write(buffer, 0, count)
- }
- } catch (e: Exception) {
- logError(e)
- isFailed = true
- updateNotification()
+ fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) {
+ downloadFileInfoTemplate = template
+ updateFileInfo()
}
- // REMOVE AND EXIT ALL
- fileStream.close()
- connectionInputStream.close()
- notificationCoroutine.cancel()
+ init {
+ if (id != null) {
+ downloadEvent += downloadEventListener
+ }
+ }
- try {
- if (parentId != null)
+ override fun close() {
+ // as we may need to resume hls downloads, we save the current written index
+ if (isHLS || totalBytes == null) {
+ updateFileInfo()
+ }
+ if (id != null) {
downloadEvent -= downloadEventListener
- } catch (e: Exception) {
- logError(e)
+ downloadStatus -= id
+ }
+ stopListener = null
}
- try {
- parentId?.let {
- downloadStatus.remove(it)
+ var type
+ get() = internalType
+ set(value) {
+ internalType = value
+ notify()
}
- } catch (e: Exception) {
- // IDK MIGHT ERROR
+
+ fun onDelete() {
+ bytesDownloaded = 0
+ hlsWrittenProgress = 0
+ hlsProgress = 0
+ if (id != null)
+ downloadDeleteEvent(id)
+
+ //internalType = DownloadType.IsStopped
+ notify()
}
- // RETURN MESSAGE
- return when {
- isFailed -> {
- parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) }
- ERROR_CONNECTION_ERROR
- }
- isStopped -> {
- parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) }
- deleteFile()
- }
- else -> {
- parentId?.let { id ->
- downloadProgressEvent.invoke(
- Triple(
- id,
+ companion object {
+ const val UPDATE_RATE_MS: Long = 1000L
+ }
+
+ @JvmName("DownloadMetaDataNotify")
+ private fun notify() {
+ // max 10 sec between notifications, min 0.1s, this is to stop div by zero
+ val dt = (System.currentTimeMillis() - lastUpdatedMs).coerceIn(100, 10000)
+
+ val bytesPerSecond =
+ ((bytesDownloaded - lastDownloadedBytes) * 1000L) / dt
+
+ lastDownloadedBytes = bytesDownloaded
+ lastUpdatedMs = System.currentTimeMillis()
+ try {
+ val bytes = approxTotalBytes
+
+ // notification creation
+ if (isHLS) {
+ createNotificationCallback(
+ CreateNotificationMetadata(
+ internalType,
bytesDownloaded,
- bytesTotal
+ bytes,
+ hlsTotal = hlsTotal?.toLong(),
+ hlsProgress = hlsProgress.toLong(),
+ bytesPerSecond = bytesPerSecond
+ )
+ )
+ } else {
+ createNotificationCallback(
+ CreateNotificationMetadata(
+ internalType,
+ bytesDownloaded,
+ bytes,
+ bytesPerSecond = bytesPerSecond
)
)
}
- isDone = true
- updateNotification()
- SUCCESS_DOWNLOAD_DONE
+
+ // as hls has an approx file size we want to update this metadata
+ if (isHLS) {
+ updateFileInfo()
+ }
+
+ if (internalType == DownloadType.IsStopped || internalType == DownloadType.IsFailed) {
+ stopListener?.invoke()
+ stopListener = null
+ }
+
+ // push all events, this *should* not crash, TODO MUTEX?
+ if (id != null) {
+ downloadStatus[id] = type
+ downloadProgressEvent(Triple(id, bytesDownloaded, bytes))
+ downloadStatusEvent(id to type)
+ }
+ } catch (t: Throwable) {
+ logError(t)
+ if (BuildConfig.DEBUG) {
+ throw t
+ }
+ }
+ }
+
+ private fun checkNotification() {
+ if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return
+ notify()
+ }
+
+
+ /** adds the length and pushes a notification if necessary */
+ fun addBytes(length: Long) {
+ bytesDownloaded += length
+ // we don't want to update the notification after it is paused,
+ // download progress may not stop directly when we "pause" it
+ if (type == DownloadType.IsDownloading) checkNotification()
+ }
+
+ fun addBytesWritten(length: Long) {
+ bytesWritten += length
+ }
+
+ /** adds the length + hsl progress and pushes a notification if necessary */
+ fun addSegment(length: Long) {
+ hlsProgress += 1
+ addBytes(length)
+ }
+
+ fun setWrittenSegment(segmentIndex: Int) {
+ hlsWrittenProgress = segmentIndex + 1
+ // in case of abort we need to save every written progress
+ updateFileInfo()
+ }
+ }
+
+ /** bytes have the size end-start where the byte range is [start,end)
+ * note that ByteArray is a pointer and therefore cant be stored without cloning it */
+ data class LazyStreamDownloadResponse(
+ val bytes: ByteArray,
+ val startByte: Long,
+ val endByte: Long,
+ ) {
+ val size get() = endByte - startByte
+
+ override fun toString(): String {
+ return "$startByte->$endByte"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is LazyStreamDownloadResponse) return false
+ return other.startByte == startByte && other.endByte == endByte
+ }
+
+ override fun hashCode(): Int {
+ return Objects.hash(startByte, endByte)
+ }
+ }
+
+ data class LazyStreamDownloadData(
+ private val url: String,
+ private val headers: Map,
+ private val referer: String,
+ /** This specifies where chunck i starts and ends,
+ * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1}
+ * where out of bounds => bytes=${chuckStartByte[ i ]}- */
+ private val chuckStartByte: LongArray,
+ val totalLength: Long?,
+ val downloadLength: Long?,
+ val chuckSize: Long,
+ val bufferSize: Int,
+ ) {
+ val size get() = chuckStartByte.size
+
+ /** returns what byte it has downloaded,
+ * so start at 10 and download 4 bytes = return 14
+ *
+ * the range is [startByte, endByte) to be able to do [a, b) [b, c) ect
+ *
+ * [a, null) will return inclusive to eof = [a, eof]
+ *
+ * throws an error if initial get request fails, can be specified as return startByte
+ * */
+ @Throws
+ private suspend fun resolve(
+ startByte: Long,
+ endByte: Long?,
+ callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit)
+ ): Long = withContext(Dispatchers.IO) {
+ var currentByte: Long = startByte
+ val stopAt = endByte ?: Long.MAX_VALUE
+ if (currentByte >= stopAt) return@withContext currentByte
+
+ val request = app.get(
+ url,
+ headers = headers + mapOf(
+ // range header is inclusive so [startByte, endByte-1] = [startByte, endByte)
+ // if nothing at end the server will continue until eof
+ "Range" to "bytes=$startByte-" // ${endByte?.minus(1)?.toString() ?: "" }
+ ),
+ referer = referer,
+ verify = false
+ )
+ val requestStream = request.body.byteStream()
+
+ val buffer = ByteArray(bufferSize)
+ var read: Int
+
+ try {
+ while (requestStream.read(buffer, 0, bufferSize).also { read = it } >= 0) {
+ val start = currentByte
+ currentByte += read.toLong()
+
+ // this stops overflow
+ if (currentByte >= stopAt) {
+ callback(LazyStreamDownloadResponse(buffer, start, stopAt))
+ break
+ } else {
+ callback(LazyStreamDownloadResponse(buffer, start, currentByte))
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (t: Throwable) {
+ logError(t)
+ } finally {
+ requestStream.closeQuietly()
+ }
+
+ return@withContext currentByte
+ }
+
+ /** retries the resolve n times and returns true if successful */
+ suspend fun resolveSafe(
+ index: Int,
+ retries: Int = 3,
+ callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit)
+ ): Boolean {
+ var start = chuckStartByte.getOrNull(index) ?: return false
+ val end = chuckStartByte.getOrNull(index + 1)
+
+ for (i in 0 until retries) {
+ try {
+ // in case
+ start = resolve(start, end, callback)
+ // no end defined, so we don't care exactly where it ended
+ if (end == null) return true
+ // we have download more or exactly what we needed
+ if (start >= end) return true
+ } catch (e: IllegalStateException) {
+ return false
+ } catch (e: CancellationException) {
+ return false
+ } catch (t: Throwable) {
+ continue
+ }
+ }
+ return false
+ }
+
+ }
+
+ @Throws
+ suspend fun streamLazy(
+ url: String,
+ headers: Map,
+ referer: String,
+ startByte: Long,
+ /** how many bytes every connection should be, by default it is 10 MiB */
+ chuckSize: Long = (1 shl 20) * 10,
+ /** maximum bytes in the buffer that responds */
+ bufferSize: Int = DEFAULT_BUFFER_SIZE
+ ): LazyStreamDownloadData {
+ // we don't want to make a separate connection for every 1kb
+ require(chuckSize > 1000)
+
+ var contentLength =
+ app.head(url = url, headers = headers, referer = referer, verify = false).size
+ if (contentLength != null && contentLength <= 0) contentLength = null
+
+ var downloadLength: Long? = null
+ var totalLength: Long? = null
+
+ val ranges = if (contentLength == null) {
+ // is the equivalent of [startByte..EOF] as we don't know the size we can only do one
+ // connection
+ LongArray(1) { startByte }
+ } else {
+ downloadLength = contentLength - startByte
+ totalLength = contentLength
+ // div with ceiling as
+ // this makes the last part "unknown ending" and it will break at EOF
+ // so eg startByte = 0, downloadLength = 13, chuckSize = 10
+ // = LongArray(2) { 0, 10 } = [0,10) + [10..EOF]
+ LongArray(((downloadLength + chuckSize - 1) / chuckSize).toInt()) { idx ->
+ startByte + idx * chuckSize
+ }
+ }
+
+ return LazyStreamDownloadData(
+ url = url,
+ headers = headers,
+ referer = referer,
+ chuckStartByte = ranges,
+ downloadLength = downloadLength,
+ totalLength = totalLength,
+ chuckSize = chuckSize,
+ bufferSize = bufferSize
+ )
+ }
+
+ /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp
+ * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4)
+ * */
+ private fun Map.appendAndDontOverride(rhs: Map): Map {
+ val out = this.toMutableMap()
+ val current = this.keys.map { it.lowercase() }
+ for ((key, value) in rhs) {
+ if (current.contains(key.lowercase())) continue
+ out[key] = value
+ }
+ return out
+ }
+
+ private fun List.cancel() {
+ forEach { job ->
+ try {
+ job.cancel()
+ } catch (t: Throwable) {
+ logError(t)
}
}
}
+ private suspend fun List.join() {
+ forEach { job ->
+ try {
+ job.join()
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ }
+ }
- /**
- * Guarantees a directory is present with the dir name (if createMissingDirectories is true).
- * Works recursively when '/' is present.
- * Will remove any file with the dir name if present and add directory.
- * Will not work if the parent directory does not exist.
- *
- * @param directoryName if null will use the current path.
- * @return UniFile / null if createMissingDirectories = false and folder is not found.
- * */
- private fun UniFile.gotoDir(
- directoryName: String?,
- createMissingDirectories: Boolean = true
- ): UniFile? {
-
- // May give this error on scoped storage.
- // W/DocumentsContract: Failed to create document
- // java.lang.IllegalArgumentException: Parent document isn't a directory
-
- // Not present in latest testing.
-
-// println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}")
+ suspend fun downloadThing(
+ context: Context,
+ link: IDownloadableMinimum,
+ name: String,
+ folder: String,
+ extension: String,
+ tryResume: Boolean,
+ parentId: Int?,
+ createNotificationCallback: (CreateNotificationMetadata) -> Unit,
+ parallelConnections: Int = 3
+ ): DownloadStatus = withContext(Dispatchers.IO) {
+ // we cant download torrents with this implementation, aria2c might be used in the future
+ if (link.url.startsWith("magnet") || link.url.endsWith(".torrent") || parallelConnections < 1) {
+ return@withContext DOWNLOAD_INVALID_INPUT
+ }
+ var fileStream: OutputStream? = null
+ //var requestStream: InputStream? = null
+ val metadata = DownloadMetaData(
+ totalBytes = 0,
+ bytesDownloaded = 0,
+ createNotificationCallback = createNotificationCallback,
+ id = parentId,
+ )
try {
- // Creates itself from parent if doesn't exist.
- if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) {
- if (this.parentFile != null) {
- this.parentFile?.createDirectory(this.name)
- } else if (this.filePath != null) {
- UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name)
- }
- }
+ // get the file path
+ val (baseFile, basePath) = context.getBasePath()
+ val displayName = getDisplayName(name, extension)
+ if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
- val allDirectories = directoryName?.split("/")
- return if (allDirectories?.size == 1 || allDirectories == null) {
- val found = this.findFile(directoryName)
- when {
- directoryName.isNullOrBlank() -> this
- found?.isDirectory == true -> found
+ // set up the download file
+ val stream = setupStream(baseFile, name, folder, extension, tryResume)
- !createMissingDirectories -> null
- // Below creates directories
- found?.isFile == true -> {
- found.delete()
- this.createDirectory(directoryName)
+ fileStream = stream.open()
+
+ metadata.setResumeLength(stream.startAt)
+ metadata.type = DownloadType.IsPending
+
+ val items = streamLazy(
+ url = link.url.replace(" ", "%20"),
+ referer = link.referer,
+ startByte = stream.startAt,
+ headers = link.headers.appendAndDontOverride(
+ mapOf(
+ "Accept-Encoding" to "identity",
+ "accept" to "*/*",
+ "user-agent" to USER_AGENT,
+ "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
+ "sec-fetch-mode" to "navigate",
+ "sec-fetch-dest" to "video",
+ "sec-fetch-user" to "?1",
+ "sec-ch-ua-mobile" to "?0",
+ )
+ )
+ )
+
+ metadata.totalBytes = items.totalLength
+ metadata.type = DownloadType.IsDownloading
+ metadata.setDownloadFileInfoTemplate(
+ DownloadedFileInfo(
+ totalBytes = metadata.approxTotalBytes,
+ relativePath = folder,
+ displayName = displayName,
+ basePath = basePath
+ )
+ )
+
+ val currentMutex = Mutex()
+ val current = (0 until items.size).iterator()
+
+ val fileMutex = Mutex()
+ // start to data
+ val pendingData: HashMap =
+ hashMapOf()
+
+ val fileChecker = launch(Dispatchers.IO) {
+ while (isActive) {
+ if (stream.exists) {
+ delay(5000)
+ continue
}
- this.isDirectory -> this.createDirectory(directoryName)
- else -> this.parentFile?.createDirectory(directoryName)
+ fileMutex.withLock {
+ metadata.type = DownloadType.IsStopped
+ }
+ break
}
- } else {
- var currentDirectory = this
- allDirectories.forEach {
- // If the next directory is not found it returns the deepest directory possible.
- val nextDir = currentDirectory.gotoDir(it, createMissingDirectories)
- currentDirectory = nextDir ?: return null
- }
- currentDirectory
}
- } catch (e: Exception) {
+
+ val jobs = (0 until parallelConnections).map {
+ launch(Dispatchers.IO) {
+
+ // @downloadexplanation
+ // this may seem a bit complex but it more or less acts as a queue system
+ // imagine we do the downloading [0,3] and it response in the order 0,2,3,1
+ // file: [_,_,_,_] queue: [_,_,_,_] Initial condition
+ // file: [X,_,_,_] queue: [_,_,_,_] + added 0 directly to file
+ // file: [X,_,_,_] queue: [_,_,X,_] + added 2 to queue
+ // file: [X,_,_,_] queue: [_,_,X,X] + added 3 to queue
+ // file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file
+ // file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it
+
+ // note that this is a bit more complex compared to hsl as ever segment
+ // will return several bytearrays, and is therefore chained by the byte
+ // so every request has a front and back byte instead of an index
+ // this *requires* that no gap exist due because of resolve
+ val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) =
+ callback@{ response ->
+ if (!isActive) return@callback
+ fileMutex.withLock {
+ // wait until not paused
+ while (metadata.type == DownloadType.IsPaused) delay(100)
+ // if stopped then throw
+ if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed) {
+ this.cancel()
+ return@callback
+ }
+
+ val responseSize = response.size
+ metadata.addBytes(response.size)
+
+ if (response.startByte == metadata.bytesWritten) {
+ // if we are first in the queue then write it directly
+ fileStream.write(
+ response.bytes,
+ 0,
+ responseSize.toInt()
+ )
+ metadata.addBytesWritten(responseSize)
+ } else {
+ // otherwise append to queue, we need to clone the bytes as they will be overridden otherwise
+ pendingData[response.startByte] =
+ response.copy(bytes = response.bytes.clone())
+ }
+
+ while (true) {
+ // remove the current queue start, so no possibility of
+ // while(true) { continue } in case size = 0, and removed extra
+ // garbage
+ val pending = pendingData.remove(metadata.bytesWritten) ?: break
+
+ val size = pending.size
+
+ fileStream.write(
+ pending.bytes,
+ 0,
+ size.toInt()
+ )
+ metadata.addBytesWritten(size)
+ }
+ }
+ }
+
+ // this will take up the first available job and resolve
+ while (true) {
+ if (!isActive) return@launch
+ fileMutex.withLock {
+ if (metadata.type == DownloadType.IsStopped
+ || metadata.type == DownloadType.IsFailed
+ ) return@launch
+ }
+
+ // mutex just in case, we never want this to fail due to multithreading
+ val index = currentMutex.withLock {
+ if (!current.hasNext()) return@launch
+ current.nextInt()
+ }
+
+ // in case something has gone wrong set to failed if the fail is not caused by
+ // user cancellation
+ if (!items.resolveSafe(index, callback = callback)) {
+ fileMutex.withLock {
+ if (metadata.type != DownloadType.IsStopped) {
+ metadata.type = DownloadType.IsFailed
+ }
+ }
+ return@launch
+ }
+ }
+ }
+ }
+
+ // fast stop as the jobs may be in a slow request
+ metadata.setOnStop {
+ jobs.cancel()
+ }
+
+ jobs.join()
+ fileChecker.cancel()
+
+ // jobs are finished so we don't want to stop them anymore
+ metadata.removeStopListener()
+ if (!stream.exists) metadata.type = DownloadType.IsStopped
+
+ if (metadata.type == DownloadType.IsFailed) {
+ return@withContext DOWNLOAD_FAILED
+ }
+
+ if (metadata.type == DownloadType.IsStopped) {
+ // we need to close before delete
+ fileStream.closeQuietly()
+ metadata.onDelete()
+ stream.delete()
+ return@withContext DOWNLOAD_STOPPED
+ }
+
+ metadata.type = DownloadType.IsDone
+ return@withContext DOWNLOAD_SUCCESS
+ } catch (e: IOException) {
+ // some sort of IO error, this should not happened
+ // we just rethrow it
logError(e)
- return null
+ throw e
+ } catch (t: Throwable) {
+ // some sort of network error, will error
+
+ // note that when failing we don't want to delete the file,
+ // only user interaction has that power
+ metadata.type = DownloadType.IsFailed
+ return@withContext DOWNLOAD_FAILED
+ } finally {
+ fileStream?.closeQuietly()
+ //requestStream?.closeQuietly()
+ metadata.close()
+ }
+ }
+
+ private suspend fun downloadHLS(
+ context: Context,
+ link: ExtractorLink,
+ name: String,
+ folder: String,
+ parentId: Int?,
+ startIndex: Int?,
+ createNotificationCallback: (CreateNotificationMetadata) -> Unit,
+ parallelConnections: Int = 3
+ ): DownloadStatus = withContext(Dispatchers.IO) {
+ if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT
+
+ val metadata = DownloadMetaData(
+ createNotificationCallback = createNotificationCallback,
+ id = parentId
+ )
+ var fileStream: OutputStream? = null
+ try {
+ val extension = "mp4"
+
+ // the start .ts index
+ var startAt = startIndex ?: 0
+
+ // set up the file data
+ val (baseFile, basePath) = context.getBasePath()
+ if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
+
+ val displayName = getDisplayName(name, extension)
+ val stream =
+ setupStream(baseFile, name, folder, extension, startAt > 0)
+ if (!stream.resume) startAt = 0
+ fileStream = stream.open()
+
+ // push the metadata
+ metadata.setResumeLength(stream.startAt)
+ metadata.hlsProgress = startAt
+ metadata.type = DownloadType.IsPending
+ metadata.setDownloadFileInfoTemplate(
+ DownloadedFileInfo(
+ totalBytes = 0,
+ relativePath = folder,
+ displayName = displayName,
+ basePath = basePath
+ )
+ )
+
+ // do the initial get request to fetch the segments
+ val m3u8 = M3u8Helper.M3u8Stream(
+ link.url, link.quality, link.headers.appendAndDontOverride(
+ mapOf(
+ "Accept-Encoding" to "identity",
+ "accept" to "*/*",
+ "user-agent" to USER_AGENT,
+ ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap()
+ )
+ )
+ val items = M3u8Helper2.hslLazy(listOf(m3u8))
+
+ metadata.hlsTotal = items.size
+ metadata.type = DownloadType.IsDownloading
+
+ val currentMutex = Mutex()
+ val current = (startAt until items.size).iterator()
+
+ val fileMutex = Mutex()
+ val pendingData: HashMap = hashMapOf()
+
+ val fileChecker = launch(Dispatchers.IO) {
+ while (isActive) {
+ if (stream.exists) {
+ delay(5000)
+ continue
+ }
+ fileMutex.withLock {
+ metadata.type = DownloadType.IsStopped
+ }
+ break
+ }
+ }
+
+ // see @downloadexplanation for explanation of this download strategy,
+ // this keeps all jobs working at all times,
+ // does several connections in parallel instead of a regular for loop to improve
+ // download speed
+ val jobs = (0 until parallelConnections).map {
+ launch(Dispatchers.IO) {
+ while (true) {
+ if (!isActive) return@launch
+ fileMutex.withLock {
+ if (metadata.type == DownloadType.IsStopped
+ || metadata.type == DownloadType.IsFailed
+ ) return@launch
+ }
+
+ // mutex just in case, we never want this to fail due to multithreading
+ val index = currentMutex.withLock {
+ if (!current.hasNext()) return@launch
+ current.nextInt()
+ }
+
+ // in case something has gone wrong set to failed if the fail is not caused by
+ // user cancellation
+ val bytes = items.resolveLinkSafe(index) ?: run {
+ fileMutex.withLock {
+ if (metadata.type != DownloadType.IsStopped) {
+ metadata.type = DownloadType.IsFailed
+ }
+ }
+ return@launch
+ }
+
+ try {
+ fileMutex.lock()
+ // user pause
+ while (metadata.type == DownloadType.IsPaused) delay(100)
+ // if stopped then break to delete
+ if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch
+
+ val segmentLength = bytes.size.toLong()
+ // send notification, no matter the actual write order
+ metadata.addSegment(segmentLength)
+
+ // directly write the bytes if you are first
+ if (metadata.hlsWrittenProgress == index) {
+ fileStream.write(bytes)
+
+ metadata.addBytesWritten(segmentLength)
+ metadata.setWrittenSegment(index)
+ } else {
+ // no need to clone as there will be no modification of this bytearray
+ pendingData[index] = bytes
+ }
+
+ // write the cached bytes submitted by other threads
+ while (true) {
+ val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break
+ val cacheLength = cache.size.toLong()
+
+ fileStream.write(cache)
+
+ metadata.addBytesWritten(cacheLength)
+ metadata.setWrittenSegment(metadata.hlsWrittenProgress)
+ }
+ } catch (t: Throwable) {
+ // this is in case of write fail
+ logError(t)
+ if (metadata.type != DownloadType.IsStopped) {
+ metadata.type = DownloadType.IsFailed
+ }
+ } finally {
+ try {
+ // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling
+ fileMutex.unlock()
+ } catch (t : Throwable) {
+ logError(t)
+ }
+ }
+ }
+ }
+ }
+
+ // fast stop as the jobs may be in a slow request
+ metadata.setOnStop {
+ jobs.cancel()
+ }
+
+ jobs.join()
+ fileChecker.cancel()
+
+ metadata.removeStopListener()
+
+ if (!stream.exists) metadata.type = DownloadType.IsStopped
+
+ if (metadata.type == DownloadType.IsFailed) {
+ return@withContext DOWNLOAD_FAILED
+ }
+
+ if (metadata.type == DownloadType.IsStopped) {
+ // we need to close before delete
+ fileStream.closeQuietly()
+ metadata.onDelete()
+ stream.delete()
+ return@withContext DOWNLOAD_STOPPED
+ }
+
+ metadata.type = DownloadType.IsDone
+ return@withContext DOWNLOAD_SUCCESS
+ } catch (t: Throwable) {
+ logError(t)
+ metadata.type = DownloadType.IsFailed
+ return@withContext DOWNLOAD_FAILED
+ } finally {
+ fileStream?.closeQuietly()
+ metadata.close()
}
}
@@ -1018,21 +1458,10 @@ object VideoDownloadManager {
* As of writing UniFile is used for everything but download directory on scoped storage.
* Special ContentResolver fuckery is needed for that as UniFile doesn't work.
* */
- fun getDownloadDir(): UniFile? {
+ fun getDefaultDir(context: Context): SafeFile? {
// See https://www.py4u.net/discuss/614761
- return UniFile.fromFile(
- File(
- Environment.getExternalStorageDirectory().absolutePath + File.separatorChar +
- Environment.DIRECTORY_DOWNLOADS
- )
- )
- }
-
- @Deprecated("TODO fix UniFile to work with download directory.")
- private fun getRelativePath(folder: String?): String {
- return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace(
- '/',
- File.separatorChar
+ return SafeFile.fromMedia(
+ context, MediaFileContentType.Downloads
)
}
@@ -1040,11 +1469,11 @@ object VideoDownloadManager {
* Turns a string to an UniFile. Used for stored string paths such as settings.
* Should only be used to get a download path.
* */
- private fun basePathToFile(context: Context, path: String?): UniFile? {
+ private fun basePathToFile(context: Context, path: String?): SafeFile? {
return when {
- path.isNullOrBlank() -> getDownloadDir()
- path.startsWith("content://") -> UniFile.fromUri(context, path.toUri())
- else -> UniFile.fromFile(File(path))
+ path.isNullOrBlank() -> getDefaultDir(context)
+ path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri())
+ else -> SafeFile.fromFile(context, File(path))
}
}
@@ -1053,300 +1482,12 @@ object VideoDownloadManager {
* Returns the file and a string to be stored for future file retrieval.
* UniFile.filePath is not sufficient for storage.
* */
- fun Context.getBasePath(): Pair {
+ fun Context.getBasePath(): Pair {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null)
return basePathToFile(this, basePathSetting) to basePathSetting
}
- fun UniFile?.isDownloadDir(): Boolean {
- return this != null && this.filePath == getDownloadDir()?.filePath
- }
-
- private fun delete(
- context: Context,
- name: String,
- folder: String?,
- extension: String,
- parentId: Int?,
- basePath: UniFile?
- ): Int {
- val displayName = getDisplayName(name, extension)
-
- // delete all subtitle files
- if (extension == "mp4") {
- try {
- delete(context, name, folder, "vtt", parentId, basePath)
- delete(context, name, folder, "srt", parentId, basePath)
- } catch (e: Exception) {
- logError(e)
- }
- }
-
- // If scoped storage and using download dir (not accessible with UniFile)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.isDownloadDir()) {
- val relativePath = getRelativePath(folder)
- val lastContent =
- context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName)
- if (lastContent != null) {
- context.contentResolver.delete(lastContent, null, null)
- }
- } else {
- val dir = basePath?.gotoDir(folder)
- val file = dir?.findFile(displayName)
- val success = file?.delete()
- if (success != true) return ERROR_DELETING_FILE else {
- // Cleans up empty directory
- if (dir.listFiles()?.isEmpty() == true) dir.delete()
- }
-// }
- parentId?.let {
- downloadDeleteEvent.invoke(parentId)
- }
- }
- return SUCCESS_STOPPED
- }
-
- private fun downloadHLS(
- context: Context,
- link: ExtractorLink,
- name: String,
- folder: String?,
- parentId: Int?,
- startIndex: Int?,
- createNotificationCallback: (CreateNotificationMetadata) -> Unit
- ): Int {
- val extension = "mp4"
- fun logcatPrint(vararg items: Any?) {
- items.forEach {
- println("[HLS]: $it")
- }
- }
-
- val m3u8Helper = M3u8Helper()
- logcatPrint("initialised the HLS downloader.")
-
- val m3u8 = M3u8Helper.M3u8Stream(
- link.url, link.quality, mapOf("referer" to link.referer)
- )
-
- var realIndex = startIndex ?: 0
- val basePath = context.getBasePath()
-
- val relativePath =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath(
- folder
- ) else folder
-
- val stream = setupStream(context, name, relativePath, extension, realIndex > 0)
- if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode
-
- if (!stream.resume!!) realIndex = 0
- val fileLengthAdd = stream.fileLength!!
- val tsIterator = runBlocking {
- m3u8Helper.hlsYield(listOf(m3u8), realIndex)
- }
-
- val displayName = getDisplayName(name, extension)
-
- val fileStream = stream.fileStream!!
-
- val firstTs = tsIterator.next()
-
- var isDone = false
- var isFailed = false
- var isPaused = false
- var bytesDownloaded = firstTs.bytes.size.toLong() + fileLengthAdd
- var tsProgress = 1L + realIndex
- val totalTs = firstTs.totalTs.toLong()
-
- fun deleteFile(): Int {
- return delete(context, name, relativePath, extension, parentId, basePath.first)
- }
- /*
- Most of the auto generated m3u8 out there have TS of the same size.
- And only the last TS might have a different size.
-
- But oh well, in cases of handmade m3u8 streams this will go all over the place ¯\_(ツ)_/¯
- So ya, this calculates an estimate of how many bytes the file is going to be.
-
- > (bytesDownloaded/tsProgress)*totalTs
- */
-
- fun updateInfo() {
- parentId?.let {
- setKey(
- KEY_DOWNLOAD_INFO,
- it.toString(),
- DownloadedFileInfo(
- (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(),
- relativePath ?: "",
- displayName,
- tsProgress.toString(),
- basePath = basePath.second
- )
- )
- }
- }
-
- updateInfo()
-
- fun updateNotification() {
- val type = when {
- isDone -> DownloadType.IsDone
- isFailed -> DownloadType.IsFailed
- isPaused -> DownloadType.IsPaused
- else -> DownloadType.IsDownloading
- }
-
- parentId?.let { id ->
- try {
- downloadStatus[id] = type
- downloadStatusEvent.invoke(Pair(id, type))
- downloadProgressEvent.invoke(
- Triple(
- id,
- bytesDownloaded,
- (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(),
- )
- )
- } catch (e: Exception) {
- // IDK MIGHT ERROR
- }
- }
-
- createNotificationCallback.invoke(
- CreateNotificationMetadata(
- type,
- bytesDownloaded,
- (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(),
- tsProgress,
- totalTs
- )
- )
- }
-
- fun stopIfError(ts: M3u8Helper.HlsDownloadData): Int? {
- if (ts.errored || ts.bytes.isEmpty()) {
- val error: Int = if (!ts.errored) {
- logcatPrint("Error: No stream was found.")
- ERROR_UNKNOWN
- } else {
- logcatPrint("Error: Failed to fetch data.")
- ERROR_CONNECTION_ERROR
- }
- isFailed = true
- fileStream.close()
- deleteFile()
- updateNotification()
- return error
- }
- return null
- }
-
- val notificationCoroutine = main {
- while (true) {
- if (!isDone) {
- updateNotification()
- }
- for (i in 1..10) {
- delay(100)
- }
- }
- }
-
- val downloadEventListener = { event: Pair ->
- if (event.first == parentId) {
- when (event.second) {
- DownloadActionType.Stop -> {
- isFailed = true
- }
- DownloadActionType.Pause -> {
- isPaused =
- true // Pausing is not supported since well...I need to know the index of the ts it was paused at
- // it may be possible to store it in a variable, but when the app restarts it will be lost
- }
- DownloadActionType.Resume -> {
- isPaused = false
- }
- }
- updateNotification()
- }
- }
-
- fun closeAll() {
- try {
- if (parentId != null)
- downloadEvent -= downloadEventListener
- } catch (e: Exception) {
- logError(e)
- }
- try {
- parentId?.let {
- downloadStatus.remove(it)
- }
- } catch (e: Exception) {
- logError(e)
- // IDK MIGHT ERROR
- }
- notificationCoroutine.cancel()
- }
-
- stopIfError(firstTs).let {
- if (it != null) {
- closeAll()
- return it
- }
- }
-
- if (parentId != null)
- downloadEvent += downloadEventListener
-
- fileStream.write(firstTs.bytes)
-
- fun onFailed() {
- fileStream.close()
- deleteFile()
- updateNotification()
- closeAll()
- }
-
- for (ts in tsIterator) {
- while (isPaused) {
- if (isFailed) {
- onFailed()
- return SUCCESS_STOPPED
- }
- sleep(100)
- }
-
- if (isFailed) {
- onFailed()
- return SUCCESS_STOPPED
- }
-
- stopIfError(ts).let {
- if (it != null) {
- closeAll()
- return it
- }
- }
-
- fileStream.write(ts.bytes)
- tsProgress = ts.currentIndex.toLong()
- bytesDownloaded += ts.bytes.size.toLong()
- logcatPrint("Download progress ${((tsProgress.toFloat() / totalTs.toFloat()) * 100).roundToInt()}%")
- updateInfo()
- }
- isDone = true
- fileStream.close()
- updateNotification()
-
- closeAll()
- updateInfo()
- return SUCCESS_DOWNLOAD_DONE
- }
-
fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String {
return getFileName(context, metadata.name, metadata.episode, metadata.season)
}
@@ -1379,7 +1520,7 @@ object VideoDownloadManager {
)
}
- private fun downloadSingleEpisode(
+ private suspend fun downloadSingleEpisode(
context: Context,
source: String?,
folder: String?,
@@ -1387,7 +1528,7 @@ object VideoDownloadManager {
link: ExtractorLink,
notificationCallback: (Int, Notification) -> Unit,
tryResume: Boolean = false,
- ): Int {
+ ): DownloadStatus {
val name = getFileName(context, ep)
// Make sure this is cancelled when download is done or cancelled.
@@ -1397,155 +1538,167 @@ object VideoDownloadManager {
}
}
- if (link.isM3u8 || URL(link.url).path.endsWith(".m3u8")) {
- val startIndex = if (tryResume) {
- context.getKey(
- KEY_DOWNLOAD_INFO,
- ep.id.toString(),
- null
- )?.extraInfo?.toIntOrNull()
- } else null
- return downloadHLS(context, link, name, folder, ep.id, startIndex) { meta ->
- main {
- createNotification(
- context,
- source,
- link.name,
- ep,
- meta.type,
- meta.bytesDownloaded,
- meta.bytesTotal,
- notificationCallback,
- meta.hlsProgress,
- meta.hlsTotal
- )
- }
- }.also { extractorJob.cancel() }
- }
-
- return normalSafeApiCall {
- downloadThing(context, link, name, folder, "mp4", tryResume, ep.id) { meta ->
- main {
- createNotification(
- context,
- source,
- link.name,
- ep,
- meta.type,
- meta.bytesDownloaded,
- meta.bytesTotal,
- notificationCallback
- )
- }
- }
- }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN
- }
-
- fun downloadCheck(
- context: Context, notificationCallback: (Int, Notification) -> Unit,
- ): Int? {
- if (currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0) {
- val pkg = downloadQueue.removeFirst()
- val item = pkg.item
- val id = item.ep.id
- if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT
- downloadEvent.invoke(Pair(id, DownloadActionType.Resume))
- /** ID needs to be returned to the work-manager to properly await notification */
- return id
- }
-
- currentDownloads.add(id)
-
+ val callback: (CreateNotificationMetadata) -> Unit = { meta ->
main {
- try {
- for (index in (pkg.linkIndex ?: 0) until item.links.size) {
- val link = item.links[index]
- val resume = pkg.linkIndex == index
-
- setKey(
- KEY_RESUME_PACKAGES,
- id.toString(),
- DownloadResumePackage(item, index)
- )
- val connectionResult = withContext(Dispatchers.IO) {
- normalSafeApiCall {
- downloadSingleEpisode(
- context,
- item.source,
- item.folder,
- item.ep,
- link,
- notificationCallback,
- resume
- ).also { println("Single episode finished with return code: $it") }
- }
- }
- if (connectionResult != null && connectionResult > 0) { // SUCCESS
- removeKey(KEY_RESUME_PACKAGES, id.toString())
- break
- } else if (index == item.links.lastIndex) {
- downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
- }
- }
- } catch (e: Exception) {
- logError(e)
- } finally {
- currentDownloads.remove(id)
- // Because otherwise notifications will not get caught by the workmanager
- downloadCheckUsingWorker(context)
- }
+ createNotification(
+ context,
+ source,
+ link.name,
+ ep,
+ meta.type,
+ meta.bytesDownloaded,
+ meta.bytesTotal,
+ notificationCallback,
+ meta.hlsProgress,
+ meta.hlsTotal,
+ meta.bytesPerSecond
+ )
}
}
- return null
- }
- fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
- val res = getDownloadFileInfo(context, id)
- if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
- return res
- }
-
- private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? {
try {
- val info =
- context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
- val base = basePathToFile(context, info.basePath)
+ if (link.isM3u8 || normalSafeApiCall { URL(link.url).path.endsWith(".m3u8") } == true) {
+ val startIndex = if (tryResume) {
+ context.getKey(
+ KEY_DOWNLOAD_INFO,
+ ep.id.toString(),
+ null
+ )?.extraInfo?.toIntOrNull()
+ } else null
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) {
- val cr = context.contentResolver ?: return null
- val fileUri =
- cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName)
- ?: return null
- val fileLength = cr.getFileLength(fileUri) ?: return null
- if (fileLength == 0L) return null
- return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri)
+ return downloadHLS(
+ context,
+ link,
+ name,
+ folder ?: "",
+ ep.id,
+ startIndex,
+ callback, parallelConnections = maxConcurrentConnections
+ )
} else {
+ return downloadThing(
+ context,
+ link,
+ name,
+ folder ?: "",
+ "mp4",
+ tryResume,
+ ep.id,
+ callback, parallelConnections = maxConcurrentConnections
+ )
+ }
+ } catch (t: Throwable) {
+ return DOWNLOAD_FAILED
+ } finally {
+ extractorJob.cancel()
+ }
+ }
- val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName)
+ suspend fun downloadCheck(
+ context: Context, notificationCallback: (Int, Notification) -> Unit,
+ ) {
+ if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return
-// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName)
-// val dFile = File(normalPath)
+ val pkg = downloadQueue.removeFirst()
+ val item = pkg.item
+ val id = item.ep.id
+ if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT
+ downloadEvent.invoke(id to DownloadActionType.Resume)
+ return
+ }
- if (file?.exists() != true) return null
+ currentDownloads.add(id)
+ try {
+ for (index in (pkg.linkIndex ?: 0) until item.links.size) {
+ val link = item.links[index]
+ val resume = pkg.linkIndex == index
- return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri)
+ setKey(
+ KEY_RESUME_PACKAGES,
+ id.toString(),
+ DownloadResumePackage(item, index)
+ )
+
+ var connectionResult =
+ downloadSingleEpisode(
+ context,
+ item.source,
+ item.folder,
+ item.ep,
+ link,
+ notificationCallback,
+ resume
+ )
+
+ if (connectionResult.retrySame) {
+ connectionResult = downloadSingleEpisode(
+ context,
+ item.source,
+ item.folder,
+ item.ep,
+ link,
+ notificationCallback,
+ true
+ )
+ }
+
+ if (connectionResult.success) { // SUCCESS
+ removeKey(KEY_RESUME_PACKAGES, id.toString())
+ break
+ } else if (!connectionResult.tryNext || index >= item.links.lastIndex) {
+ downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
+ break
+ }
}
} catch (e: Exception) {
logError(e)
- return null
+ } finally {
+ currentDownloads.remove(id)
+ // Because otherwise notifications will not get caught by the work manager
+ downloadCheckUsingWorker(context)
}
+
+ // return id
}
- /**
- * Gets the true download size as Scoped Storage sometimes wrongly returns 0.
- * */
- fun UniFile.size(): Long {
- val len = length()
- return if (len <= 1) {
- val inputStream = this.openInputStream()
- return inputStream.available().toLong().also { inputStream.closeQuietly() }
- } else {
- len
+ /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
+ val res = getDownloadFileInfo(context, id)
+ if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
+ return res
+ }
+ */
+ fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? =
+ getDownloadFileInfo(context, id, removeKeys = true)
+
+ private fun DownloadedFileInfo.toFile(context: Context): SafeFile? {
+ return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath)
+ ?.findFile(displayName)
+ }
+
+ private fun getDownloadFileInfo(
+ context: Context,
+ id: Int,
+ removeKeys: Boolean = false
+ ): DownloadedFileInfoResult? {
+ try {
+ val info =
+ context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
+ val file = info.toFile(context)
+
+ // only delete the key if the file is not found
+ if (file == null || !file.existsOrThrow()) {
+ //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD
+ return null
+ }
+
+ return DownloadedFileInfoResult(
+ file.lengthOrThrow(),
+ info.totalBytes,
+ file.uriOrThrow()
+ )
+ } catch (e: Exception) {
+ logError(e)
+ return null
}
}
@@ -1555,67 +1708,53 @@ object VideoDownloadManager {
return success
}
+ /*private fun deleteFile(
+ context: Context,
+ folder: SafeFile?,
+ relativePath: String,
+ displayName: String
+ ): Boolean {
+ val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false
+ if (file.exists() == false) return true
+ return try {
+ file.delete()
+ } catch (e: Exception) {
+ logError(e)
+ (context.contentResolver?.delete(file.uri() ?: return true, null, null)
+ ?: return false) > 0
+ }
+ }*/
+
private fun deleteFile(context: Context, id: Int): Boolean {
val info =
context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false
- downloadEvent.invoke(Pair(id, DownloadActionType.Stop))
+ downloadEvent.invoke(id to DownloadActionType.Stop)
downloadProgressEvent.invoke(Triple(id, 0, 0))
- downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped))
+ downloadStatusEvent.invoke(id to DownloadType.IsStopped)
downloadDeleteEvent.invoke(id)
- val base = basePathToFile(context, info.basePath)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) {
- val cr = context.contentResolver ?: return false
- val fileUri =
- cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName)
- ?: return true // FILE NOT FOUND, ALREADY DELETED
-
- return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0
- } else {
- val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName)
-// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName)
-// val dFile = File(normalPath)
- if (file?.exists() != true) return true
- return try {
- file.delete()
- } catch (e: Exception) {
- logError(e)
- val cr = context.contentResolver
- cr.delete(file.uri, null, null) > 0
- }
- }
+ return info.toFile(context)?.delete() ?: false
}
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {
return context.getKey(KEY_RESUME_PACKAGES, id.toString())
}
- fun downloadFromResume(
+ suspend fun downloadFromResume(
context: Context,
pkg: DownloadResumePackage,
notificationCallback: (Int, Notification) -> Unit,
setKey: Boolean = true
) {
- if (!currentDownloads.any { it == pkg.item.ep.id }) {
-// if (currentDownloads.size == maxConcurrentDownloads) {
-// main {
-//// showToast( // can be replaced with regular Toast
-//// context,
-//// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${
-//// context.getString(
-//// R.string.queued
-//// )
-//// }",
-//// Toast.LENGTH_SHORT
-//// )
-// }
-// }
+ if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) {
downloadQueue.addLast(pkg)
downloadCheck(context, notificationCallback)
if (setKey) saveQueue()
+ //ret
} else {
- downloadEvent.invoke(
- Pair(pkg.item.ep.id, DownloadActionType.Resume)
+ downloadEvent(
+ pkg.item.ep.id to DownloadActionType.Resume
)
+ //null
}
}
@@ -1641,7 +1780,7 @@ object VideoDownloadManager {
return false
}*/
- fun downloadEpisode(
+ suspend fun downloadEpisode(
context: Context?,
source: String?,
folder: String?,
@@ -1650,13 +1789,12 @@ object VideoDownloadManager {
notificationCallback: (Int, Notification) -> Unit,
) {
if (context == null) return
- if (links.isNotEmpty()) {
- downloadFromResume(
- context,
- DownloadResumePackage(DownloadItem(source, folder, ep, links), null),
- notificationCallback
- )
- }
+ if (links.isEmpty()) return
+ downloadFromResume(
+ context,
+ DownloadResumePackage(DownloadItem(source, folder, ep, links), null),
+ notificationCallback
+ )
}
/** Worker stuff */
diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml
new file mode 100644
index 00000000..100cb1fc
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_stop_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/fragment_home_head.xml b/app/src/main/res/layout/fragment_home_head.xml
index e13b96a8..90386ccf 100644
--- a/app/src/main/res/layout/fragment_home_head.xml
+++ b/app/src/main/res/layout/fragment_home_head.xml
@@ -61,7 +61,7 @@
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="-50dp"
- android:background="?android:attr/selectableItemBackgroundBorderless"
+ android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/account"
android:nextFocusLeft="@id/home_search"
android:padding="10dp"
@@ -288,4 +288,4 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/home_result_grid" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml
index ee3477b0..87de7186 100644
--- a/app/src/main/res/layout/fragment_result.xml
+++ b/app/src/main/res/layout/fragment_result.xml
@@ -476,7 +476,7 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="2"
tools:listitem="@layout/cast_item"
- tools:visibility="gone" />
+ tools:visibility="visible" />
-->
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-ajp/strings.xml b/app/src/main/res/values-ajp/strings.xml
new file mode 100644
index 00000000..42eba3cc
--- /dev/null
+++ b/app/src/main/res/values-ajp/strings.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/app/src/main/res/values-am/strings.xml b/app/src/main/res/values-am/strings.xml
new file mode 100644
index 00000000..5a799eb4
--- /dev/null
+++ b/app/src/main/res/values-am/strings.xml
@@ -0,0 +1,5 @@
+
+
+ %s ክፍል %d
+ ተዋናዮች: %s
+
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 9b440e6f..0c11f7e9 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -584,4 +584,5 @@
@string/default_subtitles
لا توجد اضافة في المستودع
المستودع لم يتم العثور عليه، تحقق من العنوان اوجرب شبكة افتراضية خاصة(vpn)
+ لقد صوتت بالفعل
diff --git a/app/src/main/res/values-ars/strings.xml b/app/src/main/res/values-ars/strings.xml
index 42eba3cc..ea8aa05c 100644
--- a/app/src/main/res/values-ars/strings.xml
+++ b/app/src/main/res/values-ars/strings.xml
@@ -1,2 +1,203 @@
-
+
+ لافتة
+ تغيير مزود
+ جارى التحميل
+ بث%s
+ ملء
+ تخطي التحميل
+ تحميل…
+ ترجمات
+ إعادة محاولة الاتصال …
+ %sييبي%d
+ الحلقة%dسيتم نشرها في
+ %dي%dس%dد
+ %dس%dد
+ %dد
+ لافتة الحلقة
+ اللافتة الاساسية
+ اذهب للخالف
+ معاينة الخلفية
+ سرعة(%.2fx)
+ فتح مع كلاودستريم
+ الصفحة الاساسية
+ ...%sابحث
+ لايوجد بيانات
+ المزيد من الخيارات
+ فتح في المتصفح
+ المتصفح
+ شاهد الفلم
+ دفق التورنت
+ بدأ التنزيل
+ عشوائي قادم
+ تشغيل المقطع الدعائي
+ الأنواع
+ توقف التنزيل
+ خطط للمشاهدة
+ لا يوجد
+ إعادة المشاهدة
+ !تم العثور على تحديث جديد
+\n%s->%s
+ %.1f:قدر
+ %dاقل
+ كلاودستريم
+ بحث
+ التحميلات
+ اعدادات
+ ...بحث
+ الحلقة القادمة
+ شارك
+ مشاهدة
+ في التوقف
+ مكتمل
+ توقف
+ تشغيل البث المباشر
+ مصادر
+ تشغيل الحلقة
+ تم إلغاء التنزيل
+ تم التنزيل
+ تنززل
+ تحميل
+ عُد
+ التحميل فشل
+ استخدم سطوع النظام في مشغل التطبيق بدلاً من التراكب الداكن
+ تم تحميل ملف النسخ الاحتياطي
+ البحث المتقدم
+ إزالة الحدود السوداء
+ ترجمات
+ يضيف خيار السرعة في المشغل
+ انقر نقرا مزدوجا للبحث
+ انقر نقرًا مزدوجًا للإيقاف المؤقت
+ اللاعب يبحث عن المبلغ (بالثواني)
+ اسحب من جانب إلى آخر للتحكم بموقعك في الفيديو
+ ابدأ الحلقة التالية عندما تنتهي الحلقة الحالية
+ استخدام سطوع النظام
+ تحديث مراقبة التقدم
+ قم بمزامنة تقدم الحلقة الحالية تلقائيًا
+ اسحب لتغيير الإعدادات
+ استعادة البيانات من النسخة الاحتياطية
+ فشل في استعادة البيانات من الملف %s
+ انقر مرتين على الجانب الأيمن أو الأيسر للبحث للأمام أو للخلف
+ البيانات المخزنة
+ اضغط مرتين في المنتصف للتوقف مؤقتًا
+ أذونات التخزين مفقودة. حاول مرة اخرى.
+ حدث خطأ أثناء النسخ الاحتياطي %s
+ بحث
+ مكتبة
+ معلومات
+ التحديثات والنسخ الاحتياطي
+ يعطيك نتائج البحث مفصولة حسب المزود
+ يرسل فقط البيانات عن الأعطال
+ عرض المقطورات
+ عرض الملصقات من كيتسو
+ حسابات
+ لا يرسل أي بيانات
+ عرض حلقة حشو للأنمي
+ إخفاء جودة الفيديو المحددة في نتائج البحث
+ تحديثات البرنامج المساعد التلقائي
+ البحث تلقائيًا عن التحديثات الجديدة بعد بدء تشغيل التطبيق.
+ التحديث إلى الإصداراالمسبق
+ تنزيل المكونات الإضافية تلقائيًا
+ إعادة عملية الإعداد
+ ابحث عن تحديثات الإصدار التجريبي بدلاً من الإصدارات الكاملة فقط
+ حدد الوضع لتصفية تنزيل المكونات الإضافية
+ قم تلقائيًا بتثبيت جميع المكونات الإضافية التي لم يتم تثبيتها بعد من المستودعات المضافة.
+ إعدادات ترجمات كرومكاست
+ وضع إيجينجرافي
+ انتقد للبحث
+ نسخ إحتياطي للبيانات
+ إظهار تحديثات التطبيق
+ إعدادات ترجمات المشغل
+ ترجمات كرومكاست
+ قم بالتمرير لأعلى أو لأسفل على الجانب الأيسر أو الأيمن لتغيير السطوع أو مستوى الصوت
+ التشغيل التلقائي للحلقة القادمة
+ تطبيق رواية خفيفة من نفس المطورين
+ أعط بينيني للمطورين
+ جيتهب
+ تطبيق انيمي من نفس المطورين
+ لغة التطبيق
+ انضم إلى الديسكورد
+ بنيني معطا
+ بعض الهواتف لا تدعم مثبت الحزمة الجديد. جرب الخيار القديم إذا لم يتم تثبيت التحديثات.
+ مثبت تتبيق
+ اجتاز
+ الحلقات
+ موسم
+ تم نسخ الرابط إلى الحافظة
+ مسح
+ وقف
+ جارٍ تنزيل تحديث التطبيق…
+ إعادة التعيين إلى القيمة العادية
+ س
+ %d%s
+ لا يتمتع هذا المزود بدعم كرومكاست
+ لم يتم العثور على أي روابط
+ تشغيل الحلقة
+ عذرًا، تعطل التطبيق. سيتم إرسال تقرير خطأ مجهول إلى المطورين
+ %s%d%s
+ لا يوجد موسم
+ حلقة
+ %d-%d
+ يي
+ امسح التاريخ
+ جارٍ تثبيت تحديث التطبيق…
+ بدأ
+ لم يتم العثور على أي حلقات
+ إظهار تخطي النوافذ المنبثقة للفتح/الإنهاء
+ الكثير من النص. غير قادر على الحفظ في الحافظة.
+ وضع علامة كما شاهدت
+ إزالة من شاهد
+ حذف ملف
+ فشل
+ اكتمل
+ -30
+ +30
+ تاريخ
+ هل أنت متأكد أنك تريد الخروج؟
+ نعم
+ لا
+ تعذر تثبيت الإصدار الجديد من التطبيق
+ إرث
+ منزل المجموعة
+ التقييم (من الأقل إلى الأعلى)
+ تم التحديث (من الجديد إلى القديم)
+ تم التحديث (القديم إلى الجديد)
+ أبجديًا (من الألف إلى الياء)
+ مكتبتك فارغة :(
+\nقم بتسجيل الدخول إلى حساب المكتبة أو قم بإضافة العروض إلى مكتبتك المحلية.
+ !تم العثور على ملف الوضع الآمن
+\n.عدم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف
+ ارجع
+ تحديث العروض المشتركة
+ الوضع العادي
+ حرر
+ ملفات تعريفية
+ مساعدة
+ .هنا يمكنك تغيير كيفية ترتيب المصادر. إذا كان للفيديو أولوية أعلى، فسيظهر في مكان أعلى في تحديد المصدر. مجموع أولوية المصدر وأولوية الجودة هو أولوية الفيديو
+\n
+\nالمصدر أ: 3
+\nالجودة ب: 7
+\nستكون أولوية الفيديو المدمجة .10
+\n
+\n!ملاحظة: إذا كان المجموع 10 أو أكثر، فسيقوم اللاعب تلقائيًا بتخطي التحميل عند تحميل هذا الرابط
+ لقد صوت بالفعل
+ أبجديًا (ياء إلى ألف)
+ ترتيب حسب
+ مشترك
+ سيتم تحديث التطبيق عند الخروج
+ رتب
+ التقييم (من الأعلى إلى الأقل)
+ حدد المكتبة
+ افتع مع
+ .هذه القائمة فارغة. حاول التبديل إلى واحد آخر
+ %sتم الاشتراك في
+ %sتم إلغاء الاشتراك من
+ !%dتم إصدار الحلقة
+ خلفية الملف الشخصي
+ %dملف التعريف
+ واي فاي
+ بيانات الجوال
+ استخدم
+ %sتعذر إنشاء واجهة المستخدم بشكل صحيح، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور
+ الصفات
+
diff --git a/app/src/main/res/values-bp/strings.xml b/app/src/main/res/values-bp/strings.xml
index 38424e56..b70eec12 100644
--- a/app/src/main/res/values-bp/strings.xml
+++ b/app/src/main/res/values-bp/strings.xml
@@ -10,7 +10,7 @@
%dm
Poster
- @string/result_poster_img_des
+ Pôster
Episode Poster
Main Poster
Next Random
@@ -66,7 +66,7 @@
Erro Carregando Links
Armazenamento Interno
Dub
- Leg
+ Sub
Deletar Arquivo
Assistir Arquivo
Retomar Download
@@ -156,7 +156,7 @@
Não enviar nenhum dado
Mostrar episódios de Filler em anime
Mostrar trailers
- Mostrar posters do kitsu
+ Mostrar posters do Kitsu
Esconder qualidades de vídeo selecionadas nos resultados da Pesquisa
Atualizações de plugin automáticas
Mostrar atualizações do app
@@ -183,7 +183,7 @@
S
E
Nenhum Episódio encontrado
- Deletar Arquivo
+ Apagar Arquivo
Deletar
Pausar
Retomar
@@ -257,7 +257,7 @@
Não mostrar de novo
Pular essa Atualização
Atualizar
- Qualidade preferida
+ Qualidade preferida de reprodução (Wi-fi)
Máximo de caracteres do título de vídeos
Resolução do player de vídeo
Tamanho do buffer do vídeo
@@ -410,15 +410,19 @@
Transferido %d %s com sucesso
Tudo %s já transferido
Transferência em batch
- plugin
- plugins
+ Plugin
+ Plugins
Isto irá apagar todos os repositórios de plugins
Apagar repositório
Transferir lista de sites a usar
Transferido: %d
Desativado: %d
Não transferido: %d
- Adicionar um repositório para instalar extensões de sites
+ CloudStream não tem fontes instaladas por padrão. Você precisa instalar um site de repositórios.
+\n
+\nPor causa das limitações do DMCA (Digital Millennium Copyright Act ) feito em nome de Sky UK Limited 🤮nós não podemos adicionar site de repositórios no app.
+\n
+\nEntre no nosso Discord ou pesquise online.
Ver repositórios da comunidade
Lista pública
Todas as legendas em maiúsculas
@@ -428,4 +432,122 @@
Começa o próximo episódio quando o atual termina
Ativar NSFW em fornecedores compatíveis
Fornecedores
+ Reverter
+ Ações
+ votou com sucesso
+ Baixando atualização do aplicativo…
+ Referencias
+ Atualizações do App
+ Tocar com CloudStream
+ Automaticamente instale todos os plugins não instalados dos repositórios adicionados.
+ Reproduzir Trailer
+ Navegador
+ Copia de Segurança
+ A Barra de Progresso pode ser usada quando o player estiver oculto
+ Inscrever
+ Essa lista está vazia. Tente mudar para outra.
+ Reproduzir Livestream
+ Log do Teste
+ Baixa plugins automaticamente
+ Selecione o modo para filtrar os plugins baixados
+ Teste falhou
+ A Barra de Progresso pode ser usada quando o player estiver visível
+ Organizar
+ Sim
+ Você tem certeza que deseja sair\?
+ Instalando atualização do aplicativo…
+ Editar
+ Perfis
+ Exibindo Player - procure na Barra de Progresso
+ Remover dos assistidos
+ Extensões
+ Alfabética(A => Z)
+ Abrir com
+ Selecionar Biblioteca
+ Passou
+ Sua biblioteca está vazia :0
+\nEntre numa conta de biblioteca ou adicione Midias para sua biblioteca local.
+ Qualidade preferida de reprodução (Dados Móveis)
+ Legado
+ Biblioteca
+ Não
+ Trilhas Sonoras
+ Votação (Baixa para Alta)
+ Atualização iniciada
+ Conteúdo +18
+ Ajuda
+ Processo de configuração de Redo
+ Não pudemos instalar a nova versão do App
+ instalador de pacotes
+ Organizar por
+ Votação (Alta para Baixa)
+ Alfabética(Z => A)
+ Qualidade
+ Perfil de plano de fundo
+ Aqui você pode alterar como as fontes são ordenadas. Se um vídeo tiver uma prioridade mais alta, aparecerá mais alto na seleção da fonte. A soma da prioridade da fonte e da prioridade da qualidade é a prioridade do vídeo.
+\n
+\nFonte A: 3
+\nQualidade B: 7
+\nTerá uma prioridade de vídeo combinada de 10.
+\n
+\nNOTA: Se a soma for 10 ou mais, o Player pulará automaticamente o carregamento quando o link for carregado!
+ Arquivo de modo de segurança encontrado!
+\nNão carregar nenhuma extensão na inicialização até que o arquivo seja removido.
+ Inscrevel em %d
+ Episódio %d Lançado
+ Selecionar padrão
+ Disinscrevel em %d
+ Alguns aparelhos não possuem suporte para este pacote de instalação. Tente a opção legada se a atualização não instalar.
+ Dados móveis
+ Perfil %d
+ Atualizando shows inscritos
+ Player oculto - Procure na barra de progresso
+ Conteúdo +18
+ Reiniciar
+ Parar
+ Marcar como assistido
+ Aplicativo precisa ser fechado para atualizar
+ Mostrar popups pulados para abertura e finalização
+ %d-%d
+ Player interno
+ Tamanho
+ Abrindo
+ %s %d%s
+ %d plugins atualizados
+ Todos as extensões serão desligadas para ajuda se talvez estejam causando algum bug.
+ Aplicativo não encontrado
+ Recapitular
+ Todas as linguagens
+ Pula %s
+ Mistura terminada
+ Modo seguro ligado
+ Ranquear: %s
+ Linguagem
+ Lista de reprodução HLS
+ Terminando
+ %d %s
+ Adicionado em (antigo para novo)
+ Introdução
+ plug-ins não foram encontrados no repositório
+ Repositório não encontrado, verifique o URL e tente usa uma VPN
+ Descrição
+ Versão
+ Autores
+ Instale a extensão primeiro
+ Créditos
+ Historico
+ Limpar historico
+ Tem Muito texto. Não é possível salvar no clipboard.
+ Player de vídeo preferido
+ Começar
+ Suportado
+ Status
+ MPV
+ Abrindo mistura
+ VLC
+ Aplicar quando reiniciar
+ Visualização info de crash
+ Faixas de áudio
+ Adicionado em (novo para antigo)
+ Faixas de video
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index f304199e..46bd860d 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -576,4 +576,5 @@
V repozitáři nebyly nalezeny žádné doplňky
Repozitář nenalezen, zkontrolujte adresu URL a zkuste použít VPN
@string/default_subtitles
+ Již jste hlasovali
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 45a6a66c..6739465a 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -92,7 +92,7 @@
Abbrechen
Kopieren
Schließen
- Löschen
+ Leeren
Speichern
Player-Geschwindigkeit
Untertiteleinstellungen
@@ -390,7 +390,7 @@
Einrichtung überspringen
Aussehen der App passend zu dem des Geräts ändern
Absturzmeldung
- Was möchtest du anschauen\?
+ Was möchten Sie sehen\?
Fertig
Erweiterungen
Repository hinzufügen
@@ -546,4 +546,10 @@
\nWerden eine kombinierte Videopriorität von 10 haben.
\n
\nHINWEIS: Wenn die Summe 10 oder mehr beträgt, überspringt der Player automatisch das Laden, wenn der Link geladen wird!
+ Filtermodus für Plugin-Downloads auswählen
+ Es wurde bereits abgestimmt
+ Keine Plugins im Repository gefunden
+ Repository nicht gefunden, überprüfe die URL und probiere eine VPN
+ Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein schwerwiegender Fehler und sollte sofort gemeldet werden. %s
+ Deaktivieren
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 42e07c90..8e9f9c2c 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -552,4 +552,5 @@
@string/default_subtitles
No se encontraron complementos en el repositorio
Repositorio no encontrado, comprueba la URL y prueba la VPN
+ Ya has votado
diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml
new file mode 100644
index 00000000..42eba3cc
--- /dev/null
+++ b/app/src/main/res/values-fil/strings.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 36c1cf1f..208e6140 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -530,4 +530,26 @@
Joueur représenté - Montant de la recherche
Joueur caché - Montant de la recherche
Impossible d\'accéder à GitHub. Activation du proxy jsDelivr…
+ Vous avez déjà voté
+ Désactivé
+ Ici, vous pouvez modifier la façon dont les sources sont ordonnées. Si une vidéo a une priorité plus élevée, elle apparaîtra plus haut dans la sélection de la source. La somme de la priorité source et de la priorité qualité est la priorité vidéo.
+\n
+\nSource A : 3
+\nQualité B : 7
+\nLa priorité vidéo combinée sera de 10.
+\n
+\nREMARQUE : Si la somme est de 10 ou plus, le joueur sautera automatiquement le chargement lorsque ce lien est chargé !
+ Aucun plugin trouvé dans ce dossier
+ Dossier non trouvé, vérifiez l\'url et essayé un VPN
+ Données mobiles
+ Définir par défaut
+ Utiliser
+ Modifier
+ Profils
+ Aide
+ Profil %d
+ Wi-Fi
+ Qualités
+ L\'interface utilisateur n\'a pas pu être créée correctement. Il s\'agit d\'un bogue majeur qui doit être signalé immédiatement %s
+ Sélectionnez le mode pour filtrer le téléchargement des plugins
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 46407f76..05a7f0a7 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -190,7 +190,7 @@
Adatok eltárolva
Hiba a biztonsági mentés során %s
Fiókok
- Szolgáltatás szerinti keresés eredmények
+ Szolgáltató szerint elkülönítve adja meg a keresési eredményeket
Nem küld adatokat
Poszterek megjelenítése Kitsu-ról
Kiválasztott videóminőségek elrejtése keresési eredményekbe
@@ -198,7 +198,7 @@
Bővítmények automatikus letöltése
Automatikusan telepíti az összes még nem telepített bővítményt a hozzáadott tárolókból.
Alkalmazás frissítések megjelenítése
- Automatikusan keressen új frissítéseket indításkor
+ Automatikusan keressen új frissítéseket indításkor.
Frissítés az előzetes kiadásokhoz (prerelease)
Csak előzetesen kiadott frissítések (prerelease) keresése a teljes kiadások helyett
Github
@@ -232,30 +232,30 @@
Lejátszás böngészőben
Feliratok letöltése
Újracsatlakozás…
- Swipe balra vagy jobbra a videólejátszóban az idő vezérléséhez
+ Húzd balra vagy jobbra a videólejátszóban az idő vezérléséhez
Csúsztassa ujját a beállítások módosításához
- Csúsztassa az újját bal vagy jobb oldalon a fényerő vagy hangerő megváltoztatásához
+ Csúsztassa felfelé vagy lefelé a bal vagy jobb oldalon a fényerő vagy a hangerő megváltoztatásához
Biztonsági mentés
0 Banán a fejlesztőknek
- Swipe to seek
+ Húzás a kereséshez
Következő epizód automatikus lejátszása
Következő epizód lejátszása amikor az aktuális epizód véget ér
- Dupla koppintás to seek
+ Dupla koppintás a kereséshez
Dupla koppintás a szüneteltetéshez
- Player seek amount
+ Lejátszó keresési értéke (Másodpercben)
Koppintson kétszer a jobb vagy bal oldalra az előre vagy hátra ugráshoz
- Koppintson középre a szüneteltetéshez
+ Koppintson kétszer középen a szüneteltetéshez
Rendszer fényerejének használata
Rendszer fényerejének használata az appban a sötét átfedés helyett
Előrehaladás frissítése
Automatikusan szinkronizálja az aktuális epizód előrehaladását
- Adatok visszaállítása a biztonsági mentésből
+ Adatok visszaállítása biztonsági mentésből
Biztonsági mentés betöltve
Információ
Folytatás
-30
Frissítés elkezdődött
- Nem sikerült visszaállítani az adatok a fájlból %s
+ Nem sikerült visszaállítani az adatokat a %s fájlból
Tárolási engedélyek hiányoznak. Kérjük próbálja újra.
Csak összeomlásokról küld adatokat
APK Telepítő
@@ -280,7 +280,7 @@
DNS HTTPS-en keresztül
Böngésző
Android TV
- kézmozdulatok
+ Kézmozdulatok
frissítés kihagyása
Alkalmazásfrissítések
Szolgáltatók
@@ -496,4 +496,18 @@
HQ
%d letöltve
Start
+ Emulátor elrendezés
+ Nyomkövetés hozzáadása
+ Telefon elrendezés
+ Poszter cím helye
+ Tegye a címet a poszter alá
+ Az átugrás mértéke, amikor a lejátszó el van rejtve
+ Jogi nyilatkozat
+ Lejátszó megjelenítve - Ugrási Érték
+ Lejátszó elrejtve - Ugrási Érték
+ Klónozott oldal
+ Egy meglévő webhely klónjának hozzáadása, más URL-címmel
+ TV elrendezés
+ Automatikus
+ Az átugrás mértéke, amikor a lejátszó látható
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 2bd86090..d514bcc4 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -574,4 +574,6 @@
Pilih mode untuk memfilter unduhan plugin
Tidak ada plugin yang ditemukan di repositori
Repositori tidak ditemukan, periksa URL dan coba VPN
+ Kamu sudah voting
+ @string/default_subtitles
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index dddc57c4..0c34e89a 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -574,4 +574,5 @@
Seleziona la modalità per filtrare il download dei plugin
@string/default_subtitles
Disabilita
+ Hai già votato
diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml
new file mode 100644
index 00000000..0cb44373
--- /dev/null
+++ b/app/src/main/res/values-my/strings.xml
@@ -0,0 +1,556 @@
+
+
+ သရုပ်ဆောင်များ: %s
+ %dရက် %ddနာရီ %ddမိနစ်
+ %ddနာရီ %ddမိနစ်
+ %ddမိနစ်
+ ပိုစတာ
+ အပိုင်း ပိုစတာ
+ မိန်း ပိုစတာ
+ နောက် ကျပန်း
+ နောက်သို့
+ နောက်ခံပုံရိပ်ကို အကြိုကြည့်ရန်
+ အဆင့်: %.1f
+ အပ်ဒိတ်အသစ်!
+\n%s -> %s
+ စစ်ထုတ်မှု
+ %d မိနစ်
+ CloudStream
+ CloudStream ဖြင့်ကြည့်ရန်
+ ပင်မ
+ ရှာရန်
+ ရှာရန်…
+ ရှာရန် %s…
+ အချက်အလက်မရှိပါ
+ အခြားရွေးစရာများ
+ နောက်အပိုင်း
+ ကဏ္ဍများ
+ မျှဝေမည်
+ ဘရောက်ဇာတွင်ဖွင့်ရန်
+ ဘရောက်ဇာ
+ မစောင့်တော့ပါ
+ ကြည့်နေသည်
+ ကြည့်ပြီး
+ ကြည့်ခြင်းရပ်ထားသော
+ ဘာမျှ
+ လင့်များချိတ်ဆက်ရာတွင်အချို့အယွင်း
+ ဖုန်း သိုလှောင်ရုံ
+ စာမှတ်များ စစ်ထုတ်မှု
+ စာမှတ်များ
+ ဖယ်ရှားရန်
+ ကြည့်ရှုမှုအခြေအနေသတ်မှတ်ခြင်း
+ မိတ္တူကူးရန်
+ ပိတ်ရန်
+ ရှင်းလင်းရန်
+ သိမ်းဆည်းရန်
+ ကြည့်ရှုမှုအရှိန်
+ နောက်ခံ အရောင်
+ ဝင်းဒိုး အရောင်
+ အစွန်းနားပုံစံ
+ စာတန်းထိုး အမြင့်
+ ဖောင့်
+ ဖောင့် အရွယ်အစား
+ အမျိုးအစားများအသုံးပြု၍ရှာရန်
+ %d အက်ပ်ဖန်တီးသူတွေကိုကျေးဇူးတင်ကြောင်းပို့မည်
+ အလိုအလျောက် ဘာသာစကားရွေးချယ်ခြင်း
+ ဒေါင်းလုဒ် လုပ်ထားသော ဘာသာစကားများ
+ မူရင်းပုံစံအတိုင်းပြန်ထားရန်ဖိထားပါ
+ ဆက်လက်ကြည့်ရှုမည်
+ ဖယ်ရှားရန်
+ ပိုမို၍
+ @string/home_play
+ ဒီဟာကတောရပ်တစ်ခုပါ ဗီပီအန်တစ်ခုသုံးဖို့အကြံပြုပါတယ်
+ ဖော်ပြချက်
+ ဇာတ်လမ်းသွား မတွေ့ပါ
+ ဖော်ပြချက် မရိှပါ
+ Logcat ပြရန် 🐈
+ Log
+ ရုပ်ပုံထပ်
+ ကြည့်ရှုမှု စခရင်အရွယ်အစားချိန်ညိှမှု
+ စာတန်းထိုးများ
+ ကြည့်ရှုမှုစာတန်းထိုးပြုပြင်စရာများ
+ Eigengravy လုပ်ဆောင်မှု
+ ရစ်ရန်ဘယ်ညာဆွဲပါ
+ သင်ရောက်နေတဲ့နေရာပြောင်းရန်ဘယ်ညာဆွဲပါ
+ ပြုပြင်စရာရိှပါက ပွတ်ဆွဲပါ
+ နောက်အပိုင်းကို အလိုအလျောက် ဖွင့်ပါ
+ ကျော်ရန်နှစ်ချက်နှိပ်ပါ
+ ရပ်ရန်နှစ်ချက်နှိပ်ပါ
+ ကျော်လိုသောပမာဏ (စက္ကန့်များ)
+ ရှေ့သို့ကျော်ရန် သို့ နောက်သို့ရစ်ရန် ဘယ် သို့ ညာ ပေါ်မှာနှစ်ချက်နှိပ်ပါ
+ ရပ်ရန် အလယ်တွင်နှစ်ချက်နှိပ်ပါ
+ ဖုန်းအလင်းအမှောင်အတိုင်းသုံးမည်
+ အက်ပ်ကြည့်ရှုမှုထဲမှာ ဖုန်းအလင်းအမှောင်အတိုင်းသုံးမည်
+ ကြည့်ရှုမှုတိုးတက်ခြင်းကိုအပ်ဒိတ်လုပ်ပါ
+ အရန်သိမ်းဖိုင်မှပြန်သိုလှောင်မည်
+ အရန်သိမ်းမည်
+ အရန်သိမ်းဖိုင်များရယူပြီး
+ အရန်သိမ်းဖိုင်မှပြန်သိုလှောငးခြင်မအောင်မြင်ပါ %s
+ သိုလှောင်ပြီး
+ သိုလှောင်ရုံခွင့်ပြုချက်မရိှပါ။ပြန်ကြိုးစားပါ။
+ အရန်သိမ်းနေစဥ်အချို့အယွင်း %s
+ လိုက်ဘရီ
+ အပ်ဒိတ်များနှင့်အရန်သိမ်းဆည်းမှု
+ နက်နက်ရှိုင်းရှိုင်းရှာခြင်း
+ သင့်ကိုဝန်ဆောင်မှုပေးသူအလိုက်ရှာဖွေမှုရလဒ်များပေးမည်
+ ချို့ယွင်းမှုအကြီးစားဖြစ်မှသာဒေတာများပေးပို့ပါ
+ anime များအတွက်ဖြည့်စွက်အပိုင်းကိုပြရန်
+ ထွေလာများကိုပြရန်
+ Kitsu မှ ပိုစတာများကိုပြရန်
+ အလိုအလျောက် ဖြည့်စွက်လုပ်ဆောင်ချက်များကိုအပ်ဒိတ်တင်ခြင်း
+ အပိုလုပ်ဆောင်ချက်များကိုစစ်ထုတ်ရန်မုဒ်ရွေးပါ
+ အက်ပ်အပ်ဒိတ်များပြရန်
+ အစီအစဥ်ချခြင်းကိုပြန်စမည်
+ အက်ပ်ထည့်သွင်းခြင်း
+ အချို့ဖုန်းတွေက အက်ပ်ထည့်သွင်းခြင်းလုပ်ဆောင်ချက်အသစ်ကို မပံ့ပိုးပါဘူး။အကယ်၍အလုပ်မဖြစ်ပါကသမားရိုးကျနည်းလမ်းကိုအသုံးပြုပါ။
+ Github
+ ဤဝန်ဆောင်မှုပေးသူသည် Chromecast ကိုမပံ့ပိုးပါ
+ လင့်များမတွေ့ပါ
+ ကလစ်ဘုတ်သို့မိတ္တူကူးပြီး
+ အပိုင်းကြည့်မည်
+ အတွဲ
+ အတွဲမရှိပါ
+ အပိုင်း
+ အပိုင်းများ
+ %d-%d
+ ဒါကအပြီးဖျက်ခြင်းဖြစ်ပါသည် %s
+\nသင်သေချာပါသလား။
+ %dမိနစ်
+\nကျန်ရိှသည်
+ ထုတ်လွှင့်နေဆဲ
+ ထုတ်လွှင့်မှုပြီးဆုံး
+ အခြေအနေ
+ ခုနစ်
+ အဆင့်သတ်မှတ်ချက်
+ ကြာချိန်
+ ဆိုဒ်
+ အကျဥ်းချုပ်
+ နောက်အစီအစဥ်
+ စာတန်းထိုးမထည့်
+ ပုံသေ
+ @string/default_subtitles
+ ကျန်ရှိသော
+ အက်ပ်
+ ရုပ်ရှင်များ
+ ဇာတ်လမ်းတွဲများ
+ ကာတွန်းများ
+ Anime
+ ေတာရပ်များ
+ မှတ်တမ်းရုပ်ရှင်များ
+ OVA
+ အာရှ ဒရာမာများ
+ တိုက်ရိုက်ထုတ်လွှင်မှုများ
+ အပြာဗီဒီယိုများ
+ အခြား
+ ရုပ်ရှင်
+ ဇာတ်လမ်းတွဲ
+ OVA
+ တောရပ်
+ မှတ်တမ်းရုပ်ရှင်
+ အာရှ ဒရာမာ
+ တိုက်ရိုက်ထုတ်လွှင့်မှု
+ အပြာဗီဒီယို
+ ဗီဒီယို
+ ရင်းမြစ်အချို့အယွင်း
+ အဝေးထိန်းချုပ်မှုအချို့အယွင်း
+ တင်ဆက်သူ အချို့အယွင်း
+ မျှော်လင့်မထားသော အချို့အယွင်း
+ Chromecast အပိုင်း
+ Chromecast ဖန်သားပြင်
+ အက်ပ်တွင်းဖွင့်
+ ဖွင့်ရန် %s
+ ဘရောက်ဇာထဲမှာ ဖွင့်ရန်
+ လင့်ကူးယူရန်
+ အလိုအလျောက်ဒေါင်းလုဒ်
+ လင့်များကို ပြန်စစ်ရန်
+ အရည်အသွေး အမှတ်အသား
+ နောက်ခံအသံ အမှတ်အသား
+ စာတန်း အမှတ်အသား
+ ခေါင်းစဥ်
+ အပ်ဒိတ်မရှိပါ
+ အပ်ဒိတ်စစ်ရန်
+ လော့ခ်ခတ်ရန်
+ ရင်းမြစ်
+ OPကိုကျော်ရန်
+ ဒီအပ်ဒိတ်ကိုကျော်ပါ
+ အပ်ဒိတ်
+ ခေါင်းစဥ်အတွက်စာလုံးရေပြည့်ခြင်း
+ ကြည့်ရှုမှု အရည်အသွေး
+ ဗီဒီယိုရှေ့ပြေးသိမ်းဆည်းမှုပမာဏ
+ ကြည့်ရှုမှုဘားမြင်တွေ့ရချိန်တွင်ပြသသောပမာဏ
+ ဝှက်ထားသောကြည့်ရှုပြီးသောပမာဏ
+ ဝှက်ထားသည့်အခါ အသုံးပြုသည့် ရှာဖွေမှုပမာဏ
+ Android TV ကဲ့သို့သော မမ်မိုရီနည်းသော စက်ပစ္စည်းများတွင် သတ်မှတ်နှုန်း အလွန်မြင့်မားပါက ပျက်စီးမှုများ ဖြစ်စေသည်။
+ DNS over HTTPS
+ raw.githubusercontent.com ပရောက်စီ
+ GitHub သို့ မရောက်ရှိနိုင်ပါ။ jsDelivr ပရောက်စီကို ဖွင့်နေသည်…
+ ကလုန်း ဆိုဒ်
+ ဆိုဒ်ကိုဖယ်ရှားရန်
+ မတူညီသော URL တစ်ခုဖြင့် ရှိပြီးသား ဝဘ်ဆိုက်တစ်ခု၏ ပုံတူတစ်ခုကို ထည့်ပါ
+ ဒေါင်းလုဒ်လမ်းကြောင်း
+ NGINX ဆာဗာ URL
+ Dubbed/Subbed Anime ကိုပြသပါ
+ မျက်နှာပြင်နှင့် အံကိုက်
+ ဆန့်သည်
+ ချဲ့သည်
+ ရှင်းလင်းချက်
+ ISP ရှောင်လွှဲမှုများ
+ လင့်များ
+ အက်ပ်အပ်ဒိတ်များ
+ Extensions
+ ဆောင်ရွက်ချက်များ
+ Cache
+ Android တီဗွီ
+ စာတန်းထိုးများ
+ ပုံသေများ
+ ပုံပန်းသဏ္ဌာန်
+ အထွေထွေ
+ ပင်မစာမျက်နှာမှာကျပန်းခလုတ်ကိုပြပါ
+ %s အပိုင်း %d
+ အပိုင်း %d ထုတ်လွှင့်ပြသမည်
+ ပိုစတာ
+ ပံ့ပိုးပေးသောဝန်ဆောင်မှုပြောင်းရန်
+ အရှိန် (%.2fx)
+ ဒေါင်းလုဒ်များ
+ ပြင်ဆင်ရန်
+ ခဏစောင့်ပါ…
+ ကြည့်ဆဲ
+ ကြည့်ရန်
+ ပြန်ကြည့်နေသည်
+ စာတန်းထိုး
+ ရုပ်ရှင်ကြည့်မည်
+ ထွေလာ ကြည့်မည်
+ လိုက်ခ် ကြည့်မည်
+ တောရပ် ကြည့်မည်
+ ရင်းမြစ်များ
+ ချိတ်ဆက်မှုပြန်ကြိုးစား…
+ နောက်သို့
+ အပိုင်း ကြည့်မည်
+ ဒေါင်းလုဒ်
+ ဒေါင်းလုဒ် လုပ်ပြီး
+ ဒေါင်းလုဒ် လုပ်နေသည်
+ ဒေါင်းလုဒ် ရပ်ထား
+ ဒေါင်းလုဒ်စတင်
+ ဒေါင်းလုဒ် မအောင်မြင်
+ ဒေါင်းလုဒ် ပယ်ဖျက်ပြီး
+ ဒေါင်းလုဒ်ပြီးစီး
+ အပ်ဒိတ်စတင်
+ တိုက်ရိုက်ကြည့်မည်
+ နောက်ခံအသံ
+ ရှာဖွေမှုရလဒ်များတွင်ရွေးချယ်ထားသောဗီဒီယိုအရည်အသွေးကိုဝှက်ထားရန်
+ စာတန်းထိုး
+ ဖိုင်ဖျက်ရန်
+ ဖိုင်ကို ဖွင့်ရန်
+ ဒေါင်းလုဒ် ဆက်လုပ်ရန်
+ ဒေါင်းလုဒ် ရပ်ရန်
+ အလိုအလျောက်အက်ပ်ချို့ယွင်းချက်ပေးပို့ခြင်းကိုပိတ်မည်
+ ပိုမို၍
+ ပ့ံပိုးပေးသောဝန်ဆောင်မှုများအသုံးပြု၍ရှာရန်
+ ဝုက်ရန်
+ ကျေးဇူးတင်ကြောင်းမပို့ရသေး
+ ကြည့်မည်
+ အချက်အလက်
+ စာတန်းထိုး ဘာသာစကား
+ ဒီမှာနေရာချခြင်းဖြင့်ဖောင့်များကိုသွင်းပါ %s
+ အတည်ပြု
+ ပယ်ဖျက်ရန်
+ စာတန်းထိုး ပြုပြင်ခြင်း
+ စာသား အရောင်
+ အနားကွပ် အရောင်
+ ဒီပံ့ပိုးမှုကောင်းမွန်စွာအလုပ်လုပ်ရန်ဗီပီအန်တစ်ခုလိုနိုင်ပါတယ်
+ အသေးစိတ်အချက်အလက်များပြမထားပါ။ဝဘ်ဆိုဒ်ပေါ်မှာမရှိလျှင်ကြည့်ရှု၍မရနိုင်ပါ။
+ ဖြည့်စွက်လုပ်ဆောင်ချက်များကို အလိုအလျောက်ဒေါင်းလုဒ်လုပ်ခြင်း
+ ရီပိုစစ်ထရီများမှမထည့်သွင်းရသေးသောဖြည့်စွက်လုပ်ဆောင်ချက်များအားလုံးကိုထည့်သွင်းပါ။
+ ပြန်ကြည့်ခြင်းကိုအသေးစား ကြည့်ရှုမှုတွင်ဆက်ပြပါ
+ အနက်ရောင်ဘောင်များကို ဖယ်ရှားရန်
+ အက်ပ်ထဲဝင်လိုက်သည့်နှင့်အက်ပ်အပ်ဒိတ်ကိုစစ်ဆေးပါ။
+ Chromecast စာတန်းထိုးများ
+ Chromecast စာတန်းထိုး ပြုပြင်ရန်
+ ကြည့်ရှုမှုပုံစံထဲမှာအရိှန်ရွေးစရာတစ်ခုထည့်ရန်
+ အသံအတိုးအကျယ်နှင့်အလင်းအမှောင်များကိုချိန်ညိှရန် ဘယ် သို့ ညာ ဘက်တွင် အပေါ်အောက်ဆွဲပါ
+ ယခုကြည့်နေသောအပိုင်းပြီးပါကနောက်အပိုင်းကိုဖွင့်ပါ
+ သင့်၏အပိုင်းကြည်ရှုမှုရောက်ရှိနေရာကိုအလိုအလျောက်သိမ်းဆည်းပါ
+ ရှာရန်
+ အကောင့်များ
+ အချက်အလက်
+ ဒေတာများမပို့ရန်
+ ကြည့်ရှုပြီးသောအချိန်ပမာဏ
+ Android TV ကဲ့သို့သော သိုလှောင်မှုနေရာနည်းပါးသော စက်ပစ္စည်းများတွင် အလွန်မြင့်မားစွာ သတ်မှတ်ပါက ပြဿနာများ ဖြစ်လာနိုင်သည်။
+ ISP ပိတ်ဆို့ခြင်းကို ကျော်လွှားရန်အတွက် အသုံးဝင်သည်
+ jsDelivr ကို အသုံးပြု၍ GitHub ပိတ်ဆို့ခြင်းကို ကျော်ဖြတ်သည်။ အပ်ဒိတ်များကို ရက်အနည်းငယ်ကြာအောင် နှောင့်နှေးစေနိုင်သည်။
+ အရန်သိမ်းထားသော
+ လက်ဟန်များ
+ ကြည့်ရှုမှုလုပ်ဆောင်ချက်များ
+ အပြင်အဆင်
+ လုပ်ဆောင်ချက်များ
+ ကျပန်းခလုတ်
+ ရှေ့ပြေးအပ်ဒိတ်များကိုထည့်သွင်းပါ
+ ပုံမှန်အပ်ဒိတ်များအစား ရှေ့ပြေးအက်ဒိတ်များကိုရှာပါ
+ တူညီသောအက်ပ်ရေးသားသူများ၏ ဝတ္ထုရှည်များဖတ်နိုင်သည့် အက်ပ်
+ တူညီသောအက်ပ်ရေးသားသူများ၏ Anime အက်ပ်
+ Discord ကိုဝင်ရန်
+ အက်ပ်ရေးသားသူများထံ ကျေးဇူးတင်စာပို့မည်
+ ပေးခဲ့သောစာအရေအတွက်
+ အက်ပ်ဘာသာစကား
+ မူလအခြေအနေများကိုပြန်ထားပါ
+ စိတ်မကောင်းပါ။အက်ပ်ရပ်တန့်သွားပါတယ်။အမည်မဖော်ထားတဲ့တင်ပြချက်ကို အက်ပ်ရေးသားသူများထံ ပို့မှာဖြစ်ပါတယ်
+ %s %d%s
+ အတွဲ
+ %d %s
+ အပိုင်း
+ အပိုင်းများမတွေ့ပါ
+ ဖိုင်ကိုဖျက်ရန်
+ ဖျက်ရန်
+ ရပ်ရန်
+ စရန်
+ မအောင်မြင်ပါ
+ ကျော်ဖြတ်ပြီး
+ ကြည့်လက်စ
+ -30
+ +30
+ အသုံးပြုပြီးသော
+ ကာတွန်း
+ Anime
+ ဒေါင်းလုဒ် အချို့အယွင်း၊သိုလှောင်ရုံခွင့်ပြုချက်တွေကိုစစ်ဆေးပါ
+ ဒေါင်းလုဒ် ကြေးမုံ
+ စာတန်းထိုးများကို ဒေါင်းလုဒ်လုပ်ရန်
+ ပိုစတာပေါ်ရှိ UI အစိတ်အပိုင်းများကို ပြောင်းပါ
+ ပြန်ညိှ
+ နောက်ထပ်မပြရန်
+ ဝိုင်ဖိုင်ဖြင့်ကြည့်စဥ်ဗီဒီယိုအရည်အသွေး
+ မိုဘိုင်းဒေတာဖြင့်ကြည့်စဥ်ဗီဒီယိုအရည်အသွေး
+ ဗီဒီယိုရှေ့ပြေးသိမ်းဆည်းမှုအကွာအဝေး
+ ဗီဒီယိုcacheအများ
+ ဗီဒီယို cache နှင့် ရုပ်ပုံ cache များကိူရှင်းလင်းရန်
+ ပံ့ပိုးပေးထားသည့် ဝန်ဆောင်မှုများပေါ်တွင် အပြာဗီဒီယို ကို ဖွင့်ပါ
+ ဝန်ဆောင်မှုပံ့ပိုးသူဘာသာစကား
+ အက်ပ်အပြင်အဆင်
+ ဦးစားပေးမီဒီယာ
+ အက်ပ် အပြင်အဆင်
+ ပိုစတာခေါင်းစဉ်တည်နေရာ
+ ခေါင်းစဉ်ကို ပိုစတာအောက်မှာ ထားပါ
+ ဖုန်းအပြင်အဆင်
+ အင်မြူလိတ်တာ အပြင်အဆင်
+ အဓိကအရောင်
+ အကြံပြုသည်
+ ဒီဗွီဒီ
+ 4K
+ ထုတ်လွှင့်ရန် လင့်ခ် နှင့်ချိတ်ပါ
+ ရည်ညွှန်းသည်
+ ရှေ့သို့
+ နောက်သို့
+ အပ်ဒိတ်လုပ်ပြီး %d ဖြည့်စွက်များ
+ ဒေါင်းလုဒ်မလုပ်ရသေး: %d
+ ဝဘ်ဘရောက်ဇာ
+ အက်ပ်မတွေ့ပါ
+ ဘာသာစကားအားလုံး
+ ကျော်ရန် %s
+ အစမှပြန်စ
+ ရောထားသောအဆုံးပိုင်း
+ ရောထားသောအစပိုင်း
+ ခရက်ဒစ်များ
+ အစ
+ သေချာသည်
+ သမားရိုးကျ
+ ထည့်သွင်းသူ
+ ထွက်ချိန်တွင် အက်ပ်ကို အပ်ဒိတ်လုပ်ပါမည်
+ CloudStream တွင် မူရင်းအတိုင်း ထည့်သွင်းထားသည့်ဆိုက်များ မရှိပါ။ ရီပိုစစ်ထရီများ မှ ဆိုဒ် များကို ထည့်သွင်းရန်လိုအပ်သည်။
+\n
+\nSky UK Limited မှ ဦးနှောက်မဲ့ DMCA ကို ဖယ်ရှားလိုက်ခြင်းကြောင့် 🤮 ကျွန်ုပ်တို့သည် ရီပိုစစ်ထရီဆိုဒ်ကို အက်ပ်တွင် ချိတ်ဆက်၍မရပါ။
+\n
+\nကျွန်ုပ်တို့၏ Discord တွင်ပါဝင်ပါ သို့မဟုတ် အွန်လိုင်းတွင်ရှာဖွေပါ။
+ အခြားသူများ၏ရီပိုစစ်ထရီများကိုရှာဖွေမည်
+ အသံများ
+ အသံဖိုင်များ
+ ဗီဒီယိုအသံဖိုင်များ
+ ပြန်စတင်ချိန်မှာအသုံးပြုပါ
+ ရပ်ရန်
+ လုံခြုံသောမုဖွင့်ရန်
+ ဖုန်းတွင်းကြည့်ရှုမှု
+ ဦးစားပေး ဗီဒီယိုဖွင့်စက်
+ ပြဿနာဖြစ်စေသည့်အရာကို သင်ရှာဖွေရာတွင် အထောက်အကူဖြစ်စေရန်အတွက် ပျက်စီးမှုတစ်ခုကြောင့် အဆက်များအားလုံးကို ပိတ်ထားသည်။
+ အဆင့်သတ်မှတ်ချက်များ: %s
+ ပျက်စီးမှုအချက်အလက်ကို ကြည့်ပါ
+ ဖော်ပြချက်
+ ဗားရှင်း
+ အခြေအနေ
+ အရွယ်အစား
+ ရေးသားသူများ
+ HLS ဖွင့်စဥ်
+ ကြည့်ရှုခဲ့သည်များ
+ အက်ပ်၏ ဗားရှင်းအသစ်ကို ထည့်သွင်း၍မရပါ
+ အစပိုင်း/အဆုံးပိုင်းအတွက် ကျော်နိုင်သော ပေါ့ပ်အပ်များကို ပြပါ
+ စာသားအလွန်များသဖြင့်ကလစ်ဘုတ်တွင် သိမ်းဆည်း၍မရပါ။
+ ပံ့ပိုးပေးသူများ
+ အပြင်အဆင်
+ အလိုအလျောက်
+ တီဗွီအပြင်အဆင်
+ သင့်စကားဝှက်
+ သင့်ယူဇာနိမ်း
+ သင့်အီးမေးလ် လိပ်စာ
+ 127.0.0.1
+ သင့်ဆိုဒ်
+ example.com
+ အရိပ်
+ ထမြောက်မှု
+ စာတန်းထိုးများ ထပ်တူပြုရန်
+ စာတန်းထိုးများ အလွန်စောနေပါက %d ms ဒီဟာကိုသုံးပါ
+ The quick brown fox jumps over the lazy dog
+ တင်ပြီး %s
+ ဖိုင်မှတင်သွင်းပြီး
+ ရုံရိုက်
+ အကြည်
+ အကြည်
+ UHD
+ HDR
+ SDR
+ Web
+ WP
+ SD
+ ကြည့်ရှုမှု
+ ပိုစတာပုံရိပ်
+ စာတန်းထိုးများမှ bloat ကိုဖယ်ရှားပါ
+ နှစ်သက်ရာ မီဒီယာဘာသာစကားဖြင့် စစ်ထုတ်ပါ
+ အပိုများ
+ ဒေတာမမှန်ပါ
+ URL မမှန်ပါ
+ အချို့အယွင်း
+ စာတန်းထိုးများမှ ပိတ်ထားသော စာတန်းများကို ဖယ်ရှားပါ
+ ထွေလာ
+ ဖြည့်စွက်များ
+ ရီပိုစစ်ထရီ ဖြည့်စွက်များအားလုံးကိုဖျက်မည်ဖြစ်သည်
+ ရီပိုစစ်ထရီ ကိုဖျက်ရန်
+ ဤရီပိုစစ်ထရီမှ ဖြည့်စွက်များအားလုံးကို ဒေါင်းလုဒ်လုပ်မှာလား\?
+ %s (ပိတ်ပြီး)
+ ထောက်ပံ့ထားသော
+ ဘာသာစကား
+ အဆက်များကိုအရင်သွင်းပါ
+ VLC
+ MPV
+ ဝဘ်ထဲတွင်ဖွင့်ရန်
+ အစပိုင်း
+ အဆုံးပိုင်း
+ ကြည့်ရှုခဲ့သည်များကိုရှင်းရန်
+ ကြည့်ပြီးသည်မှဖယ်ရှားရန်
+ သင်ထွက်ရန်သေချာပြီလား
+ မသေချာပါ
+ အက်ပ်အပ်ဒိတ်အားဒေါင်းလုဒ်လုပ်နေသည်…
+ အက်ပ်အပ်ဒိတ်အားသွင်းနေသည်…
+ အပ်ဒိတ်ဖြစ်မှု (အသစ် မှ အဟောင်း)
+ သင့်လိုက်ဘရီသည် ဗလာဖြစ်နေသည် :(
+\nအကောင့်ဝင်ပါ သို့မဟုတ် သင့်ဖုန်းလိုက်ဘရီတွင် ကြည့်စရာများထည့်ပါ။
+ သုံးရန်
+ တည်းဖြတ်ရန်
+ အရည်အသွေးများ
+ စာတန်းထိုး ကုဒ်လုပ်ခြင်း
+ ပံ့ပိုးပေးသူ စစ်ဆေးမှု
+ %s %s
+ အကောင့်ဝင်မည်
+ အကောင့်ပြောင်းမည်
+ ချိန်ညိှခြင်း
+ /%d
+ အင်တာနက်မှ တင်သွင်းမည်
+ နောက်ခံ
+ အကောင့်မဝင်ရောက်နိုင်ပါ %s
+ ဘာမျှ
+ အနည်းဆုံး
+ အနားကွပ်
+ ကျပန်း
+ ချုံ့ပြီး
+ 1000 ms
+ စာတန်းထိုး ကြန့်ကြာမှု
+ စာတန်းထိုးများအလွန်နောက်ကျနေပါက %d ms ဒီဟာကိုသုံးပါ
+ စာတန်းထိုး ကြန့်ကြာမှု သတ်မှတ်ထားခြင်းမရှိ
+ ရုံရိုက်
+ ရုံရိုက်
+ TC
+ အရည်အသွေး
+ အစီအစဥ်ချခြင်းကိုကျော်မည်
+ ချို့ယွင်းမှုသတင်းပေးပို့ခြင်း
+ ဘာတွေကြည့်ချင်လဲ
+ ပြီးပြီ
+ အဆက်များ
+ မသွင်းနိုင်ပါ %s
+ သင့်စက်ပစ္စည်းနှင့် ကိုက်ညီစေရန် အက်ပ်၏အသွင်အပြင်ကို ပြောင်းလဲပါ
+ ရီပိုစစ်ထရီထည့်ရန်
+ အသက်ပြည့်ပြီးသူများသာ
+ ရီပိုစစ်ထရီအမည်
+ ရီပိုစစ်ထရီ URL
+ ဖြည့်စွက်များ ထည့်ပြီး
+ ဤဘာသာစကားများဖြင့် ဗီဒီယိုများကို ကြည့်ရှုပါ
+ ဖြည့်စွက်များ ဒေါင်းလုဒ်လုပ်ပြီး
+ ဖြည့်စွက်များဖျက်ပြီး
+ ဒေါင်းလုဒ်လုပ်ခြင်း စတင်သည် %d %s…
+ ဒေါင်းလုဒ်လုပ်ပြီး %d %s
+ အားလုံး %s ဒေါင်းလုဒ်လုပ်ပြီးသား
+ ရီပိုစစ်ထရီထဲတွင်ဖြည့်စွက်များမတွေ့ပါ
+ ရီပိုစစ်ထရီမတွေ့ပါ၊URLကိုပြန်စစ်ပြီးဗီပီအန်ဖြင့်ကြိုးစားကြည့်ပါ
+ အသုတ်လိုက် ဒေါင်းလုဒ်
+ ဖြည့်စွက်
+ သင်အသုံးပြုလိုသောဆိုက်များစာရင်းကို ဒေါင်းလုဒ်လုပ်ပါ
+ ဒေါင်းလုဒ်လုပ်ပြီး: %d
+ ပိတ်ပြီး: %d
+ ဘာသာစကားကုဒ် (en)
+ အကောင့်
+ အကောင့်ထွက်မည်
+ အကောင့်ထည့်မည်
+ အကောင့်ဖွင့်မည်
+ စောင့်ကြည့်ခြင်းထည့်မည်
+ ထည့်ပြီး %s
+ အဆင့်သတ်မှတ်ထားပြီး
+ %d / 10
+ /\?\?
+ %s ချိတ်ဆက်ပြီး
+ ပိတ်ပါ
+ ပုံမှန်
+ အားလုံး
+ အပြည့်
+ ဖိုင်ဒေါင်းလုဒ်လုပ်ပြီး
+ အဓိက
+ ထောက်ပံ့သည်
+ ရင်းမြစ်
+ မကြာမီလာမည်…
+ TS
+ Blu-ray
+ အရည်အသွေးနှင့်ခေါင်းစဥ်
+ ခေါင်းစဥ်
+ အိုင်ဒီမမှန်ပါ
+ အများမြင်နိုင်သော
+ စာတန်းထိုးအားလုံးကို စာလုံးအကြီးပြောင်းပါ
+ ပြန်စတင်မည်
+ ကြည့်ပြီးအဖြစ်မှတ်ရန်
+ အစီအစဥ်ချမှု
+ အစီအစဥ်
+ အဆင့်သတ်မှတ်ချက် (အမြင့်ဆုံးမှအနိမ့်ဆုံးသို့)
+ အဆင့်သတ်မှတ်ချက် (အနိမ့်ဆုံး မှ အမြင့်ဆုံးသို့)
+ အပ်ဒိတ်ဖြစ်မှု (အဟောင် မှ အသစ်)
+ အက္ခရာစဥ်လိုက် (A မှ Z)
+ အက္ခရာစဥ်လိုက် (Z မှ A)
+ ပြောင်းပြန်
+ စာရင်းသွင်းထားသောရှိုးများကိုအပ်ဒိတ်လုပ်နေသည်
+ စာရင်းသွင်းပြီး
+ စာရင်းသွင်းပြီး %s
+ စာရင်းသွင်းမှုပယ်ဖျက်ပြီး %s
+ ဤစာရင်းသည် ဗလာဖြစ်နေသည်။ အခြားတစ်ခုသို့ ပြောင်းကြည့်ပါ။
+ Safe mode ဖိုင်ကို တွေ့ရှိခဲ့သည်။
+\nဖိုင်ကိုမဖယ်ရှားမချင်း စတင်ဖွင့်စတွင် မည်သည့် extension များကိုမျှ မတင်ပါ။
+ အပိုင်းသစ် %d ထွက်ပြီ
+ ပရိုဖိုင် %d
+ ဝိုင်ဖိုင်
+ မိုဘိုင်းဒေတာ
+ ပုံသေထားရန်
+ ပရိုဖိုင်များ
+ အကူအညီ
+ ဤနေရာတွင် သင်သည် အရင်းအမြစ်များကို မည်ကဲ့သို့ အစီအစဥ်ချမည်ကို ပြောင်းလဲနိုင်သည်။ ဗီဒီယိုတစ်ခုတွင် ပိုမိုဦးစားပေးပါက ရင်းမြစ်ရွေးချယ်မှုတွင် ပိုမိုမြင့်မားလာမည်ဖြစ်သည်။ အရင်းအမြစ် ဦးစားပေးနှင့် အရည်အသွေး ဦးစားပေး၏ ပေါင်းစုသည် ဗီဒီယို ဦးစားပေးဖြစ်သည်။
+\n
+\nအရင်းအမြစ် A: 3
+\nအရည်အသွေး B: 7
+\nပေါင်းစပ်ဗီဒီယို ဦးစားပေး 10 ခု ရှိပါမည်။
+\n
+\nမှတ်ချက်- ပေါင်းလဒ်သည် 10 သို့မဟုတ် ထို့ထက်ပိုပါက ထိုလင့်ခ်ကို တင်သည့်အခါ ဗီဒီယိုဖွင့်စက်သည် အလိုအလျောက် ဒေါင်းလုဒ်ကို ကျော်သွားမည်ဖြစ်သည်
+ ပရိုဖိုင်နောက်ခံ
+ UI ကို မှန်ကန်စွာ ဖန်တီး၍မရပါ၊ ၎င်းသည် အဓိက ချို့ယွင်းချက်တစ်ခုဖြစ်ပြီး ချက်ချင်းသတင်းပို့သင့်သည်။ %s
+ သင်နဂိုတည်းကသတ်မှတ်ပြီး
+ လိုက်ဘရီရွေးချယ်ရန်
+ ဖြင့်ဖွင့်မည်
+
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 5f60ac14..d19726fd 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -574,4 +574,5 @@
Uitzetten
De gebruikersinterface kon niet correct worden gemaakt, dit is een ERNSTIG PROBLEEM en moet onmiddellijk gerapporteerd worden %s
@string/default_subtitles
+ Je hebt al gestemd
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 6db36065..a170d610 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -553,4 +553,7 @@
Wybierz tryb filtrowania pobieranych rozszerzeń
Wyłączać
@string/default_subtitles
+ Nie znaleziono żadnych wtyczek w repozytorium
+ Już oddano głos
+ Nie znaleziono tego repozytorium, sprawdź adres URL lub spróbuj połączyć się przez VPN
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index b2504e84..908ddb0d 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -552,4 +552,5 @@
Desativar
Não foram encontrados plugins no repositório
Repositório não encontrado, verifique o URL e tente a VPN
+ Você já votou
diff --git a/app/src/main/res/values-qt/strings.xml b/app/src/main/res/values-qt/strings.xml
index f763d795..9c68c008 100644
--- a/app/src/main/res/values-qt/strings.xml
+++ b/app/src/main/res/values-qt/strings.xml
@@ -248,4 +248,5 @@
aoaaaaaoooghhh
oooooh uuaagh
@string/home_play
+ oouuhhh ahhooo-ahah
diff --git a/app/src/main/res/values-ti/strings.xml b/app/src/main/res/values-ti/strings.xml
new file mode 100644
index 00000000..a9079ed5
--- /dev/null
+++ b/app/src/main/res/values-ti/strings.xml
@@ -0,0 +1,6 @@
+
+
+ %s ክፋል %d
+ ክፋል %d በ ላይ ይወጣል
+ ተዋሳእቲ፡ %s
+
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 2c5d4197..4866ecd4 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -80,14 +80,14 @@
Сюжет не знайдено
Опис не знайдено
Показати Logcat 🐈
- Продовження відтворення в мініатюрному плеєрі поверх інших застосунків
+ Продовжує відтворення в мініатюрному плеєрі поверх інших застосунків
Прибирає чорні рамки
Субтитри
Субтитри Chromecast
Налаштування субтитрів Chromecast
Режим Eigengravy
Проведіть пальцем, щоб змінити налаштування
- Проведіть пальцем вгору або вниз, ліворуч або праворуч, щоб змінити яскравість чи гучність
+ Проведіть вгору або вниз з лівого або правого боку, щоб змінити яскравість чи гучність
Відтворювати наступний епізод після закінчення поточного
Головна
CloudStream
@@ -121,8 +121,8 @@
Колір тексту
Колір контуру
Автовідтворення наступного епізоду
- Проведіть пальцем з боку в бік, щоб керувати своїм положенням у відео
- %d Бананів для розробників
+ Проведіть з боку в бік, щоб керувати своїм положенням у відео
+ %d бананів для розробників
Кнопка зміни розміру плеєра
@string/home_play
Для коректної роботи цього постачальника може знадобитися VPN
@@ -133,7 +133,7 @@
Проведіть пальцем, щоб перемотати
Двічі торкніться, щоб перемотати
Двічі торкніться для паузи
- Крок перемотки (Секунди)
+ Крок перемотки (секунди)
Натисніть двічі посередині, щоб призупинити відтворення відео
Використовувати яскравість системи
Оновити прогрес перегляду
@@ -150,8 +150,8 @@
Надає результати пошуку, розділені за постачальниками
Надсилає дані лише про збої
Не надсилає даних
- Показати заповнюючий епізод для аніме
- Показати трейлери
+ Показувати філери до аніме
+ Показувати трейлери
Приховати вибрану якість відео в результатах пошуку
Автоматичне завантаження плагінів
Показувати оновлення застосунку
@@ -214,12 +214,12 @@
Завантажити дзеркало
Перевірити наявність оновлень
Заблокувати
- Пропустити OP
+ Пропускати OP
Не показувати знову
Оновити
Бажана якість перегляду (WiFi)
Заголовок
- Перемикання елементів інтерфейсу на плакаті
+ Перемикання елементів інтерфейсу на постері
Оновлення не знайдено
Двічі торкніться праворуч або ліворуч, щоб перемотати відео вперед або назад
Використовуйте системну яскравість у плеєрі замість темної накладки
@@ -227,10 +227,10 @@
Торренти
Автоматична синхронізація прогресу поточного епізоду
Відсутні дозволи на зберігання. Будь ласка, спробуйте ще раз.
- Показати постери від Kitsu
+ Показувати постери від Kitsu
Автоматичне оновлення плагінів
Автоматично встановлювати всі ще не встановлені плагіни з доданих репозиторіїв.
- Автоматично шукати нові оновлення після запуску застосунку.
+ Автоматично шукає нові оновлення після запуску застосунку.
Оновлення до бета-версій
Посилання скопійовано в буфер обміну
Деякі телефони не підтримують новий інсталятор пакетів. Спробуйте стару версію, якщо оновлення не встановлюються.
@@ -354,7 +354,7 @@
DNS через HTTPS
Шлях завантаження
Додайте клон існуючого сайту, з іншою URL-адресою
- Відображати мітку Дубляж/Субтитри в аніме
+ Відображати мітку Дубляж/Субтитри до аніме
Застереження
Розширення
Дії
@@ -382,7 +382,7 @@
Підтримка
Фон
Blu-ray
- Видалити закриті титри з субтитрів
+ Видаляти закриті титри з субтитрів
DVD
Недійсні дані
Фільтрувати за бажаною мовою медіа
@@ -400,7 +400,7 @@
HD
TS
TC
- Видалити роздуття субтитрів
+ Видаляти роздуття субтитрів
Referer
Далі
Дивіться відео на цих мовах
@@ -418,7 +418,7 @@
Почалося завантаження %d %s…
Завантажено %d %s
Всі %s вже завантажено
- Пакетне завантаження
+ Завантажити пакети
плагін
плагіни
Видалити репозиторій
@@ -451,8 +451,8 @@
Вбудований плеєр
VLC
MPV
- Відтворення веб-відео
- Веб-браузер
+ Web Video Cast
+ Веббраузер
Ендінґ
Коротке повторення
Пропустити %s
@@ -462,7 +462,7 @@
Вступ
Очистити історію
Історія
- Показувати спливаючі вікна для опенінґу/ендінґу
+ Показує спливаюче вікно для пропуску опенінґу/ендінґу
Забагато тексту. Не вдалося зберегти в буфер обміну.
Позначити як переглянуте
Ви впевнені що хочете вийти\?
@@ -552,4 +552,5 @@
@string/default_subtitles
Репозиторій не знайдено, перевірте URL-адресу та спробуйте VPN
Не знайдено жодних плагінів у репозиторії
+ Ви вже проголосували
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 4b394227..217d2791 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -267,7 +267,7 @@
Cập nhật
Chất lượng xem ưu tiên (WiFi)
Kí tự tối đa trên tiêu đề
- Độ phân giải trình phát video
+ Nội dung trình phát video
Kích thước bộ nhớ đệm video
Thời lượng bộ nhớ đệm
Lưu bộ nhớ đệm video trên ổ cứng
@@ -380,8 +380,8 @@
Web
Ảnh áp phích
Trình phát
- Độ phân giải và Tiêu đề
- Tiêu đề
+ Độ phân giải và Tên nguồn
+ Tên nguồn
Độ phân giải
Id không hợp lệ
Lỗi dữ liệu
@@ -561,4 +561,11 @@
\n
\nLƯU Ý: Nếu tổng là 10 hoặc nhiều hơn, trình phát sẽ tự động bỏ tải khi liên kết đó được tải!
Các phẩm chất
+ Bạn đã bình chọn
+ Vô hiệu hoá
+ Không tìm thấy tiện ích, hãy kiểm tra URL và thử VPN
+ Không tìm thấy plugin
+ Không thể khởi tạo UI, đây là một LỖI LỚN và cần được báo cáo ngay lập tức tới %s
+ Chọn chế độ để lọc plugin tải xuống
+ @string/default_subtitles
diff --git a/fastlane/metadata/android/ar-SA/changelogs/2.txt b/fastlane/metadata/android/ar-SA/changelogs/2.txt
new file mode 100644
index 00000000..cc43acf1
--- /dev/null
+++ b/fastlane/metadata/android/ar-SA/changelogs/2.txt
@@ -0,0 +1 @@
+تمت إضافة سجل التغيير!
diff --git a/fastlane/metadata/android/ar-SA/full_description.txt b/fastlane/metadata/android/ar-SA/full_description.txt
index 2107b338..9668a9b1 100644
--- a/fastlane/metadata/android/ar-SA/full_description.txt
+++ b/fastlane/metadata/android/ar-SA/full_description.txt
@@ -1,14 +1,10 @@
-يتيح لك كلاود ستريم -3 بث وتنزيل الأفلام والمسلسلات التلفزيونية والأنيمي. يأتي التطبيق بدون أي إعلانات وتحليلات. و يدعم العديد من مواقع البث الاولي(التريلر) والأفلام والمزيد. وتشمل الميزات:
-
+يسمح لك كلاود ستريم -3 ببث وتنزيل الأفلام, المسلسلات التلفزيونية, والأنيمي.
+يأتي التطبيق بدون أي إعلانات وتحليلات و
+ يدعم العديد من مواقع البث الاولي(التريلر) ,والأفلام, والمزيد.
إشارات مرجعية
-
-قم بتنزيل ودفق الأفلام والبرامج التلفزيونية والأنيمي
-
-
تنزيلات الترجمة
-
دعم كروم كاست
diff --git a/fastlane/metadata/android/ar-SA/short_description.txt b/fastlane/metadata/android/ar-SA/short_description.txt
index f396ff81..7ccd9743 100644
--- a/fastlane/metadata/android/ar-SA/short_description.txt
+++ b/fastlane/metadata/android/ar-SA/short_description.txt
@@ -1 +1 @@
-بث وتحميل الأفلام والأنمي والمسلسلات التلفزيونية.
+بث وتحميل الأفلام, الأنمي, والمسلسلات التلفزيونية.
diff --git a/fastlane/metadata/android/ar-SA/title.txt b/fastlane/metadata/android/ar-SA/title.txt
index 635e1390..7977b290 100644
--- a/fastlane/metadata/android/ar-SA/title.txt
+++ b/fastlane/metadata/android/ar-SA/title.txt
@@ -1 +1 @@
-كلاود ستريم
+كلاودستريم
diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt
index df314372..ea2a8750 100644
--- a/fastlane/metadata/android/de-DE/full_description.txt
+++ b/fastlane/metadata/android/de-DE/full_description.txt
@@ -1,11 +1,11 @@
-Mit CloudStream-3 kannst du Filme, TV-Serien und Anime streamen und herunterladen.
+Mit CloudStream-3 kannst du Filme, TV-Serien und Anime streamen und herunterladen.
Die App kommt ganz ohne Werbung und Analytik aus.
-Sie unterstützt mehrere Trailer-, Filmseiten und vieles mehr. Integrierte Features:
+Sie unterstützt zahlreiche Trailer, Filmseiten und vieles mehr, unter anderem:
Lesezeichen
-Herunterladen und Streamen von Filmen, Fernsehsendungen und Animes
+Herunterladen und Streaming von Filmen, Fernsehsendungen und Animes
Downloads von Untertiteln
diff --git a/fastlane/metadata/android/pt/changelogs/2.txt b/fastlane/metadata/android/pt/changelogs/2.txt
new file mode 100644
index 00000000..1153e632
--- /dev/null
+++ b/fastlane/metadata/android/pt/changelogs/2.txt
@@ -0,0 +1 @@
+- Adicionado o registo de alterações!
diff --git a/fastlane/metadata/android/pt/full_description.txt b/fastlane/metadata/android/pt/full_description.txt
new file mode 100644
index 00000000..48bf36ce
--- /dev/null
+++ b/fastlane/metadata/android/pt/full_description.txt
@@ -0,0 +1,10 @@
+O CloudStream-3 permite-lhe transmitir e descarregar filmes, séries de TV e anime.
+
+A aplicação é fornecida sem quaisquer anúncios e análises e
+suporta vários sites de trailers e filmes, e muito mais, por exemplo
+
+Marcadores
+
+Downloads de legendas
+
+Suporte para Chromecast
diff --git a/fastlane/metadata/android/pt/short_description.txt b/fastlane/metadata/android/pt/short_description.txt
new file mode 100644
index 00000000..d0392f34
--- /dev/null
+++ b/fastlane/metadata/android/pt/short_description.txt
@@ -0,0 +1 @@
+Transmita e transfira filmes, séries de TV e anime.
diff --git a/fastlane/metadata/android/pt/title.txt b/fastlane/metadata/android/pt/title.txt
new file mode 100644
index 00000000..dde89d58
--- /dev/null
+++ b/fastlane/metadata/android/pt/title.txt
@@ -0,0 +1 @@
+CloudStream
diff --git a/fastlane/metadata/android/vi/changelogs/2.txt b/fastlane/metadata/android/vi/changelogs/2.txt
new file mode 100644
index 00000000..e03e458e
--- /dev/null
+++ b/fastlane/metadata/android/vi/changelogs/2.txt
@@ -0,0 +1 @@
+- Đã thêm Nhật ký thay đổi!
diff --git a/fastlane/metadata/android/vi/full_description.txt b/fastlane/metadata/android/vi/full_description.txt
new file mode 100644
index 00000000..90ea7ab7
--- /dev/null
+++ b/fastlane/metadata/android/vi/full_description.txt
@@ -0,0 +1,10 @@
+CloudStream-3 cho phép bạn xem và tải xuống phim lẻ, phim bộ và anime.
+
+Ứng dụng không có quảng cáo hay và phân tích nào,
+đồng thời hỗ trợ nhiều trang web xem phim, v.v.
+
+Đánh dấu
+
+Tải phụ đề
+
+Hỗ trợ Chromecast
diff --git a/fastlane/metadata/android/vi/short_description.txt b/fastlane/metadata/android/vi/short_description.txt
new file mode 100644
index 00000000..e4e20bd5
--- /dev/null
+++ b/fastlane/metadata/android/vi/short_description.txt
@@ -0,0 +1 @@
+Xem và tải xuống phim lẻ, phim bộ và anime.
diff --git a/fastlane/metadata/android/vi/title.txt b/fastlane/metadata/android/vi/title.txt
new file mode 100644
index 00000000..dde89d58
--- /dev/null
+++ b/fastlane/metadata/android/vi/title.txt
@@ -0,0 +1 @@
+CloudStream