forked from recloudstream/cloudstream
Compare commits
84 Commits
Author | SHA1 | Date |
---|---|---|
LagradOst | 67b0549fd2 | |
Osten | 52d495f425 | |
Cloudburst | 0cbee70683 | |
Lag | 4235c826a5 | |
Cloudburst | 5245eff6e1 | |
Lag | 9c40abc4d3 | |
Lag | 019399952f | |
Lag | cc99899cf1 | |
Lag | 8fff809b79 | |
recloudstream[bot] | 67318a62a3 | |
Hosted Weblate | 288c5ffa39 | |
Lag | 8ebf5185a3 | |
Cloudburst | 7bfcf25df4 | |
Lag | 2d7126d71f | |
Lag | 40a4f319b6 | |
Lag | 19dc1a2456 | |
Cloudburst | ac1012bcb8 | |
Cloudburst | ec3950ed4f | |
Hosted Weblate | 3e2b0f2a17 | |
LikDev-256 | 29174dbb30 | |
Lag | 7b47f93190 | |
Lag | 13ee8e21d0 | |
recloudstream[bot] | 3a5d872545 | |
Hosted Weblate | fab55d82c4 | |
Hosted Weblate | 8b2881f5f6 | |
PokerFace | 37244ab0f7 | |
Lag | e85b31c35d | |
Hosted Weblate | 1eaa4620dc | |
no-commit | 76545f55c3 | |
Stormunblessed | f0515c4dc9 | |
no-commit | ab324b93e8 | |
Stormunblessed | d6df24eff2 | |
Hosted Weblate | e5834d485b | |
Sarlay | 6524eb220b | |
Allen Baby | 2926dc6c8e | |
Hexated | f722785a37 | |
Lag | aeab423d29 | |
recloudstream[bot] | 1da6a92569 | |
Cloudburst | b2fa765a2d | |
Cloudburst | bec0a2e7b9 | |
Cloudburst | 51137701f2 | |
Hosted Weblate | 5f12d067f9 | |
no-commit | 00a91ca5fb | |
LikDev-256 | 33aecfbba5 | |
no-commit | 0185854682 | |
no-commit | b4065b69be | |
MhmdIbrahim1 | b6ac155350 | |
Lag | aacd57cb5d | |
Lag | 3dd0fc6c8e | |
Lag | 135f63afff | |
Lag | 789cd14ef6 | |
recloudstream[bot] | 9d0cce47a6 | |
Lag | 4a8ee55018 | |
Stormunblessed | df6c395acb | |
Cloudburst | 1117271a71 | |
Cloudburst | 7b11b9b585 | |
recloudstream[bot] | 5c20b479e5 | |
Hosted Weblate | 0d2613d183 | |
Cloudburst | dd38556102 | |
reduplicated | 84493b7f3b | |
reduplicated | 4596afee06 | |
Sir Aguacata | 3e2c2a5c86 | |
Terry Hanoman | 6c646d65a8 | |
LagradOst | 329966732f | |
Hosted Weblate | 19b2cae851 | |
recloudstream[bot] | 45eb9758e3 | |
Cloudburst | f6be6081dc | |
recloudstream[bot] | 80f22cea16 | |
Cloudburst | bf78fc95c2 | |
Cloudburst | a148f347cd | |
Cloudburst | ff9942407b | |
Hosted Weblate | 0ea624ff14 | |
Cloudburst | f939e4cff2 | |
no-commit | 2ff90c03ca | |
no-commit | 9988753432 | |
LagradOst | b0921161a3 | |
Cloudburst | 490381451b | |
hexated | b26a41bdaf | |
Hosted Weblate | c7c5fa250e | |
Blatzar | 6e9b1cb855 | |
Blatzar | fd2648df45 | |
Blatzar | 9905618a47 | |
LagradOst | 2771dcb612 | |
LagradOst | 3c82548c20 |
Binary file not shown.
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
Before Width: | Height: | Size: 136 KiB |
|
@ -1,13 +1,14 @@
|
|||
import re
|
||||
import glob
|
||||
import requests
|
||||
import lxml.etree as ET # builtin library doesn't preserve comments
|
||||
|
||||
|
||||
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
||||
START_MARKER = "/* begin language list */"
|
||||
END_MARKER = "/* end language list */"
|
||||
XML_NAME = "app/src/main/res/values-"
|
||||
ISO_MAP_URL = "https://gist.githubusercontent.com/Josantonius/b455e315bc7f790d14b136d61d9ae469/raw"
|
||||
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
|
||||
INDENT = " "*4
|
||||
|
||||
iso_map = requests.get(ISO_MAP_URL, timeout=300).json()
|
||||
|
@ -27,7 +28,8 @@ for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
|
|||
for folder in glob.glob(f"{XML_NAME}*"):
|
||||
iso = folder[len(XML_NAME):]
|
||||
if iso not in languages.keys():
|
||||
languages[iso] = ("", iso_map.get(iso.lower(),iso))
|
||||
entry = iso_map.get(iso.lower(),{'nativeName':iso})
|
||||
languages[iso] = ("", entry['nativeName'].split(',')[0])
|
||||
|
||||
# Create triples
|
||||
triples = []
|
||||
|
@ -44,4 +46,18 @@ open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
|||
"\n" +
|
||||
END_MARKER +
|
||||
after_src
|
||||
)
|
||||
)
|
||||
|
||||
# Go through each values.xml file and fix escaped \@string
|
||||
for file in glob.glob(f"{XML_NAME}*/strings.xml"):
|
||||
try:
|
||||
tree = ET.parse(file)
|
||||
for child in tree.getroot():
|
||||
if child.text.startswith("\\@string/"):
|
||||
print(f"[{file}] fixing {child.attrib['name']}")
|
||||
child.text = child.text.replace("\\@string/", "@string/")
|
||||
with open(file, 'wb') as fp:
|
||||
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
|
||||
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
|
||||
except ET.ParseError as ex:
|
||||
print(f"[{file}] {ex}")
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
Before Width: | Height: | Size: 149 KiB |
|
@ -15,6 +15,7 @@ jobs:
|
|||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
- name: Similarity analysis
|
||||
id: similarity
|
||||
uses: actions-cool/issues-similarity-analysis@v1
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
@ -24,6 +25,18 @@ jobs:
|
|||
### Your issue looks similar to these issues:
|
||||
Please close if duplicate.
|
||||
comment-body: '${index}. ${similarity} #${number}'
|
||||
- name: Label if possible duplicate
|
||||
if: steps.similarity.outputs.similar-issues-found =='true'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ steps.generate_token.outputs.token }}
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ["possible duplicate"]
|
||||
})
|
||||
- uses: actions/checkout@v2
|
||||
- name: Automatically close issues that dont follow the issue template
|
||||
uses: lucasbento/auto-close-issues@v1.0.2
|
||||
|
@ -53,6 +66,18 @@ jobs:
|
|||
Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
|
||||
|
||||
Found provider name: `${{ steps.provider_check.outputs.name }}`
|
||||
- name: Label if mentions provider
|
||||
if: steps.provider_check.outputs.name != 'none'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ steps.generate_token.outputs.token }}
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ["possible provider issue"]
|
||||
})
|
||||
- name: Add eyes reaction to all issues
|
||||
uses: actions-cool/emoji-helper@v1.0.0
|
||||
with:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: Update locale lists
|
||||
name: Fix locale issues
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
@ -9,7 +9,7 @@ on:
|
|||
- master
|
||||
|
||||
concurrency:
|
||||
group: "locale-list"
|
||||
group: "locale"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
@ -26,6 +26,9 @@ jobs:
|
|||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip3 install lxml
|
||||
- name: Edit files
|
||||
run: |
|
||||
python3 .github/locales.py
|
||||
|
@ -35,5 +38,5 @@ jobs:
|
|||
git config --local user.name "recloudstream[bot]"
|
||||
git add .
|
||||
# "echo" returns true so the build succeeds, even if no changed files
|
||||
git commit -m 'update list of locales' || echo
|
||||
git commit -m 'chore(locales): fix locale issues' || echo
|
||||
git push
|
||||
|
|
|
@ -12,12 +12,7 @@
|
|||
+ Download and stream movies, tv-shows and anime
|
||||
+ Chromecast
|
||||
|
||||
### Screenshots:
|
||||
|
||||
<img src="./.github/home.jpg" height="400"/><img src="./.github/search.jpg" height="400"/><img src="./.github/downloads.jpg" height="400"/><img src="./.github/results.jpg" height="400"/>
|
||||
<img src="./.github/player.jpg" height="200"/>
|
||||
|
||||
### Supported languages:
|
||||
<a href="https://hosted.weblate.org/engage/cloudstream/">
|
||||
<img src="https://hosted.weblate.org/widgets/cloudstream/-/app/multi-auto.svg" alt="Translation status" />
|
||||
</a>
|
||||
</a>
|
||||
|
|
|
@ -47,8 +47,8 @@ android {
|
|||
minSdk = 21
|
||||
targetSdk = 33
|
||||
|
||||
versionCode = 56
|
||||
versionName = "3.5.0"
|
||||
versionCode = 57
|
||||
versionName = "4.0.0"
|
||||
|
||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||
|
||||
|
@ -159,6 +159,8 @@ dependencies {
|
|||
implementation("com.google.android.exoplayer:extension-cast:2.18.2")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:2.18.2")
|
||||
implementation("com.google.android.exoplayer:extension-okhttp:2.18.2")
|
||||
// Use the Jellyfin ffmpeg extension for easy ffmpeg audio decoding in exoplayer. Thank you Jellyfin <3
|
||||
// implementation("org.jellyfin.exoplayer:exoplayer-ffmpeg-extension:2.18.2+1")
|
||||
|
||||
//implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
|
||||
|
||||
|
@ -184,13 +186,13 @@ dependencies {
|
|||
//implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
|
||||
|
||||
// Downloading
|
||||
implementation("androidx.work:work-runtime:2.7.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.7.1")
|
||||
implementation("androidx.work:work-runtime:2.8.0")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.0")
|
||||
|
||||
// Networking
|
||||
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
|
||||
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
|
||||
implementation("com.github.Blatzar:NiceHttp:0.4.1")
|
||||
implementation("com.github.Blatzar:NiceHttp:0.4.2")
|
||||
// To fix SSL fuckery on android 9
|
||||
implementation("org.conscrypt:conscrypt-android:2.2.1")
|
||||
// Util to skip the URI file fuckery 🙏
|
||||
|
@ -220,6 +222,9 @@ dependencies {
|
|||
|
||||
// Library/extensions searching with Levenshtein distance
|
||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||
|
||||
// color pallette for images -> colors
|
||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||
}
|
||||
|
||||
tasks.register("androidSourcesJar", Jar::class) {
|
||||
|
@ -250,4 +255,4 @@ tasks.withType<DokkaTask>().configureEach {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
import com.lagradost.cloudstream3.utils.TestingUtils
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
@ -16,142 +15,11 @@ import org.junit.runner.RunWith
|
|||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
//@Test
|
||||
//fun useAppContext() {
|
||||
// // Context of the app under test.
|
||||
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
// assertEquals("com.lagradost.cloudstream3", appContext.packageName)
|
||||
//}
|
||||
|
||||
private fun getAllProviders(): List<MainAPI> {
|
||||
println("Providers: ${APIHolder.allProviders.size}")
|
||||
return APIHolder.allProviders //.filter { !it.usesWebView }
|
||||
}
|
||||
|
||||
private suspend fun loadLinks(api: MainAPI, url: String?): Boolean {
|
||||
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
|
||||
if (url == null) return true
|
||||
var linksLoaded = 0
|
||||
try {
|
||||
val success = api.loadLinks(url, false, {}) { link ->
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} returns link with invalid Quality",
|
||||
Qualities.values().map { it.value }.contains(link.quality)
|
||||
)
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} returns link with invalid url ${link.url}",
|
||||
link.url.length > 4
|
||||
)
|
||||
linksLoaded++
|
||||
}
|
||||
if (success) {
|
||||
return linksLoaded > 0
|
||||
}
|
||||
Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success)
|
||||
} catch (e: Exception) {
|
||||
if (e.cause is NotImplementedError) {
|
||||
Assert.fail("Provider has not implemented .loadLinks")
|
||||
}
|
||||
logError(e)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun testSingleProviderApi(api: MainAPI): Boolean {
|
||||
val searchQueries = listOf("over", "iron", "guy")
|
||||
var correctResponses = 0
|
||||
var searchResult: List<SearchResponse>? = null
|
||||
for (query in searchQueries) {
|
||||
val response = try {
|
||||
api.search(query)
|
||||
} catch (e: Exception) {
|
||||
if (e.cause is NotImplementedError) {
|
||||
Assert.fail("Provider has not implemented .search")
|
||||
}
|
||||
logError(e)
|
||||
null
|
||||
}
|
||||
if (!response.isNullOrEmpty()) {
|
||||
correctResponses++
|
||||
if (searchResult == null) {
|
||||
searchResult = response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (correctResponses == 0 || searchResult == null) {
|
||||
System.err.println("Api ${api.name} did not return any valid search responses")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
var validResults = false
|
||||
for (result in searchResult) {
|
||||
Assert.assertEquals(
|
||||
"Invalid apiName on response on ${api.name}",
|
||||
result.apiName,
|
||||
api.name
|
||||
)
|
||||
val load = api.load(result.url) ?: continue
|
||||
Assert.assertEquals(
|
||||
"Invalid apiName on load on ${api.name}",
|
||||
load.apiName,
|
||||
result.apiName
|
||||
)
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} on load does not contain any of the supportedTypes",
|
||||
api.supportedTypes.contains(load.type)
|
||||
)
|
||||
when (load) {
|
||||
is AnimeLoadResponse -> {
|
||||
val gotNoEpisodes =
|
||||
load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() }
|
||||
|
||||
if (gotNoEpisodes) {
|
||||
println("Api ${api.name} got no episodes on ${load.url}")
|
||||
continue
|
||||
}
|
||||
|
||||
val url = (load.episodes[load.episodes.keys.first()])?.first()?.data
|
||||
validResults = loadLinks(api, url)
|
||||
if (!validResults) continue
|
||||
}
|
||||
is MovieLoadResponse -> {
|
||||
val gotNoEpisodes = load.dataUrl.isBlank()
|
||||
if (gotNoEpisodes) {
|
||||
println("Api ${api.name} got no movie on ${load.url}")
|
||||
continue
|
||||
}
|
||||
|
||||
validResults = loadLinks(api, load.dataUrl)
|
||||
if (!validResults) continue
|
||||
}
|
||||
is TvSeriesLoadResponse -> {
|
||||
val gotNoEpisodes = load.episodes.isEmpty()
|
||||
if (gotNoEpisodes) {
|
||||
println("Api ${api.name} got no episodes on ${load.url}")
|
||||
continue
|
||||
}
|
||||
|
||||
validResults = loadLinks(api, load.episodes.first().data)
|
||||
if (!validResults) continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!validResults) {
|
||||
System.err.println("Api ${api.name} did not load on any")
|
||||
}
|
||||
|
||||
return validResults
|
||||
} catch (e: Exception) {
|
||||
if (e.cause is NotImplementedError) {
|
||||
Assert.fail("Provider has not implemented .load")
|
||||
}
|
||||
logError(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providersExist() {
|
||||
Assert.assertTrue(getAllProviders().isNotEmpty())
|
||||
|
@ -159,6 +27,7 @@ class ExampleInstrumentedTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@Throws(AssertionError::class)
|
||||
fun providerCorrectData() {
|
||||
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
|
||||
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
|
||||
|
@ -181,67 +50,20 @@ class ExampleInstrumentedTest {
|
|||
fun providerCorrectHomepage() {
|
||||
runBlocking {
|
||||
getAllProviders().amap { api ->
|
||||
if (api.hasMainPage) {
|
||||
try {
|
||||
val f = api.mainPage.first()
|
||||
val homepage =
|
||||
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
|
||||
when {
|
||||
homepage == null -> {
|
||||
System.err.println("Homepage provider ${api.name} did not correctly load homepage!")
|
||||
}
|
||||
homepage.items.isEmpty() -> {
|
||||
System.err.println("Homepage provider ${api.name} does not contain any items!")
|
||||
}
|
||||
homepage.items.any { it.list.isEmpty() } -> {
|
||||
System.err.println("Homepage provider ${api.name} does not have any items on result!")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e.cause is NotImplementedError) {
|
||||
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
|
||||
}
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
TestingUtils.testHomepage(api, ::println)
|
||||
}
|
||||
}
|
||||
println("Done providerCorrectHomepage")
|
||||
}
|
||||
|
||||
// @Test
|
||||
// fun testSingleProvider() {
|
||||
// testSingleProviderApi(ThenosProvider())
|
||||
// }
|
||||
|
||||
@Test
|
||||
fun providerCorrect() {
|
||||
fun testAllProvidersCorrect() {
|
||||
runBlocking {
|
||||
val invalidProvider = ArrayList<Pair<MainAPI, Exception?>>()
|
||||
val providers = getAllProviders()
|
||||
providers.amap { api ->
|
||||
try {
|
||||
println("Trying $api")
|
||||
if (testSingleProviderApi(api)) {
|
||||
println("Success $api")
|
||||
} else {
|
||||
System.err.println("Error $api")
|
||||
invalidProvider.add(Pair(api, null))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
invalidProvider.add(Pair(api, e))
|
||||
}
|
||||
}
|
||||
if (invalidProvider.isEmpty()) {
|
||||
println("No Invalid providers! :D")
|
||||
} else {
|
||||
println("Invalid providers are: ")
|
||||
for (provider in invalidProvider) {
|
||||
println("${provider.first}")
|
||||
}
|
||||
}
|
||||
TestingUtils.getDeferredProviderTests(
|
||||
this,
|
||||
getAllProviders(),
|
||||
::println
|
||||
) { _, _ -> }
|
||||
}
|
||||
println("Done providerCorrect")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,6 +98,16 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="cloudstreamplayer" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
|
|
@ -13,12 +13,14 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.ui.result.UiText
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import okhttp3.Interceptor
|
||||
import org.mozilla.javascript.Scriptable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.absoluteValue
|
||||
|
@ -82,7 +84,7 @@ object APIHolder {
|
|||
initMap()
|
||||
return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
|
||||
// Leave the ?. null check, it can crash regardless
|
||||
?: allProviders.firstOrNull { it?.name == apiName }
|
||||
?: allProviders.firstOrNull { it.name == apiName }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -160,6 +162,53 @@ object APIHolder {
|
|||
return null
|
||||
}
|
||||
|
||||
private var trackerCache: HashMap<String, AniSearch> = hashMapOf()
|
||||
|
||||
/**
|
||||
* Get anime tracker information based on title, year and type.
|
||||
* Both titles are attempted to be matched with both Romaji and English title.
|
||||
* Uses the consumet api.
|
||||
*
|
||||
* @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that
|
||||
* @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes()
|
||||
* @param year Optional parameter to only get anime with a specific year
|
||||
**/
|
||||
suspend fun getTracker(
|
||||
titles: List<String>,
|
||||
types: Set<TrackerType>?,
|
||||
year: Int?
|
||||
): Tracker? {
|
||||
return try {
|
||||
require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" }
|
||||
|
||||
val mainTitle = titles[0]
|
||||
val search =
|
||||
trackerCache[mainTitle]
|
||||
?: app.get("https://api.consumet.org/meta/anilist/$mainTitle")
|
||||
.parsedSafe<AniSearch>()?.also {
|
||||
trackerCache[mainTitle] = it
|
||||
} ?: return null
|
||||
|
||||
val res = search.results?.find { media ->
|
||||
val matchingYears = year == null || media.releaseDate == year
|
||||
val matchingTitles = media.title?.let { title ->
|
||||
titles.any { userTitle ->
|
||||
title.isMatchingTitles(userTitle)
|
||||
}
|
||||
} ?: false
|
||||
|
||||
val matchingTypes = types?.any { it.name.equals(media.type, true) } == true
|
||||
matchingTitles && matchingTypes && matchingYears
|
||||
} ?: return null
|
||||
|
||||
Tracker(res.malId, res.aniId, res.image, res.cover)
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Context.getApiSettings(): HashSet<String> {
|
||||
//val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
|
@ -510,6 +559,20 @@ abstract class MainAPI {
|
|||
open val hasMainPage = false
|
||||
open val hasQuickSearch = false
|
||||
|
||||
/**
|
||||
* A set of which ids the provider can open with getLoadUrl()
|
||||
* If the set contains SyncIdName.Imdb then getLoadUrl() can be started with
|
||||
* an Imdb class which inherits from SyncId.
|
||||
*
|
||||
* getLoadUrl() is then used to get page url based on that ID.
|
||||
*
|
||||
* Example:
|
||||
* "tt6723592" -> getLoadUrl(ImdbSyncId("tt6723592")) -> "mainUrl/imdb/tt6723592" -> load("mainUrl/imdb/tt6723592")
|
||||
*
|
||||
* This is used to launch pages from personal lists or recommendations using IDs.
|
||||
**/
|
||||
open val supportedSyncNames = setOf<SyncIdName>()
|
||||
|
||||
open val supportedTypes = setOf(
|
||||
TvType.Movie,
|
||||
TvType.TvSeries,
|
||||
|
@ -580,6 +643,14 @@ abstract class MainAPI {
|
|||
open fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the load() url based on a sync ID like IMDb or MAL.
|
||||
* Only contains SyncIds based on supportedSyncUrls.
|
||||
**/
|
||||
open suspend fun getLoadUrl(name: SyncIdName, id: String): String? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Might need a different implementation for desktop*/
|
||||
|
@ -665,6 +736,19 @@ fun fixTitle(str: String): String {
|
|||
.replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get rhino context in a safe way as it needs to be initialized on the main thread.
|
||||
* Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects()
|
||||
* Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null)
|
||||
**/
|
||||
suspend fun getRhinoContext(): org.mozilla.javascript.Context {
|
||||
return Coroutines.mainWork {
|
||||
val rhino = org.mozilla.javascript.Context.enter()
|
||||
rhino.initSafeStandardObjects()
|
||||
rhino.optimizationLevel = -1
|
||||
rhino
|
||||
}
|
||||
}
|
||||
|
||||
/** https://www.imdb.com/title/tt2861424/ -> tt2861424 */
|
||||
fun imdbUrlToId(url: String): String? {
|
||||
|
@ -1243,7 +1327,7 @@ fun LoadResponse?.isAnimeBased(): Boolean {
|
|||
|
||||
fun TvType?.isEpisodeBased(): Boolean {
|
||||
if (this == null) return false
|
||||
return (this == TvType.TvSeries || this == TvType.Anime)
|
||||
return (this == TvType.TvSeries || this == TvType.Anime || this == TvType.AsianDrama)
|
||||
}
|
||||
|
||||
|
||||
|
@ -1267,6 +1351,7 @@ interface EpisodeResponse {
|
|||
var showStatus: ShowStatus?
|
||||
var nextAiring: NextAiring?
|
||||
var seasonNames: List<SeasonData>?
|
||||
fun getLatestEpisodes(): Map<DubStatus, Int?>
|
||||
}
|
||||
|
||||
@JvmName("addSeasonNamesString")
|
||||
|
@ -1335,7 +1420,18 @@ data class AnimeLoadResponse(
|
|||
override var nextAiring: NextAiring? = null,
|
||||
override var seasonNames: List<SeasonData>? = null,
|
||||
override var backgroundPosterUrl: String? = null,
|
||||
) : LoadResponse, EpisodeResponse
|
||||
) : LoadResponse, EpisodeResponse {
|
||||
override fun getLatestEpisodes(): Map<DubStatus, Int?> {
|
||||
return episodes.map { (status, episodes) ->
|
||||
val maxSeason = episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }
|
||||
.takeUnless { it == Int.MIN_VALUE }
|
||||
status to episodes
|
||||
.filter { it.season == maxSeason }
|
||||
.maxOfOrNull { it.episode ?: Int.MIN_VALUE }
|
||||
.takeUnless { it == Int.MIN_VALUE }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If episodes already exist appends the list.
|
||||
|
@ -1533,7 +1629,17 @@ data class TvSeriesLoadResponse(
|
|||
override var nextAiring: NextAiring? = null,
|
||||
override var seasonNames: List<SeasonData>? = null,
|
||||
override var backgroundPosterUrl: String? = null,
|
||||
) : LoadResponse, EpisodeResponse
|
||||
) : LoadResponse, EpisodeResponse {
|
||||
override fun getLatestEpisodes(): Map<DubStatus, Int?> {
|
||||
val maxSeason =
|
||||
episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }.takeUnless { it == Int.MIN_VALUE }
|
||||
val max = episodes
|
||||
.filter { it.season == maxSeason }
|
||||
.maxOfOrNull { it.episode ?: Int.MIN_VALUE }
|
||||
.takeUnless { it == Int.MIN_VALUE }
|
||||
return mapOf(DubStatus.None to max)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun MainAPI.newTvSeriesLoadResponse(
|
||||
name: String,
|
||||
|
@ -1565,3 +1671,61 @@ fun fetchUrls(text: String?): List<String> {
|
|||
|
||||
fun String?.toRatingInt(): Int? =
|
||||
this?.replace(" ", "")?.trim()?.toDoubleOrNull()?.absoluteValue?.times(1000f)?.toInt()
|
||||
|
||||
data class Tracker(
|
||||
val malId: Int? = null,
|
||||
val aniId: String? = null,
|
||||
val image: String? = null,
|
||||
val cover: String? = null,
|
||||
)
|
||||
|
||||
data class Title(
|
||||
@JsonProperty("romaji") val romaji: String? = null,
|
||||
@JsonProperty("english") val english: String? = null,
|
||||
) {
|
||||
fun isMatchingTitles(title: String?): Boolean {
|
||||
if (title == null) return false
|
||||
return english.equals(title, true) || romaji.equals(title, true)
|
||||
}
|
||||
}
|
||||
|
||||
data class Results(
|
||||
@JsonProperty("id") val aniId: String? = null,
|
||||
@JsonProperty("malId") val malId: Int? = null,
|
||||
@JsonProperty("title") val title: Title? = null,
|
||||
@JsonProperty("releaseDate") val releaseDate: Int? = null,
|
||||
@JsonProperty("type") val type: String? = null,
|
||||
@JsonProperty("image") val image: String? = null,
|
||||
@JsonProperty("cover") val cover: String? = null,
|
||||
)
|
||||
|
||||
data class AniSearch(
|
||||
@JsonProperty("results") val results: ArrayList<Results>? = arrayListOf()
|
||||
)
|
||||
|
||||
/**
|
||||
* used for the getTracker() method
|
||||
**/
|
||||
enum class TrackerType {
|
||||
MOVIE,
|
||||
TV,
|
||||
TV_SHORT,
|
||||
ONA,
|
||||
OVA,
|
||||
SPECIAL,
|
||||
MUSIC;
|
||||
|
||||
companion object {
|
||||
fun getTypes(type: TvType): Set<TrackerType> {
|
||||
return when (type) {
|
||||
TvType.Movie -> setOf(MOVIE)
|
||||
TvType.AnimeMovie -> setOf(MOVIE)
|
||||
TvType.TvSeries -> setOf(TV, TV_SHORT)
|
||||
TvType.Anime -> setOf(TV, TV_SHORT, ONA, OVA)
|
||||
TvType.OVA -> setOf(OVA, SPECIAL, ONA)
|
||||
TvType.Others -> setOf(MUSIC)
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
|
@ -32,6 +34,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|||
import com.google.android.gms.cast.framework.*
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.navigationrail.NavigationRailView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
|
||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
|
@ -55,6 +58,7 @@ import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
|
|||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringPlayer
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
|
||||
|
@ -63,6 +67,9 @@ import com.lagradost.cloudstream3.ui.APIRepository
|
|||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
||||
import com.lagradost.cloudstream3.ui.home.HomeViewModel
|
||||
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
||||
import com.lagradost.cloudstream3.ui.result.setImage
|
||||
|
@ -79,6 +86,7 @@ import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
|
|||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.html
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isNetworkAvailable
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadCache
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
||||
|
@ -86,6 +94,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
|
|||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
|
||||
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.DataStore.setKey
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
|
||||
|
@ -166,7 +175,12 @@ open class ResultResume(
|
|||
|
||||
val VLC = object : ResultResume(
|
||||
VLC_PACKAGE,
|
||||
"org.videolan.vlc.player.result",
|
||||
// Android 13 intent restrictions fucks up specifically launching the VLC player
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
"org.videolan.vlc.player.result"
|
||||
} else {
|
||||
Intent.ACTION_VIEW
|
||||
},
|
||||
"extra_position",
|
||||
"extra_duration",
|
||||
) {
|
||||
|
@ -265,6 +279,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
isWebview: Boolean
|
||||
): Boolean =
|
||||
with(activity) {
|
||||
// TODO MUCH BETTER HANDLING
|
||||
|
||||
// Invalid URIs can crash
|
||||
fun safeURI(uri: String) = normalSafeApiCall { URI(uri) }
|
||||
|
||||
|
@ -315,7 +331,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
} else if (safeURI(str)?.scheme == appStringSearch) {
|
||||
nextSearchQuery =
|
||||
URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8")
|
||||
nav_view.selectedItemId = R.id.navigation_search
|
||||
|
||||
// Use both navigation views to support both layouts.
|
||||
// It might be better to use the QuickSearch.
|
||||
nav_view?.selectedItemId = R.id.navigation_search
|
||||
nav_rail_view?.selectedItemId = R.id.navigation_search
|
||||
} else if (safeURI(str)?.scheme == appStringPlayer) {
|
||||
val uri = Uri.parse(str)
|
||||
val name = uri.getQueryParameter("name")
|
||||
val url = URLDecoder.decode(uri.authority, "UTF-8")
|
||||
|
||||
navigate(
|
||||
R.id.global_to_navigation_player,
|
||||
GeneratorPlayer.newInstance(
|
||||
LinkGenerator(
|
||||
listOf(BasicLink(url, name)),
|
||||
extract = true,
|
||||
)
|
||||
)
|
||||
)
|
||||
} else if (safeURI(str)?.scheme == appStringResumeWatching) {
|
||||
val id =
|
||||
str.substringAfter("$appStringResumeWatching://").toIntOrNull()
|
||||
|
@ -347,7 +381,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
}
|
||||
}
|
||||
|
||||
var lastPopup : SearchResponse? = null
|
||||
var lastPopup: SearchResponse? = null
|
||||
fun loadPopup(result: SearchResponse) {
|
||||
lastPopup = result
|
||||
viewModel.load(
|
||||
|
@ -388,6 +422,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
val isNavVisible = listOf(
|
||||
R.id.navigation_home,
|
||||
R.id.navigation_search,
|
||||
R.id.navigation_library,
|
||||
R.id.navigation_downloads,
|
||||
R.id.navigation_settings,
|
||||
R.id.navigation_download_child,
|
||||
|
@ -401,6 +436,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
R.id.navigation_settings_general,
|
||||
R.id.navigation_settings_extensions,
|
||||
R.id.navigation_settings_plugins,
|
||||
R.id.navigation_test_providers,
|
||||
).contains(destination.id)
|
||||
|
||||
|
||||
|
@ -438,6 +474,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
|
||||
nav_view?.isVisible = isNavVisible && !landscape
|
||||
nav_rail_view?.isVisible = isNavVisible && landscape
|
||||
|
||||
// Hide library on TV since it is not supported yet :(
|
||||
val isTrueTv = isTrueTvSettings()
|
||||
nav_view?.menu?.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
|
||||
nav_rail_view?.menu?.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
|
||||
}
|
||||
|
||||
//private var mCastSession: CastSession? = null
|
||||
|
@ -710,7 +751,35 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
|
||||
changeStatusBarState(isEmulatorSettings())
|
||||
|
||||
if (lastError == null) {
|
||||
// Automatically enable jsdelivr if cant connect to raw.githubusercontent.com
|
||||
if (this.getKey<Boolean>(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) {
|
||||
main {
|
||||
if (checkGithubConnectivity()) {
|
||||
this.setKey(getString(R.string.jsdelivr_proxy_key), false)
|
||||
} else {
|
||||
this.setKey(getString(R.string.jsdelivr_proxy_key), true)
|
||||
val parentView: View = findViewById(android.R.id.content)
|
||||
Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG)
|
||||
.let { snackbar ->
|
||||
snackbar.setAction(R.string.revert) {
|
||||
setKey(getString(R.string.jsdelivr_proxy_key), false)
|
||||
}
|
||||
snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground))
|
||||
snackbar.setTextColor(colorFromAttribute(R.attr.textColor))
|
||||
snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary))
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (PluginManager.checkSafeModeFile()) {
|
||||
normalSafeApiCall {
|
||||
showToast(this, R.string.safe_mode_file, Toast.LENGTH_LONG)
|
||||
}
|
||||
} else if (lastError == null) {
|
||||
ioSafe {
|
||||
getKey<String>(USER_SELECTED_HOMEPAGE_API)?.let { homeApi ->
|
||||
mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi))
|
||||
|
@ -1078,4 +1147,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
// }
|
||||
|
||||
}
|
||||
|
||||
suspend fun checkGithubConnectivity(): Boolean {
|
||||
return try {
|
||||
app.get(
|
||||
"https://raw.githubusercontent.com/recloudstream/.github/master/connectivitycheck",
|
||||
timeout = 5
|
||||
).text.trim() == "ok"
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ 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.lagradost.cloudstream3.utils.Qualities
|
||||
import java.net.URL
|
||||
|
||||
|
@ -42,18 +43,9 @@ open class Dailymotion : ExtractorApi() {
|
|||
)
|
||||
val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = cookies)
|
||||
.parsedSafe<MetaData>() ?: return
|
||||
metaData.qualities.forEach { (key, video) ->
|
||||
metaData.qualities.forEach { (_, video) ->
|
||||
video.forEach {
|
||||
callback.invoke(
|
||||
ExtractorLink(
|
||||
name,
|
||||
"$name $key",
|
||||
it.url,
|
||||
"",
|
||||
Qualities.Unknown.value,
|
||||
true
|
||||
)
|
||||
)
|
||||
getStream(it.url, this.name, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -75,6 +67,17 @@ open class Dailymotion : ExtractorApi() {
|
|||
return null
|
||||
}
|
||||
|
||||
private suspend fun getStream(
|
||||
streamLink: String,
|
||||
name: String,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
return generateM3u8(
|
||||
name,
|
||||
streamLink,
|
||||
"",
|
||||
).forEach(callback)
|
||||
}
|
||||
data class Config(
|
||||
val context: Context,
|
||||
val dmInternalData: InternalData
|
||||
|
|
|
@ -38,6 +38,9 @@ class DoodWsExtractor : DoodLaExtractor() {
|
|||
override var mainUrl = "https://dood.ws"
|
||||
}
|
||||
|
||||
class DoodYtExtractor : DoodLaExtractor() {
|
||||
override var mainUrl = "https://dood.yt"
|
||||
}
|
||||
|
||||
open class DoodLaExtractor : ExtractorApi() {
|
||||
override var name = "DoodStream"
|
||||
|
|
|
@ -16,26 +16,7 @@ open class Evoload : ExtractorApi() {
|
|||
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val lang = url.substring(0, 2)
|
||||
val flag =
|
||||
if (lang == "vo") {
|
||||
" \uD83C\uDDEC\uD83C\uDDE7"
|
||||
}
|
||||
else if (lang == "vf"){
|
||||
" \uD83C\uDDE8\uD83C\uDDF5"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val cleaned_url = if (lang == "ht") { // if url doesn't contain a flag and the url starts with http://
|
||||
url
|
||||
} else {
|
||||
url.substring(2, url.length)
|
||||
}
|
||||
//println(lang)
|
||||
//println(cleaned_url)
|
||||
|
||||
val id = cleaned_url.replace("https://evoload.io/e/", "") // wanted media id
|
||||
val id = url.replace("https://evoload.io/e/", "") // wanted media id
|
||||
val csrv_token = app.get("https://csrv.evosrv.com/captcha?m412548=").text // whatever that is
|
||||
val captchaPass = app.get("https://cd2.evosrv.com/html/jsx/e.jsx").text.take(300).split("captcha_pass = '")[1].split("\'")[0] //extract the captcha pass from the js response (located in the 300 first chars)
|
||||
val payload = mapOf("code" to id, "csrv_token" to csrv_token, "pass" to captchaPass)
|
||||
|
@ -44,9 +25,9 @@ open class Evoload : ExtractorApi() {
|
|||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name + flag,
|
||||
name,
|
||||
link,
|
||||
cleaned_url,
|
||||
url,
|
||||
Qualities.Unknown.value,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -1,38 +1,57 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.SubtitleFile
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8
|
||||
|
||||
|
||||
class Ztreamhub : Filesim() {
|
||||
override val mainUrl: String = "https://ztreamhub.com" //Here 'cause works
|
||||
override val name = "Zstreamhub"
|
||||
}
|
||||
class FileMoon : Filesim() {
|
||||
override val mainUrl = "https://filemoon.to"
|
||||
override val name = "FileMoon"
|
||||
}
|
||||
|
||||
class FileMoonSx : Filesim() {
|
||||
override val mainUrl = "https://filemoon.sx"
|
||||
override val name = "FileMoonSx"
|
||||
}
|
||||
|
||||
open class Filesim : ExtractorApi() {
|
||||
override val name = "Filesim"
|
||||
override val mainUrl = "https://files.im"
|
||||
override val requiresReferer = false
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val sources = mutableListOf<ExtractorLink>()
|
||||
with(app.get(url).document) {
|
||||
this.select("script").map { script ->
|
||||
if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
|
||||
val data = getAndUnpack(script.data()).substringAfter("sources:[").substringBefore("]")
|
||||
tryParseJson<List<ResponseSource>>("[$data]")?.map {
|
||||
M3u8Helper.generateM3u8(
|
||||
name,
|
||||
it.file,
|
||||
"$mainUrl/",
|
||||
).forEach { m3uData -> sources.add(m3uData) }
|
||||
}
|
||||
override suspend fun getUrl(
|
||||
url: String,
|
||||
referer: String?,
|
||||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
val response = app.get(url, referer = mainUrl).document
|
||||
response.select("script[type=text/javascript]").map { script ->
|
||||
if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) {
|
||||
val unpackedscript = getAndUnpack(script.data())
|
||||
val m3u8Regex = Regex("file.\\\"(.*?m3u8.*?)\\\"")
|
||||
val m3u8 = m3u8Regex.find(unpackedscript)?.destructured?.component1() ?: ""
|
||||
if (m3u8.isNotEmpty()) {
|
||||
generateM3u8(
|
||||
name,
|
||||
m3u8,
|
||||
mainUrl
|
||||
).forEach(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
private data class ResponseSource(
|
||||
/* private data class ResponseSource(
|
||||
@JsonProperty("file") val file: String,
|
||||
@JsonProperty("type") val type: String?,
|
||||
@JsonProperty("label") val label: String?
|
||||
)
|
||||
) */
|
||||
|
||||
}
|
|
@ -6,6 +6,11 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
|
|||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
|
||||
class Vanfem : GuardareStream() {
|
||||
override var name = "Vanfem"
|
||||
override var mainUrl = "https://vanfem.com/"
|
||||
}
|
||||
|
||||
class CineGrabber : GuardareStream() {
|
||||
override var name = "CineGrabber"
|
||||
override var mainUrl = "https://cinegrabber.com"
|
||||
|
|
|
@ -18,31 +18,36 @@ open class Linkbox : ExtractorApi() {
|
|||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
val id = Regex("""(/file/|id=)(\S+)[&/?]""").find(url)?.groupValues?.get(2)
|
||||
app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe<Responses>()?.data?.rList?.map { link ->
|
||||
callback.invoke(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link.url,
|
||||
url,
|
||||
getQualityFromName(link.resolution)
|
||||
val id = Regex("""(?:/f/|/file/|\?id=)(\w+)""").find(url)?.groupValues?.get(1)
|
||||
app.get("$mainUrl/api/file/detail?itemId=$id", referer = url)
|
||||
.parsedSafe<Responses>()?.data?.itemInfo?.resolutionList?.map { link ->
|
||||
callback.invoke(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link.url ?: return@map null,
|
||||
url,
|
||||
getQualityFromName(link.resolution)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RList(
|
||||
@JsonProperty("url") val url: String,
|
||||
@JsonProperty("resolution") val resolution: String?,
|
||||
data class Resolutions(
|
||||
@JsonProperty("url") val url: String? = null,
|
||||
@JsonProperty("resolution") val resolution: String? = null,
|
||||
)
|
||||
|
||||
data class ItemInfo(
|
||||
@JsonProperty("resolutionList") val resolutionList: ArrayList<Resolutions>? = arrayListOf(),
|
||||
)
|
||||
|
||||
data class Data(
|
||||
@JsonProperty("rList") val rList: List<RList>?,
|
||||
@JsonProperty("itemInfo") val itemInfo: ItemInfo? = null,
|
||||
)
|
||||
|
||||
data class Responses(
|
||||
@JsonProperty("data") val data: Data?,
|
||||
@JsonProperty("data") val data: Data? = null,
|
||||
)
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.lagradost.cloudstream3.SubtitleFile
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8
|
||||
|
||||
open class Sendvid : ExtractorApi() {
|
||||
override var name = "Sendvid"
|
||||
override val mainUrl = "https://sendvid.com"
|
||||
override val requiresReferer = false
|
||||
override suspend fun getUrl(
|
||||
url: String,
|
||||
referer: String?,
|
||||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
val doc = app.get(url).document
|
||||
val urlString = doc.select("head meta[property=og:video:secure_url]").attr("content")
|
||||
if (urlString.contains("m3u8")) {
|
||||
generateM3u8(
|
||||
name,
|
||||
urlString,
|
||||
mainUrl,
|
||||
).forEach(callback)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -77,6 +77,10 @@ class StreamSB10 : StreamSB() {
|
|||
override var mainUrl = "https://sbplay2.xyz"
|
||||
}
|
||||
|
||||
class StreamSB11 : StreamSB() {
|
||||
override var mainUrl = "https://sbbrisk.com"
|
||||
}
|
||||
|
||||
// This is a modified version of https://github.com/jmir1/aniyomi-extensions/blob/master/src/en/genoanime/src/eu/kanade/tachiyomi/animeextension/en/genoanime/extractors/StreamSBExtractor.kt
|
||||
// The following code is under the Apache License 2.0 https://github.com/jmir1/aniyomi-extensions/blob/master/LICENSE
|
||||
open class StreamSB : ExtractorApi() {
|
||||
|
@ -130,7 +134,7 @@ open class StreamSB : ExtractorApi() {
|
|||
it.value.replace(Regex("(embed-|/e/)"), "")
|
||||
}.first()
|
||||
// val master = "$mainUrl/sources48/6d6144797752744a454267617c7c${bytesToHex.lowercase()}7c7c4e61755a56456f34385243727c7c73747265616d7362/6b4a33767968506e4e71374f7c7c343837323439333133333462353935333633373836643638376337633462333634663539343137373761333635313533333835333763376333393636363133393635366136323733343435323332376137633763373337343732363536313664373336327c7c504d754478413835306633797c7c73747265616d7362"
|
||||
val master = "$mainUrl/sources50/" + bytesToHex("||$id||||streamsb".toByteArray()) + "/"
|
||||
val master = "$mainUrl/sources15/" + bytesToHex("||$id||||streamsb".toByteArray()) + "/"
|
||||
val headers = mapOf(
|
||||
"watchsb" to "sbstream",
|
||||
)
|
||||
|
@ -156,4 +160,4 @@ open class StreamSB : ExtractorApi() {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,10 @@ class Uqload1 : Uqload() {
|
|||
override var mainUrl = "https://uqload.com"
|
||||
}
|
||||
|
||||
class Uqload2 : Uqload() {
|
||||
override var mainUrl = "https://uqload.co"
|
||||
}
|
||||
|
||||
open class Uqload : ExtractorApi() {
|
||||
override val name: String = "Uqload"
|
||||
override val mainUrl: String = "https://www.uqload.com"
|
||||
|
@ -15,30 +19,14 @@ open class Uqload : ExtractorApi() {
|
|||
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
||||
val lang = url.substring(0, 2)
|
||||
val flag =
|
||||
if (lang == "vo") {
|
||||
" \uD83C\uDDEC\uD83C\uDDE7"
|
||||
}
|
||||
else if (lang == "vf"){
|
||||
" \uD83C\uDDE8\uD83C\uDDF5"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val cleaned_url = if (lang == "ht") { // if url doesn't contain a flag and the url starts with http://
|
||||
url
|
||||
} else {
|
||||
url.substring(2, url.length)
|
||||
}
|
||||
with(app.get(cleaned_url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile"
|
||||
with(app.get(url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile"
|
||||
srcRegex.find(this.text)?.groupValues?.get(1)?.replace("\"", "")?.let { link ->
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name + flag,
|
||||
name,
|
||||
link,
|
||||
cleaned_url,
|
||||
url,
|
||||
Qualities.Unknown.value,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -59,8 +59,8 @@ open class VidSrcExtractor : ExtractorApi() {
|
|||
if (datahash.isNotBlank()) {
|
||||
val links = try {
|
||||
app.get(
|
||||
"$absoluteUrl/src/$datahash",
|
||||
referer = "https://source.vidsrc.me/"
|
||||
"$absoluteUrl/srcrcp/$datahash",
|
||||
referer = "https://rcp.vidsrc.me/"
|
||||
).url
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
|
@ -71,7 +71,7 @@ open class VidSrcExtractor : ExtractorApi() {
|
|||
|
||||
serverslist.amap { server ->
|
||||
val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/")
|
||||
if (linkfixed.contains("/pro")) {
|
||||
if (linkfixed.contains("/prorcp")) {
|
||||
val srcresponse = app.get(server, referer = absoluteUrl).text
|
||||
val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)")
|
||||
val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.getAndUnpack
|
||||
|
||||
class Vido : ExtractorApi() {
|
||||
override var name = "Vido"
|
||||
override var mainUrl = "https://vido.lol"
|
||||
private val srcRegex = Regex("""sources:\s*\["(.*?)"\]""")
|
||||
override val requiresReferer = true
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
||||
val methode = app.get(url.replace("/e/", "/embed-")) // fix wiflix and mesfilms
|
||||
with(methode) {
|
||||
if (!methode.isSuccessful) return null
|
||||
//val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
|
||||
srcRegex.find(this.text)?.groupValues?.get(1)?.let { link ->
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link,
|
||||
url,
|
||||
Qualities.Unknown.value,
|
||||
true,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
package com.lagradost.cloudstream3.metaproviders
|
||||
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
||||
import com.lagradost.cloudstream3.utils.SyncUtil
|
||||
|
||||
object SyncRedirector {
|
||||
val syncApis = SyncApis
|
||||
|
||||
suspend fun redirect(url: String, preferredUrl: String): String {
|
||||
for (api in syncApis) {
|
||||
if (url.contains(api.mainUrl)) {
|
||||
val otherApi = when (api.name) {
|
||||
aniListApi.name -> "anilist"
|
||||
malApi.name -> "myanimelist"
|
||||
else -> return url
|
||||
}
|
||||
|
||||
return SyncUtil.getUrlsFromId(api.getIdFromUrl(url), otherApi).firstOrNull { realUrl ->
|
||||
realUrl.contains(preferredUrl)
|
||||
} ?: run {
|
||||
throw ErrorLoadingException("Page does not exist on $preferredUrl")
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package com.lagradost.cloudstream3.metaproviders
|
||||
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
|
||||
object SyncRedirector {
|
||||
val syncApis = SyncApis
|
||||
private val syncIds =
|
||||
listOf(
|
||||
SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""),
|
||||
SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""")
|
||||
)
|
||||
|
||||
suspend fun redirect(
|
||||
url: String,
|
||||
providerApi: MainAPI
|
||||
): String {
|
||||
// Deprecated since providers should do this instead!
|
||||
|
||||
// Tries built in ID -> ProviderUrl
|
||||
/*
|
||||
for (api in syncApis) {
|
||||
if (url.contains(api.mainUrl)) {
|
||||
val otherApi = when (api.name) {
|
||||
aniListApi.name -> "anilist"
|
||||
malApi.name -> "myanimelist"
|
||||
else -> return url
|
||||
}
|
||||
|
||||
SyncUtil.getUrlsFromId(api.getIdFromUrl(url), otherApi).firstOrNull { realUrl ->
|
||||
realUrl.contains(providerApi.mainUrl)
|
||||
}?.let {
|
||||
return it
|
||||
}
|
||||
// ?: run {
|
||||
// throw ErrorLoadingException("Page does not exist on $preferredUrl")
|
||||
// }
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Tries provider solution
|
||||
// This goes through all sync ids and finds supported id by said provider
|
||||
return syncIds.firstNotNullOfOrNull { (syncName, syncRegex) ->
|
||||
if (providerApi.supportedSyncNames.contains(syncName)) {
|
||||
syncRegex.find(url)?.value?.let {
|
||||
suspendSafeApiCall {
|
||||
providerApi.getLoadUrl(syncName, it)
|
||||
}
|
||||
}
|
||||
} else null
|
||||
} ?: url
|
||||
}
|
||||
}
|
|
@ -49,7 +49,7 @@ inline fun debugWarning(assert: () -> Boolean, message: () -> String) {
|
|||
}
|
||||
}
|
||||
|
||||
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||
}
|
||||
|
||||
|
@ -121,13 +121,21 @@ suspend fun <T> suspendSafeApiCall(apiCall: suspend () -> T): T? {
|
|||
}
|
||||
}
|
||||
|
||||
fun Throwable.getAllMessages(): String {
|
||||
return (this.localizedMessage ?: "") + (this.cause?.getAllMessages()?.let { "\n$it" } ?: "")
|
||||
}
|
||||
|
||||
fun Throwable.getStackTracePretty(showMessage: Boolean = true): String {
|
||||
val prefix = if (showMessage) this.localizedMessage?.let { "\n$it" } ?: "" else ""
|
||||
return prefix + this.stackTrace.joinToString(
|
||||
separator = "\n"
|
||||
) {
|
||||
"${it.fileName} ${it.lineNumber}"
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> safeFail(throwable: Throwable): Resource<T> {
|
||||
val stackTraceMsg =
|
||||
(throwable.localizedMessage ?: "") + "\n\n" + throwable.stackTrace.joinToString(
|
||||
separator = "\n"
|
||||
) {
|
||||
"${it.fileName} ${it.lineNumber}"
|
||||
}
|
||||
val stackTraceMsg = throwable.getStackTracePretty()
|
||||
return Resource.Failure(false, null, null, stackTraceMsg)
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import com.google.gson.Gson
|
|||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
||||
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
|
@ -144,8 +145,10 @@ object PluginManager {
|
|||
return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray()
|
||||
}
|
||||
|
||||
private val LOCAL_PLUGINS_PATH =
|
||||
Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins"
|
||||
private val CLOUD_STREAM_FOLDER =
|
||||
Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/"
|
||||
|
||||
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
|
||||
|
||||
public var currentlyLoading: String? = null
|
||||
|
||||
|
@ -163,11 +166,11 @@ object PluginManager {
|
|||
private var loadedLocalPlugins = false
|
||||
private val gson = Gson()
|
||||
|
||||
private suspend fun maybeLoadPlugin(activity: Activity, file: File) {
|
||||
private suspend fun maybeLoadPlugin(context: Context, file: File) {
|
||||
val name = file.name
|
||||
if (file.extension == "zip" || file.extension == "cs3") {
|
||||
loadPlugin(
|
||||
activity,
|
||||
context,
|
||||
file,
|
||||
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
|
||||
)
|
||||
|
@ -197,7 +200,7 @@ object PluginManager {
|
|||
|
||||
// var allCurrentOutDatedPlugins: Set<OnlinePluginData> = emptySet()
|
||||
|
||||
suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean {
|
||||
suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean {
|
||||
return (getPluginsOnline().firstOrNull {
|
||||
// Most of the time the provider ends with Provider which isn't part of the api name
|
||||
it.internalName.replace("provider", "", ignoreCase = true) == apiName
|
||||
|
@ -207,7 +210,7 @@ object PluginManager {
|
|||
})?.let { savedData ->
|
||||
// OnlinePluginData(savedData, onlineData)
|
||||
loadPlugin(
|
||||
activity,
|
||||
context,
|
||||
File(savedData.filePath),
|
||||
savedData
|
||||
)
|
||||
|
@ -369,11 +372,11 @@ object PluginManager {
|
|||
/**
|
||||
* Use updateAllOnlinePluginsAndLoadThem
|
||||
* */
|
||||
fun loadAllOnlinePlugins(activity: Activity) {
|
||||
fun loadAllOnlinePlugins(context: Context) {
|
||||
// Load all plugins as fast as possible!
|
||||
(getPluginsOnline()).toList().apmap { pluginData ->
|
||||
loadPlugin(
|
||||
activity,
|
||||
context,
|
||||
File(pluginData.filePath),
|
||||
pluginData
|
||||
)
|
||||
|
@ -396,7 +399,7 @@ object PluginManager {
|
|||
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
|
||||
* and reload all pages even if they are previously valid
|
||||
**/
|
||||
fun loadAllLocalPlugins(activity: Activity, forceReload: Boolean) {
|
||||
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||
val dir = File(LOCAL_PLUGINS_PATH)
|
||||
removeKey(PLUGINS_KEY_LOCAL)
|
||||
|
||||
|
@ -414,24 +417,39 @@ object PluginManager {
|
|||
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins")
|
||||
|
||||
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
|
||||
maybeLoadPlugin(activity, file)
|
||||
maybeLoadPlugin(context, file)
|
||||
}
|
||||
|
||||
loadedLocalPlugins = true
|
||||
afterPluginsLoadedEvent.invoke(forceReload)
|
||||
}
|
||||
|
||||
/**
|
||||
* This can be used to override any extension loading to fix crashes!
|
||||
* @return true if safe mode file is present
|
||||
**/
|
||||
fun checkSafeModeFile(): Boolean {
|
||||
return normalSafeApiCall {
|
||||
val folder = File(CLOUD_STREAM_FOLDER)
|
||||
if (!folder.exists()) return@normalSafeApiCall false
|
||||
val files = folder.listFiles { _, name ->
|
||||
name.equals("safe", ignoreCase = true)
|
||||
}
|
||||
files?.any()
|
||||
} ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if successful, false if not
|
||||
* */
|
||||
private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean {
|
||||
private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
|
||||
val fileName = file.nameWithoutExtension
|
||||
val filePath = file.absolutePath
|
||||
currentlyLoading = fileName
|
||||
Log.i(TAG, "Loading plugin: $data")
|
||||
|
||||
return try {
|
||||
val loader = PathClassLoader(filePath, activity.classLoader)
|
||||
val loader = PathClassLoader(filePath, context.classLoader)
|
||||
var manifest: Plugin.Manifest
|
||||
loader.getResourceAsStream("manifest.json").use { stream ->
|
||||
if (stream == null) {
|
||||
|
@ -475,22 +493,22 @@ object PluginManager {
|
|||
addAssetPath.invoke(assets, file.absolutePath)
|
||||
pluginInstance.resources = Resources(
|
||||
assets,
|
||||
activity.resources.displayMetrics,
|
||||
activity.resources.configuration
|
||||
context.resources.displayMetrics,
|
||||
context.resources.configuration
|
||||
)
|
||||
}
|
||||
plugins[filePath] = pluginInstance
|
||||
classLoaders[loader] = pluginInstance
|
||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||
pluginInstance.load(activity)
|
||||
pluginInstance.load(context)
|
||||
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
|
||||
currentlyLoading = null
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
|
||||
showToast(
|
||||
activity,
|
||||
activity.getString(R.string.plugin_load_fail).format(fileName),
|
||||
context.getActivity(),
|
||||
context.getString(R.string.plugin_load_fail).format(fileName),
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
currentlyLoading = null
|
||||
|
|
|
@ -2,8 +2,10 @@ package com.lagradost.cloudstream3.plugins
|
|||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.amap
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
@ -71,6 +73,15 @@ object RepositoryManager {
|
|||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||
}
|
||||
val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||
|
||||
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
||||
fun convertRawGitUrl(url: String): String {
|
||||
if (getKey<Boolean>(context!!.getString(R.string.jsdelivr_proxy_key)) != true) return url
|
||||
val match = GH_REGEX.find(url) ?: return url
|
||||
val (user, repo, rest) = match.destructured
|
||||
return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest"
|
||||
}
|
||||
|
||||
suspend fun parseRepoUrl(url: String): String? {
|
||||
val fixedUrl = url.trim()
|
||||
|
@ -84,10 +95,15 @@ object RepositoryManager {
|
|||
}
|
||||
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
|
||||
suspendSafeApiCall {
|
||||
app.get("https://l.cloudstream.cf/${fixedUrl}").let {
|
||||
return@let if (it.isSuccessful && !it.url.startsWith("https://cutt.ly/branded-domains")) it.url
|
||||
else app.get("https://cutt.ly/${fixedUrl}").let let2@{ it2 ->
|
||||
return@let2 if (it2.isSuccessful) it2.url else null
|
||||
app.get("https://l.cloudstream.cf/${fixedUrl}", allowRedirects = false).let {
|
||||
it.headers["Location"]?.let { url ->
|
||||
return@suspendSafeApiCall if (!url.startsWith("https://cutt.ly/branded-domains")) url
|
||||
else null
|
||||
}
|
||||
app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
|
||||
it2.headers["Location"]?.let { url ->
|
||||
return@suspendSafeApiCall if (url.startsWith("https://cutt.ly/404")) url else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -97,14 +113,14 @@ object RepositoryManager {
|
|||
suspend fun parseRepository(url: String): Repository? {
|
||||
return suspendSafeApiCall {
|
||||
// Take manifestVersion and such into account later
|
||||
app.get(url).parsedSafe()
|
||||
app.get(convertRawGitUrl(url)).parsedSafe()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
|
||||
// Take manifestVersion and such into account later
|
||||
return try {
|
||||
val response = app.get(pluginUrls)
|
||||
val response = app.get(convertRawGitUrl(pluginUrls))
|
||||
// Normal parsed function not working?
|
||||
// return response.parsedSafe()
|
||||
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
|
||||
|
@ -139,7 +155,7 @@ object RepositoryManager {
|
|||
}
|
||||
file.createNewFile()
|
||||
|
||||
val body = app.get(pluginUrl).okhttpResponse.body
|
||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||
write(body.byteStream(), file.outputStream())
|
||||
file
|
||||
}
|
||||
|
|
|
@ -0,0 +1,224 @@
|
|||
package com.lagradost.cloudstream3.services
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.*
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||
import com.lagradost.cloudstream3.ui.result.txt
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions"
|
||||
const val SUBSCRIPTION_WORK_NAME = "work_subscription"
|
||||
const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions"
|
||||
const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows"
|
||||
const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique
|
||||
|
||||
class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
companion object {
|
||||
fun enqueuePeriodicWork(context: Context?) {
|
||||
if (context == null) return
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val periodicSyncDataWork =
|
||||
PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS)
|
||||
.addTag(SUBSCRIPTION_WORK_NAME)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
SUBSCRIPTION_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
periodicSyncDataWork
|
||||
)
|
||||
|
||||
// Uncomment below for testing
|
||||
|
||||
// val oneTimeSyncDataWork =
|
||||
// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java)
|
||||
// .addTag(SUBSCRIPTION_WORK_NAME)
|
||||
// .setConstraints(constraints)
|
||||
// .build()
|
||||
//
|
||||
// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork)
|
||||
}
|
||||
}
|
||||
|
||||
private val progressNotificationBuilder =
|
||||
NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
|
||||
.setAutoCancel(false)
|
||||
.setColorized(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||
.setContentTitle(context.getString(R.string.subscription_in_progress_notification))
|
||||
.setSmallIcon(R.drawable.quantum_ic_refresh_white_24)
|
||||
.setProgress(0, 0, true)
|
||||
|
||||
private val updateNotificationBuilder =
|
||||
NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
|
||||
.setColorized(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||
.setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
|
||||
|
||||
private val notificationManager: NotificationManager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) {
|
||||
notificationManager.notify(
|
||||
SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder
|
||||
.setProgress(max, progress, indeterminate)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// println("Update subscriptions!")
|
||||
context.createNotificationChannel(
|
||||
SUBSCRIPTION_CHANNEL_ID,
|
||||
SUBSCRIPTION_CHANNEL_NAME,
|
||||
SUBSCRIPTION_CHANNEL_DESCRIPTION
|
||||
)
|
||||
|
||||
setForeground(
|
||||
ForegroundInfo(
|
||||
SUBSCRIPTION_NOTIFICATION_ID,
|
||||
progressNotificationBuilder.build()
|
||||
)
|
||||
)
|
||||
|
||||
val subscriptions = getAllSubscriptions()
|
||||
|
||||
if (subscriptions.isEmpty()) {
|
||||
WorkManager.getInstance(context).cancelWorkById(this.id)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val max = subscriptions.size
|
||||
var progress = 0
|
||||
|
||||
updateProgress(max, progress, true)
|
||||
|
||||
// We need all plugins loaded.
|
||||
PluginManager.loadAllOnlinePlugins(context)
|
||||
PluginManager.loadAllLocalPlugins(context, false)
|
||||
|
||||
subscriptions.apmap { savedData ->
|
||||
try {
|
||||
val id = savedData.id ?: return@apmap null
|
||||
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
|
||||
|
||||
// Reasonable timeout to prevent having this worker run forever.
|
||||
val response = withTimeoutOrNull(60_000) {
|
||||
api.load(savedData.url) as? EpisodeResponse
|
||||
} ?: return@apmap null
|
||||
|
||||
val dubPreference =
|
||||
getDub(id) ?: if (
|
||||
context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
|
||||
) {
|
||||
DubStatus.Dubbed
|
||||
} else {
|
||||
DubStatus.Subbed
|
||||
}
|
||||
|
||||
val latestEpisodes = response.getLatestEpisodes()
|
||||
val latestPreferredEpisode = latestEpisodes[dubPreference]
|
||||
|
||||
val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
|
||||
val latestSeenEpisode =
|
||||
savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
|
||||
val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
|
||||
shouldUpdate to latestPreferredEpisode
|
||||
} else {
|
||||
val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
|
||||
val latestSeenEpisode =
|
||||
savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
|
||||
val shouldUpdate = latestEpisode > latestSeenEpisode
|
||||
shouldUpdate to latestEpisode
|
||||
}
|
||||
|
||||
DataStoreHelper.updateSubscribedData(
|
||||
id,
|
||||
savedData,
|
||||
response
|
||||
)
|
||||
|
||||
if (shouldUpdate) {
|
||||
val updateHeader = savedData.name
|
||||
val updateDescription = txt(
|
||||
R.string.subscription_episode_released,
|
||||
latestEpisode,
|
||||
savedData.name
|
||||
).asString(context)
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
data = savedData.url.toUri()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
|
||||
val pendingIntent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
} else {
|
||||
PendingIntent.getActivity(context, 0, intent, 0)
|
||||
}
|
||||
|
||||
val poster = ioWork {
|
||||
savedData.posterUrl?.let { url ->
|
||||
context.getImageBitmapFromUrl(
|
||||
url,
|
||||
savedData.posterHeaders
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val updateNotification =
|
||||
updateNotificationBuilder.setContentTitle(updateHeader)
|
||||
.setContentText(updateDescription)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setLargeIcon(poster)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(id, updateNotification)
|
||||
}
|
||||
|
||||
// You can probably get some issues here since this is async but it does not matter much.
|
||||
updateProgress(max, ++progress, false)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
|
@ -1,11 +1,22 @@
|
|||
package com.lagradost.cloudstream3.services
|
||||
|
||||
import android.app.IntentService
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class VideoDownloadService : IntentService("VideoDownloadService") {
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
class VideoDownloadService : Service() {
|
||||
|
||||
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
val id = intent.getIntExtra("id", -1)
|
||||
val type = intent.getStringExtra("type")
|
||||
|
@ -14,10 +25,36 @@ class VideoDownloadService : IntentService("VideoDownloadService") {
|
|||
"resume" -> VideoDownloadManager.DownloadActionType.Resume
|
||||
"pause" -> VideoDownloadManager.DownloadActionType.Pause
|
||||
"stop" -> VideoDownloadManager.DownloadActionType.Stop
|
||||
else -> return
|
||||
else -> return START_NOT_STICKY
|
||||
}
|
||||
|
||||
downloadScope.launch {
|
||||
VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||
}
|
||||
VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
downloadScope.coroutineContext.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
// override fun onHandleIntent(intent: Intent?) {
|
||||
// if (intent != null) {
|
||||
// val id = intent.getIntExtra("id", -1)
|
||||
// val type = intent.getStringExtra("type")
|
||||
// if (id != -1 && type != null) {
|
||||
// val state = when (type) {
|
||||
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
|
||||
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
|
||||
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
|
||||
// else -> return
|
||||
// }
|
||||
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -13,6 +13,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||
val indexSubtitlesApi = IndexSubtitleApi()
|
||||
val addic7ed = Addic7ed()
|
||||
val localListApi = LocalList()
|
||||
|
||||
// used to login via app intent
|
||||
val OAuth2Apis
|
||||
|
@ -29,7 +30,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
// used for active syncing
|
||||
val SyncApis
|
||||
get() = listOf(
|
||||
SyncRepo(malApi), SyncRepo(aniListApi)
|
||||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
|
||||
)
|
||||
|
||||
val inAppAuths
|
||||
|
@ -44,6 +45,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
|
||||
const val appString = "cloudstreamapp"
|
||||
const val appStringRepo = "cloudstreamrepo"
|
||||
const val appStringPlayer = "cloudstreamplayer"
|
||||
|
||||
// Instantly start the search given a query
|
||||
const val appStringSearch = "cloudstreamsearch"
|
||||
|
|
|
@ -1,10 +1,31 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.ui.result.UiText
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
|
||||
enum class SyncIdName {
|
||||
Anilist,
|
||||
MyAnimeList,
|
||||
Trakt,
|
||||
Imdb,
|
||||
LocalList
|
||||
}
|
||||
|
||||
interface SyncAPI : OAuth2API {
|
||||
/**
|
||||
* Set this to true if the user updates something on the list like watch status or score
|
||||
**/
|
||||
var requireLibraryRefresh: Boolean
|
||||
val mainUrl: String
|
||||
|
||||
/**
|
||||
* Allows certain providers to open pages from
|
||||
* library links.
|
||||
**/
|
||||
val syncIdName: SyncIdName
|
||||
|
||||
/**
|
||||
-1 -> None
|
||||
0 -> Watching
|
||||
|
@ -22,7 +43,9 @@ interface SyncAPI : OAuth2API {
|
|||
|
||||
suspend fun search(name: String): List<SyncSearchResult>?
|
||||
|
||||
fun getIdFromUrl(url : String) : String
|
||||
suspend fun getPersonalLibrary(): LibraryMetadata?
|
||||
|
||||
fun getIdFromUrl(url: String): String
|
||||
|
||||
data class SyncSearchResult(
|
||||
override val name: String,
|
||||
|
@ -42,7 +65,7 @@ interface SyncAPI : OAuth2API {
|
|||
val score: Int?,
|
||||
val watchedEpisodes: Int?,
|
||||
var isFavorite: Boolean? = null,
|
||||
var maxEpisodes : Int? = null,
|
||||
var maxEpisodes: Int? = null,
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
|
@ -63,9 +86,9 @@ interface SyncAPI : OAuth2API {
|
|||
var genres: List<String>? = null,
|
||||
var synonyms: List<String>? = null,
|
||||
var trailers: List<String>? = null,
|
||||
var isAdult : Boolean? = null,
|
||||
var isAdult: Boolean? = null,
|
||||
var posterUrl: String? = null,
|
||||
var backgroundPosterUrl : String? = null,
|
||||
var backgroundPosterUrl: String? = null,
|
||||
|
||||
/** In unixtime */
|
||||
var startDate: Long? = null,
|
||||
|
@ -76,4 +99,61 @@ interface SyncAPI : OAuth2API {
|
|||
var prevSeason: SyncSearchResult? = null,
|
||||
var actors: List<ActorData>? = null,
|
||||
)
|
||||
|
||||
|
||||
data class Page(
|
||||
val title: UiText, var items: List<LibraryItem>
|
||||
) {
|
||||
fun sort(method: ListSorting?, query: String? = null) {
|
||||
items = when (method) {
|
||||
ListSorting.Query ->
|
||||
if (query != null) {
|
||||
items.sortedBy {
|
||||
-FuzzySearch.partialRatio(
|
||||
query.lowercase(), it.name.lowercase()
|
||||
)
|
||||
}
|
||||
} else items
|
||||
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) }
|
||||
ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) }
|
||||
ListSorting.AlphabeticalA -> items.sortedBy { it.name }
|
||||
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
||||
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
||||
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
|
||||
else -> items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LibraryMetadata(
|
||||
val allLibraryLists: List<LibraryList>,
|
||||
val supportedListSorting: Set<ListSorting>
|
||||
)
|
||||
|
||||
data class LibraryList(
|
||||
val name: UiText,
|
||||
val items: List<LibraryItem>
|
||||
)
|
||||
|
||||
data class LibraryItem(
|
||||
override val name: String,
|
||||
override val url: String,
|
||||
/**
|
||||
* Unique unchanging string used for data storage.
|
||||
* This should be the actual id when you change scores and status
|
||||
* since score changes from library might get added in the future.
|
||||
**/
|
||||
val syncId: String,
|
||||
val episodesCompleted: Int?,
|
||||
val episodesTotal: Int?,
|
||||
/** Out of 100 */
|
||||
val personalRating: Int?,
|
||||
val lastUpdatedUnixTime: Long?,
|
||||
override val apiName: String,
|
||||
override var type: TvType?,
|
||||
override var posterUrl: String?,
|
||||
override var posterHeaders: Map<String, String>?,
|
||||
override var quality: SearchQuality?,
|
||||
override var id: Int? = null,
|
||||
) : SearchResponse
|
||||
}
|
|
@ -11,26 +11,38 @@ class SyncRepo(private val repo: SyncAPI) {
|
|||
val icon = repo.icon
|
||||
val mainUrl = repo.mainUrl
|
||||
val requiresLogin = repo.requiresLogin
|
||||
val syncIdName = repo.syncIdName
|
||||
var requireLibraryRefresh: Boolean
|
||||
get() = repo.requireLibraryRefresh
|
||||
set(value) {
|
||||
repo.requireLibraryRefresh = value
|
||||
}
|
||||
|
||||
suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource<Boolean> {
|
||||
return safeApiCall { repo.score(id, status) }
|
||||
}
|
||||
|
||||
suspend fun getStatus(id : String) : Resource<SyncAPI.SyncStatus> {
|
||||
suspend fun getStatus(id: String): Resource<SyncAPI.SyncStatus> {
|
||||
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
|
||||
}
|
||||
|
||||
suspend fun getResult(id : String) : Resource<SyncAPI.SyncResult> {
|
||||
suspend fun getResult(id: String): Resource<SyncAPI.SyncResult> {
|
||||
return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") }
|
||||
}
|
||||
|
||||
suspend fun search(query : String) : Resource<List<SyncAPI.SyncSearchResult>> {
|
||||
suspend fun search(query: String): Resource<List<SyncAPI.SyncSearchResult>> {
|
||||
return safeApiCall { repo.search(query) ?: throw ErrorLoadingException() }
|
||||
}
|
||||
|
||||
fun hasAccount() : Boolean {
|
||||
suspend fun getPersonalLibrary(): Resource<SyncAPI.LibraryMetadata> {
|
||||
return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() }
|
||||
}
|
||||
|
||||
fun hasAccount(): Boolean {
|
||||
return normalSafeApiCall { repo.loginInfo() != null } ?: false
|
||||
}
|
||||
|
||||
fun getIdFromUrl(url : String) : String = repo.getIdFromUrl(url)
|
||||
fun getIdFromUrl(url: String): String? = normalSafeApiCall {
|
||||
repo.getIdFromUrl(url)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
@ -12,6 +12,9 @@ import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
|||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.ui.result.txt
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
|
@ -27,10 +30,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
override val key = "6871"
|
||||
override val redirectUrl = "anilistlogin"
|
||||
override val idPrefix = "anilist"
|
||||
override var requireLibraryRefresh = true
|
||||
override var mainUrl = "https://anilist.co"
|
||||
override val icon = R.drawable.ic_anilist_icon
|
||||
override val requiresLogin = false
|
||||
override val createAccountUrl = "$mainUrl/signup"
|
||||
override val syncIdName = SyncIdName.Anilist
|
||||
|
||||
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||
// context.getUser(true)?.
|
||||
|
@ -45,6 +50,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
}
|
||||
|
||||
override fun logOut() {
|
||||
requireLibraryRefresh = true
|
||||
removeAccountKeys()
|
||||
}
|
||||
|
||||
|
@ -64,8 +70,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
switchToNewAccount()
|
||||
setKey(accountId, ANILIST_UNIXTIME_KEY, endTime)
|
||||
setKey(accountId, ANILIST_TOKEN_KEY, token)
|
||||
setKey(ANILIST_SHOULD_UPDATE_LIST, true)
|
||||
val user = getUser()
|
||||
requireLibraryRefresh = true
|
||||
return user != null
|
||||
}
|
||||
|
||||
|
@ -140,7 +146,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
this.name,
|
||||
recMedia.id?.toString() ?: return@mapNotNull null,
|
||||
getUrlFromId(recMedia.id),
|
||||
recMedia.coverImage?.large ?: recMedia.coverImage?.medium
|
||||
recMedia.coverImage?.extraLarge ?: recMedia.coverImage?.large
|
||||
?: recMedia.coverImage?.medium
|
||||
)
|
||||
},
|
||||
trailers = when (season.trailer?.site?.lowercase()?.trim()) {
|
||||
|
@ -170,7 +177,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
fromIntToAnimeStatus(status.status),
|
||||
status.score,
|
||||
status.watchedEpisodes
|
||||
)
|
||||
).also {
|
||||
requireLibraryRefresh = requireLibraryRefresh || it
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -181,7 +190,6 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api
|
||||
const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile
|
||||
const val ANILIST_CACHED_LIST: String = "anilist_cached_list"
|
||||
const val ANILIST_SHOULD_UPDATE_LIST: String = "anilist_should_update_list"
|
||||
|
||||
private fun fixName(name: String): String {
|
||||
return name.lowercase(Locale.ROOT).replace(" ", "")
|
||||
|
@ -219,7 +227,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
romaji
|
||||
}
|
||||
idMal
|
||||
coverImage { medium large }
|
||||
coverImage { medium large extraLarge }
|
||||
averageScore
|
||||
}
|
||||
}
|
||||
|
@ -232,7 +240,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
format
|
||||
id
|
||||
idMal
|
||||
coverImage { medium large }
|
||||
coverImage { medium large extraLarge }
|
||||
averageScore
|
||||
title {
|
||||
english
|
||||
|
@ -292,15 +300,13 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
val shows = searchShows(name.replace(blackListRegex, ""))
|
||||
|
||||
shows?.data?.Page?.media?.find {
|
||||
malId ?: "NONE" == it.idMal.toString()
|
||||
(malId ?: "NONE") == it.idMal.toString()
|
||||
}?.let { return it }
|
||||
|
||||
val filtered =
|
||||
shows?.data?.Page?.media?.filter {
|
||||
(
|
||||
it.startDate.year ?: year.toString() == year.toString()
|
||||
|| year == null
|
||||
)
|
||||
(((it.startDate.year ?: year.toString()) == year.toString()
|
||||
|| year == null))
|
||||
}
|
||||
filtered?.forEach {
|
||||
it.title.romaji?.let { romaji ->
|
||||
|
@ -312,14 +318,14 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
}
|
||||
|
||||
// Changing names of these will show up in UI
|
||||
enum class AniListStatusType(var value: Int) {
|
||||
Watching(0),
|
||||
Completed(1),
|
||||
Paused(2),
|
||||
Dropped(3),
|
||||
Planning(4),
|
||||
ReWatching(5),
|
||||
None(-1)
|
||||
enum class AniListStatusType(var value: Int, @StringRes val stringRes: Int) {
|
||||
Watching(0, R.string.type_watching),
|
||||
Completed(1, R.string.type_completed),
|
||||
Paused(2, R.string.type_on_hold),
|
||||
Dropped(3, R.string.type_dropped),
|
||||
Planning(4, R.string.type_plan_to_watch),
|
||||
ReWatching(5, R.string.type_re_watching),
|
||||
None(-1, R.string.none)
|
||||
}
|
||||
|
||||
fun fromIntToAnimeStatus(inp: Int): AniListStatusType {//= AniListStatusType.values().first { it.value == inp }
|
||||
|
@ -335,7 +341,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
}
|
||||
}
|
||||
|
||||
fun convertAnilistStringToStatus(string: String): AniListStatusType {
|
||||
fun convertAniListStringToStatus(string: String): AniListStatusType {
|
||||
return fromIntToAnimeStatus(aniListStatusString.indexOf(string))
|
||||
}
|
||||
|
||||
|
@ -526,7 +532,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
app.post(
|
||||
"https://graphql.anilist.co/",
|
||||
headers = mapOf(
|
||||
"Authorization" to "Bearer " + (getAuth() ?: return@suspendSafeApiCall null),
|
||||
"Authorization" to "Bearer " + (getAuth()
|
||||
?: return@suspendSafeApiCall null),
|
||||
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
|
||||
),
|
||||
cacheTime = 0,
|
||||
|
@ -575,7 +582,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
|
||||
data class CoverImage(
|
||||
@JsonProperty("medium") val medium: String?,
|
||||
@JsonProperty("large") val large: String?
|
||||
@JsonProperty("large") val large: String?,
|
||||
@JsonProperty("extraLarge") val extraLarge: String?
|
||||
)
|
||||
|
||||
data class Media(
|
||||
|
@ -602,7 +610,29 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
@JsonProperty("score") val score: Int,
|
||||
@JsonProperty("private") val private: Boolean,
|
||||
@JsonProperty("media") val media: Media
|
||||
)
|
||||
) {
|
||||
fun toLibraryItem(): SyncAPI.LibraryItem {
|
||||
return SyncAPI.LibraryItem(
|
||||
// English title first
|
||||
this.media.title.english ?: this.media.title.romaji
|
||||
?: this.media.synonyms.firstOrNull()
|
||||
?: "",
|
||||
"https://anilist.co/anime/${this.media.id}/",
|
||||
this.media.id.toString(),
|
||||
this.progress,
|
||||
this.media.episodes,
|
||||
this.score,
|
||||
this.updatedAt.toLong(),
|
||||
"AniList",
|
||||
TvType.Anime,
|
||||
this.media.coverImage.extraLarge ?: this.media.coverImage.large
|
||||
?: this.media.coverImage.medium,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Lists(
|
||||
@JsonProperty("status") val status: String?,
|
||||
|
@ -617,40 +647,59 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
@JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
|
||||
)
|
||||
|
||||
fun getAnilistListCached(): Array<Lists>? {
|
||||
private fun getAniListListCached(): Array<Lists>? {
|
||||
return getKey(ANILIST_CACHED_LIST) as? Array<Lists>
|
||||
}
|
||||
|
||||
suspend fun getAnilistAnimeListSmart(): Array<Lists>? {
|
||||
private suspend fun getAniListAnimeListSmart(): Array<Lists>? {
|
||||
if (getAuth() == null) return null
|
||||
|
||||
if (checkToken()) return null
|
||||
return if (getKey(ANILIST_SHOULD_UPDATE_LIST, true) == true) {
|
||||
val list = getFullAnilistList()?.data?.MediaListCollection?.lists?.toTypedArray()
|
||||
return if (requireLibraryRefresh) {
|
||||
val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray()
|
||||
if (list != null) {
|
||||
setKey(ANILIST_CACHED_LIST, list)
|
||||
setKey(ANILIST_SHOULD_UPDATE_LIST, false)
|
||||
}
|
||||
list
|
||||
} else {
|
||||
getAnilistListCached()
|
||||
getAniListListCached()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFullAnilistList(): FullAnilistList? {
|
||||
var userID: Int? = null
|
||||
/** WARNING ASSUMES ONE USER! **/
|
||||
getKeys(ANILIST_USER_KEY)?.forEach { key ->
|
||||
getKey<AniListUser>(key, null)?.let {
|
||||
userID = it.id
|
||||
}
|
||||
}
|
||||
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
|
||||
val list = getAniListAnimeListSmart()?.groupBy {
|
||||
convertAniListStringToStatus(it.status ?: "").stringRes
|
||||
}?.mapValues { group ->
|
||||
group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
|
||||
} ?: emptyMap()
|
||||
|
||||
val fixedUserID = userID ?: return null
|
||||
// To fill empty lists when AniList does not return them
|
||||
val baseMap =
|
||||
AniListStatusType.values().filter { it.value >= 0 }.associate {
|
||||
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
||||
}
|
||||
|
||||
return SyncAPI.LibraryMetadata(
|
||||
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
||||
setOf(
|
||||
ListSorting.AlphabeticalA,
|
||||
ListSorting.AlphabeticalZ,
|
||||
ListSorting.UpdatedNew,
|
||||
ListSorting.UpdatedOld,
|
||||
ListSorting.RatingHigh,
|
||||
ListSorting.RatingLow,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getFullAniListList(): FullAnilistList? {
|
||||
/** WARNING ASSUMES ONE USER! **/
|
||||
|
||||
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return null
|
||||
val mediaType = "ANIME"
|
||||
|
||||
val query = """
|
||||
query (${'$'}userID: Int = $fixedUserID, ${'$'}MEDIA: MediaType = $mediaType) {
|
||||
query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) {
|
||||
MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) {
|
||||
lists {
|
||||
status
|
||||
|
@ -661,7 +710,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
startedAt { year month day }
|
||||
updatedAt
|
||||
progress
|
||||
score
|
||||
score (format: POINT_100)
|
||||
private
|
||||
media
|
||||
{
|
||||
|
@ -677,7 +726,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
english
|
||||
romaji
|
||||
}
|
||||
coverImage { medium }
|
||||
coverImage { extraLarge large medium }
|
||||
synonyms
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
|
@ -710,6 +759,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
return data != ""
|
||||
}
|
||||
|
||||
/** Used to query a saved MediaItem on the list to get the id for removal */
|
||||
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
|
||||
data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = null)
|
||||
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||
|
||||
private suspend fun postDataAboutId(
|
||||
id: Int,
|
||||
type: AniListStatusType,
|
||||
|
@ -717,19 +771,43 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
progress: Int?
|
||||
): Boolean {
|
||||
val q =
|
||||
"""mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
|
||||
aniListStatusString[maxOf(
|
||||
0,
|
||||
type.value
|
||||
)]
|
||||
}, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
|
||||
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
score
|
||||
}
|
||||
// Delete item if status type is None
|
||||
if (type == AniListStatusType.None) {
|
||||
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return false
|
||||
// Get list ID for deletion
|
||||
val idQuery = """
|
||||
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
|
||||
MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
val response = postApi(idQuery)
|
||||
val listId =
|
||||
tryParseJson<MediaListItemRoot>(response)?.data?.MediaList?.id ?: return false
|
||||
"""
|
||||
mutation(${'$'}id: Int = $listId) {
|
||||
DeleteMediaListEntry(id: ${'$'}id) {
|
||||
deleted
|
||||
}
|
||||
}
|
||||
"""
|
||||
} else {
|
||||
"""mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
|
||||
aniListStatusString[maxOf(
|
||||
0,
|
||||
type.value
|
||||
)]
|
||||
}, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
|
||||
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
score
|
||||
}
|
||||
}"""
|
||||
}
|
||||
|
||||
val data = postApi(q)
|
||||
return data != ""
|
||||
}
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.ui.result.txt
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||
|
||||
class LocalList : SyncAPI {
|
||||
override val name = "Local"
|
||||
override val icon: Int = R.drawable.ic_baseline_storage_24
|
||||
override val requiresLogin = false
|
||||
override val createAccountUrl: Nothing? = null
|
||||
override val idPrefix = "local"
|
||||
override var requireLibraryRefresh = true
|
||||
|
||||
override fun loginInfo(): AuthAPI.LoginInfo {
|
||||
return AuthAPI.LoginInfo(
|
||||
null,
|
||||
null,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
override fun logOut() {
|
||||
|
||||
}
|
||||
|
||||
override val key: String = ""
|
||||
override val redirectUrl = ""
|
||||
override suspend fun handleRedirect(url: String): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun authenticate(activity: FragmentActivity?) {
|
||||
}
|
||||
|
||||
override val mainUrl = ""
|
||||
override val syncIdName = SyncIdName.LocalList
|
||||
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
|
||||
val watchStatusIds = ioWork {
|
||||
getAllWatchStateIds()?.map { id ->
|
||||
Pair(id, getResultWatchState(id))
|
||||
}
|
||||
}?.distinctBy { it.first } ?: return null
|
||||
|
||||
val list = ioWork {
|
||||
watchStatusIds.groupBy {
|
||||
it.second.stringRes
|
||||
}.mapValues { group ->
|
||||
group.value.mapNotNull {
|
||||
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
|
||||
}
|
||||
} + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
|
||||
it.toLibraryItem()
|
||||
})
|
||||
}
|
||||
|
||||
val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate {
|
||||
// None is not something to display
|
||||
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
||||
} + mapOf(R.string.subscription_list_name to emptyList())
|
||||
|
||||
return SyncAPI.LibraryMetadata(
|
||||
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
||||
setOf(
|
||||
ListSorting.AlphabeticalA,
|
||||
ListSorting.AlphabeticalZ,
|
||||
// ListSorting.UpdatedNew,
|
||||
// ListSorting.UpdatedOld,
|
||||
// ListSorting.RatingHigh,
|
||||
// ListSorting.RatingLow,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getIdFromUrl(url: String): String {
|
||||
return url
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
|
@ -8,11 +9,15 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
|||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.ShowStatus
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.ui.result.txt
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
|
||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||
|
@ -31,13 +36,15 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
override val redirectUrl = "mallogin"
|
||||
override val idPrefix = "mal"
|
||||
override var mainUrl = "https://myanimelist.net"
|
||||
val apiUrl = "https://api.myanimelist.net"
|
||||
private val apiUrl = "https://api.myanimelist.net"
|
||||
override val icon = R.drawable.mal_logo
|
||||
override val requiresLogin = false
|
||||
|
||||
override val syncIdName = SyncIdName.MyAnimeList
|
||||
override var requireLibraryRefresh = true
|
||||
override val createAccountUrl = "$mainUrl/register.php"
|
||||
|
||||
override fun logOut() {
|
||||
requireLibraryRefresh = true
|
||||
removeAccountKeys()
|
||||
}
|
||||
|
||||
|
@ -90,7 +97,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
fromIntToAnimeStatus(status.status),
|
||||
status.score,
|
||||
status.watchedEpisodes
|
||||
)
|
||||
).also {
|
||||
requireLibraryRefresh = requireLibraryRefresh || it
|
||||
}
|
||||
}
|
||||
|
||||
data class MalAnime(
|
||||
|
@ -248,10 +257,45 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
|
||||
const val MAL_USER_KEY: String = "mal_user" // user data like profile
|
||||
const val MAL_CACHED_LIST: String = "mal_cached_list"
|
||||
const val MAL_SHOULD_UPDATE_LIST: String = "mal_should_update_list"
|
||||
const val MAL_UNIXTIME_KEY: String = "mal_unixtime" // When token expires
|
||||
const val MAL_REFRESH_TOKEN_KEY: String = "mal_refresh_token" // refresh token
|
||||
const val MAL_TOKEN_KEY: String = "mal_token" // anilist token for api
|
||||
|
||||
fun convertToStatus(string: String): MalStatusType {
|
||||
return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
|
||||
}
|
||||
|
||||
enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) {
|
||||
Watching(0, R.string.type_watching),
|
||||
Completed(1, R.string.type_completed),
|
||||
OnHold(2, R.string.type_on_hold),
|
||||
Dropped(3, R.string.type_dropped),
|
||||
PlanToWatch(4, R.string.type_plan_to_watch),
|
||||
None(-1, R.string.type_none)
|
||||
}
|
||||
|
||||
private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
|
||||
return when (inp) {
|
||||
-1 -> MalStatusType.None
|
||||
0 -> MalStatusType.Watching
|
||||
1 -> MalStatusType.Completed
|
||||
2 -> MalStatusType.OnHold
|
||||
3 -> MalStatusType.Dropped
|
||||
4 -> MalStatusType.PlanToWatch
|
||||
5 -> MalStatusType.Watching
|
||||
else -> MalStatusType.None
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDateLong(string: String?): Long? {
|
||||
return try {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(
|
||||
string ?: return null
|
||||
)?.time?.div(1000)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleRedirect(url: String): Boolean {
|
||||
|
@ -275,7 +319,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
switchToNewAccount()
|
||||
storeToken(res)
|
||||
val user = getMalUser()
|
||||
setKey(MAL_SHOULD_UPDATE_LIST, true)
|
||||
requireLibraryRefresh = true
|
||||
return user != null
|
||||
}
|
||||
}
|
||||
|
@ -308,9 +352,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime))
|
||||
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token)
|
||||
setKey(accountId, MAL_TOKEN_KEY, token.access_token)
|
||||
requireLibraryRefresh = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -329,7 +374,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
).text
|
||||
storeToken(res)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -382,7 +427,24 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
data class Data(
|
||||
@JsonProperty("node") val node: Node,
|
||||
@JsonProperty("list_status") val list_status: ListStatus?,
|
||||
)
|
||||
) {
|
||||
fun toLibraryItem(): SyncAPI.LibraryItem {
|
||||
return SyncAPI.LibraryItem(
|
||||
this.node.title,
|
||||
"https://myanimelist.net/anime/${this.node.id}/",
|
||||
this.node.id.toString(),
|
||||
this.list_status?.num_episodes_watched,
|
||||
this.node.num_episodes,
|
||||
this.list_status?.score?.times(10),
|
||||
parseDateLong(this.list_status?.updated_at),
|
||||
"MAL",
|
||||
TvType.Anime,
|
||||
this.node.main_picture?.large ?: this.node.main_picture?.medium,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Paging(
|
||||
@JsonProperty("next") val next: String?
|
||||
|
@ -413,18 +475,43 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
return getKey(MAL_CACHED_LIST) as? Array<Data>
|
||||
}
|
||||
|
||||
suspend fun getMalAnimeListSmart(): Array<Data>? {
|
||||
private suspend fun getMalAnimeListSmart(): Array<Data>? {
|
||||
if (getAuth() == null) return null
|
||||
return if (getKey(MAL_SHOULD_UPDATE_LIST, true) == true) {
|
||||
return if (requireLibraryRefresh) {
|
||||
val list = getMalAnimeList()
|
||||
setKey(MAL_CACHED_LIST, list)
|
||||
setKey(MAL_SHOULD_UPDATE_LIST, false)
|
||||
list
|
||||
} else {
|
||||
getMalAnimeListCached()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
|
||||
val list = getMalAnimeListSmart()?.groupBy {
|
||||
convertToStatus(it.list_status?.status ?: "").stringRes
|
||||
}?.mapValues { group ->
|
||||
group.value.map { it.toLibraryItem() }
|
||||
} ?: emptyMap()
|
||||
|
||||
// To fill empty lists when MAL does not return them
|
||||
val baseMap =
|
||||
MalStatusType.values().filter { it.value >= 0 }.associate {
|
||||
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
||||
}
|
||||
|
||||
return SyncAPI.LibraryMetadata(
|
||||
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
||||
setOf(
|
||||
ListSorting.AlphabeticalA,
|
||||
ListSorting.AlphabeticalZ,
|
||||
ListSorting.UpdatedNew,
|
||||
ListSorting.UpdatedOld,
|
||||
ListSorting.RatingHigh,
|
||||
ListSorting.RatingLow,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getMalAnimeList(): Array<Data> {
|
||||
checkMalToken()
|
||||
var offset = 0
|
||||
|
@ -440,10 +527,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
return fullList.toTypedArray()
|
||||
}
|
||||
|
||||
fun convertToStatus(string: String): MalStatusType {
|
||||
return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
|
||||
}
|
||||
|
||||
private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? {
|
||||
val user = "@me"
|
||||
val auth = getAuth() ?: return null
|
||||
|
@ -557,28 +640,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
return user
|
||||
}
|
||||
|
||||
enum class MalStatusType(var value: Int) {
|
||||
Watching(0),
|
||||
Completed(1),
|
||||
OnHold(2),
|
||||
Dropped(3),
|
||||
PlanToWatch(4),
|
||||
None(-1)
|
||||
}
|
||||
|
||||
private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
|
||||
return when (inp) {
|
||||
-1 -> MalStatusType.None
|
||||
0 -> MalStatusType.Watching
|
||||
1 -> MalStatusType.Completed
|
||||
2 -> MalStatusType.OnHold
|
||||
3 -> MalStatusType.Dropped
|
||||
4 -> MalStatusType.PlanToWatch
|
||||
5 -> MalStatusType.Watching
|
||||
else -> MalStatusType.None
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setScoreRequest(
|
||||
id: Int,
|
||||
status: MalStatusType? = null,
|
||||
|
|
|
@ -7,7 +7,8 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
|
||||
class GrdLayoutManager(val context: Context, _spanCount: Int) : GridLayoutManager(context, _spanCount) {
|
||||
class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
||||
GridLayoutManager(context, _spanCount) {
|
||||
override fun onFocusSearchFailed(
|
||||
focused: View,
|
||||
focusDirection: Int,
|
||||
|
@ -34,7 +35,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : GridLayoutManage
|
|||
val pos = maxOf(0, getPosition(focused!!) - 2)
|
||||
parent.scrollToPosition(pos)
|
||||
super.onRequestChildFocus(parent, state, child, focused)
|
||||
} catch (e: Exception){
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import com.lagradost.cloudstream3.mvvm.observe
|
|||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||
|
@ -40,6 +39,7 @@ import kotlinx.android.synthetic.main.stream_input.*
|
|||
import android.text.format.Formatter.formatShortFileSize
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
import java.net.URI
|
||||
|
||||
|
@ -225,7 +225,7 @@ class DownloadFragment : Fragment() {
|
|||
R.id.global_to_navigation_player,
|
||||
GeneratorPlayer.newInstance(
|
||||
LinkGenerator(
|
||||
listOf(url),
|
||||
listOf(BasicLink(url)),
|
||||
extract = true,
|
||||
referer = referer,
|
||||
isM3u8 = dialog.hls_switch?.isChecked
|
||||
|
|
|
@ -569,7 +569,7 @@ class HomeFragment : Fragment() {
|
|||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||
listHomepageItems.clear()
|
||||
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList(
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(
|
||||
d.values.toMutableList(),
|
||||
home_master_recycler
|
||||
)
|
||||
|
@ -621,7 +621,7 @@ class HomeFragment : Fragment() {
|
|||
//home_loaded?.isVisible = false
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList(listOf())
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(listOf())
|
||||
home_loading_shimmer?.startShimmer()
|
||||
home_loading?.isVisible = true
|
||||
home_loading_error?.isVisible = false
|
||||
|
|
|
@ -185,7 +185,7 @@ open class ParentItemAdapter(
|
|||
) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
val title: TextView = itemView.home_child_more_info
|
||||
val recyclerView: RecyclerView = itemView.home_child_recyclerview
|
||||
private val recyclerView: RecyclerView = itemView.home_child_recyclerview
|
||||
|
||||
fun update(expand: HomeViewModel.ExpandableHomepageList) {
|
||||
val info = expand.list
|
||||
|
|
|
@ -0,0 +1,395 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AlphaAnimation
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||
import com.lagradost.cloudstream3.ui.result.txt
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import kotlinx.android.synthetic.main.fragment_library.*
|
||||
import kotlin.math.abs
|
||||
|
||||
const val LIBRARY_FOLDER = "library_folder"
|
||||
|
||||
|
||||
enum class LibraryOpenerType(@StringRes val stringRes: Int) {
|
||||
Default(R.string.default_subtitles), // TODO FIX AFTER MERGE
|
||||
Provider(R.string.none),
|
||||
Browser(R.string.browser),
|
||||
Search(R.string.search),
|
||||
None(R.string.none),
|
||||
}
|
||||
|
||||
/** Used to store how the user wants to open said poster */
|
||||
data class LibraryOpener(
|
||||
val openType: LibraryOpenerType,
|
||||
val providerData: ProviderLibraryData?,
|
||||
)
|
||||
|
||||
data class ProviderLibraryData(
|
||||
val apiName: String
|
||||
)
|
||||
|
||||
class LibraryFragment : Fragment() {
|
||||
companion object {
|
||||
fun newInstance() = LibraryFragment()
|
||||
|
||||
/**
|
||||
* Store which page was last seen when exiting the fragment and returning
|
||||
**/
|
||||
const val VIEWPAGER_ITEM_KEY = "viewpager_item"
|
||||
}
|
||||
|
||||
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_library, container, false)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
viewpager?.currentItem?.let { currentItem ->
|
||||
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
|
||||
}
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
context?.fixPaddingStatusbar(search_status_bar_padding)
|
||||
|
||||
sort_fab?.setOnClickListener {
|
||||
val methods = libraryViewModel.sortingMethods.map {
|
||||
txt(it.stringRes).asString(view.context)
|
||||
}
|
||||
|
||||
activity?.showBottomDialog(methods,
|
||||
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
|
||||
txt(R.string.sort_by).asString(view.context),
|
||||
false,
|
||||
{},
|
||||
{
|
||||
val method = libraryViewModel.sortingMethods[it]
|
||||
libraryViewModel.sort(method)
|
||||
})
|
||||
}
|
||||
|
||||
main_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
libraryViewModel.sort(ListSorting.Query, query)
|
||||
return true
|
||||
}
|
||||
|
||||
// This is required to prevent the first text change
|
||||
// When this is attached it'll immediately send a onQueryTextChange("")
|
||||
// Which we do not want
|
||||
var hasInitialized = false
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
if (!hasInitialized) {
|
||||
hasInitialized = true
|
||||
return true
|
||||
}
|
||||
|
||||
libraryViewModel.sort(ListSorting.Query, newText)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
libraryViewModel.reloadPages(false)
|
||||
|
||||
list_selector?.setOnClickListener {
|
||||
val items = libraryViewModel.availableApiNames
|
||||
val currentItem = libraryViewModel.currentApiName.value
|
||||
|
||||
activity?.showBottomDialog(items,
|
||||
items.indexOf(currentItem),
|
||||
txt(R.string.select_library).asString(it.context),
|
||||
false,
|
||||
{}) { index ->
|
||||
val selectedItem = items.getOrNull(index) ?: return@showBottomDialog
|
||||
libraryViewModel.switchList(selectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows a plugin selection dialogue and saves the response
|
||||
**/
|
||||
fun Activity.showPluginSelectionDialog(
|
||||
key: String,
|
||||
syncId: SyncIdName,
|
||||
apiName: String? = null,
|
||||
) {
|
||||
val availableProviders = allProviders.filter {
|
||||
it.supportedSyncNames.contains(syncId)
|
||||
}.map { it.name } +
|
||||
// Add the api if it exists
|
||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList())
|
||||
|
||||
val baseOptions = listOf(
|
||||
LibraryOpenerType.Default,
|
||||
LibraryOpenerType.None,
|
||||
LibraryOpenerType.Browser,
|
||||
LibraryOpenerType.Search
|
||||
)
|
||||
|
||||
val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders
|
||||
|
||||
val savedSelection = getKey<LibraryOpener>(LIBRARY_FOLDER, key)
|
||||
val selectedIndex =
|
||||
when {
|
||||
savedSelection == null -> 0
|
||||
// If provider
|
||||
savedSelection.openType == LibraryOpenerType.Provider
|
||||
&& savedSelection.providerData?.apiName != null -> {
|
||||
availableProviders.indexOf(savedSelection.providerData.apiName)
|
||||
.takeIf { it != -1 }
|
||||
?.plus(baseOptions.size) ?: 0
|
||||
}
|
||||
// Else base option
|
||||
else -> baseOptions.indexOf(savedSelection.openType)
|
||||
}
|
||||
|
||||
this.showBottomDialog(
|
||||
items,
|
||||
selectedIndex,
|
||||
txt(R.string.open_with).asString(this),
|
||||
false,
|
||||
{},
|
||||
) {
|
||||
val savedData = if (it < baseOptions.size) {
|
||||
LibraryOpener(
|
||||
baseOptions[it],
|
||||
null
|
||||
)
|
||||
} else {
|
||||
LibraryOpener(
|
||||
LibraryOpenerType.Provider,
|
||||
ProviderLibraryData(items[it])
|
||||
)
|
||||
}
|
||||
|
||||
setKey(
|
||||
LIBRARY_FOLDER,
|
||||
key,
|
||||
savedData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
provider_selector?.setOnClickListener {
|
||||
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
||||
activity?.showPluginSelectionDialog(syncName.name, syncName)
|
||||
}
|
||||
|
||||
viewpager?.setPageTransformer(LibraryScrollTransformer())
|
||||
viewpager?.adapter =
|
||||
viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean ->
|
||||
if (isScrollingDown) {
|
||||
sort_fab?.shrink()
|
||||
} else {
|
||||
sort_fab?.extend()
|
||||
}
|
||||
}) callback@{ searchClickCallback ->
|
||||
// To prevent future accidents
|
||||
debugAssert({
|
||||
searchClickCallback.card !is SyncAPI.LibraryItem
|
||||
}, {
|
||||
"searchClickCallback ${searchClickCallback.card} is not a LibraryItem"
|
||||
})
|
||||
|
||||
val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId
|
||||
val syncName =
|
||||
libraryViewModel.currentSyncApi?.syncIdName ?: return@callback
|
||||
|
||||
when (searchClickCallback.action) {
|
||||
SEARCH_ACTION_SHOW_METADATA -> {
|
||||
activity?.showPluginSelectionDialog(
|
||||
syncId,
|
||||
syncName,
|
||||
searchClickCallback.card.apiName
|
||||
)
|
||||
}
|
||||
|
||||
SEARCH_ACTION_LOAD -> {
|
||||
// This basically first selects the individual opener and if that is default then
|
||||
// selects the whole list opener
|
||||
val savedListSelection =
|
||||
getKey<LibraryOpener>(LIBRARY_FOLDER, syncName.name)
|
||||
val savedSelection = getKey<LibraryOpener>(LIBRARY_FOLDER, syncId).takeIf {
|
||||
it?.openType != LibraryOpenerType.Default
|
||||
} ?: savedListSelection
|
||||
|
||||
when (savedSelection?.openType) {
|
||||
null, LibraryOpenerType.Default -> {
|
||||
// Prevents opening MAL/AniList as a provider
|
||||
if (APIHolder.getApiFromNameNull(searchClickCallback.card.apiName) != null) {
|
||||
activity?.loadSearchResult(
|
||||
searchClickCallback.card
|
||||
)
|
||||
} else {
|
||||
// Search when no provider can open
|
||||
QuickSearchFragment.pushSearch(
|
||||
activity,
|
||||
searchClickCallback.card.name
|
||||
)
|
||||
}
|
||||
}
|
||||
LibraryOpenerType.None -> {}
|
||||
LibraryOpenerType.Provider ->
|
||||
savedSelection.providerData?.apiName?.let { apiName ->
|
||||
activity?.loadResult(
|
||||
searchClickCallback.card.url,
|
||||
apiName,
|
||||
)
|
||||
}
|
||||
LibraryOpenerType.Browser ->
|
||||
openBrowser(searchClickCallback.card.url)
|
||||
LibraryOpenerType.Search -> {
|
||||
QuickSearchFragment.pushSearch(
|
||||
activity,
|
||||
searchClickCallback.card.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewpager?.offscreenPageLimit = 2
|
||||
viewpager?.reduceDragSensitivity()
|
||||
|
||||
val startLoading = Runnable {
|
||||
gridview?.numColumns = context?.getSpanCount() ?: 3
|
||||
gridview?.adapter =
|
||||
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
||||
library_loading_overlay?.isVisible = true
|
||||
library_loading_shimmer?.startShimmer()
|
||||
empty_list_textview?.isVisible = false
|
||||
}
|
||||
|
||||
val stopLoading = Runnable {
|
||||
gridview?.adapter = null
|
||||
library_loading_overlay?.isVisible = false
|
||||
library_loading_shimmer?.stopShimmer()
|
||||
}
|
||||
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
observe(libraryViewModel.pages) { resource ->
|
||||
when (resource) {
|
||||
is Resource.Success -> {
|
||||
handler.removeCallbacks(startLoading)
|
||||
val pages = resource.value
|
||||
val showNotice = pages.all { it.items.isEmpty() }
|
||||
empty_list_textview?.isVisible = showNotice
|
||||
if (showNotice) {
|
||||
if (libraryViewModel.availableApiNames.size > 1) {
|
||||
empty_list_textview?.setText(R.string.empty_library_logged_in_message)
|
||||
} else {
|
||||
empty_list_textview?.setText(R.string.empty_library_no_accounts_message)
|
||||
}
|
||||
}
|
||||
|
||||
(viewpager.adapter as? ViewpagerAdapter)?.pages = pages
|
||||
// Using notifyItemRangeChanged keeps the animations when sorting
|
||||
viewpager.adapter?.notifyItemRangeChanged(0, viewpager.adapter?.itemCount ?: 0)
|
||||
|
||||
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
||||
// Without this there would be a flashing effect:
|
||||
// loading -> show old viewpager -> black screen -> show new viewpager
|
||||
handler.postDelayed(stopLoading, 300)
|
||||
|
||||
savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos ->
|
||||
if (currentPos < 0) return@let
|
||||
viewpager?.setCurrentItem(currentPos, false)
|
||||
// Using remove() sets the key to 0 instead of removing it
|
||||
savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1)
|
||||
}
|
||||
|
||||
// Since the animation to scroll multiple items is so much its better to just hide
|
||||
// the viewpager a bit while the fastest animation is running
|
||||
fun hideViewpager(distance: Int) {
|
||||
if (distance < 3) return
|
||||
|
||||
val hideAnimation = AlphaAnimation(1f, 0f).apply {
|
||||
duration = distance * 50L
|
||||
fillAfter = true
|
||||
}
|
||||
val showAnimation = AlphaAnimation(0f, 1f).apply {
|
||||
duration = distance * 50L
|
||||
startOffset = distance * 100L
|
||||
fillAfter = true
|
||||
}
|
||||
viewpager?.startAnimation(hideAnimation)
|
||||
viewpager?.startAnimation(showAnimation)
|
||||
}
|
||||
|
||||
TabLayoutMediator(
|
||||
library_tab_layout,
|
||||
viewpager,
|
||||
) { tab, position ->
|
||||
tab.text = pages.getOrNull(position)?.title?.asStringNull(context)
|
||||
tab.view.setOnClickListener {
|
||||
val currentItem = viewpager?.currentItem ?: return@setOnClickListener
|
||||
val distance = abs(position - currentItem)
|
||||
hideViewpager(distance)
|
||||
}
|
||||
}.attach()
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
// Only start loading after 200ms to prevent loading cached lists
|
||||
handler.postDelayed(startLoading, 200)
|
||||
}
|
||||
is Resource.Failure -> {
|
||||
stopLoading.run()
|
||||
// No user indication it failed :(
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
(viewpager.adapter as? ViewpagerAdapter)?.rebind()
|
||||
super.onConfigurationChanged(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
class MenuSearchView(context: Context) : SearchView(context) {
|
||||
override fun onActionViewCollapsed() {
|
||||
super.onActionViewCollapsed()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import android.view.View
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import kotlinx.android.synthetic.main.library_viewpager_page.view.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class LibraryScrollTransformer : ViewPager2.PageTransformer {
|
||||
override fun transformPage(page: View, position: Float) {
|
||||
val padding = (-position * page.width).roundToInt()
|
||||
page.page_recyclerview.setPadding(
|
||||
padding, 0,
|
||||
-padding, 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class ListSorting(@StringRes val stringRes: Int) {
|
||||
Query(R.string.none),
|
||||
RatingHigh(R.string.sort_rating_desc),
|
||||
RatingLow(R.string.sort_rating_asc),
|
||||
UpdatedNew(R.string.sort_updated_new),
|
||||
UpdatedOld(R.string.sort_updated_old),
|
||||
AlphabeticalA(R.string.sort_alphabetical_a),
|
||||
AlphabeticalZ(R.string.sort_alphabetical_z),
|
||||
}
|
||||
|
||||
const val LAST_SYNC_API_KEY = "last_sync_api"
|
||||
|
||||
class LibraryViewModel : ViewModel() {
|
||||
private val _pages: MutableLiveData<Resource<List<SyncAPI.Page>>> = MutableLiveData(null)
|
||||
val pages: LiveData<Resource<List<SyncAPI.Page>>> = _pages
|
||||
|
||||
private val _currentApiName: MutableLiveData<String> = MutableLiveData("")
|
||||
val currentApiName: LiveData<String> = _currentApiName
|
||||
|
||||
private val availableSyncApis
|
||||
get() = SyncApis.filter { it.hasAccount() }
|
||||
|
||||
var currentSyncApi = availableSyncApis.let { allApis ->
|
||||
val lastSelection = getKey<String>(LAST_SYNC_API_KEY)
|
||||
availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull()
|
||||
}
|
||||
private set(value) {
|
||||
field = value
|
||||
setKey(LAST_SYNC_API_KEY, field?.name)
|
||||
}
|
||||
|
||||
val availableApiNames: List<String>
|
||||
get() = availableSyncApis.map { it.name }
|
||||
|
||||
var sortingMethods = emptyList<ListSorting>()
|
||||
private set
|
||||
|
||||
var currentSortingMethod: ListSorting? = sortingMethods.firstOrNull()
|
||||
private set
|
||||
|
||||
fun switchList(name: String) {
|
||||
currentSyncApi = availableSyncApis[availableApiNames.indexOf(name)]
|
||||
_currentApiName.postValue(currentSyncApi?.name)
|
||||
reloadPages(true)
|
||||
}
|
||||
|
||||
fun sort(method: ListSorting, query: String? = null) {
|
||||
val currentList = pages.value ?: return
|
||||
currentSortingMethod = method
|
||||
(currentList as? Resource.Success)?.value?.forEachIndexed { _, page ->
|
||||
page.sort(method, query)
|
||||
}
|
||||
_pages.postValue(currentList)
|
||||
}
|
||||
|
||||
fun reloadPages(forceReload: Boolean) {
|
||||
// Only skip loading if its not forced and pages is not empty
|
||||
if (!forceReload && (pages.value as? Resource.Success)?.value?.isNotEmpty() == true &&
|
||||
currentSyncApi?.requireLibraryRefresh != true
|
||||
) return
|
||||
|
||||
ioSafe {
|
||||
currentSyncApi?.let { repo ->
|
||||
_currentApiName.postValue(repo.name)
|
||||
_pages.postValue(Resource.Loading())
|
||||
val libraryResource = repo.getPersonalLibrary()
|
||||
if (libraryResource is Resource.Failure) {
|
||||
_pages.postValue(libraryResource)
|
||||
return@let
|
||||
}
|
||||
val library = (libraryResource as? Resource.Success)?.value ?: return@let
|
||||
|
||||
sortingMethods = library.supportedListSorting.toList()
|
||||
currentSortingMethod = null
|
||||
|
||||
repo.requireLibraryRefresh = false
|
||||
|
||||
val pages = library.allLibraryLists.map {
|
||||
SyncAPI.Page(
|
||||
it.name,
|
||||
it.items
|
||||
)
|
||||
}
|
||||
|
||||
_pages.postValue(Resource.Success(pages))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ListPopupWindow.MATCH_PARENT
|
||||
import android.widget.RelativeLayout
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlinx.android.synthetic.main.loading_poster_dynamic.view.*
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class LoadingPosterAdapter(context: Context, private val itemCount: Int) :
|
||||
BaseAdapter() {
|
||||
private val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
|
||||
override fun getCount(): Int {
|
||||
return itemCount
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Any? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return convertView ?: inflater.inflate(R.layout.loading_poster_dynamic, parent, false)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.AcraApplication
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlinx.android.synthetic.main.search_result_grid_expanded.view.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class PageAdapter(
|
||||
override val items: MutableList<SyncAPI.LibraryItem>,
|
||||
private val resView: AutofitRecyclerView,
|
||||
val clickCallback: (SearchClickCallback) -> Unit
|
||||
) :
|
||||
AppUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return LibraryItemViewHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.search_result_grid_expanded, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is LibraryItemViewHolder -> {
|
||||
holder.bind(items[position], position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDark(color: Int): Boolean {
|
||||
return ColorUtils.calculateLuminance(color) < 0.5
|
||||
}
|
||||
|
||||
fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int {
|
||||
return if (isDark(color)) {
|
||||
ColorUtils.blendARGB(color, Color.WHITE, ratio)
|
||||
} else {
|
||||
ColorUtils.blendARGB(color, Color.BLACK, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
inner class LibraryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val cardView: ImageView = itemView.imageView
|
||||
|
||||
private val compactView = false//itemView.context.getGridIsCompact()
|
||||
private val coverHeight: Int =
|
||||
if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt()
|
||||
|
||||
fun bind(item: SyncAPI.LibraryItem, position: Int) {
|
||||
/** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */
|
||||
|
||||
SearchResultBuilder.bind(
|
||||
this@PageAdapter.clickCallback,
|
||||
item,
|
||||
position,
|
||||
itemView,
|
||||
colorCallback = { palette ->
|
||||
AcraApplication.context?.let { ctx ->
|
||||
val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg)
|
||||
var bg = palette.getDarkVibrantColor(defColor)
|
||||
if (bg == defColor) {
|
||||
bg = palette.getDarkMutedColor(defColor)
|
||||
}
|
||||
if (bg == defColor) {
|
||||
bg = palette.getVibrantColor(defColor)
|
||||
}
|
||||
|
||||
val fg =
|
||||
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
|
||||
itemView.text_rating.apply {
|
||||
setTextColor(ColorStateList.valueOf(fg))
|
||||
}
|
||||
itemView.text_rating_holder?.backgroundTintList = ColorStateList.valueOf(bg)
|
||||
itemView.watchProgress?.apply {
|
||||
progressTintList = ColorStateList.valueOf(fg)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// See searchAdaptor for this, it basically fixes the height
|
||||
if (!compactView) {
|
||||
cardView.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
coverHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val showProgress = item.episodesCompleted != null && item.episodesTotal != null
|
||||
itemView.watchProgress.isVisible = showProgress
|
||||
if (showProgress) {
|
||||
itemView.watchProgress.max = item.episodesTotal!!
|
||||
itemView.watchProgress.progress = item.episodesCompleted!!
|
||||
}
|
||||
|
||||
itemView.imageText.text = item.name
|
||||
|
||||
val showRating = (item.personalRating ?: 0) != 0
|
||||
itemView.text_rating_holder.isVisible = showRating
|
||||
if (showRating) {
|
||||
// We want to show 8.5 but not 8.0 hence the replace
|
||||
val rating = ((item.personalRating ?: 0).toDouble() / 10).toString()
|
||||
.replace(".0", "")
|
||||
|
||||
itemView.text_rating.text = "★ $rating"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.OnFlingListener
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import kotlinx.android.synthetic.main.library_viewpager_page.view.*
|
||||
|
||||
class ViewpagerAdapter(
|
||||
var pages: List<SyncAPI.Page>,
|
||||
val scrollCallback: (isScrollingDown: Boolean) -> Unit,
|
||||
val clickCallback: (SearchClickCallback) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return PageViewHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.library_viewpager_page, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is PageViewHolder -> {
|
||||
holder.bind(pages[position], unbound.remove(position))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val unbound = mutableSetOf<Int>()
|
||||
/**
|
||||
* Used to mark all pages for re-binding and forces all items to be refreshed
|
||||
* Without this the pages will still use the same adapters
|
||||
**/
|
||||
fun rebind() {
|
||||
unbound.addAll(0..pages.size)
|
||||
this.notifyItemRangeChanged(0, pages.size)
|
||||
}
|
||||
|
||||
inner class PageViewHolder(private val itemViewTest: View) :
|
||||
RecyclerView.ViewHolder(itemViewTest) {
|
||||
fun bind(page: SyncAPI.Page, rebind: Boolean) {
|
||||
itemView.page_recyclerview?.spanCount =
|
||||
this@PageViewHolder.itemView.context.getSpanCount() ?: 3
|
||||
|
||||
if (itemViewTest.page_recyclerview?.adapter == null || rebind) {
|
||||
// Only add the items after it has been attached since the items rely on ItemWidth
|
||||
// Which is only determined after the recyclerview is attached.
|
||||
// If this fails then item height becomes 0 when there is only one item
|
||||
itemViewTest.page_recyclerview?.doOnAttach {
|
||||
itemViewTest.page_recyclerview?.adapter = PageAdapter(
|
||||
page.items.toMutableList(),
|
||||
itemViewTest.page_recyclerview,
|
||||
clickCallback
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(itemViewTest.page_recyclerview?.adapter as? PageAdapter)?.updateList(page.items)
|
||||
itemViewTest.page_recyclerview?.scrollToPosition(0)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
itemViewTest.page_recyclerview.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY ->
|
||||
val diff = scrollY - oldScrollY
|
||||
if (diff == 0) return@setOnScrollChangeListener
|
||||
|
||||
scrollCallback.invoke(diff > 0)
|
||||
}
|
||||
} else {
|
||||
itemViewTest.page_recyclerview.onFlingListener = object : OnFlingListener() {
|
||||
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
|
||||
scrollCallback.invoke(velocityY > 0)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return pages.size
|
||||
}
|
||||
}
|
|
@ -9,8 +9,11 @@ import android.widget.FrameLayout
|
|||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.*
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
|
||||
import com.google.android.exoplayer2.source.*
|
||||
import com.google.android.exoplayer2.text.TextRenderer
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
|
@ -35,11 +38,13 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSession
|
||||
|
@ -535,15 +540,17 @@ class CS3IPlayer : IPlayer {
|
|||
OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT)
|
||||
}
|
||||
|
||||
// Do no include empty referer, if the provider wants those they can use the header map.
|
||||
val refererMap =
|
||||
if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer)
|
||||
val headers = mapOf(
|
||||
"referer" to link.referer,
|
||||
"accept" to "*/*",
|
||||
"sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
|
||||
"sec-ch-ua-mobile" to "?0",
|
||||
"sec-fetch-user" to "?1",
|
||||
"sec-fetch-mode" to "navigate",
|
||||
"sec-fetch-dest" to "video"
|
||||
) + link.headers // Adds the headers from the provider, e.g Authorization
|
||||
) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization
|
||||
|
||||
return source.apply {
|
||||
setDefaultRequestProperties(headers)
|
||||
|
@ -666,23 +673,27 @@ class CS3IPlayer : IPlayer {
|
|||
val exoPlayerBuilder =
|
||||
ExoPlayer.Builder(context)
|
||||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
||||
DefaultRenderersFactory(context).createRenderers(
|
||||
eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput
|
||||
).map {
|
||||
if (it is TextRenderer) {
|
||||
currentTextRenderer = CustomTextRenderer(
|
||||
subtitleOffset,
|
||||
textRendererOutput,
|
||||
eventHandler.looper,
|
||||
CustomSubtitleDecoderFactory()
|
||||
)
|
||||
currentTextRenderer!!
|
||||
} else it
|
||||
}.toTypedArray()
|
||||
DefaultRenderersFactory(context).apply {
|
||||
// setEnableDecoderFallback(true)
|
||||
// Enable Ffmpeg extension
|
||||
// setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
||||
}.createRenderers(
|
||||
eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput
|
||||
).map {
|
||||
if (it is TextRenderer) {
|
||||
currentTextRenderer = CustomTextRenderer(
|
||||
subtitleOffset,
|
||||
textRendererOutput,
|
||||
eventHandler.looper,
|
||||
CustomSubtitleDecoderFactory()
|
||||
)
|
||||
currentTextRenderer!!
|
||||
} else it
|
||||
}.toTypedArray()
|
||||
}
|
||||
.setTrackSelector(
|
||||
trackSelector ?: getTrackSelector(
|
||||
|
@ -847,7 +858,7 @@ class CS3IPlayer : IPlayer {
|
|||
Log.i(TAG, "loadExo")
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val maxVideoHeight = settingsManager.getInt(
|
||||
context.getString(com.lagradost.cloudstream3.R.string.quality_pref_key),
|
||||
context.getString(if (context.isUsingMobileData()) com.lagradost.cloudstream3.R.string.quality_pref_mobile_data_key else com.lagradost.cloudstream3.R.string.quality_pref_key),
|
||||
Int.MAX_VALUE
|
||||
)
|
||||
|
||||
|
@ -1193,10 +1204,10 @@ class CS3IPlayer : IPlayer {
|
|||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||
}
|
||||
|
||||
val mime = if (link.isM3u8) {
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
} else {
|
||||
MimeTypes.VIDEO_MP4
|
||||
val mime = when {
|
||||
link.isM3u8 -> MimeTypes.APPLICATION_M3U8
|
||||
link.isDash -> MimeTypes.APPLICATION_MPD
|
||||
else -> MimeTypes.VIDEO_MP4
|
||||
}
|
||||
|
||||
val mediaItems = if (link is ExtractorLinkPlayList) {
|
||||
|
@ -1246,4 +1257,4 @@ class CS3IPlayer : IPlayer {
|
|||
loadOfflinePlayer(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,13 +4,16 @@ import android.content.Context
|
|||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.text.SubtitleDecoder
|
||||
import com.google.android.exoplayer2.text.SubtitleDecoderFactory
|
||||
import com.google.android.exoplayer2.text.SubtitleInputBuffer
|
||||
import com.google.android.exoplayer2.text.SubtitleOutputBuffer
|
||||
import com.google.android.exoplayer2.text.*
|
||||
import com.google.android.exoplayer2.text.cea.Cea608Decoder
|
||||
import com.google.android.exoplayer2.text.cea.Cea708Decoder
|
||||
import com.google.android.exoplayer2.text.dvb.DvbDecoder
|
||||
import com.google.android.exoplayer2.text.pgs.PgsDecoder
|
||||
import com.google.android.exoplayer2.text.ssa.SsaDecoder
|
||||
import com.google.android.exoplayer2.text.subrip.SubripDecoder
|
||||
import com.google.android.exoplayer2.text.ttml.TtmlDecoder
|
||||
import com.google.android.exoplayer2.text.tx3g.Tx3gDecoder
|
||||
import com.google.android.exoplayer2.text.webvtt.Mp4WebvttDecoder
|
||||
import com.google.android.exoplayer2.text.webvtt.WebvttDecoder
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import com.lagradost.cloudstream3.R
|
||||
|
@ -19,7 +22,11 @@ import org.mozilla.universalchardet.UniversalDetector
|
|||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class CustomDecoder : SubtitleDecoder {
|
||||
/**
|
||||
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
|
||||
* enough to identify the subtitle format.
|
||||
**/
|
||||
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
|
||||
companion object {
|
||||
fun updateForcedEncoding(context: Context) {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
@ -139,7 +146,7 @@ class CustomDecoder : SubtitleDecoder {
|
|||
val inputString = getStr(inputBuffer)
|
||||
if (realDecoder == null && !inputString.isNullOrBlank()) {
|
||||
var str: String = inputString
|
||||
// this way we read the subtitle file and decide what decoder to use instead of relying on mimetype
|
||||
// this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype
|
||||
Log.i(TAG, "Got data from queueInputBuffer")
|
||||
//https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388
|
||||
realDecoder = when {
|
||||
|
@ -148,8 +155,31 @@ class CustomDecoder : SubtitleDecoder {
|
|||
(str.startsWith(
|
||||
"[Script Info]",
|
||||
ignoreCase = true
|
||||
) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder()
|
||||
) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder(fallbackFormat?.initializationData)
|
||||
str.startsWith("1", ignoreCase = true) -> SubripDecoder()
|
||||
fallbackFormat != null -> {
|
||||
when (val mimeType = fallbackFormat.sampleMimeType) {
|
||||
MimeTypes.TEXT_VTT -> WebvttDecoder()
|
||||
MimeTypes.TEXT_SSA -> SsaDecoder(fallbackFormat.initializationData)
|
||||
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder()
|
||||
MimeTypes.APPLICATION_TTML -> TtmlDecoder()
|
||||
MimeTypes.APPLICATION_SUBRIP -> SubripDecoder()
|
||||
MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(fallbackFormat.initializationData)
|
||||
MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder(
|
||||
mimeType,
|
||||
fallbackFormat.accessibilityChannel,
|
||||
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS
|
||||
)
|
||||
MimeTypes.APPLICATION_CEA708 -> Cea708Decoder(
|
||||
fallbackFormat.accessibilityChannel,
|
||||
fallbackFormat.initializationData
|
||||
)
|
||||
MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(fallbackFormat.initializationData)
|
||||
MimeTypes.APPLICATION_PGS -> PgsDecoder()
|
||||
MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
Log.i(
|
||||
|
@ -246,28 +276,6 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
|
|||
}
|
||||
|
||||
override fun createDecoder(format: Format): SubtitleDecoder {
|
||||
return CustomDecoder()
|
||||
//return when (val mimeType = format.sampleMimeType) {
|
||||
// MimeTypes.TEXT_VTT -> WebvttDecoder()
|
||||
// MimeTypes.TEXT_SSA -> SsaDecoder(format.initializationData)
|
||||
// MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder()
|
||||
// MimeTypes.APPLICATION_TTML -> TtmlDecoder()
|
||||
// MimeTypes.APPLICATION_SUBRIP -> SubripDecoder()
|
||||
// MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(format.initializationData)
|
||||
// MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> return Cea608Decoder(
|
||||
// mimeType,
|
||||
// format.accessibilityChannel,
|
||||
// Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS
|
||||
// )
|
||||
// MimeTypes.APPLICATION_CEA708 -> Cea708Decoder(
|
||||
// format.accessibilityChannel,
|
||||
// format.initializationData
|
||||
// )
|
||||
// MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(format.initializationData)
|
||||
// MimeTypes.APPLICATION_PGS -> PgsDecoder()
|
||||
// MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder()
|
||||
// // Default WebVttDecoder
|
||||
// else -> WebvttDecoder()
|
||||
//}
|
||||
return CustomDecoder(format)
|
||||
}
|
||||
}
|
|
@ -42,7 +42,7 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
|||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||
LinkGenerator(
|
||||
listOf(
|
||||
url
|
||||
BasicLink(url)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -40,6 +40,7 @@ import com.lagradost.cloudstream3.R
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
|
@ -83,6 +84,7 @@ const val HORIZONTAL_MULTIPLIER = 2.0f
|
|||
const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L
|
||||
const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time
|
||||
const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions
|
||||
private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay"
|
||||
|
||||
// All the UI Logic for the player
|
||||
open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||
|
@ -109,6 +111,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
protected var currentPrefQuality =
|
||||
Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell
|
||||
protected var fastForwardTime = 10000L
|
||||
protected var androidTVInterfaceOffSeekTime = 10000L;
|
||||
protected var androidTVInterfaceOnSeekTime = 30000L;
|
||||
protected var swipeHorizontalEnabled = false
|
||||
protected var swipeVerticalEnabled = false
|
||||
protected var playBackSpeedEnabled = false
|
||||
|
@ -1051,19 +1055,19 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
if (!isShowing && !isLocked) {
|
||||
player.seekTime(-10000L)
|
||||
player.seekTime(-androidTVInterfaceOffSeekTime)
|
||||
return true
|
||||
} else if (player_pause_play?.isFocused == true) {
|
||||
player.seekTime(-30000L)
|
||||
player.seekTime(-androidTVInterfaceOnSeekTime)
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
if (!isShowing && !isLocked) {
|
||||
player.seekTime(10000L)
|
||||
player.seekTime(androidTVInterfaceOffSeekTime)
|
||||
return true
|
||||
} else if (player_pause_play?.isFocused == true) {
|
||||
player.seekTime(30000L)
|
||||
player.seekTime(androidTVInterfaceOnSeekTime)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -1117,11 +1121,20 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
resetRewindText()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
// As this is video specific it is better to not do any setKey/getKey
|
||||
outState.putLong(SUBTITLE_DELAY_BUNDLE_KEY, subtitleDelay)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
// init variables
|
||||
setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f)
|
||||
savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let {
|
||||
subtitleDelay = it
|
||||
}
|
||||
|
||||
// handle tv controls
|
||||
playerEventListener = { eventType ->
|
||||
|
@ -1207,6 +1220,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10)
|
||||
.toLong() * 1000L
|
||||
|
||||
androidTVInterfaceOffSeekTime =
|
||||
settingsManager.getInt(ctx.getString(R.string.android_tv_interface_off_seek_key), 10)
|
||||
.toLong() * 1000L
|
||||
androidTVInterfaceOnSeekTime =
|
||||
settingsManager.getInt(ctx.getString(R.string.android_tv_interface_on_seek_key), 10)
|
||||
.toLong() * 1000L
|
||||
|
||||
navigationBarHeight = ctx.getNavigationBarHeight()
|
||||
statusBarHeight = ctx.getStatusBarHeight()
|
||||
|
||||
|
@ -1237,9 +1257,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
ctx.getString(R.string.double_tap_pause_enabled_key),
|
||||
false
|
||||
)
|
||||
|
||||
currentPrefQuality = settingsManager.getInt(
|
||||
ctx.getString(R.string.quality_pref_key),
|
||||
ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key),
|
||||
currentPrefQuality
|
||||
)
|
||||
// useSystemBrightness =
|
||||
|
|
|
@ -11,11 +11,8 @@ import android.util.Log
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.*
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isGone
|
||||
|
@ -528,7 +525,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
}
|
||||
|
||||
var selectSourceDialog: AlertDialog? = null
|
||||
var selectSourceDialog: Dialog? = null
|
||||
// var selectTracksDialog: AlertDialog? = null
|
||||
|
||||
override fun showMirrorsDialogue() {
|
||||
|
@ -540,10 +537,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
player.handleEvent(CSPlayerEvent.Pause)
|
||||
val currentSubtitles = sortSubs(currentSubs)
|
||||
|
||||
val sourceBuilder = AlertDialog.Builder(ctx, R.style.AlertDialogCustomBlack)
|
||||
.setView(R.layout.player_select_source_and_subs)
|
||||
|
||||
val sourceDialog = sourceBuilder.create()
|
||||
val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
|
||||
sourceDialog.setContentView(R.layout.player_select_source_and_subs)
|
||||
|
||||
selectSourceDialog = sourceDialog
|
||||
|
||||
|
@ -738,19 +733,17 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
val currentAudioTracks = tracks.allAudioTracks
|
||||
|
||||
val trackBuilder = AlertDialog.Builder(ctx, R.style.AlertDialogCustomBlack)
|
||||
.setView(R.layout.player_select_tracks)
|
||||
|
||||
val tracksDialog = trackBuilder.create()
|
||||
val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
|
||||
trackDialog.setContentView(R.layout.player_select_tracks)
|
||||
trackDialog.show()
|
||||
|
||||
// selectTracksDialog = tracksDialog
|
||||
|
||||
tracksDialog.show()
|
||||
val videosList = tracksDialog.video_tracks_list
|
||||
val audioList = tracksDialog.auto_tracks_list
|
||||
val videosList = trackDialog.video_tracks_list
|
||||
val audioList = trackDialog.auto_tracks_list
|
||||
|
||||
tracksDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1
|
||||
tracksDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1
|
||||
trackDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1
|
||||
trackDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1
|
||||
|
||||
fun dismiss() {
|
||||
if (isPlaying) {
|
||||
|
@ -785,7 +778,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
videosList.setItemChecked(which, true)
|
||||
}
|
||||
|
||||
tracksDialog.setOnDismissListener {
|
||||
trackDialog.setOnDismissListener {
|
||||
dismiss()
|
||||
// selectTracksDialog = null
|
||||
}
|
||||
|
@ -815,11 +808,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
audioList.setItemChecked(which, true)
|
||||
}
|
||||
|
||||
tracksDialog.cancel_btt?.setOnClickListener {
|
||||
tracksDialog.dismissSafe(activity)
|
||||
trackDialog.cancel_btt?.setOnClickListener {
|
||||
trackDialog.dismissSafe(activity)
|
||||
}
|
||||
|
||||
tracksDialog.apply_btt?.setOnClickListener {
|
||||
trackDialog.apply_btt?.setOnClickListener {
|
||||
val currentTrack = currentAudioTracks.getOrNull(audioIndexStart)
|
||||
player.setPreferredAudioTrack(
|
||||
currentTrack?.language, currentTrack?.id
|
||||
|
@ -832,7 +825,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
player.setMaxVideoSize(width, height, currentVideo?.id)
|
||||
}
|
||||
|
||||
tracksDialog.dismissSafe(activity)
|
||||
trackDialog.dismissSafe(activity)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -1149,7 +1142,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL"
|
||||
|
||||
val title = when (titleRez) {
|
||||
val title = when (titleRez) {
|
||||
0 -> ""
|
||||
1 -> extra
|
||||
2 -> source
|
||||
|
|
|
@ -5,8 +5,15 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
|||
import com.lagradost.cloudstream3.utils.*
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Used to open the player more easily with the LinkGenerator
|
||||
**/
|
||||
data class BasicLink(
|
||||
val url: String,
|
||||
val name: String? = null,
|
||||
)
|
||||
class LinkGenerator(
|
||||
private val links: List<String>,
|
||||
private val links: List<BasicLink>,
|
||||
private val extract: Boolean = true,
|
||||
private val referer: String? = null,
|
||||
private val isM3u8: Boolean? = null
|
||||
|
@ -47,7 +54,7 @@ class LinkGenerator(
|
|||
offset: Int
|
||||
): Boolean {
|
||||
links.amap { link ->
|
||||
if (!extract || !loadExtractor(link, referer, {
|
||||
if (!extract || !loadExtractor(link.url, referer, {
|
||||
subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it))
|
||||
}) {
|
||||
callback(it to null)
|
||||
|
@ -57,11 +64,11 @@ class LinkGenerator(
|
|||
callback(
|
||||
ExtractorLink(
|
||||
"",
|
||||
link,
|
||||
unshortenLinkSafe(link), // unshorten because it might be a raw link
|
||||
link.name ?: link.url,
|
||||
unshortenLinkSafe(link.url), // unshorten because it might be a raw link
|
||||
referer ?: "",
|
||||
Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall {
|
||||
URI(link).path?.substringAfterLast(".")?.contains("m3u")
|
||||
URI(link.url).path?.substringAfterLast(".")?.contains("m3u")
|
||||
} ?: false
|
||||
) to null
|
||||
)
|
||||
|
|
|
@ -220,7 +220,7 @@ class QuickSearchFragment : Fragment() {
|
|||
when (it) {
|
||||
is Resource.Success -> {
|
||||
it.value.let { data ->
|
||||
(quick_search_autofit_results?.adapter as? SearchAdapter?)?.updateList(
|
||||
(quick_search_autofit_results?.adapter as? SearchAdapter)?.updateList(
|
||||
context?.filterSearchResultByFilmQuality(data) ?: data
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,13 +7,13 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
||||
fun RecyclerView?.setLinearListLayout(isHorizontal: Boolean = true) {
|
||||
if(this == null) return
|
||||
if (this == null) return
|
||||
this.layoutManager =
|
||||
this.context?.let { LinearListLayout(it).apply { if (isHorizontal) setHorizontal() else setVertical() } }
|
||||
?: this.layoutManager
|
||||
}
|
||||
|
||||
class LinearListLayout(context: Context?) :
|
||||
open class LinearListLayout(context: Context?) :
|
||||
LinearLayoutManager(context) {
|
||||
|
||||
fun setHorizontal() {
|
||||
|
@ -24,7 +24,8 @@ class LinearListLayout(context: Context?) :
|
|||
orientation = VERTICAL
|
||||
}
|
||||
|
||||
private fun getCorrectParent(focused: View): View? {
|
||||
private fun getCorrectParent(focused: View?): View? {
|
||||
if (focused == null) return null
|
||||
var current: View? = focused
|
||||
val last: ArrayList<View> = arrayListOf(focused)
|
||||
while (current != null && current !is RecyclerView) {
|
||||
|
@ -54,10 +55,17 @@ class LinearListLayout(context: Context?) :
|
|||
linearSmoothScroller.targetPosition = position
|
||||
startSmoothScroll(linearSmoothScroller)
|
||||
}*/
|
||||
|
||||
override fun onInterceptFocusSearch(focused: View, direction: Int): View? {
|
||||
val dir = if (orientation == HORIZONTAL) {
|
||||
if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) return null
|
||||
if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) {
|
||||
// This scrolls the recyclerview before doing focus search, which
|
||||
// allows the focus search to work better.
|
||||
|
||||
// Without this the recyclerview focus location on the screen
|
||||
// would change when scrolling between recyclerviews.
|
||||
(focused.parent as? RecyclerView)?.focusSearch(direction)
|
||||
return null
|
||||
}
|
||||
if (direction == View.FOCUS_RIGHT) 1 else -1
|
||||
} else {
|
||||
if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) return null
|
||||
|
|
|
@ -15,24 +15,28 @@ import android.view.ViewGroup
|
|||
import android.widget.AbsListView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.discord.panels.OverlappingPanelsLayout
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.DubStatus
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.mvvm.*
|
||||
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||
|
@ -277,7 +281,7 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
private var downloadButton: EasyDownloadButton? = null
|
||||
override fun onDestroyView() {
|
||||
updateUIListener = null
|
||||
(result_episodes?.adapter as EpisodeAdapter?)?.killAdapter()
|
||||
(result_episodes?.adapter as? EpisodeAdapter)?.killAdapter()
|
||||
downloadButton?.dispose()
|
||||
|
||||
super.onDestroyView()
|
||||
|
@ -458,7 +462,7 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
temporary_no_focus?.requestFocus()
|
||||
}
|
||||
|
||||
(result_episodes?.adapter as? EpisodeAdapter?)?.updateList(episodes.value)
|
||||
(result_episodes?.adapter as? EpisodeAdapter)?.updateList(episodes.value)
|
||||
|
||||
if (isTv && hasEpisodes) main {
|
||||
delay(500)
|
||||
|
@ -528,6 +532,25 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
result_cast_items?.layoutManager = object : LinearListLayout(view.context) {
|
||||
override fun onRequestChildFocus(
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
child: View,
|
||||
focused: View?
|
||||
): Boolean {
|
||||
// Make the cast always focus the first visible item when focused
|
||||
// from somewhere else. Otherwise it jumps to the last item.
|
||||
return if (parent.focusedChild == null) {
|
||||
scrollToPosition(this.findFirstCompletelyVisibleItemPosition())
|
||||
true
|
||||
} else {
|
||||
super.onRequestChildFocus(parent, state, child, focused)
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
this.orientation = RecyclerView.HORIZONTAL
|
||||
}
|
||||
result_cast_items?.adapter = ActorAdaptor()
|
||||
|
||||
updateUIListener = ::updateUI
|
||||
|
@ -687,7 +710,7 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
val newList = list.filter { it.isSynced && it.hasAccount }
|
||||
|
||||
result_mini_sync?.isVisible = newList.isNotEmpty()
|
||||
(result_mini_sync?.adapter as? ImageAdapter?)?.updateList(newList.mapNotNull { it.icon })
|
||||
(result_mini_sync?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon })
|
||||
}
|
||||
|
||||
var currentSyncProgress = 0
|
||||
|
@ -850,7 +873,7 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
}
|
||||
|
||||
observe(viewModel.page) { data ->
|
||||
if(data == null) return@observe
|
||||
if (data == null) return@observe
|
||||
when (data) {
|
||||
is Resource.Success -> {
|
||||
val d = data.value
|
||||
|
@ -900,10 +923,40 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
|
||||
|
||||
result_cast_items?.isVisible = d.actors != null
|
||||
(result_cast_items?.adapter as ActorAdaptor?)?.apply {
|
||||
(result_cast_items?.adapter as? ActorAdaptor)?.apply {
|
||||
updateList(d.actors ?: emptyList())
|
||||
}
|
||||
|
||||
observeNullable(viewModel.subscribeStatus) { isSubscribed ->
|
||||
result_subscribe?.isVisible = isSubscribed != null
|
||||
if (isSubscribed == null) return@observeNullable
|
||||
|
||||
val drawable = if (isSubscribed) {
|
||||
R.drawable.ic_baseline_notifications_active_24
|
||||
} else {
|
||||
R.drawable.baseline_notifications_none_24
|
||||
}
|
||||
|
||||
result_subscribe?.setImageResource(drawable)
|
||||
}
|
||||
|
||||
result_subscribe?.setOnClickListener {
|
||||
val isSubscribed =
|
||||
viewModel.toggleSubscriptionStatus() ?: return@setOnClickListener
|
||||
|
||||
val message = if (isSubscribed) {
|
||||
// Kinda icky to have this here, but it works.
|
||||
SubscriptionWorkManager.enqueuePeriodicWork(context)
|
||||
R.string.subscription_new
|
||||
} else {
|
||||
R.string.subscription_deleted
|
||||
}
|
||||
|
||||
val name = (viewModel.page.value as? Resource.Success)?.value?.title
|
||||
?: txt(R.string.no_data).asStringNull(context) ?: ""
|
||||
showToast(activity, txt(message, name), Toast.LENGTH_SHORT)
|
||||
}
|
||||
|
||||
result_open_in_browser?.isVisible = d.url.startsWith("http")
|
||||
result_open_in_browser?.setOnClickListener {
|
||||
val i = Intent(ACTION_VIEW)
|
||||
|
@ -974,6 +1027,7 @@ open class ResultFragment : ResultTrailerPlayer() {
|
|||
chip.isCheckable = false
|
||||
chip.isFocusable = false
|
||||
chip.isClickable = false
|
||||
chip.setTextColor(context.colorFromAttribute(R.attr.textColor))
|
||||
addView(chip)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -322,9 +322,7 @@ class ResultFragmentPhone : ResultFragment() {
|
|||
// it?.dismiss()
|
||||
//}
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
|
||||
builder.show()
|
||||
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
@ -485,7 +483,7 @@ class ResultFragmentPhone : ResultFragment() {
|
|||
|
||||
result_recommendations?.post {
|
||||
rec?.let { list ->
|
||||
(result_recommendations?.adapter as SearchAdapter?)?.updateList(list.filter { it.apiName == matchAgainst })
|
||||
(result_recommendations?.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,7 +107,7 @@ class ResultFragmentTv : ResultFragment() {
|
|||
result_recommendations?.isGone = isInvalid
|
||||
result_recommendations_holder?.isGone = isInvalid
|
||||
val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName
|
||||
(result_recommendations?.adapter as SearchAdapter?)?.updateList(rec?.filter { it.apiName == matchAgainst }
|
||||
(result_recommendations?.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst }
|
||||
?: emptyList())
|
||||
|
||||
rec?.map { it.apiName }?.distinct()?.let { apiNames ->
|
||||
|
@ -176,8 +176,7 @@ class ResultFragmentTv : ResultFragment() {
|
|||
loadingDialog = null
|
||||
}
|
||||
loadingDialog = loadingDialog ?: context?.let { ctx ->
|
||||
val builder =
|
||||
BottomSheetDialog(ctx)
|
||||
val builder = BottomSheetDialog(ctx)
|
||||
builder.setContentView(R.layout.bottom_loading)
|
||||
builder.setOnDismissListener {
|
||||
loadingDialog = null
|
||||
|
@ -187,9 +186,7 @@ class ResultFragmentTv : ResultFragment() {
|
|||
// it?.dismiss()
|
||||
//}
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
|
||||
builder.show()
|
||||
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.result
|
|||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
|
@ -13,8 +14,10 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.APIHolder.getId
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.getCastSession
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
|
@ -24,6 +27,7 @@ import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
|
|||
import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie
|
||||
import com.lagradost.cloudstream3.metaproviders.SyncRedirector
|
||||
import com.lagradost.cloudstream3.mvvm.*
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
|
||||
import com.lagradost.cloudstream3.ui.APIRepository
|
||||
|
@ -412,6 +416,9 @@ class ResultViewModel2 : ViewModel() {
|
|||
private val _episodeSynopsis: MutableLiveData<String?> = MutableLiveData(null)
|
||||
val episodeSynopsis: LiveData<String?> = _episodeSynopsis
|
||||
|
||||
private val _subscribeStatus: MutableLiveData<Boolean?> = MutableLiveData(null)
|
||||
val subscribeStatus: LiveData<Boolean?> = _subscribeStatus
|
||||
|
||||
companion object {
|
||||
const val TAG = "RVM2"
|
||||
private const val EPISODE_RANGE_SIZE = 20
|
||||
|
@ -813,6 +820,42 @@ class ResultViewModel2 : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the new status is Subscribed, false if not. Null if not possible to subscribe.
|
||||
**/
|
||||
fun toggleSubscriptionStatus(): Boolean? {
|
||||
val isSubscribed = _subscribeStatus.value ?: return null
|
||||
val response = currentResponse ?: return null
|
||||
if (response !is EpisodeResponse) return null
|
||||
|
||||
val currentId = response.getId()
|
||||
|
||||
if (isSubscribed) {
|
||||
DataStoreHelper.removeSubscribedData(currentId)
|
||||
} else {
|
||||
val current = DataStoreHelper.getSubscribedData(currentId)
|
||||
|
||||
DataStoreHelper.setSubscribedData(
|
||||
currentId,
|
||||
DataStoreHelper.SubscribedData(
|
||||
currentId,
|
||||
current?.bookmarkedTime ?: unixTimeMS,
|
||||
unixTimeMS,
|
||||
response.getLatestEpisodes(),
|
||||
response.name,
|
||||
response.url,
|
||||
response.apiName,
|
||||
response.type,
|
||||
response.posterUrl,
|
||||
response.year
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
_subscribeStatus.postValue(!isSubscribed)
|
||||
return !isSubscribed
|
||||
}
|
||||
|
||||
private fun startChromecast(
|
||||
activity: Activity?,
|
||||
result: ResultEpisode,
|
||||
|
@ -1083,7 +1126,12 @@ class ResultViewModel2 : ViewModel() {
|
|||
1L
|
||||
}
|
||||
|
||||
component = VLC_COMPONENT
|
||||
// Component no longer safe to use in A13 for VLC
|
||||
// https://code.videolan.org/videolan/vlc-android/-/issues/2776
|
||||
// This will likely need to be updated once VLC fixes their documentation.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
component = VLC_COMPONENT
|
||||
}
|
||||
|
||||
putExtra("from_start", !resume)
|
||||
putExtra("position", position)
|
||||
|
@ -1421,79 +1469,128 @@ class ResultViewModel2 : ViewModel() {
|
|||
meta: SyncAPI.SyncResult?,
|
||||
syncs: Map<String, String>? = null
|
||||
): Pair<LoadResponse, Boolean> {
|
||||
if (meta == null) return resp to false
|
||||
//if (meta == null) return resp to false
|
||||
var updateEpisodes = false
|
||||
val out = resp.apply {
|
||||
Log.i(TAG, "applyMeta")
|
||||
|
||||
duration = duration ?: meta.duration
|
||||
rating = rating ?: meta.publicScore
|
||||
tags = tags ?: meta.genres
|
||||
plot = if (plot.isNullOrBlank()) meta.synopsis else plot
|
||||
posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl
|
||||
actors = actors ?: meta.actors
|
||||
if (meta != null) {
|
||||
duration = duration ?: meta.duration
|
||||
rating = rating ?: meta.publicScore
|
||||
tags = tags ?: meta.genres
|
||||
plot = if (plot.isNullOrBlank()) meta.synopsis else plot
|
||||
posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl
|
||||
actors = actors ?: meta.actors
|
||||
|
||||
if (this is EpisodeResponse) {
|
||||
nextAiring = nextAiring ?: meta.nextAiring
|
||||
if (this is EpisodeResponse) {
|
||||
nextAiring = nextAiring ?: meta.nextAiring
|
||||
}
|
||||
|
||||
val realRecommendations = ArrayList<SearchResponse>()
|
||||
val apiNames = apis.filter {
|
||||
it.name.contains("gogoanime", true) ||
|
||||
it.name.contains("9anime", true)
|
||||
}.map {
|
||||
it.name
|
||||
}
|
||||
|
||||
meta.recommendations?.forEach { rec ->
|
||||
apiNames.forEach { name ->
|
||||
realRecommendations.add(rec.copy(apiName = name))
|
||||
}
|
||||
}
|
||||
|
||||
recommendations = recommendations?.union(realRecommendations)?.toList()
|
||||
?: realRecommendations
|
||||
}
|
||||
|
||||
for ((k, v) in syncs ?: emptyMap()) {
|
||||
syncData[k] = v
|
||||
}
|
||||
|
||||
val realRecommendations = ArrayList<SearchResponse>()
|
||||
// TODO: fix
|
||||
//val apiNames = listOf(GogoanimeProvider().name, NineAnimeProvider().name)
|
||||
// meta.recommendations?.forEach { rec ->
|
||||
// apiNames.forEach { name ->
|
||||
// realRecommendations.add(rec.copy(apiName = name))
|
||||
// }
|
||||
// }
|
||||
argamap(
|
||||
{
|
||||
if (this !is AnimeLoadResponse) return@argamap
|
||||
// already exist, no need to run getTracker
|
||||
if (this.getAniListId() != null && this.getMalId() != null) return@argamap
|
||||
|
||||
recommendations = recommendations?.union(realRecommendations)?.toList()
|
||||
?: realRecommendations
|
||||
|
||||
argamap({
|
||||
addTrailer(meta.trailers)
|
||||
}, {
|
||||
if (this !is AnimeLoadResponse) return@argamap
|
||||
val map =
|
||||
Kitsu.getEpisodesDetails(
|
||||
getMalId(),
|
||||
getAniListId(),
|
||||
isResponseRequired = false
|
||||
val res = APIHolder.getTracker(
|
||||
listOfNotNull(
|
||||
this.engName,
|
||||
this.name,
|
||||
this.japName
|
||||
).filter { it.length > 2 }
|
||||
.distinct(), // the reason why we filter is due to not wanting smth like " " or "?"
|
||||
TrackerType.getTypes(this.type),
|
||||
this.year
|
||||
)
|
||||
if (map.isNullOrEmpty()) return@argamap
|
||||
updateEpisodes = DubStatus.values().map { dubStatus ->
|
||||
val current =
|
||||
this.episodes[dubStatus]?.mapIndexed { index, episode ->
|
||||
episode.apply {
|
||||
this.episode = this.episode ?: (index + 1)
|
||||
}
|
||||
}?.sortedBy { it.episode ?: 0 }?.toMutableList()
|
||||
if (current.isNullOrEmpty()) return@map false
|
||||
val episodeNumbers = current.map { ep -> ep.episode!! }
|
||||
var updateCount = 0
|
||||
map.forEach { (episode, node) ->
|
||||
episodeNumbers.binarySearch(episode).let { index ->
|
||||
current.getOrNull(index)?.let { currentEp ->
|
||||
current[index] = currentEp.apply {
|
||||
updateCount++
|
||||
val currentBack = this
|
||||
this.description = this.description ?: node.description?.en
|
||||
this.name = this.name ?: node.titles?.canonical
|
||||
this.episode =
|
||||
this.episode ?: node.num ?: episodeNumbers[index]
|
||||
this.posterUrl =
|
||||
this.posterUrl ?: node.thumbnail?.original?.url
|
||||
|
||||
val ids = arrayOf(
|
||||
AccountManager.malApi.idPrefix to res?.malId?.toString(),
|
||||
AccountManager.aniListApi.idPrefix to res?.aniId
|
||||
)
|
||||
|
||||
if (ids.any { (id, new) ->
|
||||
val current = syncData[id]
|
||||
new != null && current != null && current != new
|
||||
}
|
||||
) {
|
||||
// getTracker fucked up as it conflicts with current implementation
|
||||
return@argamap
|
||||
}
|
||||
|
||||
// set all the new data, prioritise old correct data
|
||||
ids.forEach { (id, new) ->
|
||||
new?.let {
|
||||
syncData[id] = syncData[id] ?: it
|
||||
}
|
||||
}
|
||||
|
||||
// set posters, might fuck up due to headers idk
|
||||
posterUrl = posterUrl ?: res?.image
|
||||
backgroundPosterUrl = backgroundPosterUrl ?: res?.cover
|
||||
},
|
||||
{
|
||||
if (meta == null) return@argamap
|
||||
addTrailer(meta.trailers)
|
||||
}, {
|
||||
if (this !is AnimeLoadResponse) return@argamap
|
||||
val map =
|
||||
Kitsu.getEpisodesDetails(
|
||||
getMalId(),
|
||||
getAniListId(),
|
||||
isResponseRequired = false
|
||||
)
|
||||
if (map.isNullOrEmpty()) return@argamap
|
||||
updateEpisodes = DubStatus.values().map { dubStatus ->
|
||||
val current =
|
||||
this.episodes[dubStatus]?.mapIndexed { index, episode ->
|
||||
episode.apply {
|
||||
this.episode = this.episode ?: (index + 1)
|
||||
}
|
||||
}?.sortedBy { it.episode ?: 0 }?.toMutableList()
|
||||
if (current.isNullOrEmpty()) return@map false
|
||||
val episodeNumbers = current.map { ep -> ep.episode!! }
|
||||
var updateCount = 0
|
||||
map.forEach { (episode, node) ->
|
||||
episodeNumbers.binarySearch(episode).let { index ->
|
||||
current.getOrNull(index)?.let { currentEp ->
|
||||
current[index] = currentEp.apply {
|
||||
updateCount++
|
||||
this.description = this.description ?: node.description?.en
|
||||
this.name = this.name ?: node.titles?.canonical
|
||||
this.episode =
|
||||
this.episode ?: node.num ?: episodeNumbers[index]
|
||||
this.posterUrl =
|
||||
this.posterUrl ?: node.thumbnail?.original?.url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.episodes[dubStatus] = current
|
||||
updateCount > 0
|
||||
}.any { it }
|
||||
})
|
||||
this.episodes[dubStatus] = current
|
||||
updateCount > 0
|
||||
}.any { it }
|
||||
})
|
||||
}
|
||||
return out to updateEpisodes
|
||||
}
|
||||
|
@ -1620,6 +1717,16 @@ class ResultViewModel2 : ViewModel() {
|
|||
postResume()
|
||||
}
|
||||
|
||||
private fun postSubscription(loadResponse: LoadResponse) {
|
||||
if (loadResponse.isEpisodeBased()) {
|
||||
val id = loadResponse.getId()
|
||||
val data = DataStoreHelper.getSubscribedData(id)
|
||||
DataStoreHelper.updateSubscribedData(id, data, loadResponse as? EpisodeResponse)
|
||||
val isSubscribed = data != null
|
||||
_subscribeStatus.postValue(isSubscribed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) {
|
||||
if (range == null || indexer == null) {
|
||||
return
|
||||
|
@ -1756,6 +1863,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
) {
|
||||
currentResponse = loadResponse
|
||||
postPage(loadResponse, apiRepository)
|
||||
postSubscription(loadResponse)
|
||||
if (updateEpisodes)
|
||||
postEpisodes(loadResponse, updateFillers)
|
||||
}
|
||||
|
@ -2117,7 +2225,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
autostart: AutoResume?,
|
||||
loadTrailers: Boolean = true,
|
||||
) =
|
||||
viewModelScope.launchSafe {
|
||||
ioSafe {
|
||||
_page.postValue(Resource.Loading(url))
|
||||
_episodes.postValue(ResourceSome.Loading())
|
||||
|
||||
|
@ -2135,7 +2243,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
"This provider does not exist"
|
||||
)
|
||||
)
|
||||
return@launchSafe
|
||||
return@ioSafe
|
||||
}
|
||||
|
||||
|
||||
|
@ -2143,24 +2251,18 @@ class ResultViewModel2 : ViewModel() {
|
|||
val validUrlResource = safeApiCall {
|
||||
SyncRedirector.redirect(
|
||||
url,
|
||||
api.mainUrl
|
||||
api
|
||||
)
|
||||
}
|
||||
// TODO: fix
|
||||
// val validUrlResource = safeApiCall {
|
||||
// SyncRedirector.redirect(
|
||||
// url,
|
||||
// api.mainUrl.replace(NineAnimeProvider().mainUrl, "9anime")
|
||||
// .replace(GogoanimeProvider().mainUrl, "gogoanime")
|
||||
// )
|
||||
// }
|
||||
|
||||
if (validUrlResource !is Resource.Success) {
|
||||
if (validUrlResource is Resource.Failure) {
|
||||
_page.postValue(validUrlResource)
|
||||
}
|
||||
|
||||
return@launchSafe
|
||||
return@ioSafe
|
||||
}
|
||||
|
||||
val validUrl = validUrlResource.value
|
||||
val repo = APIRepository(api)
|
||||
currentRepo = repo
|
||||
|
@ -2170,11 +2272,11 @@ class ResultViewModel2 : ViewModel() {
|
|||
_page.postValue(data)
|
||||
}
|
||||
is Resource.Success -> {
|
||||
if (!isActive) return@launchSafe
|
||||
if (!isActive) return@ioSafe
|
||||
val loadResponse = ioWork {
|
||||
applyMeta(data.value, currentMeta, currentSync).first
|
||||
}
|
||||
if (!isActive) return@launchSafe
|
||||
if (!isActive) return@ioSafe
|
||||
val mainId = loadResponse.getId()
|
||||
|
||||
preferDubStatus = getDub(mainId) ?: preferDubStatus
|
||||
|
@ -2202,7 +2304,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
updateFillers = showFillers,
|
||||
apiRepository = repo
|
||||
)
|
||||
if (!isActive) return@launchSafe
|
||||
if (!isActive) return@ioSafe
|
||||
handleAutoStart(activity, autostart)
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
|
|
|
@ -10,12 +10,16 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlinx.android.synthetic.main.search_result_compact.view.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Click */
|
||||
const val SEARCH_ACTION_LOAD = 0
|
||||
|
||||
/** Long press */
|
||||
const val SEARCH_ACTION_SHOW_METADATA = 1
|
||||
const val SEARCH_ACTION_PLAY_FILE = 2
|
||||
const val SEARCH_ACTION_FOCUSED = 4
|
||||
|
|
|
@ -420,7 +420,7 @@ class SearchFragment : Fragment() {
|
|||
is Resource.Success -> {
|
||||
it.value.let { data ->
|
||||
if (data.isNotEmpty()) {
|
||||
(search_autofit_results?.adapter as SearchAdapter?)?.updateList(data)
|
||||
(search_autofit_results?.adapter as? SearchAdapter)?.updateList(data)
|
||||
}
|
||||
}
|
||||
searchExitIcon.alpha = 1f
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
package com.lagradost.cloudstream3.ui.search
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.palette.graphics.Palette
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
|
@ -41,6 +43,7 @@ object SearchResultBuilder {
|
|||
nextFocusBehavior: Boolean? = null,
|
||||
nextFocusUp: Int? = null,
|
||||
nextFocusDown: Int? = null,
|
||||
colorCallback : ((Palette) -> Unit)? = null
|
||||
) {
|
||||
val cardView: ImageView = itemView.imageView
|
||||
val cardText: TextView? = itemView.imageText
|
||||
|
@ -100,7 +103,7 @@ object SearchResultBuilder {
|
|||
cardText?.isVisible = showTitle
|
||||
cardView.isVisible = true
|
||||
|
||||
if (!cardView.setImage(card.posterUrl, card.posterHeaders)) {
|
||||
if (!cardView.setImage(card.posterUrl, card.posterHeaders, colorCallback = colorCallback)) {
|
||||
cardView.setImageResource(R.drawable.default_cover)
|
||||
}
|
||||
|
||||
|
|
|
@ -157,6 +157,28 @@ class SettingsAccount : PreferenceFragmentCompat() {
|
|||
)
|
||||
dialog.dismissSafe()
|
||||
}
|
||||
|
||||
val displayedItems = listOf(
|
||||
dialog.login_username_input,
|
||||
dialog.login_email_input,
|
||||
dialog.login_server_input,
|
||||
dialog.login_password_input
|
||||
).filter { it.isVisible }
|
||||
|
||||
displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous ->
|
||||
item?.id?.let { previous?.nextFocusDownId = it }
|
||||
previous?.id?.let { item?.nextFocusUpId = it }
|
||||
item
|
||||
}
|
||||
|
||||
displayedItems.firstOrNull()?.let {
|
||||
dialog.create_account?.nextFocusDownId = it.id
|
||||
it.nextFocusUpId = dialog.create_account.id
|
||||
}
|
||||
dialog.apply_btt?.id?.let {
|
||||
displayedItems.lastOrNull()?.nextFocusDownId = it
|
||||
}
|
||||
|
||||
dialog.text1?.text = api.name
|
||||
|
||||
if (api.storesPasswordInPlainText) {
|
||||
|
|
|
@ -56,48 +56,50 @@ 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("", "Arabic", "ar"),
|
||||
Triple("", "Bulgarian", "bg"),
|
||||
Triple("", "Bengali", "bn"),
|
||||
Triple("\uD83C\uDDE7\uD83C\uDDF7", "Brazilian Portuguese", "bp"),
|
||||
Triple("", "Czech", "cs"),
|
||||
Triple("", "German", "de"),
|
||||
Triple("", "Greek", "el"),
|
||||
Triple("", "العربية", "ar"),
|
||||
Triple("", "български", "bg"),
|
||||
Triple("", "বাংলা", "bn"),
|
||||
Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"),
|
||||
Triple("", "čeština", "cs"),
|
||||
Triple("", "Deutsch", "de"),
|
||||
Triple("", "Ελληνικά", "el"),
|
||||
Triple("", "English", "en"),
|
||||
Triple("", "Esperanto", "eo"),
|
||||
Triple("", "Spanish", "es"),
|
||||
Triple("", "Farsi", "fa"),
|
||||
Triple("", "French", "fr"),
|
||||
Triple("", "Hindi", "hi"),
|
||||
Triple("", "Croatian", "hr"),
|
||||
Triple("", "Hungarian", "hu"),
|
||||
Triple("\uD83C\uDDEE\uD83C\uDDE9", "Indonesian", "in"),
|
||||
Triple("", "Italian", "it"),
|
||||
Triple("\uD83C\uDDEE\uD83C\uDDF1", "Hebrew", "iw"),
|
||||
Triple("", "Kannada", "kn"),
|
||||
Triple("", "Macedonian", "mk"),
|
||||
Triple("", "Malayalam", "ml"),
|
||||
Triple("", "Moldavian", "mo"),
|
||||
Triple("", "Dutch", "nl"),
|
||||
Triple("", "Norwegian Nynorsk", "nn"),
|
||||
Triple("", "Norwegian", "no"),
|
||||
Triple("", "Polish", "pl"),
|
||||
Triple("\uD83C\uDDF5\uD83C\uDDF9", "Portuguese", "pt"),
|
||||
Triple("", "Romanian", "ro"),
|
||||
Triple("", "Russian", "ru"),
|
||||
Triple("", "Slovak", "sk"),
|
||||
Triple("", "Somali", "so"),
|
||||
Triple("", "Swedish", "sv"),
|
||||
Triple("", "Tamil", "ta"),
|
||||
Triple("", "español", "es"),
|
||||
Triple("", "فارسی", "fa"),
|
||||
Triple("", "français", "fr"),
|
||||
Triple("", "हिन्दी", "hi"),
|
||||
Triple("", "hrvatski", "hr"),
|
||||
Triple("", "magyar", "hu"),
|
||||
Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"),
|
||||
Triple("", "italiano", "it"),
|
||||
Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"),
|
||||
Triple("", "日本語 (にほんご)", "ja"),
|
||||
Triple("", "ಕನ್ನಡ", "kn"),
|
||||
Triple("", "македонски", "mk"),
|
||||
Triple("", "മലയാളം", "ml"),
|
||||
Triple("", "bahasa Melayu", "ms"),
|
||||
Triple("", "Nederlands", "nl"),
|
||||
Triple("", "norsk nynorsk", "nn"),
|
||||
Triple("", "norsk bokmål", "no"),
|
||||
Triple("", "polski", "pl"),
|
||||
Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"),
|
||||
Triple("\uD83E\uDD8D", "mmmm... monke", "qt"),
|
||||
Triple("", "română", "ro"),
|
||||
Triple("", "русский", "ru"),
|
||||
Triple("", "slovenčina", "sk"),
|
||||
Triple("", "Soomaaliga", "so"),
|
||||
Triple("", "svenska", "sv"),
|
||||
Triple("", "தமிழ்", "ta"),
|
||||
Triple("", "Tagalog", "tl"),
|
||||
Triple("", "Turkish", "tr"),
|
||||
Triple("", "Ukrainian", "uk"),
|
||||
Triple("", "Urdu", "ur"),
|
||||
Triple("", "Viet Nam", "vi"),
|
||||
Triple("", "Chinese Simplified", "zh"),
|
||||
Triple("\uD83C\uDDF9\uD83C\uDDFC", "Chinese Traditional", "zh-rTW"),
|
||||
Triple("", "Türkçe", "tr"),
|
||||
Triple("", "українська", "uk"),
|
||||
Triple("", "اردو", "ur"),
|
||||
Triple("", "Tiếng Việt", "vi"),
|
||||
Triple("", "中文", "zh"),
|
||||
Triple("\uD83C\uDDF9\uD83C\uDDFC", "文言", "zh-rTW"),
|
||||
/* end language list */
|
||||
).sortedBy { it.second } //ye, we go alphabetical, so ppl don't put their lang on top
|
||||
).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top
|
||||
|
||||
class SettingsGeneral : PreferenceFragmentCompat() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -157,9 +159,6 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
|||
|
||||
getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref ->
|
||||
val tempLangs = appLanguages.toMutableList()
|
||||
//if (beneneCount > 100) {
|
||||
// tempLangs.add(Triple("\uD83E\uDD8D", "mmmm... monke", "mo"))
|
||||
//}
|
||||
val current = getCurrentLocale(pref.context)
|
||||
val languageCodes = tempLangs.map { (_, _, iso) -> iso }
|
||||
val languageNames = tempLangs.map { (emoji, name, iso) ->
|
||||
|
@ -316,6 +315,12 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
|||
} ?: emptyList()
|
||||
}
|
||||
|
||||
settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply()
|
||||
getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue ->
|
||||
setKey(getString(R.string.jsdelivr_proxy_key), newValue)
|
||||
return@setOnPreferenceChangeListener true
|
||||
}
|
||||
|
||||
getPref(R.string.download_path_key)?.setOnPreferenceClickListener {
|
||||
val dirs = getDownloadDirs()
|
||||
|
||||
|
|
|
@ -113,6 +113,30 @@ class SettingsPlayer : PreferenceFragmentCompat() {
|
|||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
getPref(R.string.quality_pref_mobile_data_key)?.setOnPreferenceClickListener {
|
||||
val prefValues = Qualities.values().map { it.value }.reversed().toMutableList()
|
||||
prefValues.remove(Qualities.Unknown.value)
|
||||
|
||||
val prefNames = prefValues.map { Qualities.getStringByInt(it) }
|
||||
|
||||
val currentQuality =
|
||||
settingsManager.getInt(
|
||||
getString(R.string.quality_pref_mobile_data_key),
|
||||
Qualities.values().last().value
|
||||
)
|
||||
|
||||
activity?.showBottomDialog(
|
||||
prefNames.toList(),
|
||||
prefValues.indexOf(currentQuality),
|
||||
getString(R.string.watch_quality_pref_data),
|
||||
true,
|
||||
{}) {
|
||||
settingsManager.edit().putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it])
|
||||
.apply()
|
||||
}
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
getPref(R.string.player_pref_key)?.setOnPreferenceClickListener {
|
||||
val prefNames = resources.getStringArray(R.array.player_pref_names)
|
||||
val prefValues = resources.getIntArray(R.array.player_pref_values)
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.lagradost.cloudstream3.ui.settings
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.*
|
||||
|
@ -16,6 +18,7 @@ import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
|||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
|
||||
class SettingsProviders : PreferenceFragmentCompat() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -56,6 +59,20 @@ class SettingsProviders : PreferenceFragmentCompat() {
|
|||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
getPref(R.string.test_providers_key)?.setOnPreferenceClickListener {
|
||||
// Somehow animations do not work without this.
|
||||
val options = NavOptions.Builder()
|
||||
.setEnterAnim(R.anim.enter_anim)
|
||||
.setExitAnim(R.anim.exit_anim)
|
||||
.setPopEnterAnim(R.anim.pop_enter)
|
||||
.setPopExitAnim(R.anim.pop_exit)
|
||||
.build()
|
||||
|
||||
this@SettingsProviders.findNavController()
|
||||
.navigate(R.id.navigation_test_providers, null, options)
|
||||
true
|
||||
}
|
||||
|
||||
getPref(R.string.prefer_media_type_key)?.setOnPreferenceClickListener {
|
||||
val names = enumValues<TvType>().sorted().map { it.name }
|
||||
val default =
|
||||
|
|
|
@ -143,7 +143,7 @@ class PluginsFragment : Fragment() {
|
|||
}
|
||||
|
||||
observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) ->
|
||||
(plugin_recycler_view?.adapter as? PluginAdapter?)?.updateList(list)
|
||||
(plugin_recycler_view?.adapter as? PluginAdapter)?.updateList(list)
|
||||
|
||||
if (scrollToTop)
|
||||
plugin_recycler_view?.scrollToPosition(0)
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
package com.lagradost.cloudstream3.ui.settings.testing
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||
import kotlinx.android.synthetic.main.fragment_testing.*
|
||||
import kotlinx.android.synthetic.main.view_test.*
|
||||
|
||||
|
||||
class TestFragment : Fragment() {
|
||||
|
||||
private val testViewModel: TestViewModel by activityViewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setUpToolbar(R.string.category_provider_test)
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
provider_test_recycler_view?.adapter = TestResultAdapter(
|
||||
mutableListOf()
|
||||
)
|
||||
|
||||
testViewModel.init()
|
||||
if (testViewModel.isRunningTest) {
|
||||
provider_test?.setState(TestView.TestState.Running)
|
||||
}
|
||||
|
||||
observe(testViewModel.providerProgress) { (passed, failed, total) ->
|
||||
provider_test?.setProgress(passed, failed, total)
|
||||
}
|
||||
|
||||
observeNullable(testViewModel.providerResults) {
|
||||
normalSafeApiCall {
|
||||
val newItems = it.sortedBy { api -> api.first.name }
|
||||
(provider_test_recycler_view?.adapter as? TestResultAdapter)?.updateList(
|
||||
newItems
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
provider_test?.setOnPlayButtonListener { state ->
|
||||
when (state) {
|
||||
TestView.TestState.Stopped -> testViewModel.stopTest()
|
||||
TestView.TestState.Running -> testViewModel.startTest()
|
||||
TestView.TestState.None -> testViewModel.startTest()
|
||||
}
|
||||
}
|
||||
|
||||
if (isTrueTvSettings()) {
|
||||
tests_play_pause?.isFocusableInTouchMode = true
|
||||
tests_play_pause?.requestFocus()
|
||||
}
|
||||
|
||||
provider_test?.playPauseButton?.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
provider_test_appbar?.setExpanded(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun focusRecyclerView() {
|
||||
// Hack to make it possible to focus the recyclerview.
|
||||
if (isTrueTvSettings()) {
|
||||
provider_test_recycler_view?.requestFocus()
|
||||
provider_test_appbar?.setExpanded(false, true)
|
||||
}
|
||||
}
|
||||
|
||||
provider_test?.setOnMainClick {
|
||||
testViewModel.setFilterMethod(TestViewModel.ProviderFilter.All)
|
||||
focusRecyclerView()
|
||||
}
|
||||
provider_test?.setOnFailedClick {
|
||||
testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Failed)
|
||||
focusRecyclerView()
|
||||
}
|
||||
provider_test?.setOnPassedClick {
|
||||
testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Passed)
|
||||
focusRecyclerView()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_testing, container, false)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package com.lagradost.cloudstream3.ui.settings.testing
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.getAllMessages
|
||||
import com.lagradost.cloudstream3.mvvm.getStackTracePretty
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
|
||||
import com.lagradost.cloudstream3.utils.TestingUtils
|
||||
import kotlinx.android.synthetic.main.provider_test_item.view.*
|
||||
|
||||
class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUtils.TestResultProvider>>) :
|
||||
AppUtils.DiffAdapter<Pair<MainAPI, TestingUtils.TestResultProvider>>(items) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return ProviderTestViewHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.provider_test_item, parent, false),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is ProviderTestViewHolder -> {
|
||||
val item = items[position]
|
||||
holder.bind(item.first, item.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ProviderTestViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val languageText: TextView = itemView.lang_icon
|
||||
private val providerTitle: TextView = itemView.main_text
|
||||
private val statusText: TextView = itemView.passed_failed_marker
|
||||
private val failDescription: TextView = itemView.fail_description
|
||||
private val logButton: ImageView = itemView.action_button
|
||||
|
||||
private fun String.lastLine(): String? {
|
||||
return this.lines().lastOrNull { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) {
|
||||
languageText.text = getFlagFromIso(api.lang)
|
||||
providerTitle.text = api.name
|
||||
|
||||
val (resultText, resultColor) = if (result.success) {
|
||||
R.string.test_passed to R.color.colorTestPass
|
||||
} else {
|
||||
R.string.test_failed to R.color.colorTestFail
|
||||
}
|
||||
|
||||
statusText.setText(resultText)
|
||||
statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor))
|
||||
|
||||
val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null }
|
||||
val messages = result.exception?.getAllMessages()?.ifBlank { null }
|
||||
val fullLog =
|
||||
result.log + (messages?.let { "\n\n$it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "")
|
||||
|
||||
failDescription.text = messages?.lastLine() ?: result.log.lastLine()
|
||||
|
||||
logButton.setOnClickListener {
|
||||
val builder: AlertDialog.Builder =
|
||||
AlertDialog.Builder(it.context, R.style.AlertDialogCustom)
|
||||
builder.setMessage(fullLog)
|
||||
.setTitle(R.string.test_log)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
package com.lagradost.cloudstream3.ui.settings.testing
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.ContextCompat
|
||||
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.AppUtils.animateProgressTo
|
||||
|
||||
class TestView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : CardView(context, attrs) {
|
||||
enum class TestState(@StringRes val stringRes: Int, @DrawableRes val icon: Int) {
|
||||
None(R.string.start, R.drawable.ic_baseline_play_arrow_24),
|
||||
|
||||
// Paused(R.string.resume, R.drawable.ic_baseline_play_arrow_24),
|
||||
Stopped(R.string.restart, R.drawable.ic_baseline_play_arrow_24),
|
||||
Running(R.string.stop, R.drawable.pause_to_play),
|
||||
}
|
||||
|
||||
var mainSection: View? = null
|
||||
var testsPassedSection: View? = null
|
||||
var testsFailedSection: View? = null
|
||||
|
||||
var mainSectionText: TextView? = null
|
||||
var mainSectionHeader: TextView? = null
|
||||
var testsPassedSectionText: TextView? = null
|
||||
var testsFailedSectionText: TextView? = null
|
||||
var totalProgressBar: ContentLoadingProgressBar? = null
|
||||
|
||||
var playPauseButton: MaterialButton? = null
|
||||
var stateListener: (TestState) -> Unit = {}
|
||||
|
||||
private var state = TestState.None
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_test, this, true)
|
||||
|
||||
mainSection = findViewById(R.id.main_test_section)
|
||||
testsPassedSection = findViewById(R.id.passed_test_section)
|
||||
testsFailedSection = findViewById(R.id.failed_test_section)
|
||||
|
||||
mainSectionHeader = findViewById(R.id.main_test_header)
|
||||
mainSectionText = findViewById(R.id.main_test_section_progress)
|
||||
testsPassedSectionText = findViewById(R.id.passed_test_section_progress)
|
||||
testsFailedSectionText = findViewById(R.id.failed_test_section_progress)
|
||||
|
||||
totalProgressBar = findViewById(R.id.test_total_progress)
|
||||
playPauseButton = findViewById(R.id.tests_play_pause)
|
||||
|
||||
attrs?.let {
|
||||
val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView)
|
||||
val headerText = typedArray.getString(R.styleable.TestView_header_text)
|
||||
mainSectionHeader?.text = headerText
|
||||
typedArray.recycle()
|
||||
}
|
||||
|
||||
playPauseButton?.setOnClickListener {
|
||||
val newState = when (state) {
|
||||
TestState.None -> TestState.Running
|
||||
TestState.Running -> TestState.Stopped
|
||||
TestState.Stopped -> TestState.Running
|
||||
}
|
||||
setState(newState)
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnPlayButtonListener(listener: (TestState) -> Unit) {
|
||||
stateListener = listener
|
||||
}
|
||||
|
||||
fun setState(newState: TestState) {
|
||||
state = newState
|
||||
stateListener.invoke(newState)
|
||||
playPauseButton?.setText(newState.stringRes)
|
||||
playPauseButton?.icon = ContextCompat.getDrawable(context, newState.icon)
|
||||
}
|
||||
|
||||
fun setProgress(passed: Int, failed: Int, total: Int?) {
|
||||
val totalProgress = passed + failed
|
||||
mainSectionText?.text = "$totalProgress / ${total?.toString() ?: "?"}"
|
||||
testsPassedSectionText?.text = passed.toString()
|
||||
testsFailedSectionText?.text = failed.toString()
|
||||
|
||||
totalProgressBar?.max = (total ?: 0) * 1000
|
||||
totalProgressBar?.animateProgressTo(totalProgress * 1000)
|
||||
|
||||
totalProgressBar?.isVisible = !(totalProgress == 0 || (total ?: 0) == 0)
|
||||
if (totalProgress == total) {
|
||||
setState(TestState.Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
fun setMainHeader(@StringRes header: Int) {
|
||||
mainSectionHeader?.setText(header)
|
||||
}
|
||||
|
||||
fun setOnMainClick(listener: OnClickListener) {
|
||||
mainSection?.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun setOnPassedClick(listener: OnClickListener) {
|
||||
testsPassedSection?.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun setOnFailedClick(listener: OnClickListener) {
|
||||
testsFailedSection?.setOnClickListener(listener)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
package com.lagradost.cloudstream3.ui.settings.testing
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.TestingUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
class TestViewModel : ViewModel() {
|
||||
data class TestProgress(
|
||||
val passed: Int,
|
||||
val failed: Int,
|
||||
val total: Int
|
||||
)
|
||||
|
||||
enum class ProviderFilter {
|
||||
All,
|
||||
Passed,
|
||||
Failed
|
||||
}
|
||||
|
||||
private val _providerProgress = MutableLiveData<TestProgress>(null)
|
||||
val providerProgress: LiveData<TestProgress> = _providerProgress
|
||||
|
||||
private val _providerResults =
|
||||
MutableLiveData<List<Pair<MainAPI, TestingUtils.TestResultProvider>>>(
|
||||
emptyList()
|
||||
)
|
||||
|
||||
val providerResults: LiveData<List<Pair<MainAPI, TestingUtils.TestResultProvider>>> =
|
||||
_providerResults
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
val isRunningTest
|
||||
get() = scope != null
|
||||
|
||||
private var filter = ProviderFilter.All
|
||||
private val providers = threadSafeListOf<Pair<MainAPI, TestingUtils.TestResultProvider>>()
|
||||
private var passed = 0
|
||||
private var failed = 0
|
||||
private var total = 0
|
||||
|
||||
private fun updateProgress() {
|
||||
_providerProgress.postValue(TestProgress(passed, failed, total))
|
||||
postProviders()
|
||||
}
|
||||
|
||||
private fun postProviders() {
|
||||
synchronized(providers) {
|
||||
val filtered = when (filter) {
|
||||
ProviderFilter.All -> providers
|
||||
ProviderFilter.Passed -> providers.filter { it.second.success }
|
||||
ProviderFilter.Failed -> providers.filter { !it.second.success }
|
||||
}
|
||||
_providerResults.postValue(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
fun setFilterMethod(filter: ProviderFilter) {
|
||||
if (this.filter == filter) return
|
||||
this.filter = filter
|
||||
postProviders()
|
||||
}
|
||||
|
||||
private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) {
|
||||
synchronized(providers) {
|
||||
val index = providers.indexOfFirst { it.first == api }
|
||||
if (index == -1) {
|
||||
providers.add(api to results)
|
||||
if (results.success) passed++ else failed++
|
||||
} else {
|
||||
providers[index] = api to results
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
val apis = APIHolder.allProviders
|
||||
total = apis.size
|
||||
updateProgress()
|
||||
}
|
||||
|
||||
fun startTest() {
|
||||
scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
val apis = APIHolder.allProviders
|
||||
total = apis.size
|
||||
failed = 0
|
||||
passed = 0
|
||||
providers.clear()
|
||||
updateProgress()
|
||||
|
||||
TestingUtils.getDeferredProviderTests(scope ?: return, apis, ::println) { api, result ->
|
||||
addProvider(api, result)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopTest() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Activity.RESULT_CANCELED
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
|
@ -17,6 +20,7 @@ import android.os.*
|
|||
import android.provider.MediaStore
|
||||
import android.text.Spanned
|
||||
import android.util.Log
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
|
@ -25,13 +29,16 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.toSpanned
|
||||
import androidx.core.widget.ContentLoadingProgressBar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.tvprovider.media.tv.*
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastState
|
||||
|
@ -65,6 +72,7 @@ import okhttp3.Cache
|
|||
import java.io.*
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
object AppUtils {
|
||||
fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) {
|
||||
|
@ -164,6 +172,48 @@ object AppUtils {
|
|||
return builder.build()
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/67441735/13746422
|
||||
fun ViewPager2.reduceDragSensitivity(f: Int = 4) {
|
||||
val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
|
||||
recyclerViewField.isAccessible = true
|
||||
val recyclerView = recyclerViewField.get(this) as RecyclerView
|
||||
|
||||
val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop")
|
||||
touchSlopField.isAccessible = true
|
||||
val touchSlop = touchSlopField.get(recyclerView) as Int
|
||||
touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally
|
||||
}
|
||||
|
||||
fun ContentLoadingProgressBar?.animateProgressTo(to: Int) {
|
||||
if (this == null) return
|
||||
val animation: ObjectAnimator = ObjectAnimator.ofInt(
|
||||
this,
|
||||
"progress",
|
||||
this.progress,
|
||||
to
|
||||
)
|
||||
animation.duration = 500
|
||||
animation.setAutoCancel(true)
|
||||
animation.interpolator = DecelerateInterpolator()
|
||||
animation.start()
|
||||
}
|
||||
|
||||
fun Context.createNotificationChannel(channelId: String, channelName: String, description: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel =
|
||||
NotificationChannel(channelId, channelName, importance).apply {
|
||||
this.description = description
|
||||
}
|
||||
|
||||
// Register the channel with the system.
|
||||
val notificationManager: NotificationManager =
|
||||
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun getAllWatchNextPrograms(context: Context): Set<Long> {
|
||||
val COLUMN_WATCH_NEXT_ID_INDEX = 0
|
||||
|
@ -329,6 +379,46 @@ object AppUtils {
|
|||
}
|
||||
}
|
||||
|
||||
abstract class DiffAdapter<T>(
|
||||
open val items: MutableList<T>,
|
||||
val comparison: (first: T, second: T) -> Boolean = { first, second ->
|
||||
first.hashCode() == second.hashCode()
|
||||
}
|
||||
) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
override fun getItemCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
|
||||
fun updateList(newList: List<T>) {
|
||||
val diffResult = DiffUtil.calculateDiff(
|
||||
GenericDiffCallback(this.items, newList)
|
||||
)
|
||||
|
||||
items.clear()
|
||||
items.addAll(newList)
|
||||
|
||||
diffResult.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
inner class GenericDiffCallback(
|
||||
private val oldList: List<T>,
|
||||
private val newList: List<T>
|
||||
) :
|
||||
DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||
comparison(oldList[oldItemPosition], newList[newItemPosition])
|
||||
|
||||
override fun getOldListSize() = oldList.size
|
||||
|
||||
override fun getNewListSize() = newList.size
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||
oldList[oldItemPosition] == newList[newItemPosition]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) {
|
||||
runOnUiThread {
|
||||
val context = this
|
||||
|
@ -401,6 +491,12 @@ object AppUtils {
|
|||
}
|
||||
}
|
||||
|
||||
fun Context.isNetworkAvailable(): Boolean {
|
||||
val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetworkInfo = manager.activeNetworkInfo
|
||||
return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false
|
||||
}
|
||||
|
||||
fun splitQuery(url: URL): Map<String, String> {
|
||||
val queryPairs: MutableMap<String, String> = LinkedHashMap()
|
||||
val query: String = url.query
|
||||
|
@ -680,8 +776,13 @@ object AppUtils {
|
|||
return networkInfo.any {
|
||||
conManager.getNetworkCapabilities(it)
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
||||
} &&
|
||||
!networkInfo.any {
|
||||
conManager.getNetworkCapabilities(it)
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun Activity?.cacheClass(clazz: String?) {
|
||||
clazz?.let { c ->
|
||||
|
@ -725,4 +826,4 @@ object AppUtils {
|
|||
}
|
||||
return currentAudioFocusRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.provider.MediaStore
|
|||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
|
@ -18,17 +19,17 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY
|
||||
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_SHOULD_UPDATE_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_KEY
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_SHOULD_UPDATE_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY
|
||||
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.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
|
||||
import com.lagradost.cloudstream3.utils.DataStore.mapper
|
||||
|
@ -52,12 +53,10 @@ object BackupUtils {
|
|||
// When sharing backup we do not want to transfer what is essentially the password
|
||||
ANILIST_TOKEN_KEY,
|
||||
ANILIST_CACHED_LIST,
|
||||
ANILIST_SHOULD_UPDATE_LIST,
|
||||
ANILIST_UNIXTIME_KEY,
|
||||
ANILIST_USER_KEY,
|
||||
MAL_TOKEN_KEY,
|
||||
MAL_REFRESH_TOKEN_KEY,
|
||||
MAL_SHOULD_UPDATE_LIST,
|
||||
MAL_CACHED_LIST,
|
||||
MAL_UNIXTIME_KEY,
|
||||
MAL_USER_KEY,
|
||||
|
@ -121,6 +120,7 @@ object BackupUtils {
|
|||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Context.restore(
|
||||
backupFile: BackupFile,
|
||||
restoreSettings: Boolean,
|
||||
|
@ -223,31 +223,29 @@ object BackupUtils {
|
|||
try {
|
||||
restoreFileSelector =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||
this.let { activity ->
|
||||
uri?.let {
|
||||
try {
|
||||
val input =
|
||||
activity.contentResolver.openInputStream(uri)
|
||||
?: return@registerForActivityResult
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val activity = this
|
||||
ioSafe {
|
||||
try {
|
||||
val input = activity.contentResolver.openInputStream(uri)
|
||||
?: return@ioSafe
|
||||
|
||||
val restoredValue =
|
||||
mapper.readValue<BackupFile>(input)
|
||||
activity.restore(
|
||||
restoredValue,
|
||||
restoreSettings = true,
|
||||
restoreDataStore = true
|
||||
val restoredValue =
|
||||
mapper.readValue<BackupFile>(input)
|
||||
|
||||
activity.restore(
|
||||
restoredValue,
|
||||
restoreSettings = true,
|
||||
restoreDataStore = true
|
||||
)
|
||||
activity.runOnUiThread { activity.recreate() }
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
main { // smth can fail in .format
|
||||
showToast(
|
||||
activity,
|
||||
getString(R.string.restore_failed_format).format(e.toString())
|
||||
)
|
||||
activity.recreate()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
try { // smth can fail in .format
|
||||
showToast(
|
||||
activity,
|
||||
getString(R.string.restore_failed_format).format(e.toString())
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.DubStatus
|
||||
import com.lagradost.cloudstream3.SearchQuality
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.result.VideoWatchState
|
||||
|
||||
|
@ -17,6 +17,7 @@ const val VIDEO_POS_DUR = "video_pos_dur"
|
|||
const val VIDEO_WATCH_STATE = "video_watch_state"
|
||||
const val RESULT_WATCH_STATE = "result_watch_state"
|
||||
const val RESULT_WATCH_STATE_DATA = "result_watch_state_data"
|
||||
const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data"
|
||||
const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes
|
||||
const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching"
|
||||
const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated"
|
||||
|
@ -39,6 +40,37 @@ object DataStoreHelper {
|
|||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to display notifications on new episodes and posters in library.
|
||||
**/
|
||||
data class SubscribedData(
|
||||
@JsonProperty("id") override var id: Int?,
|
||||
@JsonProperty("subscribedTime") val bookmarkedTime: Long,
|
||||
@JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long,
|
||||
@JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map<DubStatus, Int?>,
|
||||
@JsonProperty("name") override val name: String,
|
||||
@JsonProperty("url") override val url: String,
|
||||
@JsonProperty("apiName") override val apiName: String,
|
||||
@JsonProperty("type") override var type: TvType? = null,
|
||||
@JsonProperty("posterUrl") override var posterUrl: String?,
|
||||
@JsonProperty("year") val year: Int?,
|
||||
@JsonProperty("quality") override var quality: SearchQuality? = null,
|
||||
@JsonProperty("posterHeaders") override var posterHeaders: Map<String, String>? = null,
|
||||
) : SearchResponse {
|
||||
fun toLibraryItem(): SyncAPI.LibraryItem? {
|
||||
return SyncAPI.LibraryItem(
|
||||
name,
|
||||
url,
|
||||
id?.toString() ?: return null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
latestUpdatedTime,
|
||||
apiName, type, posterUrl, posterHeaders, quality, this.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class BookmarkedData(
|
||||
@JsonProperty("id") override var id: Int?,
|
||||
@JsonProperty("bookmarkedTime") val bookmarkedTime: Long,
|
||||
|
@ -51,7 +83,20 @@ object DataStoreHelper {
|
|||
@JsonProperty("year") val year: Int?,
|
||||
@JsonProperty("quality") override var quality: SearchQuality? = null,
|
||||
@JsonProperty("posterHeaders") override var posterHeaders: Map<String, String>? = null,
|
||||
) : SearchResponse
|
||||
) : SearchResponse {
|
||||
fun toLibraryItem(id: String): SyncAPI.LibraryItem {
|
||||
return SyncAPI.LibraryItem(
|
||||
name,
|
||||
url,
|
||||
id,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
latestUpdatedTime,
|
||||
apiName, type, posterUrl, posterHeaders, quality, this.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ResumeWatchingResult(
|
||||
@JsonProperty("name") override val name: String,
|
||||
|
@ -59,9 +104,7 @@ object DataStoreHelper {
|
|||
@JsonProperty("apiName") override val apiName: String,
|
||||
@JsonProperty("type") override var type: TvType? = null,
|
||||
@JsonProperty("posterUrl") override var posterUrl: String?,
|
||||
|
||||
@JsonProperty("watchPos") val watchPos: PosDur?,
|
||||
|
||||
@JsonProperty("id") override var id: Int?,
|
||||
@JsonProperty("parentId") val parentId: Int?,
|
||||
@JsonProperty("episode") val episode: Int?,
|
||||
|
@ -71,6 +114,9 @@ object DataStoreHelper {
|
|||
@JsonProperty("posterHeaders") override var posterHeaders: Map<String, String>? = null,
|
||||
) : SearchResponse
|
||||
|
||||
/**
|
||||
* A datastore wide account for future implementations of a multiple account system
|
||||
**/
|
||||
private var currentAccount: String = "0" //TODO ACCOUNT IMPLEMENTATION
|
||||
|
||||
fun getAllWatchStateIds(): List<Int>? {
|
||||
|
@ -177,6 +223,7 @@ object DataStoreHelper {
|
|||
fun setBookmarkedData(id: Int?, data: BookmarkedData) {
|
||||
if (id == null) return
|
||||
setKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString(), data)
|
||||
AccountManager.localListApi.requireLibraryRefresh = true
|
||||
}
|
||||
|
||||
fun getBookmarkedData(id: Int?): BookmarkedData? {
|
||||
|
@ -184,6 +231,41 @@ object DataStoreHelper {
|
|||
return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString())
|
||||
}
|
||||
|
||||
fun getAllSubscriptions(): List<SubscribedData> {
|
||||
return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull {
|
||||
getKey(it)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun removeSubscribedData(id: Int?) {
|
||||
if (id == null) return
|
||||
AccountManager.localListApi.requireLibraryRefresh = true
|
||||
removeKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new seen episodes and update time
|
||||
**/
|
||||
fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) {
|
||||
if (id == null || data == null || episodeResponse == null) return
|
||||
val newData = data.copy(
|
||||
latestUpdatedTime = unixTimeMS,
|
||||
lastSeenEpisodeCount = episodeResponse.getLatestEpisodes()
|
||||
)
|
||||
setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData)
|
||||
}
|
||||
|
||||
fun setSubscribedData(id: Int?, data: SubscribedData) {
|
||||
if (id == null) return
|
||||
setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), data)
|
||||
AccountManager.localListApi.requireLibraryRefresh = true
|
||||
}
|
||||
|
||||
fun getSubscribedData(id: Int?): SubscribedData? {
|
||||
if (id == null) return null
|
||||
return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString())
|
||||
}
|
||||
|
||||
fun setViewPos(id: Int?, pos: Long, dur: Long) {
|
||||
if (id == null) return
|
||||
if (dur < 30_000) return // too short
|
||||
|
|
|
@ -52,7 +52,7 @@ data class ExtractorLinkPlayList(
|
|||
)
|
||||
|
||||
|
||||
open class ExtractorLink(
|
||||
open class ExtractorLink constructor(
|
||||
open val source: String,
|
||||
open val name: String,
|
||||
override val url: String,
|
||||
|
@ -62,7 +62,24 @@ open class ExtractorLink(
|
|||
override val headers: Map<String, String> = mapOf(),
|
||||
/** Used for getExtractorVerifierJob() */
|
||||
open val extractorData: String? = null,
|
||||
open val isDash: Boolean = false,
|
||||
) : VideoDownloadManager.IDownloadableMinimum {
|
||||
/**
|
||||
* Old constructor without isDash, allows for backwards compatibility with extensions.
|
||||
* Should be removed after all extensions have updated their cloudstream.jar
|
||||
**/
|
||||
constructor(
|
||||
source: String,
|
||||
name: String,
|
||||
url: String,
|
||||
referer: String,
|
||||
quality: Int,
|
||||
isM3u8: Boolean = false,
|
||||
headers: Map<String, String> = mapOf(),
|
||||
/** Used for getExtractorVerifierJob() */
|
||||
extractorData: String? = null
|
||||
) : this(source, name, url, referer, quality, isM3u8, headers, extractorData, false)
|
||||
|
||||
override fun toString(): String {
|
||||
return "ExtractorLink(name=$name, url=$url, referer=$referer, isM3u8=$isM3u8)"
|
||||
}
|
||||
|
@ -229,6 +246,7 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
StreamSB8(),
|
||||
StreamSB9(),
|
||||
StreamSB10(),
|
||||
StreamSB11(),
|
||||
SBfull(),
|
||||
// Streamhub(), cause Streamhub2() works
|
||||
Streamhub2(),
|
||||
|
@ -254,6 +272,7 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
// WatchSB(), 'cause StreamSB.kt works
|
||||
Uqload(),
|
||||
Uqload1(),
|
||||
Uqload2(),
|
||||
Evoload(),
|
||||
Evoload1(),
|
||||
VoeExtractor(),
|
||||
|
@ -265,6 +284,7 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
OkRu(),
|
||||
OkRuHttps(),
|
||||
Okrulink(),
|
||||
Sendvid(),
|
||||
|
||||
// dood extractors
|
||||
DoodCxExtractor(),
|
||||
|
@ -276,6 +296,7 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
DoodShExtractor(),
|
||||
DoodWatchExtractor(),
|
||||
DoodWfExtractor(),
|
||||
DoodYtExtractor(),
|
||||
|
||||
AsianLoad(),
|
||||
|
||||
|
@ -291,6 +312,7 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
Supervideo(),
|
||||
GuardareStream(),
|
||||
CineGrabber(),
|
||||
Vanfem(),
|
||||
|
||||
// StreamSB.kt works
|
||||
// SBPlay(),
|
||||
|
@ -321,6 +343,9 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
DesuDrive(),
|
||||
|
||||
Filesim(),
|
||||
FileMoon(),
|
||||
FileMoonSx(),
|
||||
Vido(),
|
||||
Linkbox(),
|
||||
Acefile(),
|
||||
SpeedoStream(),
|
||||
|
@ -362,6 +387,7 @@ val extractorApis: MutableList<ExtractorApi> = arrayListOf(
|
|||
Cda(),
|
||||
Dailymotion(),
|
||||
ByteShare(),
|
||||
Ztreamhub()
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -91,7 +91,7 @@ class ApkInstaller(private val service: PackageInstallerService) {
|
|||
|
||||
session.openWrite(context.packageName, 0, size)
|
||||
.use { outputStream ->
|
||||
val buffer = ByteArray(1024)
|
||||
val buffer = ByteArray(4 * 1024)
|
||||
var bytesRead = inputStream.read(buffer)
|
||||
|
||||
while (bytesRead >= 0) {
|
||||
|
@ -100,6 +100,7 @@ class ApkInstaller(private val service: PackageInstallerService) {
|
|||
installProgress.invoke(bytesRead)
|
||||
}
|
||||
|
||||
session.fsync(outputStream)
|
||||
inputStream.close()
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
|||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import kotlinx.coroutines.delay
|
||||
|
@ -47,24 +48,12 @@ class PackageInstallerService : Service() {
|
|||
.setSmallIcon(R.drawable.rdload)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel =
|
||||
NotificationChannel(UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, importance).apply {
|
||||
description = UPDATE_CHANNEL_DESCRIPTION
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
createNotificationChannel()
|
||||
this.createNotificationChannel(
|
||||
UPDATE_CHANNEL_ID,
|
||||
UPDATE_CHANNEL_NAME,
|
||||
UPDATE_CHANNEL_DESCRIPTION
|
||||
)
|
||||
startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build())
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ package com.lagradost.cloudstream3.utils
|
|||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
//import com.lagradost.cloudstream3.animeproviders.AniflixProvider
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
@ -78,17 +79,21 @@ object SyncUtil {
|
|||
return null
|
||||
}
|
||||
|
||||
suspend fun getUrlsFromId(id: String, type: String = "anilist") : List<String> {
|
||||
return arrayListOf()
|
||||
// val url =
|
||||
// "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json"
|
||||
// val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).parsed<SyncPage>()
|
||||
// val pages = response.pages ?: return emptyList()
|
||||
// val current = pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values).mapNotNull { it.url }.toMutableList()
|
||||
// if(type == "anilist") { // TODO MAKE BETTER
|
||||
// current.add("${AniflixProvider().mainUrl}/anime/$id")
|
||||
// }
|
||||
// return current
|
||||
suspend fun getUrlsFromId(id: String, type: String = "anilist"): List<String> {
|
||||
val url =
|
||||
"https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json"
|
||||
val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).parsed<SyncPage>()
|
||||
val pages = response.pages ?: return emptyList()
|
||||
val current =
|
||||
pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values)
|
||||
.mapNotNull { it.url }.toMutableList()
|
||||
|
||||
if (type == "anilist") { // TODO MAKE BETTER
|
||||
apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach {
|
||||
current.add("${it.mainUrl}/anime/$id")
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
data class SyncPage(
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.Assert
|
||||
|
||||
object TestingUtils {
|
||||
open class TestResult(val success: Boolean) {
|
||||
companion object {
|
||||
val Pass = TestResult(true)
|
||||
val Fail = TestResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
class TestResultSearch(val results: List<SearchResponse>) : TestResult(true)
|
||||
class TestResultLoad(val extractorData: String) : TestResult(true)
|
||||
|
||||
class TestResultProvider(success: Boolean, val log: String, val exception: Throwable?) :
|
||||
TestResult(success)
|
||||
|
||||
@Throws(AssertionError::class, CancellationException::class)
|
||||
suspend fun testHomepage(
|
||||
api: MainAPI,
|
||||
logger: (String) -> Unit
|
||||
): TestResult {
|
||||
if (api.hasMainPage) {
|
||||
try {
|
||||
val f = api.mainPage.first()
|
||||
val homepage =
|
||||
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
|
||||
when {
|
||||
homepage == null -> {
|
||||
logger.invoke("Homepage provider ${api.name} did not correctly load homepage!")
|
||||
}
|
||||
homepage.items.isEmpty() -> {
|
||||
logger.invoke("Homepage provider ${api.name} does not contain any items!")
|
||||
}
|
||||
homepage.items.any { it.list.isEmpty() } -> {
|
||||
logger.invoke("Homepage provider ${api.name} does not have any items on result!")
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is NotImplementedError) {
|
||||
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
|
||||
} else if (e is CancellationException) {
|
||||
throw e
|
||||
}
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
return TestResult.Pass
|
||||
}
|
||||
|
||||
@Throws(AssertionError::class, CancellationException::class)
|
||||
private suspend fun testSearch(
|
||||
api: MainAPI
|
||||
): TestResult {
|
||||
val searchQueries = listOf("over", "iron", "guy")
|
||||
val searchResults = searchQueries.firstNotNullOfOrNull { query ->
|
||||
try {
|
||||
api.search(query).takeIf { !it.isNullOrEmpty() }
|
||||
} catch (e: Throwable) {
|
||||
if (e is NotImplementedError) {
|
||||
Assert.fail("Provider has not implemented search()")
|
||||
} else if (e is CancellationException) {
|
||||
throw e
|
||||
}
|
||||
logError(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return if (searchResults.isNullOrEmpty()) {
|
||||
Assert.fail("Api ${api.name} did not return any valid search responses")
|
||||
TestResult.Fail // Should not be reached
|
||||
} else {
|
||||
TestResultSearch(searchResults)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Throws(AssertionError::class, CancellationException::class)
|
||||
private suspend fun testLoad(
|
||||
api: MainAPI,
|
||||
result: SearchResponse,
|
||||
logger: (String) -> Unit
|
||||
): TestResult {
|
||||
try {
|
||||
Assert.assertEquals(
|
||||
"Invalid apiName on SearchResponse on ${api.name}",
|
||||
result.apiName,
|
||||
api.name
|
||||
)
|
||||
|
||||
val loadResponse = api.load(result.url)
|
||||
|
||||
if (loadResponse == null) {
|
||||
logger.invoke("Returned null loadResponse on ${result.url} on ${api.name}")
|
||||
return TestResult.Fail
|
||||
}
|
||||
|
||||
Assert.assertEquals(
|
||||
"Invalid apiName on LoadResponse on ${api.name}",
|
||||
loadResponse.apiName,
|
||||
result.apiName
|
||||
)
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}",
|
||||
api.supportedTypes.contains(loadResponse.type)
|
||||
)
|
||||
|
||||
val url = when (loadResponse) {
|
||||
is AnimeLoadResponse -> {
|
||||
val gotNoEpisodes =
|
||||
loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() }
|
||||
|
||||
if (gotNoEpisodes) {
|
||||
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}")
|
||||
return TestResult.Fail
|
||||
}
|
||||
|
||||
(loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data
|
||||
}
|
||||
is MovieLoadResponse -> {
|
||||
val gotNoEpisodes = loadResponse.dataUrl.isBlank()
|
||||
if (gotNoEpisodes) {
|
||||
logger.invoke("Api ${api.name} got no movie on ${loadResponse.url}")
|
||||
return TestResult.Fail
|
||||
}
|
||||
|
||||
loadResponse.dataUrl
|
||||
}
|
||||
is TvSeriesLoadResponse -> {
|
||||
val gotNoEpisodes = loadResponse.episodes.isEmpty()
|
||||
if (gotNoEpisodes) {
|
||||
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}")
|
||||
return TestResult.Fail
|
||||
}
|
||||
loadResponse.episodes.firstOrNull()?.data
|
||||
}
|
||||
is LiveStreamLoadResponse -> {
|
||||
loadResponse.dataUrl
|
||||
}
|
||||
else -> {
|
||||
logger.invoke("Unknown load response: ${loadResponse.javaClass.name}")
|
||||
return TestResult.Fail
|
||||
}
|
||||
} ?: return TestResult.Fail
|
||||
|
||||
return TestResultLoad(url)
|
||||
|
||||
// val loadTest = testLoadResponse(api, load, logger)
|
||||
// if (loadTest is TestResultLoad) {
|
||||
// testLinkLoading(api, loadTest.extractorData, logger).success
|
||||
// } else {
|
||||
// false
|
||||
// }
|
||||
// if (!validResults) {
|
||||
// logger("Api ${api.name} did not load on the first search results: ${smallSearchResults.map { it.name }}")
|
||||
// }
|
||||
|
||||
// return TestResult(validResults)
|
||||
} catch (e: Throwable) {
|
||||
if (e is NotImplementedError) {
|
||||
Assert.fail("Provider has not implemented load()")
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(AssertionError::class, CancellationException::class)
|
||||
private suspend fun testLinkLoading(
|
||||
api: MainAPI,
|
||||
url: String?,
|
||||
logger: (String) -> Unit
|
||||
): TestResult {
|
||||
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
|
||||
if (url == null) return TestResult.Fail // Should never trigger
|
||||
|
||||
var linksLoaded = 0
|
||||
try {
|
||||
val success = api.loadLinks(url, false, {}) { link ->
|
||||
logger.invoke("Video loaded: ${link.name}")
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} returns link with invalid url ${link.url}",
|
||||
link.url.length > 4
|
||||
)
|
||||
linksLoaded++
|
||||
}
|
||||
if (success) {
|
||||
logger.invoke("Links loaded: $linksLoaded")
|
||||
return TestResult(linksLoaded > 0)
|
||||
} else {
|
||||
Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
when (e) {
|
||||
is NotImplementedError -> {
|
||||
Assert.fail("Provider has not implemented loadLinks()")
|
||||
}
|
||||
else -> {
|
||||
logger.invoke("Failed link loading on ${api.name} using data: $url")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
return TestResult.Pass
|
||||
}
|
||||
|
||||
fun getDeferredProviderTests(
|
||||
scope: CoroutineScope,
|
||||
providers: List<MainAPI>,
|
||||
logger: (String) -> Unit,
|
||||
callback: (MainAPI, TestResultProvider) -> Unit
|
||||
) {
|
||||
providers.forEach { api ->
|
||||
scope.launch {
|
||||
var log = ""
|
||||
fun addToLog(string: String) {
|
||||
log += string + "\n"
|
||||
logger.invoke(string)
|
||||
}
|
||||
fun getLog(): String {
|
||||
return log.removeSuffix("\n")
|
||||
}
|
||||
|
||||
val result = try {
|
||||
addToLog("Trying ${api.name}")
|
||||
|
||||
// Test Homepage
|
||||
val homepage = testHomepage(api, logger).success
|
||||
Assert.assertTrue("Homepage failed to load", homepage)
|
||||
|
||||
// Test Search Results
|
||||
val searchResults = testSearch(api)
|
||||
Assert.assertTrue("Failed to get search results", searchResults.success)
|
||||
searchResults as TestResultSearch
|
||||
|
||||
// Test Load and LoadLinks
|
||||
// Only try the first 3 search results to prevent spamming
|
||||
val success = searchResults.results.take(3).any { searchResponse ->
|
||||
addToLog("Testing search result: ${searchResponse.url}")
|
||||
val loadResponse = testLoad(api, searchResponse, ::addToLog)
|
||||
if (loadResponse !is TestResultLoad) {
|
||||
false
|
||||
} else {
|
||||
testLinkLoading(api, loadResponse.extractorData, ::addToLog).success
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
logger.invoke("Success ${api.name}")
|
||||
TestResultProvider(true, getLog(), null)
|
||||
} else {
|
||||
logger.invoke("Error ${api.name}")
|
||||
TestResultProvider(false, getLog(), null)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
TestResultProvider(false, getLog(), e)
|
||||
}
|
||||
callback.invoke(api, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,9 @@ import android.content.Context
|
|||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
|
@ -28,15 +30,21 @@ import androidx.core.app.ActivityCompat
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.drawable.toBitmapOrNull
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.palette.graphics.Palette
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
|
||||
|
@ -105,7 +113,7 @@ object UIHelper {
|
|||
listView.requestLayout()
|
||||
}
|
||||
|
||||
fun Activity?.getSpanCount(): Int? {
|
||||
fun Context?.getSpanCount(): Int? {
|
||||
val compactView = false
|
||||
val spanCountLandscape = if (compactView) 2 else 6
|
||||
val spanCountPortrait = if (compactView) 1 else 3
|
||||
|
@ -158,12 +166,27 @@ object UIHelper {
|
|||
return color
|
||||
}
|
||||
|
||||
var createPaletteAsyncCache: HashMap<String, Palette> = hashMapOf()
|
||||
fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) {
|
||||
createPaletteAsyncCache[url]?.let { palette ->
|
||||
callback.invoke(palette)
|
||||
return
|
||||
}
|
||||
Palette.from(bitmap).generate { paletteNull ->
|
||||
paletteNull?.let { palette ->
|
||||
createPaletteAsyncCache[url] = palette
|
||||
callback(palette)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ImageView?.setImage(
|
||||
url: String?,
|
||||
headers: Map<String, String>? = null,
|
||||
@DrawableRes
|
||||
errorImageDrawable: Int? = null,
|
||||
fadeIn: Boolean = true
|
||||
fadeIn: Boolean = true,
|
||||
colorCallback: ((Palette) -> Unit)? = null
|
||||
): Boolean {
|
||||
if (this == null || url.isNullOrBlank()) return false
|
||||
|
||||
|
@ -177,6 +200,33 @@ object UIHelper {
|
|||
else req
|
||||
}
|
||||
|
||||
if (colorCallback != null) {
|
||||
builder.listener(object : RequestListener<Drawable> {
|
||||
@SuppressLint("CheckResult")
|
||||
override fun onResourceReady(
|
||||
resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
resource?.toBitmapOrNull()
|
||||
?.let { bitmap -> createPaletteAsync(url, bitmap, colorCallback) }
|
||||
return false
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val res = if (errorImageDrawable != null)
|
||||
builder.error(errorImageDrawable).into(this)
|
||||
else
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.work.Data
|
|||
import androidx.work.ExistingWorkPolicy
|
||||
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
|
||||
|
@ -213,7 +214,7 @@ object VideoDownloadManager {
|
|||
}
|
||||
|
||||
private val cachedBitmaps = hashMapOf<String, Bitmap>()
|
||||
private fun Context.getImageBitmapFromUrl(url: String): Bitmap? {
|
||||
fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? {
|
||||
try {
|
||||
if (cachedBitmaps.containsKey(url)) {
|
||||
return cachedBitmaps[url]
|
||||
|
@ -221,12 +222,14 @@ object VideoDownloadManager {
|
|||
|
||||
val bitmap = GlideApp.with(this)
|
||||
.asBitmap()
|
||||
.load(url).into(720, 720)
|
||||
.load(GlideUrl(url) { headers ?: emptyMap() })
|
||||
.into(720, 720)
|
||||
.get()
|
||||
|
||||
if (bitmap != null) {
|
||||
cachedBitmaps[url] = bitmap
|
||||
}
|
||||
return null
|
||||
return bitmap
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
return null
|
||||
|
@ -426,7 +429,7 @@ object VideoDownloadManager {
|
|||
}
|
||||
|
||||
private const val reservedChars = "|\\?*<\":>+[]/\'"
|
||||
fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String {
|
||||
fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String {
|
||||
var tempName = name
|
||||
for (c in reservedChars) {
|
||||
tempName = tempName.replace(c, ' ')
|
||||
|
@ -1612,7 +1615,7 @@ object VideoDownloadManager {
|
|||
.mapIndexed { index, any -> DownloadQueueResumePackage(index, any) }
|
||||
.toTypedArray()
|
||||
setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue)
|
||||
} catch (t : Throwable) {
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="?attr/colorPrimary" android:state_checked="true"/>
|
||||
<item android:color="?attr/colorPrimary" android:state_focused="true"/>
|
||||
<item android:color="?attr/colorPrimary" android:state_selected="true"/>
|
||||
<item android:color="?attr/grayTextColor" android:state_checked="false"/>
|
||||
</selector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="?attr/white"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,14.67L3.41,6.09L2,7.5l8.5,8.5H4v2h16v-2h-6.5l5.15,-5.15C18.91,10.95 19.2,11 19.5,11c1.38,0 2.5,-1.12 2.5,-2.5S20.88,6 19.5,6S17,7.12 17,8.5c0,0.35 0.07,0.67 0.2,0.97L12,14.67z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="?attr/white"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17L8,17v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:tint="?attr/white" android:viewportHeight="24"
|
||||
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="?attr/white" android:pathData="M20.41,8.41l-4.83,-4.83C15.21,3.21 14.7,3 14.17,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V9.83C21,9.3 20.79,8.79 20.41,8.41zM7,7h7v2H7V7zM17,17H7v-2h10V17zM17,13H7v-2h10V13z"/>
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:tint="?attr/white" android:viewportHeight="24"
|
||||
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M3,18h6v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h12v-2L3,11v2z"/>
|
||||
</vector>
|
|
@ -1,5 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="?attr/white"
|
||||
<vector android:height="12dp" android:tint="?attr/white"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
android:width="12dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
|
||||
</vector>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="50"
|
||||
android:name="vector">
|
||||
<group android:scaleX="0.1755477"
|
||||
android:scaleY="0.1755477"
|
||||
android:translateX="0"
|
||||
android:translateY="0">
|
||||
<path android:name="path"
|
||||
|
||||
android:pathData="M 245.05 148.63 C 242.249 148.627 239.463 149.052 236.79 149.89 C 235.151 141.364 230.698 133.63 224.147 127.931 C 217.597 122.233 209.321 118.893 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 245.05 203.9 C 252.375 203.9 259.408 200.987 264.587 195.807 C 269.767 190.628 272.68 183.595 272.68 176.27 C 272.68 168.945 269.767 161.912 264.587 156.733 C 259.408 151.553 252.375 148.64 245.05 148.64 Z"
|
||||
android:fillColor="#FFFFFF" android:strokeWidth="1"
|
||||
tools:ignore="VectorPath"
|
||||
android:fillAlpha="0.55"/>
|
||||
<path android:name="path_1" android:pathData="M 208.61 125 C 208.61 123.22 208.55 121.45 208.48 119.69 C 205.919 119.01 203.296 118.595 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 179 203.9 C 198.116 182.073 208.646 154.015 208.61 125 Z"
|
||||
android:fillColor="#FFFFFF" android:strokeWidth="1"
|
||||
android:fillAlpha="0.55"/>
|
||||
<path android:name="path_2" android:pathData="M 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.783 148.665 23.909 151.471 18.779 156.461 C 13.648 161.452 10.653 168.246 10.43 175.399 C 10.207 182.553 12.773 189.52 17.583 194.82 C 22.392 200.121 29.079 203.349 36.22 203.82 C 67.216 202.93 96.673 189.98 118.284 167.742 C 139.895 145.504 151.997 115.689 152 84.68 C 152 83 151.94 81.33 151.87 79.68 C 149.443 79.361 146.998 79.194 144.55 79.18 C 136.095 79.171 127.735 80.962 120.026 84.434 C 112.317 87.907 105.435 92.982 99.84 99.32 Z"
|
||||
android:fillColor="#FFFFFF" android:strokeWidth="1"
|
||||
android:fillAlpha="1"/>
|
||||
</group>
|
||||
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z"/>
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/textColor"/>
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/ratingColorBg"/>
|
||||
<corners android:radius="@dimen/rounded_image_radius"/>
|
||||
<!-- <stroke android:color="@color/subColor" android:width="2dp"/>-->
|
||||
</shape>
|
|
@ -35,9 +35,9 @@
|
|||
-->
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/nav_view"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="70dp"
|
||||
android:layout_width="0dp"
|
||||
app:labelVisibilityMode="labeled"
|
||||
app:labelVisibilityMode="unlabeled"
|
||||
android:background="?attr/primaryGrayBackground"
|
||||
|
||||
app:itemIconTint="@color/item_select_color"
|
||||
|
|
|
@ -1,34 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text1"
|
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:textStyle="bold"
|
||||
android:textSize="20sp"
|
||||
android:textColor="?attr/textColor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_rowWeight="1"
|
||||
tools:text="Test"
|
||||
android:layout_height="wrap_content" />
|
||||
android:id="@+id/text1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||
android:textColor="?attr/textColor"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Test" />
|
||||
|
||||
<ListView
|
||||
android:nextFocusRight="@id/cancel_btt"
|
||||
android:nextFocusLeft="@id/apply_btt"
|
||||
|
||||
android:id="@+id/listview1"
|
||||
android:layout_marginBottom="60dp"
|
||||
android:paddingTop="10dp"
|
||||
android:requiresFadingEdge="vertical"
|
||||
tools:listitem="@layout/sort_bottom_single_choice_no_checkmark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_rowWeight="1" />
|
||||
android:id="@+id/listview1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_marginBottom="60dp"
|
||||
android:nestedScrollingEnabled="true"
|
||||
android:nextFocusLeft="@id/apply_btt"
|
||||
android:nextFocusRight="@id/cancel_btt"
|
||||
android:paddingTop="10dp"
|
||||
android:requiresFadingEdge="vertical"
|
||||
tools:listitem="@layout/sort_bottom_single_choice_no_checkmark" />
|
||||
</LinearLayout>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/home_header"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -86,7 +85,6 @@
|
|||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -115,7 +113,6 @@
|
|||
style="@style/WhiteButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
|
||||
android:text="@string/home_play"
|
||||
app:icon="@drawable/ic_baseline_play_arrow_24" />
|
||||
|
||||
|
@ -148,17 +145,16 @@
|
|||
|
||||
<TextView
|
||||
android:id="@+id/home_watch_parent_item_title"
|
||||
|
||||
style="@style/WatchHeaderText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="0dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:contentDescription="@string/home_more_info"
|
||||
android:padding="12dp"
|
||||
android:text="@string/continue_watching"
|
||||
app:drawableRightCompat="@drawable/ic_baseline_arrow_forward_24"
|
||||
app:drawableTint="?attr/white"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:contentDescription="@string/home_more_info"/>
|
||||
app:drawableTint="?attr/white" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/home_watch_child_recyclerview"
|
||||
|
@ -167,7 +163,7 @@
|
|||
android:clipToPadding="false"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="5dp"
|
||||
android:paddingHorizontal="5dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:listitem="@layout/home_result_grid" />
|
||||
|
||||
|
@ -184,9 +180,9 @@
|
|||
<FrameLayout
|
||||
android:id="@+id/home_bookmark_parent_item_title"
|
||||
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground">
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
|
@ -262,14 +258,13 @@
|
|||
</HorizontalScrollView>
|
||||
|
||||
<ImageView
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_gravity="end"
|
||||
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:contentDescription="@string/home_more_info"
|
||||
android:src="@drawable/ic_baseline_arrow_forward_24"
|
||||
app:drawableTint="?attr/white"
|
||||
android:contentDescription="@string/home_more_info"/>
|
||||
app:drawableTint="?attr/white" />
|
||||
</FrameLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -277,10 +272,9 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
|
||||
android:descendantFocusability="afterDescendants"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="5dp"
|
||||
android:paddingHorizontal="5dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:listitem="@layout/home_result_grid" />
|
||||
</LinearLayout>
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/library_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/empty_list_textview"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_margin="30dp"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/search_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/primaryGrayBackground">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/search_status_bar_padding"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_scrollFlags="scroll|enterAlways">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/provider_selector"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:layout_marginStart="10dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/change_providers_img_des"
|
||||
android:src="@drawable/ic_baseline_extension_24"
|
||||
app:tint="?attr/textColor" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_margin="10dp"
|
||||
android:background="@drawable/search_background"
|
||||
android:visibility="visible"
|
||||
app:layout_scrollFlags="scroll|enterAlways">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/main_search"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center_vertical"
|
||||
|
||||
android:iconifiedByDefault="false"
|
||||
android:imeOptions="actionSearch"
|
||||
|
||||
android:inputType="text"
|
||||
android:nextFocusLeft="@id/nav_rail_view"
|
||||
|
||||
android:nextFocusRight="@id/search_filter"
|
||||
android:paddingStart="-10dp"
|
||||
app:iconifiedByDefault="false"
|
||||
app:queryBackground="@color/transparent"
|
||||
app:queryHint="@string/search_hint"
|
||||
app:searchIcon="@drawable/search_icon"
|
||||
tools:ignore="RtlSymmetry">
|
||||
|
||||
</androidx.appcompat.widget.SearchView>
|
||||
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/list_selector"
|
||||
android:layout_width="45dp"
|
||||
android:layout_height="45dp"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/change_providers_img_des"
|
||||
android:nextFocusLeft="@id/main_search"
|
||||
android:nextFocusRight="@id/main_search"
|
||||
android:padding="10dp"
|
||||
android:src="@drawable/ic_baseline_filter_list_24"
|
||||
app:tint="?attr/textColor" />
|
||||
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:animateLayoutChanges="true"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/viewpager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="40dp"
|
||||
tools:listitem="@layout/library_viewpager_page" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/library_loading_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/primaryBlackBackground"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.facebook.shimmer.ShimmerFrameLayout
|
||||
android:id="@+id/library_loading_shimmer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:layout_margin="2dp"
|
||||
app:shimmer_auto_start="true"
|
||||
app:shimmer_base_alpha="0.2"
|
||||
app:shimmer_duration="@integer/loading_time"
|
||||
app:shimmer_highlight_alpha="0.3">
|
||||
|
||||
<GridView
|
||||
android:id="@+id/gridview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="center"
|
||||
android:horizontalSpacing="10dp"
|
||||
android:numColumns="3"
|
||||
android:paddingBottom="120dp"
|
||||
android:verticalSpacing="10dp"
|
||||
tools:listitem="@layout/loading_poster_dynamic" />
|
||||
</com.facebook.shimmer.ShimmerFrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="40dp">
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/sort_fab"
|
||||
style="@style/ExtendedFloatingActionButton"
|
||||
android:text="@string/sort"
|
||||
android:textColor="?attr/textColor"
|
||||
app:icon="@drawable/ic_baseline_sort_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
</FrameLayout>
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/library_tab_layout"
|
||||
style="@style/Theme.Widget.Tabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="?attr/primaryGrayBackground"
|
||||
android:descendantFocusability="blocksDescendants"
|
||||
android:focusable="false"
|
||||
android:paddingHorizontal="5dp"
|
||||
app:layout_scrollFlags="noScroll"
|
||||
app:tabGravity="center"
|
||||
app:tabIndicator="@drawable/indicator_background"
|
||||
app:tabIndicatorColor="?attr/white"
|
||||
app:tabIndicatorGravity="center"
|
||||
app:tabIndicatorHeight="30dp"
|
||||
app:tabMode="scrollable"
|
||||
app:tabSelectedTextColor="?attr/primaryBlackBackground"
|
||||
app:tabTextAppearance="@style/TabNoCaps"
|
||||
app:tabTextColor="?attr/textColor" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -129,9 +129,9 @@
|
|||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/result_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:paddingBottom="100dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
android:layout_height="wrap_content">
|
||||
android:paddingBottom="100dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
@ -326,13 +326,12 @@
|
|||
|
||||
<ImageView
|
||||
android:id="@+id/result_poster"
|
||||
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="140dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:contentDescription="@string/result_poster_img_des"
|
||||
android:foreground="@drawable/outline_drawable"
|
||||
android:scaleType="centerCrop"
|
||||
android:layout_gravity="bottom"
|
||||
tools:src="@drawable/example_poster" />
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
|
@ -516,8 +515,8 @@
|
|||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
style="@style/ChipParent"
|
||||
android:id="@+id/result_tag"
|
||||
style="@style/ChipParent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
<!--<com.lagradost.cloudstream3.widget.FlowLayout
|
||||
|
@ -818,10 +817,13 @@
|
|||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="0dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:drawableEnd="@drawable/ic_baseline_keyboard_arrow_down_24"
|
||||
android:nextFocusLeft="@id/result_episode_select"
|
||||
android:nextFocusRight="@id/result_episode_select"
|
||||
android:nextFocusUp="@id/result_description"
|
||||
android:nextFocusDown="@id/result_episodes"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="5dp"
|
||||
android:visibility="gone"
|
||||
tools:text="Season 1"
|
||||
tools:visibility="visible" />
|
||||
|
@ -829,16 +831,16 @@
|
|||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/result_episode_select"
|
||||
style="@style/MultiSelectButton"
|
||||
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="0dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
|
||||
android:drawableEnd="@drawable/ic_baseline_keyboard_arrow_down_24"
|
||||
android:nextFocusLeft="@id/result_season_button"
|
||||
android:nextFocusRight="@id/result_season_button"
|
||||
|
||||
android:nextFocusUp="@id/result_description"
|
||||
android:nextFocusDown="@id/result_episodes"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="5dp"
|
||||
android:visibility="gone"
|
||||
tools:text="50-100"
|
||||
tools:visibility="visible" />
|
||||
|
@ -846,15 +848,16 @@
|
|||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/result_dub_select"
|
||||
style="@style/MultiSelectButton"
|
||||
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="0dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:drawableEnd="@drawable/ic_baseline_keyboard_arrow_down_24"
|
||||
android:nextFocusLeft="@id/result_season_button"
|
||||
android:nextFocusRight="@id/result_season_button"
|
||||
|
||||
android:nextFocusUp="@id/result_description"
|
||||
android:nextFocusDown="@id/result_episodes"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="5dp"
|
||||
android:visibility="gone"
|
||||
tools:text="Dubbed"
|
||||
tools:visibility="visible" />
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:id="@+id/media_route_button_holder"
|
||||
android:animateLayoutChanges="true"
|
||||
android:layout_gravity="center_vertical|end">
|
||||
|
||||
<androidx.mediarouter.app.MediaRouteButton
|
||||
|
@ -69,15 +70,35 @@
|
|||
app:mediaRouteButtonTint="?attr/textColor" />
|
||||
|
||||
<ImageView
|
||||
android:visibility="gone"
|
||||
android:nextFocusUp="@id/result_back"
|
||||
android:nextFocusDown="@id/result_description"
|
||||
android:nextFocusLeft="@id/result_add_sync"
|
||||
android:nextFocusRight="@id/result_share"
|
||||
|
||||
tools:visibility="visible"
|
||||
|
||||
android:id="@+id/result_subscribe"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:layout_margin="5dp"
|
||||
android:elevation="10dp"
|
||||
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/baseline_notifications_none_24"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
app:tint="?attr/textColor" />
|
||||
|
||||
<ImageView
|
||||
android:nextFocusUp="@id/result_back"
|
||||
android:nextFocusDown="@id/result_description"
|
||||
android:nextFocusLeft="@id/result_subscribe"
|
||||
android:nextFocusRight="@id/result_open_in_browser"
|
||||
|
||||
android:id="@+id/result_share"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:layout_margin="5dp"
|
||||
android:elevation="10dp"
|
||||
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
|
|
|
@ -199,17 +199,13 @@
|
|||
android:id="@+id/result_back"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginEnd="10dp"
|
||||
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/go_back"
|
||||
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
|
||||
android:nextFocusDown="@id/result_description"
|
||||
android:src="@drawable/ic_baseline_arrow_back_24"
|
||||
app:tint="?attr/white" />
|
||||
|
@ -385,8 +381,8 @@
|
|||
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
style="@style/ChipParent"
|
||||
android:id="@+id/result_tag"
|
||||
style="@style/ChipParent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
|
@ -423,11 +419,11 @@
|
|||
|
||||
|
||||
<LinearLayout
|
||||
android:animateLayoutChanges="true"
|
||||
android:id="@+id/result_movie_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:animateLayoutChanges="true"
|
||||
android:orientation="horizontal"
|
||||
tools:visibility="visible">
|
||||
|
||||
|
@ -568,6 +564,7 @@
|
|||
android:layout_weight="1"
|
||||
android:minWidth="250dp"
|
||||
android:nextFocusLeft="@id/result_movie_progress_downloaded_holder"
|
||||
android:nextFocusRight="@id/result_bookmark_button"
|
||||
android:nextFocusDown="@id/result_resume_series_button_play"
|
||||
android:text="@string/type_none"
|
||||
android:visibility="visible" />
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue