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