forked from recloudstream/cloudstream
Compare commits
235 commits
Author | SHA1 | Date | |
---|---|---|---|
|
67b0549fd2 | ||
|
52d495f425 | ||
|
0cbee70683 | ||
|
4235c826a5 | ||
|
5245eff6e1 | ||
|
9c40abc4d3 | ||
|
019399952f | ||
|
cc99899cf1 | ||
|
8fff809b79 | ||
|
67318a62a3 | ||
|
288c5ffa39 | ||
|
8ebf5185a3 | ||
|
7bfcf25df4 | ||
|
2d7126d71f | ||
|
40a4f319b6 | ||
|
19dc1a2456 | ||
|
ac1012bcb8 | ||
|
ec3950ed4f | ||
|
3e2b0f2a17 | ||
|
29174dbb30 | ||
|
7b47f93190 | ||
|
13ee8e21d0 | ||
|
3a5d872545 | ||
|
fab55d82c4 | ||
|
8b2881f5f6 | ||
|
37244ab0f7 | ||
|
e85b31c35d | ||
|
1eaa4620dc | ||
|
76545f55c3 | ||
|
f0515c4dc9 | ||
|
ab324b93e8 | ||
|
d6df24eff2 | ||
|
e5834d485b | ||
|
6524eb220b | ||
|
2926dc6c8e | ||
|
f722785a37 | ||
|
aeab423d29 | ||
|
1da6a92569 | ||
|
b2fa765a2d | ||
|
bec0a2e7b9 | ||
|
51137701f2 | ||
|
5f12d067f9 | ||
|
00a91ca5fb | ||
|
33aecfbba5 | ||
|
0185854682 | ||
|
b4065b69be | ||
|
b6ac155350 | ||
|
aacd57cb5d | ||
|
3dd0fc6c8e | ||
|
135f63afff | ||
|
789cd14ef6 | ||
|
9d0cce47a6 | ||
|
4a8ee55018 | ||
|
df6c395acb | ||
|
1117271a71 | ||
|
7b11b9b585 | ||
|
5c20b479e5 | ||
|
0d2613d183 | ||
|
dd38556102 | ||
|
84493b7f3b | ||
|
4596afee06 | ||
|
3e2c2a5c86 | ||
|
6c646d65a8 | ||
|
329966732f | ||
|
19b2cae851 | ||
|
45eb9758e3 | ||
|
f6be6081dc | ||
|
80f22cea16 | ||
|
bf78fc95c2 | ||
|
a148f347cd | ||
|
ff9942407b | ||
|
0ea624ff14 | ||
|
f939e4cff2 | ||
|
2ff90c03ca | ||
|
9988753432 | ||
|
b0921161a3 | ||
|
490381451b | ||
|
b26a41bdaf | ||
|
c7c5fa250e | ||
|
6e9b1cb855 | ||
|
fd2648df45 | ||
|
9905618a47 | ||
|
2771dcb612 | ||
|
3c82548c20 | ||
|
9d11dc76a1 | ||
|
2a1311673a | ||
|
83d2e692e0 | ||
|
b2389bf14c | ||
|
b2b16fccc5 | ||
|
01f1edab3c | ||
|
5050ff65c0 | ||
|
de720983a6 | ||
|
0b4de81811 | ||
|
60aca3ebdc | ||
|
65fda1889c | ||
|
b2b894caa9 | ||
|
c7e2a19f5d | ||
|
9f18cbbc20 | ||
|
1994edb96c | ||
|
c058409f9d | ||
|
3ecaf47c9e | ||
|
b8248d1053 | ||
|
89c5cb8a46 | ||
|
9fd2e84c7a | ||
|
8e928a8a2b | ||
|
49d672718d | ||
|
a8352d3f64 | ||
|
42f90a79c4 | ||
|
cd8c5966e6 | ||
|
307d4dd494 | ||
|
d606f84545 | ||
|
60c1eb2579 | ||
|
5c8a667e9e | ||
|
2674d370a2 | ||
|
868bb8500f | ||
|
a87bbd3cfc | ||
|
92d03fc163 | ||
|
06c2cf86ec | ||
|
0ebc12e29b | ||
|
308affb6aa | ||
|
75cc4f6dfa | ||
|
61ab957e35 | ||
|
36e780f7c9 | ||
|
e7d37aa07c | ||
|
c57fce2abc | ||
|
657971d008 | ||
|
0afb6b62aa | ||
|
e362795493 | ||
|
8712f08bb1 | ||
|
591ac137f9 | ||
|
dee269ce5e | ||
|
4926c91f6c | ||
|
2b43342854 | ||
|
6e61fe5f3e | ||
|
1e8277b087 | ||
|
710885a3b7 | ||
|
fbb7046390 | ||
|
714062c6d4 | ||
|
83132f183a | ||
|
79c8b4e523 | ||
|
c6749bf988 | ||
|
7019631146 | ||
|
4440096ea4 | ||
|
2a32f62fe3 | ||
|
7982f8c491 | ||
|
d6af1e4ab6 | ||
|
53b06612c1 | ||
|
9fc5c5352e | ||
|
5f1e790163 | ||
|
0073ad8c81 | ||
|
6db688e0bf | ||
|
c11bab4a51 | ||
|
e71b70b6a0 | ||
|
7cf9c640b8 | ||
|
9c956f68f9 | ||
|
2ba78eb37e | ||
|
9e059af0bb | ||
|
871dcf7171 | ||
|
4f4061961a | ||
|
5b26c998b4 | ||
|
5953420774 | ||
|
f3e7a5daa6 | ||
|
b6b7cceea5 | ||
|
23973042f4 | ||
|
a2dbabdb6e | ||
|
53519381d7 | ||
|
a522ef0edb | ||
|
d727099c29 | ||
|
e2fc946d91 | ||
|
a1f5786f02 | ||
|
363906cf3b | ||
|
50fc8d0ffb | ||
|
1e636c8b08 | ||
|
492c950b7a | ||
|
4d13494a93 | ||
|
956c693d1b | ||
|
6246d984a1 | ||
|
495d02d583 | ||
|
304b103e32 | ||
|
5af1a0e433 | ||
|
3fdf41869e | ||
|
7362ac9f64 | ||
|
751175b3f9 | ||
|
c11f0c101b | ||
|
7c4f177e47 | ||
|
0d7c20e3bd | ||
|
95f4a15864 | ||
|
20ac21c25f | ||
|
4f54bf3ae4 | ||
|
f7b623ffc7 | ||
|
4b0b6f6f20 | ||
|
0b17862049 | ||
|
56c79e3b6a | ||
|
6d13cf0b01 | ||
|
70dcc96026 | ||
|
3fa82cdba7 | ||
|
e7d7639776 | ||
|
514e250d68 | ||
|
5c3652d1e9 | ||
|
2222a1b07b | ||
|
42d1dd9f7d | ||
|
eb90b79bf9 | ||
|
b79e2d768f | ||
|
e215747749 | ||
|
3f658a375e | ||
|
723c554bc8 | ||
|
58593ac8da | ||
|
c513708d74 | ||
|
9be50eb28b | ||
|
789f3db554 | ||
|
e21c8f8038 | ||
|
9bca7a0780 | ||
|
a8f3d18c2e | ||
|
263f74fb9c | ||
|
dbd91d788c | ||
|
c9fe7c79dc | ||
|
924d797e07 | ||
|
2b29e8078f | ||
|
9a93b375f3 | ||
|
30316107c8 | ||
|
cf22ada266 | ||
|
456cd2e6e2 | ||
|
81adb10c1f | ||
|
639de891c6 | ||
|
2e7823034b | ||
|
e95d117ebc | ||
|
1226426389 | ||
|
aef6f93efe | ||
|
3e2c53a5b7 | ||
|
8fa00f4ca9 | ||
|
4fb65e7242 | ||
|
60bcbf0060 | ||
|
60a2f7c1c5 | ||
|
9e67e856a0 | ||
|
c10ec34ab8 |
255 changed files with 19715 additions and 7658 deletions
BIN
.github/downloads.jpg
vendored
BIN
.github/downloads.jpg
vendored
Binary file not shown.
Before Width: | Height: | Size: 58 KiB |
BIN
.github/home.jpg
vendored
BIN
.github/home.jpg
vendored
Binary file not shown.
Before Width: | Height: | Size: 136 KiB |
63
.github/locales.py
vendored
Normal file
63
.github/locales.py
vendored
Normal file
|
@ -0,0 +1,63 @@
|
|||
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://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()
|
||||
|
||||
# Load settings file
|
||||
src = open(SETTINGS_PATH, "r", encoding='utf-8').read()
|
||||
before_src, rest = src.split(START_MARKER)
|
||||
rest, after_src = rest.split(END_MARKER)
|
||||
|
||||
# Load already added langs
|
||||
languages = {}
|
||||
for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
|
||||
flag, name, iso = lang.groups()
|
||||
languages[iso] = (flag, name)
|
||||
|
||||
# Add not yet added langs
|
||||
for folder in glob.glob(f"{XML_NAME}*"):
|
||||
iso = folder[len(XML_NAME):]
|
||||
if iso not in languages.keys():
|
||||
entry = iso_map.get(iso.lower(),{'nativeName':iso})
|
||||
languages[iso] = ("", entry['nativeName'].split(',')[0])
|
||||
|
||||
# Create triples
|
||||
triples = []
|
||||
for iso in sorted(languages.keys()):
|
||||
flag, name = languages[iso]
|
||||
triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
|
||||
|
||||
# Update settings file
|
||||
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
||||
before_src +
|
||||
START_MARKER +
|
||||
"\n" +
|
||||
"\n".join(triples) +
|
||||
"\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}")
|
BIN
.github/player.jpg
vendored
BIN
.github/player.jpg
vendored
Binary file not shown.
Before Width: | Height: | Size: 48 KiB |
BIN
.github/results.jpg
vendored
BIN
.github/results.jpg
vendored
Binary file not shown.
Before Width: | Height: | Size: 96 KiB |
BIN
.github/search.jpg
vendored
BIN
.github/search.jpg
vendored
Binary file not shown.
Before Width: | Height: | Size: 149 KiB |
25
.github/workflows/issue_action.yml
vendored
25
.github/workflows/issue_action.yml
vendored
|
@ -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:
|
||||
|
|
42
.github/workflows/update_locales.yml
vendored
Normal file
42
.github/workflows/update_locales.yml
vendored
Normal file
|
@ -0,0 +1,42 @@
|
|||
name: Fix locale issues
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.xml'
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: "locale"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
create:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate access token
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@v1
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/cloudstream"
|
||||
- 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
|
||||
- name: Commit to the repo
|
||||
run: |
|
||||
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
|
||||
git config --local user.name "recloudstream[bot]"
|
||||
git add .
|
||||
# "echo" returns true so the build succeeds, even if no changed files
|
||||
git commit -m 'chore(locales): fix locale issues' || echo
|
||||
git push
|
36
README.md
36
README.md
|
@ -5,40 +5,14 @@
|
|||
|
||||
[![Discord](https://invidget.switchblade.xyz/5Hus6fM)](https://discord.gg/5Hus6fM)
|
||||
|
||||
***Features:***
|
||||
### Features:
|
||||
+ **AdFree**, No ads whatsoever
|
||||
+ No tracking/analytics
|
||||
+ Bookmarks
|
||||
+ 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"/>
|
||||
|
||||
***The list of supported languages:***
|
||||
* 🇱🇧 Arabic
|
||||
* 🇧🇬 Bulgarian
|
||||
* 🇭🇷 Croatian
|
||||
* 🇨🇿 Czech
|
||||
* 🇳🇱 Dutch
|
||||
* 🇬🇧 English
|
||||
* 🇫🇷 French
|
||||
* 🇩🇪 German
|
||||
* 🇬🇷 Greek
|
||||
* 🇮🇳 Hindi
|
||||
* 🇮🇩 Indonesian
|
||||
* 🇮🇹 Italian
|
||||
* 🇲🇰 Macedonian
|
||||
* 🇮🇳 Malayalam
|
||||
* 🇳🇴 Norsk
|
||||
* 🇵🇱 Polish
|
||||
* 🇧🇷 Portuguese (Brazil)
|
||||
* 🇷🇴 Romanian
|
||||
* 🇪🇸 Spanish
|
||||
* 🇸🇪 Swedish
|
||||
* 🇵🇭 Tagalog
|
||||
* 🇹🇷 Turkish
|
||||
* 🇻🇳 Vietnamese
|
||||
|
||||
### 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>
|
||||
|
|
|
@ -39,16 +39,16 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
compileSdk = 31
|
||||
compileSdk = 33
|
||||
buildToolsVersion = "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.lagradost.cloudstream3"
|
||||
minSdk = 21
|
||||
targetSdk = 30
|
||||
targetSdk = 33
|
||||
|
||||
versionCode = 54
|
||||
versionName = "3.2.1"
|
||||
versionCode = 57
|
||||
versionName = "4.0.0"
|
||||
|
||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||
|
||||
|
@ -155,10 +155,12 @@ dependencies {
|
|||
// implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
|
||||
|
||||
// Exoplayer
|
||||
implementation("com.google.android.exoplayer:exoplayer:2.18.1")
|
||||
implementation("com.google.android.exoplayer:extension-cast:2.18.1")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:2.18.1")
|
||||
implementation("com.google.android.exoplayer:extension-okhttp:2.18.1")
|
||||
implementation("com.google.android.exoplayer:exoplayer:2.18.2")
|
||||
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,14 +186,15 @@ 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.3.3")
|
||||
|
||||
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 🙏
|
||||
implementation("com.github.tachiyomiorg:unifile:17bec43")
|
||||
|
||||
|
@ -219,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) {
|
||||
|
@ -249,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,65 +50,20 @@ class ExampleInstrumentedTest {
|
|||
fun providerCorrectHomepage() {
|
||||
runBlocking {
|
||||
getAllProviders().amap { api ->
|
||||
if (api.hasMainPage) {
|
||||
try {
|
||||
val homepage = api.getMainPage()
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@
|
|||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- some dependency needs this -->
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
|
||||
<!-- <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> not used atm, but code exist that requires it that are not run -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Used for app notifications on Android 13+ -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
|
||||
|
||||
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!– Used for getting if vlc is installed –> -->
|
||||
<!-- Fixes android tv fuckery -->
|
||||
<uses-feature
|
||||
|
@ -94,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" />
|
||||
|
||||
|
@ -110,6 +124,30 @@
|
|||
|
||||
<data android:scheme="cloudstreamrepo" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
|
||||
<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="cloudstreamsearch" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
Allow opening from continue watching with intents: cloudstreamsearch://1234
|
||||
Used on Android TV Watch Next
|
||||
-->
|
||||
<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="cloudstreamcontinuewatching" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
@ -145,6 +183,10 @@
|
|||
android:name=".ui.ControllerActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".utils.PackageInstallerService"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
|
|
@ -43,9 +43,9 @@ class CustomReportSender : ReportSender {
|
|||
override fun send(context: Context, errorContent: CrashReportData) {
|
||||
println("Sending report")
|
||||
val url =
|
||||
"https://docs.google.com/forms/u/0/d/e/1FAIpQLSe9Vff8oHGMRXcjgCXZwkjvx3eBdNpn4DzjO0FkcWEU1gEQpA/formResponse"
|
||||
"https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse"
|
||||
val data = mapOf(
|
||||
"entry.1586460852" to errorContent.toJSON()
|
||||
"entry.753293084" to errorContent.toJSON()
|
||||
)
|
||||
|
||||
thread { // to not run it on main thread
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Context
|
||||
|
@ -16,6 +17,7 @@ import androidx.annotation.MainThread
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
|
@ -61,7 +63,9 @@ object CommonActivity {
|
|||
}
|
||||
}
|
||||
|
||||
fun showToast(act: Activity?, @StringRes message: Int, duration: Int) {
|
||||
/** duration is Toast.LENGTH_SHORT if null*/
|
||||
@MainThread
|
||||
fun showToast(act: Activity?, @StringRes message: Int, duration: Int? = null) {
|
||||
if (act == null) return
|
||||
showToast(act, act.getString(message), duration)
|
||||
}
|
||||
|
@ -69,6 +73,7 @@ object CommonActivity {
|
|||
const val TAG = "COMPACT"
|
||||
|
||||
/** duration is Toast.LENGTH_SHORT if null*/
|
||||
@MainThread
|
||||
fun showToast(act: Activity?, message: String?, duration: Int? = null) {
|
||||
if (act == null || message == null) {
|
||||
Log.w(TAG, "invalid showToast act = $act message = $message")
|
||||
|
@ -105,9 +110,18 @@ object CommonActivity {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all languages can be fetched from locale with a code.
|
||||
* This map allows sidestepping the default Locale(languageCode)
|
||||
* when setting the app language.
|
||||
**/
|
||||
val appLanguageExceptions = hashMapOf(
|
||||
"zh-rTW" to Locale.TRADITIONAL_CHINESE
|
||||
)
|
||||
|
||||
fun setLocale(context: Context?, languageCode: String?) {
|
||||
if (context == null || languageCode == null) return
|
||||
val locale = Locale(languageCode)
|
||||
val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
|
||||
val resources: Resources = context.resources
|
||||
val config = resources.configuration
|
||||
Locale.setDefault(locale)
|
||||
|
@ -143,8 +157,8 @@ object CommonActivity {
|
|||
val resultCode = result.resultCode
|
||||
val data = result.data
|
||||
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
|
||||
val pos = data.getLongExtra(resumeApp.position, -1L)
|
||||
val dur = data.getLongExtra(resumeApp.duration, -1L)
|
||||
val pos = resumeApp.getPosition(data)
|
||||
val dur = resumeApp.getDuration(data)
|
||||
if (dur > 0L && pos > 0L)
|
||||
DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur)
|
||||
removeKey(resumeApp.lastId)
|
||||
|
@ -152,6 +166,23 @@ object CommonActivity {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask for notification permissions on Android 13
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
act,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
val requestPermissionLauncher = act.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
Log.d(TAG, "Notification permission: $isGranted")
|
||||
}
|
||||
requestPermissionLauncher.launch(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Activity.enterPIPMode() {
|
||||
|
@ -337,6 +368,9 @@ object CommonActivity {
|
|||
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
|
||||
PlayerEventType.SkipOp
|
||||
}
|
||||
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
|
||||
PlayerEventType.SkipCurrentChapter
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
|
||||
PlayerEventType.PlayPauseToggle
|
||||
}
|
||||
|
@ -415,4 +449,4 @@ object CommonActivity {
|
|||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.ui.HeaderViewDecoration
|
||||
|
||||
fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
|
||||
val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
|
||||
view.addItemDecoration(HeaderViewDecoration(headerView))
|
||||
}
|
|
@ -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.settings.SettingsFragment.Companion.isTvSettings
|
||||
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
|
||||
|
@ -81,6 +83,7 @@ object APIHolder {
|
|||
synchronized(allProviders) {
|
||||
initMap()
|
||||
return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
|
||||
// Leave the ?. null check, it can crash regardless
|
||||
?: allProviders.firstOrNull { it.name == apiName }
|
||||
}
|
||||
}
|
||||
|
@ -159,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)
|
||||
|
||||
|
@ -236,7 +286,6 @@ object APIHolder {
|
|||
}
|
||||
|
||||
private fun Context.getHasTrailers(): Boolean {
|
||||
if (isTvSettings()) return false
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true)
|
||||
}
|
||||
|
@ -244,11 +293,17 @@ object APIHolder {
|
|||
fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List<MainAPI> {
|
||||
// We are getting the weirdest crash ever done:
|
||||
// java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType
|
||||
// enumValues<TvType>() might be the cause, hence I am trying TvType.values()
|
||||
// Trying fixing using classloader fuckery
|
||||
val oldLoader = Thread.currentThread().contextClassLoader
|
||||
Thread.currentThread().contextClassLoader = TvType::class.java.classLoader
|
||||
|
||||
val default = TvType.values()
|
||||
.sorted()
|
||||
.filter { it != TvType.NSFW }
|
||||
.map { it.ordinal }
|
||||
|
||||
Thread.currentThread().contextClassLoader = oldLoader
|
||||
|
||||
val defaultSet = default.map { it.toString() }.toSet()
|
||||
val currentPrefMedia = try {
|
||||
PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
@ -312,6 +367,57 @@ object APIHolder {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// THIS IS WORK IN PROGRESS API
|
||||
interface ITag {
|
||||
val name: UiText
|
||||
}
|
||||
|
||||
data class SimpleTag(override val name: UiText, val data: String) : ITag
|
||||
|
||||
enum class SelectType {
|
||||
SingleSelect,
|
||||
MultiSelect,
|
||||
MultiSelectAndExclude,
|
||||
}
|
||||
|
||||
enum class SelectValue {
|
||||
Selected,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
interface GenreSelector {
|
||||
val title: UiText
|
||||
val id : Int
|
||||
}
|
||||
|
||||
data class TagSelector(
|
||||
override val title: UiText,
|
||||
override val id : Int,
|
||||
val tags: Set<ITag>,
|
||||
val defaultTags : Set<ITag> = setOf(),
|
||||
val selectType: SelectType = SelectType.SingleSelect,
|
||||
) : GenreSelector
|
||||
|
||||
data class BoolSelector(
|
||||
override val title: UiText,
|
||||
override val id : Int,
|
||||
|
||||
val defaultValue : Boolean = false,
|
||||
) : GenreSelector
|
||||
|
||||
data class InputField(
|
||||
override val title: UiText,
|
||||
override val id : Int,
|
||||
|
||||
val hint : UiText? = null,
|
||||
) : GenreSelector
|
||||
|
||||
// This response describes how a user might filter the homepage or search results
|
||||
data class GenreResponse(
|
||||
val searchSelectors : List<GenreSelector>,
|
||||
val filterSelectors: List<GenreSelector> = searchSelectors
|
||||
) */
|
||||
|
||||
/*
|
||||
0 = Site not good
|
||||
|
@ -453,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,
|
||||
|
@ -523,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*/
|
||||
|
@ -608,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? {
|
||||
|
@ -1132,18 +1273,43 @@ interface LoadResponse {
|
|||
|
||||
fun getDurationFromString(input: String?): Int? {
|
||||
val cleanInput = input?.trim()?.replace(" ", "") ?: return null
|
||||
//Use first as sometimes the text passes on the 2 other Regex, but failed to provide accurate return value
|
||||
Regex("(\\d+\\shr)|(\\d+\\shour)|(\\d+\\smin)|(\\d+\\ssec)").findAll(input).let { values ->
|
||||
var seconds = 0
|
||||
values.forEach {
|
||||
val time_text = it.value
|
||||
if (time_text.isNotBlank()) {
|
||||
val time = time_text.filter { s -> s.isDigit() }.trim().toInt()
|
||||
val scale = time_text.filter { s -> !s.isDigit() }.trim()
|
||||
//println("Scale: $scale")
|
||||
val timeval = when (scale) {
|
||||
"hr", "hour" -> time * 60 * 60
|
||||
"min" -> time * 60
|
||||
"sec" -> time
|
||||
else -> 0
|
||||
}
|
||||
seconds += timeval
|
||||
}
|
||||
}
|
||||
if (seconds > 0) {
|
||||
return seconds / 60
|
||||
}
|
||||
}
|
||||
Regex("([0-9]*)h.*?([0-9]*)m").find(cleanInput)?.groupValues?.let { values ->
|
||||
if (values.size == 3) {
|
||||
val hours = values[1].toIntOrNull()
|
||||
val minutes = values[2].toIntOrNull()
|
||||
return if (minutes != null && hours != null) {
|
||||
hours * 60 + minutes
|
||||
} else null
|
||||
if (minutes != null && hours != null) {
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
Regex("([0-9]*)m").find(cleanInput)?.groupValues?.let { values ->
|
||||
if (values.size == 2) {
|
||||
return values[1].toIntOrNull()
|
||||
val return_value = values[1].toIntOrNull()
|
||||
if (return_value != null) {
|
||||
return return_value
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
@ -1161,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)
|
||||
}
|
||||
|
||||
|
||||
|
@ -1185,6 +1351,7 @@ interface EpisodeResponse {
|
|||
var showStatus: ShowStatus?
|
||||
var nextAiring: NextAiring?
|
||||
var seasonNames: List<SeasonData>?
|
||||
fun getLatestEpisodes(): Map<DubStatus, Int?>
|
||||
}
|
||||
|
||||
@JvmName("addSeasonNamesString")
|
||||
|
@ -1253,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.
|
||||
|
@ -1451,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,
|
||||
|
@ -1483,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.content.ComponentName
|
||||
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
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.WindowManager
|
||||
import android.view.*
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
|
@ -28,7 +32,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature
|
|||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
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
|
||||
|
@ -43,55 +49,78 @@ import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
|
|||
import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.CommonActivity.updateLocale
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.*
|
||||
import com.lagradost.cloudstream3.network.initClient
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
|
||||
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
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
|
||||
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
|
||||
import com.lagradost.cloudstream3.ui.result.setText
|
||||
import com.lagradost.cloudstream3.ui.search.SearchFragment
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
|
||||
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
|
||||
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
|
||||
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
|
||||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.IOnBackPressed
|
||||
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||
import com.lagradost.nicehttp.Requests
|
||||
import com.lagradost.nicehttp.ResponseParser
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.bottom_resultview_preview.*
|
||||
import kotlinx.android.synthetic.main.fragment_result_swipe.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
|
||||
//https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
|
||||
|
@ -112,13 +141,15 @@ val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlay
|
|||
val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
|
||||
|
||||
//TODO REFACTOR AF
|
||||
data class ResultResume(
|
||||
open class ResultResume(
|
||||
val packageString: String,
|
||||
val action: String = Intent.ACTION_VIEW,
|
||||
val position: String? = null,
|
||||
val duration: String? = null,
|
||||
var launcher: ActivityResultLauncher<Intent>? = null,
|
||||
) {
|
||||
val defaultTime = -1L
|
||||
|
||||
val lastId get() = "${packageString}_last_open_id"
|
||||
suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
|
||||
val intent = Intent(action)
|
||||
|
@ -132,21 +163,50 @@ data class ResultResume(
|
|||
callback.invoke(intent)
|
||||
launcher?.launch(intent)
|
||||
}
|
||||
|
||||
open fun getPosition(intent: Intent?): Long {
|
||||
return defaultTime
|
||||
}
|
||||
|
||||
open fun getDuration(intent: Intent?): Long {
|
||||
return defaultTime
|
||||
}
|
||||
}
|
||||
|
||||
val VLC = 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",
|
||||
)
|
||||
) {
|
||||
override fun getPosition(intent: Intent?): Long {
|
||||
return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
|
||||
}
|
||||
|
||||
val MPV = ResultResume(
|
||||
override fun getDuration(intent: Intent?): Long {
|
||||
return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
|
||||
}
|
||||
}
|
||||
|
||||
val MPV = object : ResultResume(
|
||||
MPV_PACKAGE,
|
||||
//"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
|
||||
position = "position",
|
||||
position = "position",
|
||||
duration = "duration",
|
||||
)
|
||||
) {
|
||||
override fun getPosition(intent: Intent?): Long {
|
||||
return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime
|
||||
}
|
||||
|
||||
override fun getDuration(intent: Intent?): Long {
|
||||
return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime
|
||||
}
|
||||
}
|
||||
|
||||
val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
|
||||
|
||||
|
@ -185,14 +245,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
companion object {
|
||||
const val TAG = "MAINACT"
|
||||
|
||||
/**
|
||||
* Setting this will automatically enter the query in the search
|
||||
* next time the search fragment is opened.
|
||||
* This variable will clear itself after one use. Null does nothing.
|
||||
*
|
||||
* This is a very bad solution but I was unable to find a better one.
|
||||
**/
|
||||
private var nextSearchQuery: String? = null
|
||||
|
||||
/**
|
||||
* Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread
|
||||
* Boolean signifies if stuff should be force reloaded (true if force reload, false if reload when necessary).
|
||||
*
|
||||
* The force reloading are used for plugin development to instantly reload the page on deployWithAdb
|
||||
* */
|
||||
val afterPluginsLoadedEvent = Event<Boolean>()
|
||||
val mainPluginsLoadedEvent =
|
||||
Event<Boolean>() // homepage api, used to speed up time to load for homepage
|
||||
val afterRepositoryLoadedEvent = Event<Boolean>()
|
||||
|
||||
// kinda shitty solution, but cant com main->home otherwise for popups
|
||||
val bookmarksUpdatedEvent = Event<Boolean>()
|
||||
|
||||
|
||||
/**
|
||||
* @return true if the str has launched an app task (be it successful or not)
|
||||
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
|
||||
|
@ -203,6 +279,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
isWebview: Boolean
|
||||
): Boolean =
|
||||
with(activity) {
|
||||
// TODO MUCH BETTER HANDLING
|
||||
|
||||
// Invalid URIs can crash
|
||||
fun safeURI(uri: String) = normalSafeApiCall { URI(uri) }
|
||||
|
||||
if (str != null && this != null) {
|
||||
if (str.startsWith("https://cs.repo")) {
|
||||
val realUrl = "https://" + str.substringAfter("?")
|
||||
|
@ -238,10 +319,50 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
return true
|
||||
}
|
||||
}
|
||||
} else if (URI(str).scheme == appStringRepo) {
|
||||
// This specific intent is used for the gradle deployWithAdb
|
||||
// https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46
|
||||
if (str == "$appString:") {
|
||||
PluginManager.hotReloadAllLocalPlugins(activity)
|
||||
}
|
||||
} else if (safeURI(str)?.scheme == appStringRepo) {
|
||||
val url = str.replaceFirst(appStringRepo, "https")
|
||||
loadRepository(url)
|
||||
return true
|
||||
} else if (safeURI(str)?.scheme == appStringSearch) {
|
||||
nextSearchQuery =
|
||||
URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8")
|
||||
|
||||
// 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()
|
||||
?: return false
|
||||
ioSafe {
|
||||
val resumeWatchingCard =
|
||||
HomeViewModel.getResumeWatching()?.firstOrNull { it.id == id }
|
||||
?: return@ioSafe
|
||||
activity.loadSearchResult(
|
||||
resumeWatchingCard,
|
||||
START_ACTION_RESUME_LATEST
|
||||
)
|
||||
}
|
||||
} else if (!isWebview) {
|
||||
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
|
||||
this.navigate(R.id.navigation_downloads)
|
||||
|
@ -260,6 +381,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
}
|
||||
}
|
||||
|
||||
var lastPopup: SearchResponse? = null
|
||||
fun loadPopup(result: SearchResponse) {
|
||||
lastPopup = result
|
||||
viewModel.load(
|
||||
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
||||
.contains(DubStatus.Dubbed)
|
||||
) DubStatus.Dubbed else DubStatus.Subbed, null
|
||||
)
|
||||
}
|
||||
|
||||
override fun onColorSelected(dialogId: Int, color: Int) {
|
||||
onColorSelectedEvent.invoke(Pair(dialogId, color))
|
||||
}
|
||||
|
@ -291,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,
|
||||
|
@ -304,8 +436,30 @@ 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)
|
||||
|
||||
|
||||
val dontPush = listOf(
|
||||
R.id.navigation_home,
|
||||
R.id.navigation_search,
|
||||
R.id.navigation_results_phone,
|
||||
R.id.navigation_results_tv,
|
||||
R.id.navigation_player,
|
||||
).contains(destination.id)
|
||||
|
||||
nav_host_fragment?.apply {
|
||||
val params = layoutParams as ConstraintLayout.LayoutParams
|
||||
|
||||
params.setMargins(
|
||||
if (!dontPush && isTvSettings()) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0,
|
||||
params.topMargin,
|
||||
params.rightMargin,
|
||||
params.bottomMargin
|
||||
)
|
||||
layoutParams = params
|
||||
}
|
||||
|
||||
val landscape = when (resources.configuration.orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
true
|
||||
|
@ -320,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
|
||||
|
@ -372,6 +531,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
// Start any delayed updates
|
||||
if (ApkInstaller.delayedInstaller?.startInstallation() == true) {
|
||||
Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
try {
|
||||
if (isCastApiAvailable()) {
|
||||
mSessionManager.removeSessionManagerListener(mSessionManagerListener)
|
||||
|
@ -402,12 +566,34 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
onUserLeaveHint(this)
|
||||
}
|
||||
|
||||
private fun showConfirmExitDialog() {
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
||||
builder.setTitle(R.string.confirm_exit_dialog)
|
||||
builder.apply {
|
||||
// Forceful exit since back button can actually go back to setup
|
||||
setPositiveButton(R.string.yes) { _, _ -> exitProcess(0) }
|
||||
setNegativeButton(R.string.no) { _, _ -> }
|
||||
}
|
||||
builder.show().setDefaultFocus()
|
||||
}
|
||||
|
||||
private fun backPressed() {
|
||||
this.window?.navigationBarColor =
|
||||
this.colorFromAttribute(R.attr.primaryGrayBackground)
|
||||
this.updateLocale()
|
||||
super.onBackPressed()
|
||||
this.updateLocale()
|
||||
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
|
||||
val navController = navHostFragment?.navController
|
||||
val isAtHome =
|
||||
navController?.currentDestination?.matchDestination(R.id.navigation_home) == true
|
||||
|
||||
if (isAtHome && isTrueTvSettings()) {
|
||||
showConfirmExitDialog()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
|
@ -495,6 +681,37 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
}
|
||||
}
|
||||
|
||||
lateinit var viewModel: ResultViewModel2
|
||||
|
||||
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
|
||||
viewModel =
|
||||
ViewModelProvider(this)[ResultViewModel2::class.java]
|
||||
|
||||
return super.onCreateView(name, context, attrs)
|
||||
}
|
||||
|
||||
private fun hidePreviewPopupDialog() {
|
||||
viewModel.clear()
|
||||
bottomPreviewPopup.dismissSafe(this)
|
||||
}
|
||||
|
||||
var bottomPreviewPopup: BottomSheetDialog? = null
|
||||
private fun showPreviewPopupDialog(): BottomSheetDialog {
|
||||
val ret = (bottomPreviewPopup ?: run {
|
||||
val builder =
|
||||
BottomSheetDialog(this)
|
||||
builder.setContentView(R.layout.bottom_resultview_preview)
|
||||
builder.setOnDismissListener {
|
||||
bottomPreviewPopup = null
|
||||
viewModel.clear()
|
||||
}
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
builder.show()
|
||||
builder
|
||||
})
|
||||
bottomPreviewPopup = ret
|
||||
return ret
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
app.initClient(this)
|
||||
|
@ -525,7 +742,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
}
|
||||
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN)
|
||||
|
||||
updateTv()
|
||||
if (isTvSettings()) {
|
||||
setContentView(R.layout.activity_main_tv)
|
||||
} else {
|
||||
|
@ -534,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))
|
||||
|
@ -550,12 +795,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
) {
|
||||
PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity)
|
||||
} else {
|
||||
PluginManager.loadAllOnlinePlugins(this@MainActivity)
|
||||
loadAllOnlinePlugins(this@MainActivity)
|
||||
}
|
||||
|
||||
//Automatically download not existing plugins
|
||||
if (settingsManager.getBoolean(
|
||||
getString(R.string.auto_download_plugins_key),
|
||||
false
|
||||
)
|
||||
) {
|
||||
PluginManager.downloadNotExistingPluginsAndLoad(this@MainActivity)
|
||||
}
|
||||
}
|
||||
|
||||
ioSafe {
|
||||
PluginManager.loadAllLocalPlugins(this@MainActivity)
|
||||
PluginManager.loadAllLocalPlugins(this@MainActivity, false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -572,9 +826,81 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
|
||||
setNegativeButton("Ok") { _, _ -> }
|
||||
}
|
||||
builder.show()
|
||||
builder.show().setDefaultFocus()
|
||||
}
|
||||
|
||||
observeNullable(viewModel.page) { resource ->
|
||||
if (resource == null) {
|
||||
bottomPreviewPopup.dismissSafe(this)
|
||||
return@observeNullable
|
||||
}
|
||||
when (resource) {
|
||||
is Resource.Failure -> {
|
||||
showToast(this, R.string.error)
|
||||
hidePreviewPopupDialog()
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
showPreviewPopupDialog().apply {
|
||||
resultview_preview_loading?.isVisible = true
|
||||
resultview_preview_result?.isVisible = false
|
||||
resultview_preview_loading_shimmer?.startShimmer()
|
||||
}
|
||||
}
|
||||
is Resource.Success -> {
|
||||
val d = resource.value
|
||||
showPreviewPopupDialog().apply {
|
||||
resultview_preview_loading?.isVisible = false
|
||||
resultview_preview_result?.isVisible = true
|
||||
resultview_preview_loading_shimmer?.stopShimmer()
|
||||
|
||||
resultview_preview_title?.text = d.title
|
||||
|
||||
resultview_preview_meta_type.setText(d.typeText)
|
||||
resultview_preview_meta_year.setText(d.yearText)
|
||||
resultview_preview_meta_duration.setText(d.durationText)
|
||||
resultview_preview_meta_rating.setText(d.ratingText)
|
||||
|
||||
resultview_preview_description?.setText(d.plotText)
|
||||
resultview_preview_poster?.setImage(
|
||||
d.posterImage ?: d.posterBackgroundImage
|
||||
)
|
||||
|
||||
resultview_preview_poster?.setOnClickListener {
|
||||
//viewModel.updateWatchStatus(WatchType.PLANTOWATCH)
|
||||
val value = viewModel.watchStatus.value ?: WatchType.NONE
|
||||
|
||||
this@MainActivity.showBottomDialog(
|
||||
WatchType.values().map { getString(it.stringRes) }.toList(),
|
||||
value.ordinal,
|
||||
this@MainActivity.getString(R.string.action_add_to_bookmarks),
|
||||
showApply = false,
|
||||
{}) {
|
||||
viewModel.updateWatchStatus(WatchType.values()[it])
|
||||
bookmarksUpdatedEvent(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTvSettings()) // dont want this clickable on tv layout
|
||||
resultview_preview_description?.setOnClickListener { view ->
|
||||
view.context?.let { ctx ->
|
||||
val builder: AlertDialog.Builder =
|
||||
AlertDialog.Builder(ctx, R.style.AlertDialogCustom)
|
||||
builder.setMessage(d.plotText.asString(ctx).html())
|
||||
.setTitle(d.plotHeaderText.asString(ctx))
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
resultview_preview_more_info?.setOnClickListener {
|
||||
hidePreviewPopupDialog()
|
||||
lastPopup?.let {
|
||||
loadSearchResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ioSafe {
|
||||
// val plugins =
|
||||
|
@ -616,6 +942,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
val navController = navHostFragment.navController
|
||||
|
||||
navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? ->
|
||||
// Intercept search and add a query
|
||||
if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) {
|
||||
bundle?.apply {
|
||||
this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery)
|
||||
nextSearchQuery = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//val navController = findNavController(R.id.nav_host_fragment)
|
||||
|
||||
/*navOptions = NavOptions.Builder()
|
||||
|
@ -629,7 +966,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
nav_view?.setupWithNavController(navController)
|
||||
val nav_rail = findViewById<NavigationRailView?>(R.id.nav_rail_view)
|
||||
nav_rail?.setupWithNavController(navController)
|
||||
if (isTvSettings()) {
|
||||
nav_rail?.background?.alpha = 200
|
||||
} else {
|
||||
nav_rail?.background?.alpha = 255
|
||||
|
||||
}
|
||||
nav_rail?.setOnItemSelectedListener { item ->
|
||||
onNavDestinationSelected(
|
||||
item,
|
||||
|
@ -798,10 +1140,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
// Used to check current focus for TV
|
||||
// main {
|
||||
// while (true) {
|
||||
// delay(1000)
|
||||
// delay(5000)
|
||||
// println("Current focus: $currentFocus")
|
||||
// showToast(this, currentFocus.toString(), Toast.LENGTH_LONG)
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,11 @@ package com.lagradost.cloudstream3.extractors
|
|||
|
||||
import android.util.Log
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.base64Decode
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
|
||||
class AStreamHub : ExtractorApi() {
|
||||
open class AStreamHub : ExtractorApi() {
|
||||
override val name = "AStreamHub"
|
||||
override val mainUrl = "https://astreamhub.com"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -4,7 +4,7 @@ import com.lagradost.cloudstream3.app
|
|||
import com.lagradost.cloudstream3.base64Decode
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
|
||||
class Acefile : ExtractorApi() {
|
||||
open class Acefile : ExtractorApi() {
|
||||
override val name = "Acefile"
|
||||
override val mainUrl = "https://acefile.co"
|
||||
override val requiresReferer = false
|
||||
|
@ -27,7 +27,6 @@ class Acefile : ExtractorApi() {
|
|||
res.substringAfter("\"file\":\"").substringBefore("\","),
|
||||
"$mainUrl/",
|
||||
Qualities.Unknown.value,
|
||||
headers = mapOf("range" to "bytes=0-")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.M3u8Helper
|
|||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
import java.net.URI
|
||||
|
||||
class AsianLoad : ExtractorApi() {
|
||||
open class AsianLoad : ExtractorApi() {
|
||||
override var name = "AsianLoad"
|
||||
override var mainUrl = "https://asianembed.io"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -5,7 +5,7 @@ import com.lagradost.cloudstream3.app
|
|||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
|
||||
class Blogger : ExtractorApi() {
|
||||
open class Blogger : ExtractorApi() {
|
||||
override val name = "Blogger"
|
||||
override val mainUrl = "https://www.blogger.com"
|
||||
override val requiresReferer = false
|
||||
|
|
|
@ -5,7 +5,7 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
||||
|
||||
class BullStream : ExtractorApi() {
|
||||
open class BullStream : ExtractorApi() {
|
||||
override val name = "BullStream"
|
||||
override val mainUrl = "https://bullstream.xyz"
|
||||
override val requiresReferer = false
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
|
||||
open class ByteShare : ExtractorApi() {
|
||||
override val name = "ByteShare"
|
||||
override val mainUrl = "https://byteshare.net"
|
||||
override val requiresReferer = false
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val sources = mutableListOf<ExtractorLink>()
|
||||
sources.add(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
url.replace("/embed/", "/download/"),
|
||||
"",
|
||||
Qualities.Unknown.value,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import android.util.Log
|
||||
import java.net.URLDecoder
|
||||
|
||||
open class Cda: ExtractorApi() {
|
||||
override var mainUrl = "https://ebd.cda.pl"
|
||||
override var name = "Cda"
|
||||
override val requiresReferer = false
|
||||
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
||||
val mediaId = url
|
||||
.split("/").last()
|
||||
.split("?").first()
|
||||
val doc = app.get("https://ebd.cda.pl/647x500/$mediaId", headers=mapOf(
|
||||
"Referer" to "https://ebd.cda.pl/647x500/$mediaId",
|
||||
"User-Agent" to USER_AGENT,
|
||||
"Cookie" to "cda.player=html5"
|
||||
)).document
|
||||
val dataRaw = doc.selectFirst("[player_data]")?.attr("player_data") ?: return null
|
||||
val playerData = tryParseJson<PlayerData>(dataRaw) ?: return null
|
||||
return listOf(ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
getFile(playerData.video.file),
|
||||
referer = "https://ebd.cda.pl/647x500/$mediaId",
|
||||
quality = Qualities.Unknown.value
|
||||
))
|
||||
}
|
||||
|
||||
private fun rot13(a: String): String {
|
||||
return a.map {
|
||||
when {
|
||||
it in 'A'..'M' || it in 'a'..'m' -> it + 13
|
||||
it in 'N'..'Z' || it in 'n'..'z' -> it - 13
|
||||
else -> it
|
||||
}
|
||||
}.joinToString("")
|
||||
}
|
||||
|
||||
private fun cdaUggc(a: String): String {
|
||||
val decoded = rot13(a)
|
||||
return if (decoded.endsWith("adc.mp4")) decoded.replace("adc.mp4",".mp4")
|
||||
else decoded
|
||||
}
|
||||
|
||||
private fun cdaDecrypt(b: String): String {
|
||||
var a = b
|
||||
.replace("_XDDD", "")
|
||||
.replace("_CDA", "")
|
||||
.replace("_ADC", "")
|
||||
.replace("_CXD", "")
|
||||
.replace("_QWE", "")
|
||||
.replace("_Q5", "")
|
||||
.replace("_IKSDE", "")
|
||||
a = URLDecoder.decode(a, "UTF-8")
|
||||
a = a.map { char ->
|
||||
if (32 < char.toInt() && char.toInt() < 127) {
|
||||
return@map String.format("%c", 33 + (char.toInt() + 14) % 94)
|
||||
} else {
|
||||
return@map char
|
||||
}
|
||||
}.joinToString("")
|
||||
a = a
|
||||
.replace(".cda.mp4", "")
|
||||
.replace(".2cda.pl", ".cda.pl")
|
||||
.replace(".3cda.pl", ".cda.pl")
|
||||
return if (a.contains("/upstream")) "https://" + a.replace("/upstream", ".mp4/upstream")
|
||||
else "https://${a}.mp4"
|
||||
}
|
||||
|
||||
private fun getFile(a: String) = when {
|
||||
a.startsWith("uggc") -> cdaUggc(a)
|
||||
!a.startsWith("http") -> cdaDecrypt(a)
|
||||
else -> a
|
||||
}
|
||||
|
||||
data class VideoPlayerData(
|
||||
val file: String,
|
||||
val qualities: Map<String, String> = mapOf(),
|
||||
val quality: String?,
|
||||
val ts: Int?,
|
||||
val hash2: String?
|
||||
)
|
||||
|
||||
data class PlayerData(
|
||||
val video: VideoPlayerData
|
||||
)
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
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.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
|
||||
|
||||
open class Dailymotion : ExtractorApi() {
|
||||
override val mainUrl = "https://www.dailymotion.com"
|
||||
override val name = "Dailymotion"
|
||||
override val requiresReferer = false
|
||||
|
||||
@Suppress("RegExpSimplifiable")
|
||||
private val videoIdRegex = "^[kx][a-zA-Z0-9]+\$".toRegex()
|
||||
|
||||
// https://www.dailymotion.com/video/k3JAHfletwk94ayCVIu
|
||||
// https://www.dailymotion.com/embed/video/k3JAHfletwk94ayCVIu
|
||||
override suspend fun getUrl(
|
||||
url: String,
|
||||
referer: String?,
|
||||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
val embedUrl = getEmbedUrl(url) ?: return
|
||||
val doc = app.get(embedUrl).document
|
||||
val prefix = "window.__PLAYER_CONFIG__ = "
|
||||
val configStr = doc.selectFirst("script:containsData($prefix)")?.data() ?: return
|
||||
val config = tryParseJson<Config>(configStr.substringAfter(prefix)) ?: return
|
||||
val id = getVideoId(embedUrl) ?: return
|
||||
val dmV1st = config.dmInternalData.v1st
|
||||
val dmTs = config.dmInternalData.ts
|
||||
val metaDataUrl =
|
||||
"$mainUrl/player/metadata/video/$id?locale=en&dmV1st=$dmV1st&dmTs=$dmTs&is_native_app=0"
|
||||
val cookies = mapOf(
|
||||
"v1st" to dmV1st,
|
||||
"dmvk" to config.context.dmvk,
|
||||
"ts" to dmTs.toString()
|
||||
)
|
||||
val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = cookies)
|
||||
.parsedSafe<MetaData>() ?: return
|
||||
metaData.qualities.forEach { (_, video) ->
|
||||
video.forEach {
|
||||
getStream(it.url, this.name, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmbedUrl(url: String): String? {
|
||||
if (url.contains("/embed/")) {
|
||||
return url
|
||||
}
|
||||
val vid = getVideoId(url) ?: return null
|
||||
return "$mainUrl/embed/video/$vid"
|
||||
}
|
||||
|
||||
private fun getVideoId(url: String): String? {
|
||||
val path = URL(url).path
|
||||
val id = path.substringAfter("video/")
|
||||
if (id.matches(videoIdRegex)) {
|
||||
return id
|
||||
}
|
||||
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
|
||||
)
|
||||
|
||||
data class InternalData(
|
||||
val ts: Int,
|
||||
val v1st: String
|
||||
)
|
||||
|
||||
data class Context(
|
||||
@JsonProperty("access_token") val accessToken: String?,
|
||||
val dmvk: String,
|
||||
)
|
||||
|
||||
data class MetaData(
|
||||
val qualities: Map<String, List<VideoLink>>
|
||||
)
|
||||
|
||||
data class VideoLink(
|
||||
val type: String,
|
||||
val url: String
|
||||
)
|
||||
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
|
|||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
import com.lagradost.cloudstream3.utils.httpsify
|
||||
|
||||
class Embedgram : ExtractorApi() {
|
||||
open class Embedgram : ExtractorApi() {
|
||||
override val name = "Embedgram"
|
||||
override val mainUrl = "https://embedgram.com"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -5,35 +5,50 @@ import com.lagradost.cloudstream3.app
|
|||
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.getAndUnpack
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
class Fastream: ExtractorApi() {
|
||||
open class Fastream: ExtractorApi() {
|
||||
override var mainUrl = "https://fastream.to"
|
||||
override var name = "Fastream"
|
||||
override val requiresReferer = false
|
||||
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
||||
val id = Regex("emb\\.html\\?(.*)\\=(enc|)").find(url)?.destructured?.component1() ?: return emptyList()
|
||||
val sources = mutableListOf<ExtractorLink>()
|
||||
val response = app.post("$mainUrl/dl",
|
||||
data = mapOf(
|
||||
Pair("op","embed"),
|
||||
Pair("file_code",id),
|
||||
Pair("auto","1")
|
||||
)).document
|
||||
suspend fun getstream(
|
||||
response: Document,
|
||||
sources: ArrayList<ExtractorLink>): Boolean{
|
||||
response.select("script").amap { script ->
|
||||
if (script.data().contains("sources")) {
|
||||
val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)")
|
||||
val m3u8 = m3u8regex.find(script.data())?.value ?: return@amap
|
||||
if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) {
|
||||
val unpacked = getAndUnpack(script.data())
|
||||
//val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)")
|
||||
val newm3u8link = unpacked.substringAfter("file:\"").substringBefore("\"")
|
||||
//val m3u8link = m3u8regex.find(unpacked)?.value ?: return@forEach
|
||||
generateM3u8(
|
||||
name,
|
||||
m3u8,
|
||||
newm3u8link,
|
||||
mainUrl
|
||||
).forEach { link ->
|
||||
sources.add(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val sources = ArrayList<ExtractorLink>()
|
||||
val idregex = Regex("emb.html\\?(.*)=")
|
||||
if (url.contains(Regex("(emb.html.*fastream)"))) {
|
||||
val id = idregex.find(url)?.destructured?.component1() ?: ""
|
||||
val response = app.post("https://fastream.to/dl", allowRedirects = false,
|
||||
data = mapOf(
|
||||
"op" to "embed",
|
||||
"file_code" to id,
|
||||
"auto" to "1"
|
||||
)
|
||||
).document
|
||||
getstream(response, sources)
|
||||
}
|
||||
val response = app.get(url, referer = url).document
|
||||
getstream(response, sources)
|
||||
return sources
|
||||
}
|
||||
}
|
|
@ -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 Filesim : ExtractorApi() {
|
||||
|
||||
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?
|
||||
)
|
||||
) */
|
||||
|
||||
}
|
|
@ -3,10 +3,9 @@ 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.M3u8Helper
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
|
||||
class GMPlayer : ExtractorApi() {
|
||||
open class GMPlayer : ExtractorApi() {
|
||||
override val name = "GM Player"
|
||||
override val mainUrl = "https://gmplayer.xyz"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,46 +1,53 @@
|
|||
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.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
|
||||
class Linkbox : ExtractorApi() {
|
||||
open class Linkbox : ExtractorApi() {
|
||||
override val name = "Linkbox"
|
||||
override val mainUrl = "https://www.linkbox.to"
|
||||
override val requiresReferer = true
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val id = url.substringAfter("id=")
|
||||
val sources = mutableListOf<ExtractorLink>()
|
||||
|
||||
app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe<Responses>()?.data?.rList?.map { link ->
|
||||
sources.add(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link.url,
|
||||
url,
|
||||
getQualityFromName(link.resolution)
|
||||
override suspend fun getUrl(
|
||||
url: String,
|
||||
referer: String?,
|
||||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
open class Mcloud : WcoStream() {
|
||||
override var name = "Mcloud"
|
||||
override var mainUrl = "https://mcloud.to"
|
||||
override val requiresReferer = true
|
||||
}
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
|
|||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.getAndUnpack
|
||||
|
||||
class Mp4Upload : ExtractorApi() {
|
||||
open class Mp4Upload : ExtractorApi() {
|
||||
override var name = "Mp4Upload"
|
||||
override var mainUrl = "https://www.mp4upload.com"
|
||||
private val srcRegex = Regex("""player\.src\("(.*?)"""")
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.Qualities
|
|||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
import java.net.URI
|
||||
|
||||
class MultiQuality : ExtractorApi() {
|
||||
open class MultiQuality : ExtractorApi() {
|
||||
override var name = "MultiQuality"
|
||||
override var mainUrl = "https://gogo-play.net"
|
||||
private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""")
|
||||
|
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
|
||||
class Mvidoo : ExtractorApi() {
|
||||
open class Mvidoo : ExtractorApi() {
|
||||
override val name = "Mvidoo"
|
||||
override val mainUrl = "https://mvidoo.com"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
|
||||
data class Okrulinkdata (
|
||||
@JsonProperty("status" ) var status : String? = null,
|
||||
@JsonProperty("url" ) var url : String? = null
|
||||
)
|
||||
|
||||
open class Okrulink: ExtractorApi() {
|
||||
override var mainUrl = "https://okru.link"
|
||||
override var name = "Okrulink"
|
||||
override val requiresReferer = false
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val sources = mutableListOf<ExtractorLink>()
|
||||
val key = url.substringAfter("html?t=")
|
||||
val request = app.post("https://apizz.okru.link/decoding", allowRedirects = false,
|
||||
data = mapOf("video" to key)
|
||||
).parsedSafe<Okrulinkdata>()
|
||||
if (request?.url != null) {
|
||||
sources.add(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
request.url!!,
|
||||
"",
|
||||
Qualities.Unknown.value,
|
||||
isM3u8 = false
|
||||
)
|
||||
)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ import org.jsoup.Jsoup
|
|||
* overrideMainUrl is necessary for for other vidstream clones like vidembed.cc
|
||||
* If they diverge it'd be better to make them separate.
|
||||
* */
|
||||
class Pelisplus(val mainUrl: String) {
|
||||
open class Pelisplus(val mainUrl: String) {
|
||||
val name: String = "Vidstream"
|
||||
|
||||
private fun getExtractorUrl(id: String): String {
|
||||
|
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.app
|
|||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
|
||||
class PlayLtXyz: ExtractorApi() {
|
||||
open class PlayLtXyz: ExtractorApi() {
|
||||
override val name: String = "PlayLt"
|
||||
override val mainUrl: String = "https://play.playlt.xyz"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
|
|||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
|
||||
|
||||
class Solidfiles : ExtractorApi() {
|
||||
open class Solidfiles : ExtractorApi() {
|
||||
override val name = "Solidfiles"
|
||||
override val mainUrl = "https://www.solidfiles.com"
|
||||
override val requiresReferer = false
|
||||
|
|
|
@ -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/sources48/" + 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() {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,15 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
|
||||
class StreamTape : ExtractorApi() {
|
||||
class StreamTapeNet : StreamTape() {
|
||||
override var mainUrl = "https://streamtape.net"
|
||||
}
|
||||
|
||||
class ShaveTape : StreamTape(){
|
||||
override var mainUrl = "https://shavetape.cash"
|
||||
}
|
||||
|
||||
open class StreamTape : ExtractorApi() {
|
||||
override var name = "StreamTape"
|
||||
override var mainUrl = "https://streamtape.com"
|
||||
override val requiresReferer = false
|
||||
|
@ -16,7 +24,8 @@ class StreamTape : ExtractorApi() {
|
|||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
||||
with(app.get(url)) {
|
||||
linkRegex.find(this.text)?.let {
|
||||
val extractedUrl = "https:${it.groups[1]!!.value + it.groups[2]!!.value.substring(3,)}"
|
||||
val extractedUrl =
|
||||
"https:${it.groups[1]!!.value + it.groups[2]!!.value.substring(3)}"
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.JsUnpacker
|
|||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import java.net.URI
|
||||
|
||||
class Streamhub : ExtractorApi() {
|
||||
open class Streamhub : ExtractorApi() {
|
||||
override var mainUrl = "https://streamhub.to"
|
||||
override var name = "Streamhub"
|
||||
override val requiresReferer = false
|
||||
|
|
|
@ -9,7 +9,7 @@ import com.lagradost.cloudstream3.utils.*
|
|||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import java.net.URI
|
||||
|
||||
class Streamplay : ExtractorApi() {
|
||||
open class Streamplay : ExtractorApi() {
|
||||
override val name = "Streamplay"
|
||||
override val mainUrl = "https://streamplay.to"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -11,7 +11,7 @@ data class Files(
|
|||
@JsonProperty("label") val label: String? = null,
|
||||
)
|
||||
|
||||
open class Supervideo : ExtractorApi() {
|
||||
open class Supervideo : ExtractorApi() {
|
||||
override var name = "Supervideo"
|
||||
override var mainUrl = "https://supervideo.tv"
|
||||
override val requiresReferer = false
|
||||
|
@ -20,10 +20,13 @@ data class Files(
|
|||
val response = app.get(url).text
|
||||
val jstounpack = Regex("eval((.|\\n)*?)</script>").find(response)?.groups?.get(1)?.value
|
||||
val unpacjed = JsUnpacker(jstounpack).unpack()
|
||||
val extractedUrl = unpacjed?.let { Regex("""sources:((.|\n)*?)image""").find(it) }?.groups?.get(1)?.value.toString().replace("file",""""file"""").replace("label",""""label"""").substringBeforeLast(",")
|
||||
val extractedUrl =
|
||||
unpacjed?.let { Regex("""sources:((.|\n)*?)image""").find(it) }?.groups?.get(1)?.value.toString()
|
||||
.replace("file", """"file"""").replace("label", """"label"""")
|
||||
.substringBeforeLast(",")
|
||||
val parsedlinks = parseJson<List<Files>>(extractedUrl)
|
||||
parsedlinks.forEach { data ->
|
||||
if (data.label.isNullOrBlank()){ // mp4 links (with labels) are slow. Use only m3u8 link.
|
||||
if (data.label.isNullOrBlank()) { // mp4 links (with labels) are slow. Use only m3u8 link.
|
||||
M3u8Helper.generateM3u8(
|
||||
name,
|
||||
data.id,
|
||||
|
@ -34,8 +37,6 @@ data class Files(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return extractedLinksList
|
||||
}
|
||||
}
|
|
@ -1,41 +1,64 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.mapper
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
|
||||
|
||||
class Cinestart: Tomatomatela() {
|
||||
override var name = "Cinestart"
|
||||
override var mainUrl = "https://cinestart.net"
|
||||
override var name: String = "Cinestart"
|
||||
override val mainUrl: String = "https://cinestart.net"
|
||||
override val details = "vr.php?v="
|
||||
}
|
||||
|
||||
class TomatomatelalClub: Tomatomatela() {
|
||||
override var name: String = "Tomatomatela"
|
||||
override val mainUrl: String = "https://tomatomatela.club"
|
||||
}
|
||||
|
||||
open class Tomatomatela : ExtractorApi() {
|
||||
override var name = "Tomatomatela"
|
||||
override var mainUrl = "https://tomatomatela.com"
|
||||
override val mainUrl = "https://tomatomatela.com"
|
||||
override val requiresReferer = false
|
||||
private data class Tomato (
|
||||
@JsonProperty("status") val status: Int,
|
||||
@JsonProperty("file") val file: String
|
||||
@JsonProperty("file") val file: String?
|
||||
)
|
||||
open val details = "details.php?v="
|
||||
open val embeddetails = "/embed.html#"
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
||||
val link = url.replace("$mainUrl/embed.html#","$mainUrl/$details")
|
||||
val server = app.get(link, allowRedirects = false).text
|
||||
val json = parseJson<Tomato>(server)
|
||||
if (json.status == 200) return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
json.file,
|
||||
"",
|
||||
Qualities.Unknown.value,
|
||||
isM3u8 = false
|
||||
val link = url.replace("$mainUrl$embeddetails","$mainUrl/$details")
|
||||
val sources = ArrayList<ExtractorLink>()
|
||||
val server = app.get(link, allowRedirects = false,
|
||||
headers = mapOf(
|
||||
"User-Agent" to USER_AGENT,
|
||||
"Accept" to "application/json, text/javascript, */*; q=0.01",
|
||||
"Accept-Language" to "en-US,en;q=0.5",
|
||||
"X-Requested-With" to "XMLHttpRequest",
|
||||
"DNT" to "1",
|
||||
"Connection" to "keep-alive",
|
||||
"Sec-Fetch-Dest" to "empty",
|
||||
"Sec-Fetch-Mode" to "cors",
|
||||
"Sec-Fetch-Site" to "same-origin"
|
||||
|
||||
)
|
||||
)
|
||||
return null
|
||||
).parsedSafe<Tomato>()
|
||||
if (server?.file != null) {
|
||||
sources.add(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
server.file,
|
||||
"",
|
||||
Qualities.Unknown.value,
|
||||
isM3u8 = false
|
||||
)
|
||||
)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
||||
|
||||
class UpstreamExtractor : ExtractorApi() {
|
||||
open class UpstreamExtractor : ExtractorApi() {
|
||||
override val name: String = "Upstream"
|
||||
override val mainUrl: String = "https://upstream.to"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -11,7 +11,7 @@ class VideovardSX : WcoStream() {
|
|||
override var mainUrl = "https://videovard.sx"
|
||||
}
|
||||
|
||||
class VideoVard : ExtractorApi() {
|
||||
open class VideoVard : ExtractorApi() {
|
||||
override var name = "Videovard" // Cause works for animekisa and wco
|
||||
override var mainUrl = "https://videovard.to"
|
||||
override val requiresReferer = false
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -13,39 +13,42 @@ open class VoeExtractor : ExtractorApi() {
|
|||
override val requiresReferer = false
|
||||
|
||||
private data class ResponseLinks(
|
||||
@JsonProperty("hls") val url: String?,
|
||||
@JsonProperty("hls") val hls: String?,
|
||||
@JsonProperty("mp4") val mp4: String?,
|
||||
@JsonProperty("video_height") val label: Int?
|
||||
//val type: String // Mp4
|
||||
)
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
val extractedLinksList: MutableList<ExtractorLink> = mutableListOf()
|
||||
val doc = app.get(url).text
|
||||
if (doc.isNotBlank()) {
|
||||
val start = "const sources ="
|
||||
var src = doc.substring(doc.indexOf(start))
|
||||
src = src.substring(start.length, src.indexOf(";"))
|
||||
val html = app.get(url).text
|
||||
if (html.isNotBlank()) {
|
||||
val src = html.substringAfter("const sources =").substringBefore(";")
|
||||
// Remove last comma, it is not proper json otherwise
|
||||
.replace("0,", "0")
|
||||
.trim()
|
||||
// Make json use the proper quotes
|
||||
.replace("'", "\"")
|
||||
|
||||
//Log.i(this.name, "Result => (src) ${src}")
|
||||
parseJson<ResponseLinks?>(src)?.let { voelink ->
|
||||
//Log.i(this.name, "Result => (voelink) ${voelink}")
|
||||
val linkUrl = voelink.url
|
||||
val linkLabel = voelink.label?.toString() ?: ""
|
||||
parseJson<ResponseLinks?>(src)?.let { voeLink ->
|
||||
//Log.i(this.name, "Result => (voeLink) ${voeLink}")
|
||||
|
||||
// Always defaults to the hls link, but returns the mp4 if null
|
||||
val linkUrl = voeLink.hls ?: voeLink.mp4
|
||||
val linkLabel = voeLink.label?.toString() ?: ""
|
||||
if (!linkUrl.isNullOrEmpty()) {
|
||||
extractedLinksList.add(
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name = this.name,
|
||||
source = this.name,
|
||||
url = linkUrl,
|
||||
quality = getQualityFromName(linkLabel),
|
||||
referer = url,
|
||||
isM3u8 = true
|
||||
isM3u8 = voeLink.hls != null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractedLinksList
|
||||
return emptyList()
|
||||
}
|
||||
}
|
|
@ -53,6 +53,12 @@ class VizcloudSite : WcoStream() {
|
|||
override var mainUrl = "https://vizcloud.site"
|
||||
}
|
||||
|
||||
class Mcloud : WcoStream() {
|
||||
override var name = "Mcloud"
|
||||
override var mainUrl = "https://mcloud.to"
|
||||
override val requiresReferer = true
|
||||
}
|
||||
|
||||
open class WcoStream : ExtractorApi() {
|
||||
override var name = "VidStream" // Cause works for animekisa and wco
|
||||
override var mainUrl = "https://vidstream.pro"
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
|
||||
class YourUpload: ExtractorApi() {
|
||||
open class YourUpload: ExtractorApi() {
|
||||
override val name = "Yourupload"
|
||||
override val mainUrl = "https://www.yourupload.com"
|
||||
override val requiresReferer = false
|
||||
|
|
|
@ -10,7 +10,7 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
||||
|
||||
class Zorofile : ExtractorApi() {
|
||||
open class Zorofile : ExtractorApi() {
|
||||
override val name = "Zorofile"
|
||||
override val mainUrl = "https://zorofile.com"
|
||||
override val requiresReferer = true
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -53,6 +53,10 @@ fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
|||
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||
}
|
||||
|
||||
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
liveData.observe(this) { action(it) }
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> some(value: T?): Some<T> {
|
||||
return if (value == null) {
|
||||
Some.None
|
||||
|
@ -117,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)
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.webkit.CookieManager
|
|||
import androidx.annotation.AnyThread
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.debugWarning
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.nicehttp.Requests.Companion.await
|
||||
import com.lagradost.nicehttp.cookies
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -26,7 +27,10 @@ class CloudflareKiller : Interceptor {
|
|||
|
||||
init {
|
||||
// Needs to clear cookies between sessions to generate new cookies.
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
normalSafeApiCall {
|
||||
// This can throw an exception on unsupported devices :(
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
}
|
||||
}
|
||||
|
||||
val savedCookies: MutableMap<String, Map<String, String>> = mutableMapOf()
|
||||
|
@ -35,7 +39,7 @@ class CloudflareKiller : Interceptor {
|
|||
* Gets the headers with cookies, webview user agent included!
|
||||
* */
|
||||
fun getCookieHeaders(url: String): Headers {
|
||||
val userAgentHeaders = WebViewResolver.webViewUserAgent?.let {
|
||||
val userAgentHeaders = WebViewResolver.webViewUserAgent?.let {
|
||||
mapOf("user-agent" to it)
|
||||
} ?: emptyMap()
|
||||
|
||||
|
@ -60,7 +64,9 @@ class CloudflareKiller : Interceptor {
|
|||
}
|
||||
|
||||
private fun getWebViewCookie(url: String): String? {
|
||||
return CookieManager.getInstance()?.getCookie(url)
|
||||
return normalSafeApiCall {
|
||||
CookieManager.getInstance()?.getCookie(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.network
|
|||
|
||||
import androidx.annotation.AnyThread
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.nicehttp.Requests.Companion.await
|
||||
import com.lagradost.nicehttp.Requests
|
||||
import com.lagradost.nicehttp.cookies
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
|
@ -41,7 +41,8 @@ class DdosGuardKiller(private val alwaysBypass: Boolean) : Interceptor {
|
|||
savedCookiesMap[request.url.host]
|
||||
// If no cookies are found fetch and save em.
|
||||
?: (request.url.scheme + "://" + request.url.host + (ddosBypassPath ?: "")).let {
|
||||
app.get(it, cacheTime = 0).cookies.also { cookies ->
|
||||
// Somehow app.get fails
|
||||
Requests().get(it).cookies.also { cookies ->
|
||||
savedCookiesMap[request.url.host] = cookies
|
||||
}
|
||||
}
|
||||
|
@ -51,6 +52,6 @@ class DdosGuardKiller(private val alwaysBypass: Boolean) : Interceptor {
|
|||
request.newBuilder()
|
||||
.headers(headers)
|
||||
.build()
|
||||
).await()
|
||||
).execute()
|
||||
}
|
||||
}
|
|
@ -4,16 +4,19 @@ import android.content.Context
|
|||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.nicehttp.Requests
|
||||
import com.lagradost.nicehttp.ignoreAllSSLErrors
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
import okhttp3.OkHttpClient
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.io.File
|
||||
|
||||
import java.security.Security
|
||||
|
||||
fun Requests.initClient(context: Context): OkHttpClient {
|
||||
normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0)
|
||||
baseClient = OkHttpClient.Builder()
|
||||
|
|
|
@ -10,14 +10,18 @@ import android.util.Log
|
|||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
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
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.mvvm.debugPrint
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
@ -26,6 +30,8 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
|
|||
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||
import com.lagradost.cloudstream3.ui.result.UiText
|
||||
import com.lagradost.cloudstream3.ui.result.txt
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
|
@ -139,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
|
||||
|
||||
|
@ -158,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)
|
||||
)
|
||||
|
@ -192,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
|
||||
|
@ -202,7 +210,7 @@ object PluginManager {
|
|||
})?.let { savedData ->
|
||||
// OnlinePluginData(savedData, onlineData)
|
||||
loadPlugin(
|
||||
activity,
|
||||
context,
|
||||
File(savedData.filePath),
|
||||
savedData
|
||||
)
|
||||
|
@ -219,9 +227,7 @@ object PluginManager {
|
|||
fun updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
||||
// Load all plugins as fast as possible!
|
||||
loadAllOnlinePlugins(activity)
|
||||
|
||||
afterPluginsLoadedEvent.invoke(true)
|
||||
|
||||
afterPluginsLoadedEvent.invoke(false)
|
||||
|
||||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||
|
@ -252,11 +258,12 @@ object PluginManager {
|
|||
//updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name))
|
||||
unloadPlugin(pluginData.savedData.filePath)
|
||||
} else if (pluginData.isOutdated) {
|
||||
downloadAndLoadPlugin(
|
||||
downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.savedData.internalName,
|
||||
File(pluginData.savedData.filePath)
|
||||
File(pluginData.savedData.filePath),
|
||||
true
|
||||
).let { success ->
|
||||
if (success)
|
||||
updatedPlugins.add(pluginData.onlineData.second.name)
|
||||
|
@ -265,31 +272,134 @@ object PluginManager {
|
|||
}
|
||||
|
||||
main {
|
||||
createNotification(activity, updatedPlugins)
|
||||
val uitext = txt(R.string.plugins_updated, updatedPlugins.size)
|
||||
createNotification(activity, uitext, updatedPlugins)
|
||||
}
|
||||
|
||||
// ioSafe {
|
||||
afterPluginsLoadedEvent.invoke(true)
|
||||
// }
|
||||
// ioSafe {
|
||||
afterPluginsLoadedEvent.invoke(false)
|
||||
// }
|
||||
|
||||
Log.i(TAG, "Plugin update done!")
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically download plugins not yet existing on local
|
||||
* 1. Gets all online data from online plugins repo
|
||||
* 2. Fetch all not downloaded plugins
|
||||
* 3. Download them and reload plugins
|
||||
**/
|
||||
fun downloadNotExistingPluginsAndLoad(activity: Activity) {
|
||||
val newDownloadPlugins = mutableListOf<String>()
|
||||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||
val onlinePlugins = urls.toList().apmap {
|
||||
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||
}.flatten().distinctBy { it.second.url }
|
||||
|
||||
val providerLang = activity.getApiProviderLangSettings()
|
||||
//Log.i(TAG, "providerLang => ${providerLang.toJson()}")
|
||||
|
||||
// Iterate online repos and returns not downloaded plugins
|
||||
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
|
||||
val sitePlugin = onlineData.second
|
||||
//Don't include empty urls
|
||||
if (sitePlugin.url.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (sitePlugin.repositoryUrl.isNullOrBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
//Omit already existing plugins
|
||||
if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) {
|
||||
Log.i(TAG, "Skip > ${sitePlugin.internalName}")
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
//Omit lang not selected on language setting
|
||||
val lang = sitePlugin.language ?: return@mapNotNull null
|
||||
//If set to 'universal', don't skip any language
|
||||
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
//Log.i(TAG, "sitePlugin lang => $lang")
|
||||
|
||||
//Omit NSFW, if disabled
|
||||
sitePlugin.tvTypes?.let { tvtypes ->
|
||||
if (!settingsForProvider.enableAdult) {
|
||||
if (tvtypes.contains(TvType.NSFW.name)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
}
|
||||
}
|
||||
val savedData = PluginData(
|
||||
url = sitePlugin.url,
|
||||
internalName = sitePlugin.internalName,
|
||||
isOnline = true,
|
||||
filePath = "",
|
||||
version = sitePlugin.version
|
||||
)
|
||||
OnlinePluginData(savedData, onlineData)
|
||||
}
|
||||
//Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}")
|
||||
|
||||
notDownloadedPlugins.apmap { pluginData ->
|
||||
downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.savedData.internalName,
|
||||
pluginData.onlineData.first,
|
||||
!pluginData.isDisabled
|
||||
).let { success ->
|
||||
if (success)
|
||||
newDownloadPlugins.add(pluginData.onlineData.second.name)
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
val uitext = txt(R.string.plugins_downloaded, newDownloadPlugins.size)
|
||||
createNotification(activity, uitext, newDownloadPlugins)
|
||||
}
|
||||
|
||||
// ioSafe {
|
||||
afterPluginsLoadedEvent.invoke(false)
|
||||
// }
|
||||
|
||||
Log.i(TAG, "Plugin download done!")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAllLocalPlugins(activity: Activity) {
|
||||
/**
|
||||
* Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb
|
||||
**/
|
||||
fun hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
||||
Log.d(TAG, "Reloading all local plugins!")
|
||||
if (activity == null) return
|
||||
getPluginsLocal().forEach {
|
||||
unloadPlugin(it.filePath)
|
||||
}
|
||||
loadAllLocalPlugins(activity, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
|
||||
* and reload all pages even if they are previously valid
|
||||
**/
|
||||
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||
val dir = File(LOCAL_PLUGINS_PATH)
|
||||
removeKey(PLUGINS_KEY_LOCAL)
|
||||
|
||||
|
@ -307,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(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) {
|
||||
|
@ -368,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
|
||||
|
@ -391,7 +516,7 @@ object PluginManager {
|
|||
}
|
||||
}
|
||||
|
||||
private fun unloadPlugin(absolutePath: String) {
|
||||
fun unloadPlugin(absolutePath: String) {
|
||||
Log.i(TAG, "Unloading plugin: $absolutePath")
|
||||
val plugin = plugins[absolutePath]
|
||||
if (plugin == null) {
|
||||
|
@ -442,49 +567,48 @@ object PluginManager {
|
|||
return File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}/${folderName}/$fileName.cs3")
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for fresh installs
|
||||
* */
|
||||
suspend fun downloadAndLoadPlugin(
|
||||
suspend fun downloadPlugin(
|
||||
activity: Activity,
|
||||
pluginUrl: String,
|
||||
internalName: String,
|
||||
repositoryUrl: String
|
||||
repositoryUrl: String,
|
||||
loadPlugin: Boolean
|
||||
): Boolean {
|
||||
val file = getPluginPath(activity, internalName, repositoryUrl)
|
||||
downloadAndLoadPlugin(activity, pluginUrl, internalName, file)
|
||||
return true
|
||||
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for updates.
|
||||
*
|
||||
* Uses a file instead of repository url, as extensions can get moved it is better to directly
|
||||
* update the files instead of getting the filepath from repo url.
|
||||
* */
|
||||
private suspend fun downloadAndLoadPlugin(
|
||||
suspend fun downloadPlugin(
|
||||
activity: Activity,
|
||||
pluginUrl: String,
|
||||
internalName: String,
|
||||
file: File,
|
||||
loadPlugin: Boolean
|
||||
): Boolean {
|
||||
try {
|
||||
unloadPlugin(file.absolutePath)
|
||||
|
||||
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
||||
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
||||
val newFile = downloadPluginToFile(pluginUrl, file)
|
||||
return loadPlugin(
|
||||
activity,
|
||||
newFile ?: return false,
|
||||
PluginData(
|
||||
internalName,
|
||||
pluginUrl,
|
||||
true,
|
||||
newFile.absolutePath,
|
||||
PLUGIN_VERSION_NOT_SET
|
||||
)
|
||||
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
|
||||
|
||||
val data = PluginData(
|
||||
internalName,
|
||||
pluginUrl,
|
||||
true,
|
||||
newFile.absolutePath,
|
||||
PLUGIN_VERSION_NOT_SET
|
||||
)
|
||||
|
||||
return if (loadPlugin) {
|
||||
unloadPlugin(file.absolutePath)
|
||||
loadPlugin(
|
||||
activity,
|
||||
newFile,
|
||||
data
|
||||
)
|
||||
} else {
|
||||
setPluginData(data)
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
return false
|
||||
|
@ -492,7 +616,8 @@ object PluginManager {
|
|||
}
|
||||
|
||||
suspend fun deletePlugin(file: File): Boolean {
|
||||
val list = (getPluginsLocal() + getPluginsOnline()).filter { it.filePath == file.absolutePath }
|
||||
val list =
|
||||
(getPluginsLocal() + getPluginsOnline()).filter { it.filePath == file.absolutePath }
|
||||
|
||||
return try {
|
||||
if (File(file.absolutePath).delete()) {
|
||||
|
@ -527,12 +652,14 @@ object PluginManager {
|
|||
|
||||
private fun createNotification(
|
||||
context: Context,
|
||||
extensionNames: List<String>
|
||||
uitext: UiText,
|
||||
extensions: List<String>
|
||||
): Notification? {
|
||||
try {
|
||||
if (extensionNames.isEmpty()) return null
|
||||
|
||||
val content = extensionNames.joinToString(", ")
|
||||
if (extensions.isEmpty()) return null
|
||||
|
||||
val content = extensions.joinToString(", ")
|
||||
// main { // DON'T WANT TO SLOW IT DOWN
|
||||
val builder = NotificationCompat.Builder(context, EXTENSIONS_CHANNEL_ID)
|
||||
.setAutoCancel(false)
|
||||
|
@ -541,7 +668,8 @@ object PluginManager {
|
|||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||
.setContentTitle(context.getString(R.string.plugins_updated, extensionNames.size))
|
||||
.setContentTitle(uitext.asString(context))
|
||||
//.setContentTitle(context.getString(title, extensionNames.size))
|
||||
.setSmallIcon(R.drawable.ic_baseline_extension_24)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
|
|
|
@ -2,13 +2,17 @@ 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
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
|
@ -69,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()
|
||||
|
@ -77,15 +90,20 @@ object RepositoryManager {
|
|||
} else if (fixedUrl.contains("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex())) {
|
||||
fixedUrl.replace("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex(), "").let {
|
||||
return@let if (!it.contains("^https?://".toRegex()))
|
||||
"https://${it}"
|
||||
"https://${it}"
|
||||
else fixedUrl
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -95,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()
|
||||
|
@ -132,43 +150,17 @@ object RepositoryManager {
|
|||
file.mkdirs()
|
||||
|
||||
// Overwrite if exists
|
||||
if (file.exists()) { file.delete() }
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
file.createNewFile()
|
||||
|
||||
val body = app.get(pluginUrl).okhttpResponse.body
|
||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||
write(body.byteStream(), file.outputStream())
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadPluginToFile(
|
||||
context: Context,
|
||||
pluginUrl: String,
|
||||
/** Filename without .cs3 */
|
||||
fileName: String,
|
||||
folder: String
|
||||
): File? {
|
||||
return suspendSafeApiCall {
|
||||
val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
|
||||
if (!extensionsDir.exists())
|
||||
extensionsDir.mkdirs()
|
||||
|
||||
val newDir = File(extensionsDir, folder)
|
||||
newDir.mkdirs()
|
||||
|
||||
val newFile = File(newDir, "${fileName}.cs3")
|
||||
// Overwrite if exists
|
||||
if (newFile.exists()) {
|
||||
newFile.delete()
|
||||
}
|
||||
newFile.createNewFile()
|
||||
|
||||
val body = app.get(pluginUrl).okhttpResponse.body
|
||||
write(body.byteStream(), newFile.outputStream())
|
||||
newFile
|
||||
}
|
||||
}
|
||||
|
||||
fun getRepositories(): Array<RepositoryData> {
|
||||
return getKey(REPOSITORIES_KEY) ?: emptyArray()
|
||||
}
|
||||
|
@ -200,9 +192,17 @@ object RepositoryManager {
|
|||
extensionsDir,
|
||||
getPluginSanitizedFileName(repository.url)
|
||||
)
|
||||
PluginManager.deleteRepositoryData(file.absolutePath)
|
||||
|
||||
file.delete()
|
||||
// Unload all plugins, not using deletePlugin since we
|
||||
// delete all data and files in deleteRepositoryData
|
||||
normalSafeApiCall {
|
||||
file.listFiles { plugin: File ->
|
||||
unloadPlugin(plugin.absolutePath)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
PluginManager.deleteRepositoryData(file.absolutePath)
|
||||
}
|
||||
|
||||
private fun write(stream: InputStream, output: OutputStream) {
|
||||
|
|
|
@ -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))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -12,6 +12,8 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
val aniListApi = AniListApi(0)
|
||||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||
val indexSubtitlesApi = IndexSubtitleApi()
|
||||
val addic7ed = Addic7ed()
|
||||
val localListApi = LocalList()
|
||||
|
||||
// used to login via app intent
|
||||
val OAuth2Apis
|
||||
|
@ -28,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
|
||||
|
@ -37,11 +39,19 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
val subtitleProviders
|
||||
get() = listOf(
|
||||
openSubtitlesApi,
|
||||
indexSubtitlesApi // they got anti scraping measures in place :(
|
||||
indexSubtitlesApi, // they got anti scraping measures in place :(
|
||||
addic7ed
|
||||
)
|
||||
|
||||
const val appString = "cloudstreamapp"
|
||||
const val appStringRepo = "cloudstreamrepo"
|
||||
const val appStringPlayer = "cloudstreamplayer"
|
||||
|
||||
// Instantly start the search given a query
|
||||
const val appStringSearch = "cloudstreamsearch"
|
||||
|
||||
// Instantly resume watching a show
|
||||
const val appStringResumeWatching = "cloudstreamcontinuewatching"
|
||||
|
||||
val unixTime: Long
|
||||
get() = System.currentTimeMillis() / 1000L
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
|
||||
class Addic7ed : AbstractSubApi {
|
||||
override val name = "Addic7ed"
|
||||
override val idPrefix = "addic7ed"
|
||||
override val requiresLogin = false
|
||||
override val icon: Nothing? = null
|
||||
override val createAccountUrl: Nothing? = null
|
||||
|
||||
override fun loginInfo(): Nothing? = null
|
||||
|
||||
override fun logOut() {}
|
||||
|
||||
companion object {
|
||||
const val host = "https://www.addic7ed.com"
|
||||
const val TAG = "ADDIC7ED"
|
||||
}
|
||||
|
||||
private fun fixUrl(url: String): String {
|
||||
return if (url.startsWith("/")) host + url
|
||||
else if (!url.startsWith("http")) "$host/$url"
|
||||
else url
|
||||
|
||||
}
|
||||
|
||||
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity> {
|
||||
val lang = query.lang
|
||||
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
|
||||
val queryText = query.query.trim()
|
||||
val epNum = query.epNumber ?: 0
|
||||
val seasonNum = query.seasonNumber ?: 0
|
||||
val yearNum = query.year ?: 0
|
||||
|
||||
fun cleanResources(
|
||||
results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
|
||||
name: String,
|
||||
link: String,
|
||||
headers: Map<String, String>,
|
||||
isHearingImpaired: Boolean
|
||||
) {
|
||||
results.add(
|
||||
AbstractSubtitleEntities.SubtitleEntity(
|
||||
idPrefix = idPrefix,
|
||||
name = name,
|
||||
lang = queryLang.toString(),
|
||||
data = link,
|
||||
source = this.name,
|
||||
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
||||
epNumber = epNum,
|
||||
seasonNumber = seasonNum,
|
||||
year = yearNum,
|
||||
headers = headers,
|
||||
isHearingImpaired = isHearingImpaired
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val title = queryText.substringBefore("(").trim()
|
||||
val url = "$host/search.php?search=${title}&Submit=Search"
|
||||
val hostDocument = app.get(url).document
|
||||
var searchResult = ""
|
||||
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
|
||||
else if (!hostDocument.select("table.tabel")
|
||||
.isNullOrEmpty()
|
||||
) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
|
||||
else {
|
||||
val show =
|
||||
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
|
||||
?.substringBefore(",")
|
||||
val doc = app.get(
|
||||
"$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
|
||||
referer = "$host/"
|
||||
).document
|
||||
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
|
||||
if (node.selectFirst("td")?.text()
|
||||
?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
|
||||
.text()
|
||||
.toIntOrNull() == epNum
|
||||
) searchResult = fixUrl(node.select("a").attr("href"))
|
||||
}
|
||||
}
|
||||
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
|
||||
val document = app.get(
|
||||
url = fixUrl(searchResult),
|
||||
).document
|
||||
|
||||
document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
|
||||
val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
|
||||
node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
|
||||
}" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}"
|
||||
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
|
||||
val isHearingImpaired =
|
||||
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
|
||||
cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String {
|
||||
return data.data
|
||||
}
|
||||
}
|
|
@ -1,19 +1,20 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
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
|
||||
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
|
||||
|
@ -21,6 +22,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
|||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
|
||||
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||
|
@ -28,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)?.
|
||||
|
@ -46,6 +50,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
}
|
||||
|
||||
override fun logOut() {
|
||||
requireLibraryRefresh = true
|
||||
removeAccountKeys()
|
||||
}
|
||||
|
||||
|
@ -65,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
|
||||
}
|
||||
|
||||
|
@ -141,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()) {
|
||||
|
@ -171,7 +177,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
fromIntToAnimeStatus(status.status),
|
||||
status.score,
|
||||
status.watchedEpisodes
|
||||
)
|
||||
).also {
|
||||
requireLibraryRefresh = requireLibraryRefresh || it
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -182,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(" ", "")
|
||||
|
@ -220,7 +227,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
romaji
|
||||
}
|
||||
idMal
|
||||
coverImage { medium large }
|
||||
coverImage { medium large extraLarge }
|
||||
averageScore
|
||||
}
|
||||
}
|
||||
|
@ -233,7 +240,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
format
|
||||
id
|
||||
idMal
|
||||
coverImage { medium large }
|
||||
coverImage { medium large extraLarge }
|
||||
averageScore
|
||||
title {
|
||||
english
|
||||
|
@ -293,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 ->
|
||||
|
@ -313,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 }
|
||||
|
@ -336,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))
|
||||
}
|
||||
|
||||
|
@ -522,19 +527,27 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
}
|
||||
|
||||
private suspend fun postApi(q: String, cache: Boolean = false): String? {
|
||||
return if (!checkToken()) {
|
||||
app.post(
|
||||
"https://graphql.anilist.co/",
|
||||
headers = mapOf(
|
||||
"Authorization" to "Bearer " + (getAuth() ?: return null),
|
||||
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
|
||||
),
|
||||
cacheTime = 0,
|
||||
data = mapOf("query" to q),//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||
timeout = 5 // REASONABLE TIMEOUT
|
||||
).text.replace("\\/", "/")
|
||||
} else {
|
||||
null
|
||||
return suspendSafeApiCall {
|
||||
if (!checkToken()) {
|
||||
app.post(
|
||||
"https://graphql.anilist.co/",
|
||||
headers = mapOf(
|
||||
"Authorization" to "Bearer " + (getAuth()
|
||||
?: return@suspendSafeApiCall null),
|
||||
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
|
||||
),
|
||||
cacheTime = 0,
|
||||
data = mapOf(
|
||||
"query" to URLEncoder.encode(
|
||||
q,
|
||||
"UTF-8"
|
||||
)
|
||||
), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||
timeout = 5 // REASONABLE TIMEOUT
|
||||
).text.replace("\\/", "/")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -569,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(
|
||||
|
@ -596,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?,
|
||||
|
@ -611,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
|
||||
|
@ -655,7 +710,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
startedAt { year month day }
|
||||
updatedAt
|
||||
progress
|
||||
score
|
||||
score (format: POINT_100)
|
||||
private
|
||||
media
|
||||
{
|
||||
|
@ -671,7 +726,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
english
|
||||
romaji
|
||||
}
|
||||
coverImage { medium }
|
||||
coverImage { extraLarge large medium }
|
||||
synonyms
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
|
@ -704,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,
|
||||
|
@ -711,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 != ""
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import android.util.Log
|
|||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.imdbUrlToIdNullable
|
||||
import com.lagradost.cloudstream3.network.CloudflareKiller
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
|
@ -22,7 +21,7 @@ class IndexSubtitleApi : AbstractSubApi {
|
|||
|
||||
|
||||
companion object {
|
||||
const val host = "https://subscene.cyou"
|
||||
const val host = "https://indexsubtitle.com"
|
||||
const val TAG = "INDEXSUBS"
|
||||
}
|
||||
|
||||
|
@ -242,7 +241,7 @@ class IndexSubtitleApi : AbstractSubApi {
|
|||
document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href")
|
||||
)
|
||||
} else {
|
||||
document.select("div.my-3.p-3 div.media").mapNotNull { block ->
|
||||
document.select("div.my-3.p-3 div.media").firstNotNullOf { block ->
|
||||
val name =
|
||||
block.selectFirst("strong.d-block")?.text()?.trim().toString()
|
||||
if (seasonNum!! > 0) {
|
||||
|
@ -254,7 +253,7 @@ class IndexSubtitleApi : AbstractSubApi {
|
|||
} else {
|
||||
fixUrl(block.selectFirst("a")!!.attr("href"))
|
||||
}
|
||||
}.first()
|
||||
}
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -15,6 +15,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
|||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
||||
override val idPrefix = "opensubtitles"
|
||||
|
@ -164,7 +166,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
val fixedLang = fixLanguage(query.lang)
|
||||
|
||||
val imdbId = query.imdb ?: 0
|
||||
val queryText = query.query.replace(" ", "+")
|
||||
val queryText = query.query
|
||||
val epNum = query.epNumber ?: 0
|
||||
val seasonNum = query.seasonNumber ?: 0
|
||||
val yearNum = query.year ?: 0
|
||||
|
@ -175,7 +177,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
val searchQueryUrl = when (imdbId > 0) {
|
||||
//Use imdb_id to search if its valid
|
||||
true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||
false -> "$host/subtitles?query=$queryText&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||
false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||
}
|
||||
|
||||
val req = app.get(
|
||||
|
@ -198,9 +200,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
it.data?.forEach { item ->
|
||||
val attr = item.attributes ?: return@forEach
|
||||
val featureDetails = attr.featDetails
|
||||
//Use filename as name, if its valid
|
||||
val filename = attr.files?.firstNotNullOfOrNull { subfile ->
|
||||
subfile.fileName
|
||||
}
|
||||
//Use any valid name/title in hierarchy
|
||||
val name = featureDetails?.movieName ?: featureDetails?.title
|
||||
?: featureDetails?.parentTitle ?: attr.release ?: ""
|
||||
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
||||
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
||||
val lang = fixLanguageReverse(attr.language)?: ""
|
||||
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
||||
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
||||
|
@ -328,4 +334,4 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
@JsonProperty("parent_tmdb_id") var parentTmdbId: Int? = null,
|
||||
@JsonProperty("parent_feature_id") var parentFeatureId: Int? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
|
@ -31,26 +34,65 @@ class APIRepository(val api: MainAPI) {
|
|||
return data.isEmpty() || data == "[]" || data == "about:blank"
|
||||
}
|
||||
|
||||
private val cacheHash: HashMap<Pair<String, String>, LoadResponse> = hashMapOf()
|
||||
data class SavedLoadResponse(
|
||||
val unixTime: Long,
|
||||
val response: LoadResponse,
|
||||
val hash: Pair<String, String>
|
||||
)
|
||||
|
||||
private val cache = threadSafeListOf<SavedLoadResponse>()
|
||||
private var cacheIndex: Int = 0
|
||||
const val cacheSize = 20
|
||||
}
|
||||
|
||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||
if (forceReload) {
|
||||
synchronized(cache) {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||
}
|
||||
|
||||
val hasMainPage = api.hasMainPage
|
||||
val providerType = api.providerType
|
||||
val name = api.name
|
||||
val mainUrl = api.mainUrl
|
||||
val mainPage = api.mainPage
|
||||
val hasQuickSearch = api.hasQuickSearch
|
||||
val vpnStatus = api.vpnStatus
|
||||
val providerType = api.providerType
|
||||
|
||||
suspend fun load(url: String): Resource<LoadResponse> {
|
||||
return safeApiCall {
|
||||
if (isInvalidData(url)) throw ErrorLoadingException()
|
||||
val fixedUrl = api.fixUrl(url)
|
||||
val key = Pair(api.name, url)
|
||||
cacheHash[key] ?: api.load(fixedUrl)?.also {
|
||||
// we cache 20 responses because ppl often go back to the same shit + 20 because I dont want to cause too much memory leak
|
||||
if (cacheHash.size > 20) cacheHash.remove(cacheHash.keys.random())
|
||||
cacheHash[key] = it
|
||||
val lookingForHash = Pair(api.name, fixedUrl)
|
||||
|
||||
synchronized(cache) {
|
||||
for (item in cache) {
|
||||
// 10 min save
|
||||
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
||||
return@safeApiCall item.response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.load(fixedUrl)?.also { response ->
|
||||
// Remove all blank tags as early as possible
|
||||
response.tags = response.tags?.filter { it.isNotBlank() }
|
||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||
|
||||
synchronized(cache) {
|
||||
if (cache.size > cacheSize) {
|
||||
cache[cacheIndex] = add // rolling cache
|
||||
cacheIndex = (cacheIndex + 1) % cacheSize
|
||||
} else {
|
||||
cache.add(add)
|
||||
}
|
||||
}
|
||||
} ?: throw ErrorLoadingException()
|
||||
}
|
||||
}
|
||||
|
@ -82,7 +124,6 @@ class APIRepository(val api: MainAPI) {
|
|||
delay(delta)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource<List<HomePageResponse?>> {
|
||||
return safeApiCall {
|
||||
api.lastHomepageRequest = unixTimeMS
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ class EasterEggMonke : AppCompatActivity() {
|
|||
set.duration = (Math.random() * 1500 + 2500).toLong()
|
||||
|
||||
set.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
frame.removeView(newStar)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() {
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDraw(c, parent, state)
|
||||
customView.layout(parent.left, 0, parent.right, customView.measuredHeight)
|
||||
for (i in 0 until parent.childCount) {
|
||||
val view = parent.getChildAt(i)
|
||||
if (parent.getChildAdapterPosition(view) == 0) {
|
||||
c.save()
|
||||
val height = customView.measuredHeight
|
||||
val top = view.top - height
|
||||
c.translate(0f, top.toFloat())
|
||||
customView.draw(c)
|
||||
c.restore()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (parent.getChildAdapterPosition(view) == 0) {
|
||||
customView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST),
|
||||
View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST)
|
||||
)
|
||||
outRect.set(0, customView.measuredHeight, 0, 0)
|
||||
} else {
|
||||
outRect.setEmpty()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
|
||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
|
@ -49,7 +50,7 @@ object DownloadButtonSetup {
|
|||
)
|
||||
.setPositiveButton(R.string.delete, dialogClickListener)
|
||||
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||
.show()
|
||||
.show().setDefaultFocus()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
// ye you somehow fucked up formatting did you?
|
||||
|
|
|
@ -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,8 @@ 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
|
||||
|
||||
|
||||
|
@ -178,7 +179,9 @@ class DownloadFragment : Fragment() {
|
|||
|
||||
download_list?.adapter = adapter
|
||||
download_list?.layoutManager = GridLayoutManager(context, 1)
|
||||
download_stream_button?.isGone = isTvSettings()
|
||||
|
||||
// Should be visible in emulator layout
|
||||
download_stream_button?.isGone = isTrueTvSettings()
|
||||
download_stream_button?.setOnClickListener {
|
||||
val dialog =
|
||||
Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom)
|
||||
|
@ -222,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
|
||||
|
|
|
@ -7,25 +7,20 @@ import android.content.DialogInterface
|
|||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat.getDrawable
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.*
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.ChangeBounds
|
||||
import androidx.transition.TransitionManager
|
||||
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
@ -34,80 +29,55 @@ import com.google.android.material.chip.ChipGroup
|
|||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
||||
import com.lagradost.cloudstream3.APIHolder.getId
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2.Companion.updateWatchStatus
|
||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||
import com.lagradost.cloudstream3.ui.search.*
|
||||
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.setMaxViewPoolSize
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.ownHide
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.ownShow
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllBookmarkedData
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.removeLastWatched
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState
|
||||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImageBlur
|
||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||
import com.lagradost.cloudstream3.widget.CenterZoomLayoutManager
|
||||
import kotlinx.android.synthetic.main.activity_main_tv.*
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_api_fab
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_bookmarked_child_recyclerview
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_bookmarked_holder
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_change_api_loading
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_loaded
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_loading
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_loading_error
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_loading_shimmer
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_loading_statusbar
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_master_recycler
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_plan_to_watch_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_provider_meta_info
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_provider_name
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_reload_connection_open_in_browser
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_reload_connectionerror
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_type_completed_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_type_dropped_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_type_on_hold_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_type_watching_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_watch_child_recyclerview
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_watch_holder
|
||||
import kotlinx.android.synthetic.main.fragment_home.home_watch_parent_item_title
|
||||
import kotlinx.android.synthetic.main.fragment_home.result_error_text
|
||||
import kotlinx.android.synthetic.main.fragment_home_tv.*
|
||||
import kotlinx.android.synthetic.main.fragment_result.*
|
||||
import kotlinx.android.synthetic.main.fragment_search.*
|
||||
import kotlinx.android.synthetic.main.home_episodes_expanded.*
|
||||
import kotlinx.android.synthetic.main.tvtypes_chips.*
|
||||
|
@ -139,26 +109,32 @@ class HomeFragment : Fragment() {
|
|||
|
||||
val errorProfilePic = errorProfilePics.random()
|
||||
|
||||
fun Activity.loadHomepageList(
|
||||
item: HomePageList,
|
||||
deleteCallback: (() -> Unit)? = null,
|
||||
) {
|
||||
loadHomepageList(
|
||||
expand = HomeViewModel.ExpandableHomepageList(item, 1, false),
|
||||
deleteCallback = deleteCallback,
|
||||
expandCallback = null
|
||||
)
|
||||
}
|
||||
//fun Activity.loadHomepageList(
|
||||
// item: HomePageList,
|
||||
// deleteCallback: (() -> Unit)? = null,
|
||||
//) {
|
||||
// loadHomepageList(
|
||||
// expand = HomeViewModel.ExpandableHomepageList(item, 1, false),
|
||||
// deleteCallback = deleteCallback,
|
||||
// expandCallback = null
|
||||
// )
|
||||
//}
|
||||
|
||||
// returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView
|
||||
fun Activity.loadHomepageList(
|
||||
expand: HomeViewModel.ExpandableHomepageList,
|
||||
deleteCallback: (() -> Unit)? = null,
|
||||
expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null
|
||||
) {
|
||||
expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null,
|
||||
dismissCallback : (() -> Unit),
|
||||
): BottomSheetDialog {
|
||||
val context = this
|
||||
val bottomSheetDialogBuilder = BottomSheetDialog(context)
|
||||
|
||||
bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded)
|
||||
val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!!
|
||||
|
||||
//title.findViewTreeLifecycleOwner().lifecycle.addObserver()
|
||||
|
||||
val item = expand.list
|
||||
title.text = item.name
|
||||
val recycle =
|
||||
|
@ -166,6 +142,23 @@ class HomeFragment : Fragment() {
|
|||
val titleHolder =
|
||||
bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!!
|
||||
|
||||
// main {
|
||||
//(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply {
|
||||
// println("GOT LIFE: lifecycle $this")
|
||||
// this.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
// override fun onResume(owner: LifecycleOwner) {
|
||||
// super.onResume(owner)
|
||||
// println("onResume!!!!")
|
||||
// bottomSheetDialogBuilder?.ownShow()
|
||||
// }
|
||||
|
||||
// override fun onStop(owner: LifecycleOwner) {
|
||||
// super.onStop(owner)
|
||||
// bottomSheetDialogBuilder?.ownHide()
|
||||
// }
|
||||
// })
|
||||
//}
|
||||
// }
|
||||
val delete = bottomSheetDialogBuilder.home_expanded_delete
|
||||
delete.isGone = deleteCallback == null
|
||||
if (deleteCallback != null) {
|
||||
|
@ -191,7 +184,7 @@ class HomeFragment : Fragment() {
|
|||
)
|
||||
.setPositiveButton(R.string.delete, dialogClickListener)
|
||||
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||
.show()
|
||||
.show().setDefaultFocus()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
// ye you somehow fucked up formatting did you?
|
||||
|
@ -210,7 +203,8 @@ class HomeFragment : Fragment() {
|
|||
recycle.adapter = SearchAdapter(item.list.toMutableList(), recycle) { callback ->
|
||||
handleSearchClickCallback(this, callback)
|
||||
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
||||
bottomSheetDialogBuilder.dismissSafe(this)
|
||||
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
|
||||
//bottomSheetDialogBuilder.dismissSafe(this)
|
||||
}
|
||||
}.apply {
|
||||
hasNext = expand.hasNext
|
||||
|
@ -251,12 +245,14 @@ class HomeFragment : Fragment() {
|
|||
configEvent += spanListener
|
||||
|
||||
bottomSheetDialogBuilder.setOnDismissListener {
|
||||
dismissCallback.invoke()
|
||||
configEvent -= spanListener
|
||||
}
|
||||
|
||||
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
|
||||
bottomSheetDialogBuilder.show()
|
||||
return bottomSheetDialogBuilder
|
||||
}
|
||||
|
||||
fun getPairList(
|
||||
|
@ -434,13 +430,15 @@ class HomeFragment : Fragment() {
|
|||
): View? {
|
||||
//homeViewModel =
|
||||
// ViewModelProvider(this).get(HomeViewModel::class.java)
|
||||
bottomSheetDialog?.ownShow()
|
||||
val layout =
|
||||
if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home
|
||||
return inflater.inflate(layout, container, false)
|
||||
}
|
||||
|
||||
private fun toggleMainVisibility(visible: Boolean) {
|
||||
home_main_poster_recyclerview?.isVisible = visible
|
||||
override fun onDestroyView() {
|
||||
bottomSheetDialog?.ownHide()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun fixGrid() {
|
||||
|
@ -465,19 +463,26 @@ class HomeFragment : Fragment() {
|
|||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
//(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged()
|
||||
fixGrid()
|
||||
}
|
||||
|
||||
fun bookmarksUpdated(_data : Boolean) {
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
reloadStored()
|
||||
afterPluginsLoadedEvent += ::firstLoadHomePage
|
||||
mainPluginsLoadedEvent += ::firstLoadHomePage
|
||||
bookmarksUpdatedEvent += ::bookmarksUpdated
|
||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||
mainPluginsLoadedEvent += ::afterMainPluginsLoaded
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
afterPluginsLoadedEvent -= ::firstLoadHomePage
|
||||
mainPluginsLoadedEvent -= ::firstLoadHomePage
|
||||
bookmarksUpdatedEvent -= ::bookmarksUpdated
|
||||
afterPluginsLoadedEvent -= ::afterPluginsLoaded
|
||||
mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
|
@ -490,34 +495,26 @@ class HomeFragment : Fragment() {
|
|||
homeViewModel.loadStoredData(list)
|
||||
}
|
||||
|
||||
private fun firstLoadHomePage(successful: Boolean = false) {
|
||||
// dirty hack to make it only load once
|
||||
private fun afterMainPluginsLoaded(unused: Boolean = false) {
|
||||
loadHomePage(false)
|
||||
}
|
||||
|
||||
private fun loadHomePage(forceReload: Boolean = true) {
|
||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||
loadHomePage(forceReload)
|
||||
}
|
||||
|
||||
private fun loadHomePage(forceReload: Boolean) {
|
||||
val apiName = context?.getKey<String>(USER_SELECTED_HOMEPAGE_API)
|
||||
|
||||
if (homeViewModel.apiName.value != apiName || apiName == null) {
|
||||
if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) {
|
||||
//println("Caught home: " + homeViewModel.apiName.value + " at " + apiName)
|
||||
homeViewModel.loadAndCancel(apiName, forceReload)
|
||||
}
|
||||
}
|
||||
|
||||
/*private fun handleBack(poppedFragment: Boolean) {
|
||||
if (poppedFragment) {
|
||||
reloadStored()
|
||||
}
|
||||
}*/
|
||||
|
||||
private fun focusCallback(card: SearchResponse) {
|
||||
home_focus_text?.text = card.name
|
||||
home_blur_poster?.setImageBlur(card.posterUrl, 50)
|
||||
}
|
||||
|
||||
private fun homeHandleSearch(callback: SearchClickCallback) {
|
||||
if (callback.action == SEARCH_ACTION_FOCUSED) {
|
||||
focusCallback(callback.card)
|
||||
//focusCallback(callback.card)
|
||||
} else {
|
||||
handleSearchClickCallback(activity, callback)
|
||||
}
|
||||
|
@ -526,12 +523,13 @@ class HomeFragment : Fragment() {
|
|||
private var currentApiName: String? = null
|
||||
private var toggleRandomButton = false
|
||||
|
||||
private var bottomSheetDialog: BottomSheetDialog? = null
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
fixGrid()
|
||||
|
||||
home_change_api?.setOnClickListener(apiChangeClickListener)
|
||||
home_change_api_loading?.setOnClickListener(apiChangeClickListener)
|
||||
home_api_fab?.setOnClickListener(apiChangeClickListener)
|
||||
home_random?.setOnClickListener {
|
||||
|
@ -549,211 +547,19 @@ class HomeFragment : Fragment() {
|
|||
}
|
||||
|
||||
observe(homeViewModel.preview) { preview ->
|
||||
// Always reset the padding, otherwise the will move lower and lower
|
||||
// home_fix_padding?.setPadding(0, 0, 0, 0)
|
||||
home_fix_padding?.let { v ->
|
||||
val params = v.layoutParams
|
||||
params.height = 0
|
||||
v.layoutParams = params
|
||||
}
|
||||
|
||||
when (preview) {
|
||||
is Resource.Success -> {
|
||||
home_preview?.isVisible = true
|
||||
(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.apply {
|
||||
if (!setItems(preview.value.second, preview.value.first)) {
|
||||
home_preview_viewpager?.setCurrentItem(0, false)
|
||||
}
|
||||
// home_preview_viewpager?.setCurrentItem(1000, false)
|
||||
}
|
||||
|
||||
//.also {
|
||||
//home_preview_viewpager?.adapter =
|
||||
//}
|
||||
}
|
||||
else -> {
|
||||
(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.setItems(
|
||||
listOf(),
|
||||
false
|
||||
)
|
||||
home_preview?.isVisible = false
|
||||
context?.fixPaddingStatusbarView(home_fix_padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val searchText =
|
||||
home_search?.findViewById<SearchView.SearchAutoComplete>(androidx.appcompat.R.id.search_src_text)
|
||||
searchText?.context?.getResourceColor(R.attr.white)?.let { color ->
|
||||
searchText.setTextColor(color)
|
||||
searchText.setHintTextColor(color)
|
||||
}
|
||||
|
||||
home_preview_viewpager?.apply {
|
||||
setPageTransformer(HomeScrollTransformer())
|
||||
val callback: OnPageChangeCallback = object : OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
|
||||
// home_search?.isIconified = true
|
||||
//home_search?.isVisible = true
|
||||
//home_search?.clearFocus()
|
||||
|
||||
(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.apply {
|
||||
if (position >= itemCount - 1 && hasMoreItems) {
|
||||
hasMoreItems = false // dont make two requests
|
||||
homeViewModel.loadMoreHomeScrollResponses()
|
||||
}
|
||||
|
||||
getItem(position)
|
||||
?.apply {
|
||||
home_preview_title_holder?.let { parent ->
|
||||
TransitionManager.beginDelayedTransition(parent, ChangeBounds())
|
||||
}
|
||||
|
||||
// home_preview_tags?.text = tags?.joinToString(" • ") ?: ""
|
||||
// home_preview_tags?.isGone = tags.isNullOrEmpty()
|
||||
// home_preview_image?.setImage(posterUrl, posterHeaders)
|
||||
// home_preview_title?.text = name
|
||||
|
||||
home_preview_play?.setOnClickListener {
|
||||
activity?.loadResult(url, apiName, START_ACTION_RESUME_LATEST)
|
||||
//activity.loadSearchResult(url, START_ACTION_RESUME_LATEST)
|
||||
}
|
||||
home_preview_info?.setOnClickListener {
|
||||
activity?.loadResult(url, apiName)
|
||||
//activity.loadSearchResult(random)
|
||||
}
|
||||
// very ugly code, but I dont care
|
||||
val watchType = DataStoreHelper.getResultWatchState(this.getId())
|
||||
home_preview_bookmark?.setText(watchType.stringRes)
|
||||
home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
null,
|
||||
getDrawable(home_preview_bookmark.context, watchType.iconRes),
|
||||
null,
|
||||
null
|
||||
)
|
||||
home_preview_bookmark?.setOnClickListener { fab ->
|
||||
activity?.showBottomDialog(
|
||||
WatchType.values()
|
||||
.map { fab.context.getString(it.stringRes) }
|
||||
.toList(),
|
||||
DataStoreHelper.getResultWatchState(this.getId()).ordinal,
|
||||
fab.context.getString(R.string.action_add_to_bookmarks),
|
||||
showApply = false,
|
||||
{}) {
|
||||
val newValue = WatchType.values()[it]
|
||||
home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
null,
|
||||
getDrawable(
|
||||
home_preview_bookmark.context,
|
||||
newValue.iconRes
|
||||
),
|
||||
null,
|
||||
null
|
||||
)
|
||||
home_preview_bookmark?.setText(newValue.stringRes)
|
||||
|
||||
updateWatchStatus(this, newValue)
|
||||
reloadStored()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
registerOnPageChangeCallback(callback)
|
||||
adapter = HomeScrollAdapter()
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setPreviewData(
|
||||
preview
|
||||
)
|
||||
}
|
||||
|
||||
observe(homeViewModel.apiName) { apiName ->
|
||||
currentApiName = apiName
|
||||
// setKey(USER_SELECTED_HOMEPAGE_API, apiName)
|
||||
home_api_fab?.text = apiName
|
||||
home_provider_name?.text = apiName
|
||||
try {
|
||||
home_search?.queryHint = getString(R.string.search_hint_site).format(apiName)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
home_provider_meta_info?.isVisible = false
|
||||
|
||||
getApiFromNameNull(apiName)?.let { currentApi ->
|
||||
val typeChoices = listOf(
|
||||
Pair(R.string.movies, listOf(TvType.Movie)),
|
||||
Pair(R.string.tv_series, listOf(TvType.TvSeries)),
|
||||
Pair(R.string.documentaries, listOf(TvType.Documentary)),
|
||||
Pair(R.string.cartoons, listOf(TvType.Cartoon)),
|
||||
Pair(R.string.anime, listOf(TvType.Anime, TvType.OVA, TvType.AnimeMovie)),
|
||||
Pair(R.string.torrent, listOf(TvType.Torrent)),
|
||||
Pair(R.string.asian_drama, listOf(TvType.AsianDrama)),
|
||||
).filter { item -> currentApi.supportedTypes.any { type -> item.second.contains(type) } }
|
||||
home_provider_meta_info?.text =
|
||||
typeChoices.joinToString(separator = ", ") { getString(it.first) }
|
||||
home_provider_meta_info?.isVisible = true
|
||||
}
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName(
|
||||
apiName
|
||||
)
|
||||
}
|
||||
|
||||
home_main_poster_recyclerview?.adapter =
|
||||
HomeChildItemAdapter(
|
||||
mutableListOf(),
|
||||
R.layout.home_result_big_grid,
|
||||
nextFocusUp = home_main_poster_recyclerview?.nextFocusUpId,
|
||||
nextFocusDown = home_main_poster_recyclerview?.nextFocusDownId
|
||||
) { callback ->
|
||||
homeHandleSearch(callback)
|
||||
}
|
||||
home_main_poster_recyclerview?.setLinearListLayout()
|
||||
observe(homeViewModel.randomItems) { items ->
|
||||
if (items.isNullOrEmpty()) {
|
||||
toggleMainVisibility(false)
|
||||
} else {
|
||||
val tempAdapter = home_main_poster_recyclerview?.adapter as? HomeChildItemAdapter?
|
||||
// no need to reload if it has the same data
|
||||
if (tempAdapter != null && tempAdapter.cardList == items) {
|
||||
toggleMainVisibility(true)
|
||||
return@observe
|
||||
}
|
||||
|
||||
val randomSize = items.size
|
||||
tempAdapter?.updateList(items)
|
||||
if (!isTvSettings()) {
|
||||
home_main_poster_recyclerview?.post {
|
||||
(home_main_poster_recyclerview?.layoutManager as CenterZoomLayoutManager?)?.let { manager ->
|
||||
manager.updateSize(forceUpdate = true)
|
||||
if (randomSize > 2) {
|
||||
manager.scrollToPosition(randomSize / 2)
|
||||
manager.snap { dx ->
|
||||
home_main_poster_recyclerview?.post {
|
||||
// this is the best I can do, fuck android for not including instant scroll
|
||||
home_main_poster_recyclerview?.smoothScrollBy(dx, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items.firstOrNull()?.let {
|
||||
focusCallback(it)
|
||||
}
|
||||
}
|
||||
toggleMainVisibility(true)
|
||||
}
|
||||
}
|
||||
|
||||
home_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
QuickSearchFragment.pushSearch(activity, query, currentApiName?.let { arrayOf(it) })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String): Boolean {
|
||||
//searchViewModel.quickSearch(newText)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
observe(homeViewModel.page) { data ->
|
||||
when (data) {
|
||||
is Resource.Success -> {
|
||||
|
@ -763,15 +569,15 @@ class HomeFragment : Fragment() {
|
|||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||
listHomepageItems.clear()
|
||||
|
||||
// println("ITEMCOUNT: ${d.values.size} ${home_master_recycler?.adapter?.itemCount}")
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList(
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(
|
||||
d.values.toMutableList(),
|
||||
home_master_recycler
|
||||
)
|
||||
|
||||
home_loading?.isVisible = false
|
||||
home_loading_error?.isVisible = false
|
||||
home_loaded?.isVisible = true
|
||||
home_master_recycler?.isVisible = true
|
||||
//home_loaded?.isVisible = true
|
||||
if (toggleRandomButton) {
|
||||
//Flatten list
|
||||
d.values.forEach { dlist ->
|
||||
|
@ -811,346 +617,122 @@ class HomeFragment : Fragment() {
|
|||
|
||||
home_loading?.isVisible = false
|
||||
home_loading_error?.isVisible = true
|
||||
home_loaded?.isVisible = false
|
||||
home_master_recycler?.isVisible = false
|
||||
//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
|
||||
home_loaded?.isVisible = false
|
||||
home_master_recycler?.isVisible = false
|
||||
//home_loaded?.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val toggleList = listOf(
|
||||
Pair(home_type_watching_btt, WatchType.WATCHING),
|
||||
Pair(home_type_completed_btt, WatchType.COMPLETED),
|
||||
Pair(home_type_dropped_btt, WatchType.DROPPED),
|
||||
Pair(home_type_on_hold_btt, WatchType.ONHOLD),
|
||||
Pair(home_plan_to_watch_btt, WatchType.PLANTOWATCH),
|
||||
)
|
||||
val currentSet = getKey<IntArray>(HOME_BOOKMARK_VALUE_LIST)
|
||||
?.map { WatchType.fromInternalId(it) }?.toSet() ?: emptySet()
|
||||
|
||||
for ((chip, watch) in toggleList) {
|
||||
chip.isChecked = currentSet.contains(watch)
|
||||
chip?.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) {
|
||||
homeViewModel.loadStoredData(
|
||||
setOf(watch)
|
||||
// If we filter all buttons then two can be checked at the same time
|
||||
// Revert this if you want to go back to multi selection
|
||||
// toggleList.filter { it.first?.isChecked == true }.map { it.second }.toSet()
|
||||
)
|
||||
}
|
||||
// Else if all are unchecked -> Do not load data
|
||||
else if (toggleList.all { it.first?.isChecked != true }) {
|
||||
homeViewModel.loadStoredData(emptySet())
|
||||
}
|
||||
}
|
||||
/*chip?.setOnClickListener {
|
||||
|
||||
|
||||
homeViewModel.loadStoredData(EnumSet.of(watch))
|
||||
}
|
||||
|
||||
chip?.setOnLongClickListener { itemView ->
|
||||
val list = EnumSet.noneOf(WatchType::class.java)
|
||||
itemView.context.getKey<IntArray>(HOME_BOOKMARK_VALUE_LIST)
|
||||
?.map { WatchType.fromInternalId(it) }?.let {
|
||||
list.addAll(it)
|
||||
}
|
||||
|
||||
if (list.contains(watch)) {
|
||||
list.remove(watch)
|
||||
} else {
|
||||
list.add(watch)
|
||||
}
|
||||
homeViewModel.loadStoredData(list)
|
||||
return@setOnLongClickListener true
|
||||
}*/
|
||||
}
|
||||
|
||||
observe(homeViewModel.availableWatchStatusTypes) { availableWatchStatusTypes ->
|
||||
context?.setKey(
|
||||
HOME_BOOKMARK_VALUE_LIST,
|
||||
availableWatchStatusTypes.first.map { it.internalId }.toIntArray()
|
||||
)
|
||||
|
||||
for (item in toggleList) {
|
||||
val watch = item.second
|
||||
item.first?.apply {
|
||||
isVisible = availableWatchStatusTypes.second.contains(watch)
|
||||
isSelected = availableWatchStatusTypes.first.contains(watch)
|
||||
}
|
||||
}
|
||||
|
||||
/*home_bookmark_select?.setOnClickListener {
|
||||
it.popupMenuNoIcons(availableWatchStatusTypes.second.map { type ->
|
||||
Pair(
|
||||
type.internalId,
|
||||
type.stringRes
|
||||
)
|
||||
}) {
|
||||
homeViewModel.loadStoredData(it.context, WatchType.fromInternalId(this.itemId))
|
||||
}
|
||||
}
|
||||
home_bookmarked_parent_item_title?.text = getString(availableWatchStatusTypes.first.stringRes)*/
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setAvailableWatchStatusTypes(
|
||||
availableWatchStatusTypes
|
||||
)
|
||||
}
|
||||
|
||||
observe(homeViewModel.bookmarks) { (isVis, bookmarks) ->
|
||||
home_bookmarked_holder.isVisible = isVis
|
||||
|
||||
(home_bookmarked_child_recyclerview?.adapter as? HomeChildItemAdapter?)?.updateList(
|
||||
bookmarks
|
||||
observe(homeViewModel.bookmarks) { data ->
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setBookmarkData(
|
||||
data
|
||||
)
|
||||
|
||||
home_bookmarked_child_more_info?.setOnClickListener {
|
||||
activity?.loadHomepageList(
|
||||
HomePageList(
|
||||
getString(R.string.error_bookmarks_text), //home_bookmarked_parent_item_title?.text?.toString() ?: getString(R.string.error_bookmarks_text),
|
||||
bookmarks
|
||||
)
|
||||
) {
|
||||
deleteAllBookmarkedData()
|
||||
homeViewModel.loadStoredData(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observe(homeViewModel.resumeWatching) { resumeWatching ->
|
||||
home_watch_holder?.isVisible = resumeWatching.isNotEmpty()
|
||||
(home_watch_child_recyclerview?.adapter as? HomeChildItemAdapter?)?.updateList(
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setResumeWatchingData(
|
||||
resumeWatching
|
||||
)
|
||||
|
||||
//if (context?.isTvSettings() == true) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// context?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult })
|
||||
// }
|
||||
//}
|
||||
|
||||
home_watch_child_more_info?.setOnClickListener {
|
||||
activity?.loadHomepageList(
|
||||
HomePageList(
|
||||
home_watch_parent_item_title?.text?.toString()
|
||||
?: getString(R.string.continue_watching),
|
||||
resumeWatching
|
||||
)
|
||||
) {
|
||||
deleteAllResumeStateIds()
|
||||
homeViewModel.loadResumeWatching()
|
||||
if (isTrueTvSettings()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ioSafe {
|
||||
activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
home_bookmarked_child_recyclerview.adapter = HomeChildItemAdapter(
|
||||
ArrayList(),
|
||||
nextFocusUp = home_bookmarked_child_recyclerview?.nextFocusUpId,
|
||||
nextFocusDown = home_bookmarked_child_recyclerview?.nextFocusDownId
|
||||
) { callback ->
|
||||
if (callback.action == SEARCH_ACTION_SHOW_METADATA) {
|
||||
activity?.showOptionSelectStringRes(
|
||||
callback.view,
|
||||
callback.card.posterUrl,
|
||||
listOf(
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_from_bookmarks,
|
||||
),
|
||||
listOf(
|
||||
R.string.action_open_play,
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_from_bookmarks
|
||||
)
|
||||
) { (isTv, actionId) ->
|
||||
fun play() {
|
||||
activity.loadSearchResult(callback.card, START_ACTION_RESUME_LATEST)
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
setResultWatchState(callback.card.id, WatchType.NONE.internalId)
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
fun info() {
|
||||
handleSearchClickCallback(
|
||||
activity,
|
||||
SearchClickCallback(
|
||||
SEARCH_ACTION_LOAD,
|
||||
callback.view,
|
||||
-1,
|
||||
callback.card
|
||||
)
|
||||
)
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
if (isTv) {
|
||||
when (actionId) {
|
||||
0 -> {
|
||||
play()
|
||||
}
|
||||
1 -> {
|
||||
info()
|
||||
}
|
||||
2 -> {
|
||||
remove()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (actionId) {
|
||||
0 -> {
|
||||
info()
|
||||
}
|
||||
1 -> {
|
||||
remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
homeHandleSearch(callback)
|
||||
}
|
||||
}
|
||||
home_watch_child_recyclerview.setLinearListLayout()
|
||||
home_bookmarked_child_recyclerview.setLinearListLayout()
|
||||
|
||||
home_watch_child_recyclerview?.adapter = HomeChildItemAdapter(
|
||||
ArrayList(),
|
||||
nextFocusUp = home_watch_child_recyclerview?.nextFocusUpId,
|
||||
nextFocusDown = home_watch_child_recyclerview?.nextFocusDownId
|
||||
) { callback ->
|
||||
if (callback.action == SEARCH_ACTION_SHOW_METADATA) {
|
||||
activity?.showOptionSelectStringRes(
|
||||
callback.view,
|
||||
callback.card.posterUrl,
|
||||
listOf(
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_watching
|
||||
),
|
||||
listOf(
|
||||
R.string.action_open_play,
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_watching
|
||||
)
|
||||
) { (isTv, actionId) ->
|
||||
fun play() {
|
||||
activity.loadSearchResult(callback.card, START_ACTION_RESUME_LATEST)
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
val card = callback.card
|
||||
if (card is DataStoreHelper.ResumeWatchingResult) {
|
||||
removeLastWatched(card.parentId)
|
||||
reloadStored()
|
||||
}
|
||||
}
|
||||
|
||||
fun info() {
|
||||
handleSearchClickCallback(
|
||||
activity,
|
||||
SearchClickCallback(
|
||||
SEARCH_ACTION_LOAD,
|
||||
callback.view,
|
||||
-1,
|
||||
callback.card
|
||||
)
|
||||
)
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
if (isTv) {
|
||||
when (actionId) {
|
||||
0 -> {
|
||||
play()
|
||||
}
|
||||
1 -> {
|
||||
info()
|
||||
}
|
||||
2 -> {
|
||||
remove()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (actionId) {
|
||||
0 -> {
|
||||
info()
|
||||
}
|
||||
1 -> {
|
||||
remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
homeHandleSearch(callback)
|
||||
}
|
||||
}
|
||||
|
||||
//context?.fixPaddingStatusbarView(home_statusbar)
|
||||
context?.fixPaddingStatusbar(home_padding)
|
||||
//context?.fixPaddingStatusbar(home_padding)
|
||||
context?.fixPaddingStatusbar(home_loading_statusbar)
|
||||
|
||||
home_master_recycler.adapter =
|
||||
ParentItemAdapter(mutableListOf(), { callback ->
|
||||
home_master_recycler?.adapter =
|
||||
HomeParentItemAdapterPreview(mutableListOf(), { callback ->
|
||||
homeHandleSearch(callback)
|
||||
}, { item ->
|
||||
activity?.loadHomepageList(item, expandCallback = {
|
||||
bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = {
|
||||
homeViewModel.expandAndReturn(it)
|
||||
}, dismissCallback = {
|
||||
bottomSheetDialog = null
|
||||
})
|
||||
}, { name ->
|
||||
homeViewModel.expand(name)
|
||||
}, { load ->
|
||||
activity?.loadResult(load.response.url, load.response.apiName, load.action)
|
||||
}, {
|
||||
homeViewModel.loadMoreHomeScrollResponses()
|
||||
}, {
|
||||
apiChangeClickListener.onClick(it)
|
||||
}, reloadStored = {
|
||||
reloadStored()
|
||||
}, loadStoredData = {
|
||||
homeViewModel.loadStoredData(it)
|
||||
}, { (isQuickSearch, text) ->
|
||||
if (!isQuickSearch) {
|
||||
QuickSearchFragment.pushSearch(
|
||||
activity,
|
||||
text,
|
||||
currentApiName?.let { arrayOf(it) })
|
||||
}
|
||||
})
|
||||
home_master_recycler.setLinearListLayout()
|
||||
home_master_recycler?.setMaxViewPoolSize(0, Int.MAX_VALUE)
|
||||
home_master_recycler.layoutManager = object : LinearLayoutManager(context) {
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return false
|
||||
}
|
||||
} // GridLayoutManager(context, 1).also { it.supportsPredictiveItemAnimations() }
|
||||
|
||||
reloadStored()
|
||||
loadHomePage()
|
||||
|
||||
home_loaded.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { v, _, scrollY, _, oldScrollY ->
|
||||
val dy = scrollY - oldScrollY
|
||||
if (dy > 0) { //check for scroll down
|
||||
home_api_fab?.shrink() // hide
|
||||
home_random?.shrink()
|
||||
} else if (dy < -5) {
|
||||
if (!isTvSettings()) {
|
||||
home_api_fab?.extend() // show
|
||||
home_random?.extend()
|
||||
loadHomePage(false)
|
||||
home_master_recycler?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
if (dy > 0) { //check for scroll down
|
||||
home_api_fab?.shrink() // hide
|
||||
home_random?.shrink()
|
||||
} else if (dy < -5) {
|
||||
if (!isTvSettings()) {
|
||||
home_api_fab?.extend() // show
|
||||
home_random?.extend()
|
||||
}
|
||||
}
|
||||
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
}
|
||||
})
|
||||
|
||||
// nice profile pic on homepage
|
||||
home_profile_picture_holder?.isVisible = false
|
||||
//home_profile_picture_holder?.isVisible = false
|
||||
// just in case
|
||||
if (isTvSettings()) {
|
||||
home_api_fab?.isVisible = false
|
||||
home_change_api?.isVisible = true
|
||||
if (isTrueTvSettings()) {
|
||||
home_change_api_loading?.isVisible = true
|
||||
home_change_api_loading?.isFocusable = true
|
||||
home_change_api_loading?.isFocusableInTouchMode = true
|
||||
home_change_api?.isFocusable = true
|
||||
home_change_api?.isFocusableInTouchMode = true
|
||||
}
|
||||
// home_bookmark_select?.isFocusable = true
|
||||
// home_bookmark_select?.isFocusableInTouchMode = true
|
||||
} else {
|
||||
home_api_fab?.isVisible = true
|
||||
home_change_api?.isVisible = false
|
||||
home_change_api_loading?.isVisible = false
|
||||
}
|
||||
|
||||
for (syncApi in OAuth2Apis) {
|
||||
//TODO READD THIS
|
||||
/*for (syncApi in OAuth2Apis) {
|
||||
val login = syncApi.loginInfo()
|
||||
val pic = login?.profilePicture
|
||||
if (home_profile_picture?.setImage(
|
||||
|
@ -1161,6 +743,6 @@ class HomeFragment : Fragment() {
|
|||
home_profile_picture_holder?.isVisible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,34 +4,70 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.ChangeBounds
|
||||
import androidx.transition.TransitionManager
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.lagradost.cloudstream3.APIHolder.getId
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.HomePageList
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.result.LinearListLayout
|
||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
||||
import kotlinx.android.synthetic.main.activity_main_tv.*
|
||||
import kotlinx.android.synthetic.main.activity_main_tv.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
import kotlinx.android.synthetic.main.fragment_home.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager
|
||||
import kotlinx.android.synthetic.main.homepage_parent.view.*
|
||||
|
||||
class LoadClickCallback(
|
||||
val action: Int = 0,
|
||||
val view: View,
|
||||
val position: Int,
|
||||
val response: LoadResponse
|
||||
)
|
||||
|
||||
class ParentItemAdapter(
|
||||
open class ParentItemAdapter(
|
||||
private var items: MutableList<HomeViewModel.ExpandableHomepageList>,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||
private val expandCallback: ((String) -> Unit)? = null,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, i: Int): ParentViewHolder {
|
||||
//println("onCreateViewHolder $i")
|
||||
val layout =
|
||||
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return ParentViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(layout, parent, false),
|
||||
LayoutInflater.from(parent.context).inflate(
|
||||
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent,
|
||||
parent,
|
||||
false
|
||||
),
|
||||
clickCallback,
|
||||
moreInfoClickCallback,
|
||||
expandCallback
|
||||
|
@ -39,8 +75,6 @@ class ParentItemAdapter(
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
//println("onBindViewHolder $position")
|
||||
|
||||
when (holder) {
|
||||
is ParentViewHolder -> {
|
||||
holder.bind(items[position])
|
||||
|
@ -81,47 +115,60 @@ class ParentItemAdapter(
|
|||
items.clear()
|
||||
items.addAll(new)
|
||||
|
||||
val mAdapter = this
|
||||
//val mAdapter = this
|
||||
val delta = if (this@ParentItemAdapter is HomeParentItemAdapterPreview) {
|
||||
headItems
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
mAdapter.notifyItemRangeInserted(position, count)
|
||||
//notifyItemRangeChanged(position + delta, count)
|
||||
notifyItemRangeInserted(position + delta, count)
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
mAdapter.notifyItemRangeRemoved(position, count)
|
||||
notifyItemRangeRemoved(position + delta, count)
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
mAdapter.notifyItemMoved(fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition + delta, toPosition + delta)
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
override fun onChanged(_position: Int, count: Int, payload: Any?) {
|
||||
|
||||
val position = _position + delta
|
||||
|
||||
// I know kinda messy, what this does is using the update or bind instead of onCreateViewHolder -> bind
|
||||
recyclerView?.apply {
|
||||
// this loops every viewHolder in the recycle view and checks the position to see if it is within the update range
|
||||
val missingUpdates = (position until (position + count)).toMutableSet()
|
||||
for (i in 0 until itemCount) {
|
||||
val viewHolder = getChildViewHolder(getChildAt(i))
|
||||
val absolutePosition = viewHolder.absoluteAdapterPosition
|
||||
val child = getChildAt(i) ?: continue
|
||||
val viewHolder = getChildViewHolder(child) ?: continue
|
||||
if (viewHolder !is ParentViewHolder) continue
|
||||
|
||||
val absolutePosition = viewHolder.bindingAdapterPosition
|
||||
if (absolutePosition >= position && absolutePosition < position + count) {
|
||||
val expand = items.getOrNull(absolutePosition) ?: continue
|
||||
if (viewHolder is ParentViewHolder) {
|
||||
missingUpdates -= absolutePosition
|
||||
if (viewHolder.title.text == expand.list.name) {
|
||||
viewHolder.update(expand)
|
||||
} else {
|
||||
viewHolder.bind(expand)
|
||||
}
|
||||
val expand = items.getOrNull(absolutePosition - delta) ?: continue
|
||||
missingUpdates -= absolutePosition
|
||||
//println("Updating ${viewHolder.title.text} ($absolutePosition $position) -> ${expand.list.name}")
|
||||
if (viewHolder.title.text == expand.list.name) {
|
||||
viewHolder.update(expand)
|
||||
} else {
|
||||
viewHolder.bind(expand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// just in case some item did not get updated
|
||||
for (i in missingUpdates) {
|
||||
mAdapter.notifyItemChanged(i, payload)
|
||||
notifyItemChanged(i, payload)
|
||||
}
|
||||
} ?: run { // in case we don't have a nice
|
||||
mAdapter.notifyItemRangeChanged(position, count, payload)
|
||||
} ?: run {
|
||||
// in case we don't have a nice
|
||||
notifyItemRangeChanged(position, count, payload)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -137,9 +184,8 @@ class ParentItemAdapter(
|
|||
private val expandCallback: ((String) -> Unit)? = null,
|
||||
) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
val title: TextView = itemView.home_parent_item_title
|
||||
val recyclerView: RecyclerView = itemView.home_child_recyclerview
|
||||
private val moreInfo: FrameLayout? = itemView.home_child_more_info
|
||||
val title: TextView = itemView.home_child_more_info
|
||||
private val recyclerView: RecyclerView = itemView.home_child_recyclerview
|
||||
|
||||
fun update(expand: HomeViewModel.ExpandableHomepageList) {
|
||||
val info = expand.list
|
||||
|
@ -201,9 +247,10 @@ class ParentItemAdapter(
|
|||
})
|
||||
|
||||
//(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
|
||||
|
||||
moreInfo?.setOnClickListener {
|
||||
moreInfoClickCallback.invoke(expand)
|
||||
if (!isTvSettings()) {
|
||||
title.setOnClickListener {
|
||||
moreInfoClickCallback.invoke(expand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,658 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.lagradost.cloudstream3.APIHolder.getId
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.HomePageList
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
import kotlinx.android.synthetic.main.activity_main.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head.view.home_bookmarked_child_recyclerview
|
||||
import kotlinx.android.synthetic.main.fragment_home_head.view.home_watch_parent_item_title
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_bookmarked_holder
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_none_padding
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_plan_to_watch_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_completed_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_dropped_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_on_hold_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_watching_btt
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_watch_child_recyclerview
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_watch_holder
|
||||
import kotlinx.android.synthetic.main.toast.view.*
|
||||
|
||||
class HomeParentItemAdapterPreview(
|
||||
items: MutableList<HomeViewModel.ExpandableHomepageList>,
|
||||
val clickCallback: (SearchClickCallback) -> Unit,
|
||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||
expandCallback: ((String) -> Unit)? = null,
|
||||
private val loadCallback: (LoadClickCallback) -> Unit,
|
||||
private val loadMoreCallback: (() -> Unit),
|
||||
private val changeHomePageCallback: ((View) -> Unit),
|
||||
private val reloadStored: (() -> Unit),
|
||||
private val loadStoredData: ((Set<WatchType>) -> Unit),
|
||||
private val searchQueryCallback: ((Pair<Boolean, String>) -> Unit)
|
||||
) : ParentItemAdapter(items, clickCallback, moreInfoClickCallback, expandCallback) {
|
||||
private var previewData: Resource<Pair<Boolean, List<LoadResponse>>> = Resource.Loading()
|
||||
private var resumeWatchingData: List<SearchResponse> = listOf()
|
||||
private var bookmarkData: Pair<Boolean, List<SearchResponse>> =
|
||||
false to listOf()
|
||||
private var apiName: String = "NONE"
|
||||
|
||||
val headItems = 1
|
||||
|
||||
private var availableWatchStatusTypes: Pair<Set<WatchType>, Set<WatchType>> =
|
||||
setOf<WatchType>() to setOf()
|
||||
|
||||
fun setAvailableWatchStatusTypes(data: Pair<Set<WatchType>, Set<WatchType>>) {
|
||||
availableWatchStatusTypes = data
|
||||
holder?.setAvailableWatchStatusTypes(data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_HEADER = 2
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
}
|
||||
|
||||
fun setResumeWatchingData(resumeWatching: List<SearchResponse>) {
|
||||
resumeWatchingData = resumeWatching
|
||||
holder?.updateResume(resumeWatchingData)
|
||||
}
|
||||
|
||||
fun setPreviewData(preview: Resource<Pair<Boolean, List<LoadResponse>>>) {
|
||||
previewData = preview
|
||||
holder?.updatePreview(preview)
|
||||
}
|
||||
|
||||
fun setApiName(name: String) {
|
||||
apiName = name
|
||||
holder?.updateApiName(name)
|
||||
}
|
||||
|
||||
fun setBookmarkData(data: Pair<Boolean, List<SearchResponse>>) {
|
||||
bookmarkData = data
|
||||
holder?.updateBookmarks(data)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (position) {
|
||||
0 -> VIEW_TYPE_HEADER
|
||||
else -> VIEW_TYPE_ITEM
|
||||
}
|
||||
|
||||
var holder: HeaderViewHolder? = null
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderViewHolder -> {
|
||||
holder.updatePreview(previewData)
|
||||
holder.updateResume(resumeWatchingData)
|
||||
holder.updateBookmarks(bookmarkData)
|
||||
holder.setAvailableWatchStatusTypes(availableWatchStatusTypes)
|
||||
holder.updateApiName(apiName)
|
||||
}
|
||||
else -> super.onBindViewHolder(holder, position - 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
println("onCreateViewHolder $viewType")
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_HEADER -> HeaderViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(
|
||||
if (isTvSettings()) R.layout.fragment_home_head_tv else R.layout.fragment_home_head,
|
||||
parent,
|
||||
false
|
||||
),
|
||||
loadCallback,
|
||||
loadMoreCallback,
|
||||
changeHomePageCallback,
|
||||
clickCallback,
|
||||
reloadStored,
|
||||
loadStoredData,
|
||||
searchQueryCallback,
|
||||
moreInfoClickCallback
|
||||
).also {
|
||||
this.holder = it
|
||||
}
|
||||
VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType)
|
||||
else -> error("Unhandled viewType=$viewType")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return super.getItemCount() + headItems
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
if (position == 0) return previewData.hashCode().toLong()
|
||||
return super.getItemId(position - headItems)
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
||||
when (holder) {
|
||||
is HeaderViewHolder -> {
|
||||
holder.onViewDetachedFromWindow()
|
||||
}
|
||||
else -> super.onViewDetachedFromWindow(holder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
|
||||
when (holder) {
|
||||
is HeaderViewHolder -> {
|
||||
holder.onViewAttachedToWindow()
|
||||
}
|
||||
else -> super.onViewAttachedToWindow(holder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class HeaderViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
private val clickCallback: ((LoadClickCallback) -> Unit)?,
|
||||
private val loadMoreCallback: (() -> Unit),
|
||||
private val changeHomePageCallback: ((View) -> Unit),
|
||||
private val searchClickCallback: (SearchClickCallback) -> Unit,
|
||||
private val reloadStored: () -> Unit,
|
||||
private val loadStoredData: ((Set<WatchType>) -> Unit),
|
||||
private val searchQueryCallback: ((Pair<Boolean, String>) -> Unit),
|
||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
private var previewAdapter: HomeScrollAdapter? = null
|
||||
private val previewViewpager: ViewPager2? = itemView.home_preview_viewpager
|
||||
private val previewHeader: FrameLayout? = itemView.home_preview
|
||||
private val previewCallback: ViewPager2.OnPageChangeCallback =
|
||||
object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
// home_search?.isIconified = true
|
||||
//home_search?.isVisible = true
|
||||
//home_search?.clearFocus()
|
||||
|
||||
previewAdapter?.apply {
|
||||
if (position >= itemCount - 1 && hasMoreItems) {
|
||||
hasMoreItems = false // dont make two requests
|
||||
loadMoreCallback()
|
||||
//homeViewModel.loadMoreHomeScrollResponses()
|
||||
}
|
||||
}
|
||||
previewAdapter?.getItem(position)
|
||||
?.apply {
|
||||
//itemView.home_preview_title_holder?.let { parent ->
|
||||
// TransitionManager.beginDelayedTransition(
|
||||
// parent,
|
||||
// ChangeBounds()
|
||||
// )
|
||||
//}
|
||||
itemView.home_preview_description?.isGone =
|
||||
this.plot.isNullOrBlank()
|
||||
itemView.home_preview_description?.text =
|
||||
this.plot ?: ""
|
||||
itemView.home_preview_text?.text = this.name
|
||||
itemView.home_preview_tags?.apply {
|
||||
removeAllViews()
|
||||
tags?.forEach { tag ->
|
||||
val chip = Chip(context)
|
||||
val chipDrawable =
|
||||
ChipDrawable.createFromAttributes(
|
||||
context,
|
||||
null,
|
||||
0,
|
||||
R.style.ChipFilledSemiTransparent
|
||||
)
|
||||
chip.setChipDrawable(chipDrawable)
|
||||
chip.text = tag
|
||||
chip.isChecked = false
|
||||
chip.isCheckable = false
|
||||
chip.isFocusable = false
|
||||
chip.isClickable = false
|
||||
addView(chip)
|
||||
}
|
||||
}
|
||||
itemView.home_preview_tags?.isGone =
|
||||
tags.isNullOrEmpty()
|
||||
itemView.home_preview_image?.setImage(
|
||||
posterUrl,
|
||||
posterHeaders
|
||||
)
|
||||
// itemView.home_preview_title?.text = name
|
||||
|
||||
itemView.home_preview_play?.setOnClickListener { view ->
|
||||
clickCallback?.invoke(
|
||||
LoadClickCallback(
|
||||
START_ACTION_RESUME_LATEST,
|
||||
view,
|
||||
position,
|
||||
this
|
||||
)
|
||||
)
|
||||
}
|
||||
itemView.home_preview_info?.setOnClickListener { view ->
|
||||
clickCallback?.invoke(
|
||||
LoadClickCallback(0, view, position, this)
|
||||
)
|
||||
}
|
||||
|
||||
itemView.home_preview_play_btt?.setOnClickListener { view ->
|
||||
clickCallback?.invoke(
|
||||
LoadClickCallback(
|
||||
START_ACTION_RESUME_LATEST,
|
||||
view,
|
||||
position,
|
||||
this
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// This makes the hidden next buttons only available when on the info button
|
||||
// Otherwise you might be able to go to the next item without being at the info button
|
||||
itemView.home_preview_info_btt?.setOnFocusChangeListener { _, hasFocus ->
|
||||
itemView.home_preview_hidden_next_focus?.isFocusable = hasFocus
|
||||
}
|
||||
itemView.home_preview_play_btt?.setOnFocusChangeListener { _, hasFocus ->
|
||||
itemView.home_preview_hidden_prev_focus?.isFocusable = hasFocus
|
||||
}
|
||||
|
||||
|
||||
itemView.home_preview_info_btt?.setOnClickListener { view ->
|
||||
clickCallback?.invoke(
|
||||
LoadClickCallback(0, view, position, this)
|
||||
)
|
||||
}
|
||||
|
||||
itemView.home_preview_hidden_next_focus?.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
previewViewpager?.apply {
|
||||
setCurrentItem(currentItem + 1, true)
|
||||
}
|
||||
itemView.home_preview_info_btt?.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
itemView.home_preview_hidden_prev_focus?.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
previewViewpager?.apply {
|
||||
if (currentItem <= 0) {
|
||||
nav_rail_view?.menu?.getItem(0)?.actionView?.requestFocus()
|
||||
} else {
|
||||
setCurrentItem(currentItem - 1, true)
|
||||
itemView.home_preview_play_btt?.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// very ugly code, but I dont care
|
||||
val watchType =
|
||||
DataStoreHelper.getResultWatchState(this.getId())
|
||||
itemView.home_preview_bookmark?.setText(watchType.stringRes)
|
||||
itemView.home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
null,
|
||||
ContextCompat.getDrawable(
|
||||
itemView.home_preview_bookmark.context,
|
||||
watchType.iconRes
|
||||
),
|
||||
null,
|
||||
null
|
||||
)
|
||||
itemView.home_preview_bookmark?.setOnClickListener { fab ->
|
||||
fab.context.getActivity()?.showBottomDialog(
|
||||
WatchType.values()
|
||||
.map { fab.context.getString(it.stringRes) }
|
||||
.toList(),
|
||||
DataStoreHelper.getResultWatchState(this.getId()).ordinal,
|
||||
fab.context.getString(R.string.action_add_to_bookmarks),
|
||||
showApply = false,
|
||||
{}) {
|
||||
val newValue = WatchType.values()[it]
|
||||
itemView.home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
null,
|
||||
ContextCompat.getDrawable(
|
||||
itemView.home_preview_bookmark.context,
|
||||
newValue.iconRes
|
||||
),
|
||||
null,
|
||||
null
|
||||
)
|
||||
itemView.home_preview_bookmark?.setText(newValue.stringRes)
|
||||
|
||||
ResultViewModel2.updateWatchStatus(
|
||||
this,
|
||||
newValue
|
||||
)
|
||||
reloadStored()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resumeAdapter: HomeChildItemAdapter? = null
|
||||
private var resumeHolder: View? = itemView.home_watch_holder
|
||||
private var resumeRecyclerView: RecyclerView? = itemView.home_watch_child_recyclerview
|
||||
|
||||
private var bookmarkHolder: View? = itemView.home_bookmarked_holder
|
||||
private var bookmarkAdapter: HomeChildItemAdapter? = null
|
||||
private var bookmarkRecyclerView: RecyclerView? =
|
||||
itemView.home_bookmarked_child_recyclerview
|
||||
|
||||
fun onViewDetachedFromWindow() {
|
||||
previewViewpager?.unregisterOnPageChangeCallback(previewCallback)
|
||||
}
|
||||
|
||||
fun onViewAttachedToWindow() {
|
||||
previewViewpager?.registerOnPageChangeCallback(previewCallback)
|
||||
}
|
||||
|
||||
private val toggleList = listOf(
|
||||
Pair(itemView.home_type_watching_btt, WatchType.WATCHING),
|
||||
Pair(itemView.home_type_completed_btt, WatchType.COMPLETED),
|
||||
Pair(itemView.home_type_dropped_btt, WatchType.DROPPED),
|
||||
Pair(itemView.home_type_on_hold_btt, WatchType.ONHOLD),
|
||||
Pair(itemView.home_plan_to_watch_btt, WatchType.PLANTOWATCH),
|
||||
)
|
||||
|
||||
init {
|
||||
itemView.home_preview_change_api?.setOnClickListener { view ->
|
||||
changeHomePageCallback(view)
|
||||
}
|
||||
itemView.home_preview_change_api2?.setOnClickListener { view ->
|
||||
changeHomePageCallback(view)
|
||||
}
|
||||
|
||||
previewViewpager?.apply {
|
||||
//if (!isTvSettings())
|
||||
setPageTransformer(HomeScrollTransformer())
|
||||
//else
|
||||
// setPageTransformer(null)
|
||||
|
||||
if (adapter == null)
|
||||
adapter = HomeScrollAdapter(
|
||||
if (isTvSettings()) R.layout.home_scroll_view_tv else R.layout.home_scroll_view,
|
||||
if (isTvSettings()) true else null
|
||||
)
|
||||
}
|
||||
previewAdapter = previewViewpager?.adapter as? HomeScrollAdapter?
|
||||
// previewViewpager?.registerOnPageChangeCallback(previewCallback)
|
||||
|
||||
if (resumeAdapter == null) {
|
||||
resumeRecyclerView?.adapter = HomeChildItemAdapter(
|
||||
ArrayList(),
|
||||
nextFocusUp = itemView.nextFocusUpId,
|
||||
nextFocusDown = itemView.nextFocusDownId
|
||||
) { callback ->
|
||||
if (callback.action != SEARCH_ACTION_SHOW_METADATA) {
|
||||
searchClickCallback(callback)
|
||||
return@HomeChildItemAdapter
|
||||
}
|
||||
callback.view.context?.getActivity()?.showOptionSelectStringRes(
|
||||
callback.view,
|
||||
callback.card.posterUrl,
|
||||
listOf(
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_watching
|
||||
),
|
||||
listOf(
|
||||
R.string.action_open_play,
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_watching
|
||||
)
|
||||
) { (isTv, actionId) ->
|
||||
when (actionId + if (isTv) 0 else 1) {
|
||||
// play
|
||||
0 -> {
|
||||
searchClickCallback.invoke(
|
||||
SearchClickCallback(
|
||||
START_ACTION_RESUME_LATEST,
|
||||
callback.view,
|
||||
-1,
|
||||
callback.card
|
||||
)
|
||||
)
|
||||
reloadStored()
|
||||
}
|
||||
//info
|
||||
1 -> {
|
||||
searchClickCallback(
|
||||
SearchClickCallback(
|
||||
SEARCH_ACTION_LOAD,
|
||||
callback.view,
|
||||
-1,
|
||||
callback.card
|
||||
)
|
||||
)
|
||||
|
||||
reloadStored()
|
||||
}
|
||||
// remove
|
||||
2 -> {
|
||||
val card = callback.card
|
||||
if (card is DataStoreHelper.ResumeWatchingResult) {
|
||||
DataStoreHelper.removeLastWatched(card.parentId)
|
||||
reloadStored()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resumeAdapter = resumeRecyclerView?.adapter as? HomeChildItemAdapter
|
||||
if (bookmarkAdapter == null) {
|
||||
bookmarkRecyclerView?.adapter = HomeChildItemAdapter(
|
||||
ArrayList(),
|
||||
nextFocusUp = itemView.nextFocusUpId,
|
||||
nextFocusDown = itemView.nextFocusDownId
|
||||
) { callback ->
|
||||
if (callback.action != SEARCH_ACTION_SHOW_METADATA) {
|
||||
searchClickCallback(callback)
|
||||
return@HomeChildItemAdapter
|
||||
}
|
||||
callback.view.context?.getActivity()?.showOptionSelectStringRes(
|
||||
callback.view,
|
||||
callback.card.posterUrl,
|
||||
listOf(
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_from_bookmarks,
|
||||
),
|
||||
listOf(
|
||||
R.string.action_open_play,
|
||||
R.string.action_open_watching,
|
||||
R.string.action_remove_from_bookmarks
|
||||
)
|
||||
) { (isTv, actionId) ->
|
||||
when (actionId + if (isTv) 0 else 1) { // play
|
||||
0 -> {
|
||||
searchClickCallback.invoke(
|
||||
SearchClickCallback(
|
||||
START_ACTION_RESUME_LATEST,
|
||||
callback.view,
|
||||
-1,
|
||||
callback.card
|
||||
)
|
||||
)
|
||||
reloadStored()
|
||||
}
|
||||
1 -> { // info
|
||||
searchClickCallback(
|
||||
SearchClickCallback(
|
||||
SEARCH_ACTION_LOAD,
|
||||
callback.view,
|
||||
-1,
|
||||
callback.card
|
||||
)
|
||||
)
|
||||
|
||||
reloadStored()
|
||||
}
|
||||
2 -> { // remove
|
||||
DataStoreHelper.setResultWatchState(
|
||||
callback.card.id,
|
||||
WatchType.NONE.internalId
|
||||
)
|
||||
reloadStored()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bookmarkAdapter = bookmarkRecyclerView?.adapter as? HomeChildItemAdapter
|
||||
|
||||
for ((chip, watch) in toggleList) {
|
||||
chip?.isChecked = false
|
||||
chip?.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) {
|
||||
loadStoredData(
|
||||
setOf(watch)
|
||||
// If we filter all buttons then two can be checked at the same time
|
||||
// Revert this if you want to go back to multi selection
|
||||
// toggleList.filter { it.first?.isChecked == true }.map { it.second }.toSet()
|
||||
)
|
||||
}
|
||||
// Else if all are unchecked -> Do not load data
|
||||
else if (toggleList.all { it.first?.isChecked != true }) {
|
||||
loadStoredData(emptySet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemView.home_search?.context?.fixPaddingStatusbar(itemView.home_search)
|
||||
|
||||
itemView.home_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
searchQueryCallback.invoke(false to query)
|
||||
//QuickSearchFragment.pushSearch(activity, query, currentApiName?.let { arrayOf(it) }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String): Boolean {
|
||||
searchQueryCallback.invoke(true to newText)
|
||||
//searchViewModel.quickSearch(newText)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun updateApiName(name: String) {
|
||||
itemView.home_preview_change_api2?.text = name
|
||||
itemView.home_preview_change_api?.text = name
|
||||
}
|
||||
|
||||
fun updatePreview(preview: Resource<Pair<Boolean, List<LoadResponse>>>) {
|
||||
itemView.home_preview_change_api2?.isGone = preview is Resource.Success
|
||||
if (preview is Resource.Success) {
|
||||
itemView.home_none_padding?.apply {
|
||||
val params = layoutParams
|
||||
params.height = 0
|
||||
layoutParams = params
|
||||
}
|
||||
} else {
|
||||
itemView.home_none_padding?.context?.fixPaddingStatusbarView(itemView.home_none_padding)
|
||||
}
|
||||
when (preview) {
|
||||
is Resource.Success -> {
|
||||
if (true != previewAdapter?.setItems(
|
||||
preview.value.second,
|
||||
preview.value.first
|
||||
)
|
||||
) {
|
||||
// this might seam weird and useless, however this prevents a very weird andrid bug were the viewpager is not rendered properly
|
||||
// I have no idea why that happens, but this is my ducktape solution
|
||||
previewViewpager?.setCurrentItem(0, false)
|
||||
previewViewpager?.beginFakeDrag()
|
||||
previewViewpager?.fakeDragBy(1f)
|
||||
previewViewpager?.endFakeDrag()
|
||||
previewCallback.onPageSelected(0)
|
||||
previewHeader?.isVisible = true
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
previewAdapter?.setItems(listOf(), false)
|
||||
previewViewpager?.setCurrentItem(0, false)
|
||||
previewHeader?.isVisible = false
|
||||
}
|
||||
}
|
||||
// previewViewpager?.postDelayed({ previewViewpager?.scr(100, 0) }, 1000)
|
||||
//previewViewpager?.postInvalidate()
|
||||
}
|
||||
|
||||
fun updateResume(resumeWatching: List<SearchResponse>) {
|
||||
resumeHolder?.isVisible = resumeWatching.isNotEmpty()
|
||||
resumeAdapter?.updateList(resumeWatching)
|
||||
|
||||
if (!isTvSettings()) {
|
||||
itemView.home_watch_parent_item_title?.setOnClickListener {
|
||||
moreInfoClickCallback.invoke(
|
||||
HomeViewModel.ExpandableHomepageList(
|
||||
HomePageList(
|
||||
itemView.home_watch_parent_item_title?.text.toString(),
|
||||
resumeWatching,
|
||||
false
|
||||
), 1, false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBookmarks(data: Pair<Boolean, List<SearchResponse>>) {
|
||||
bookmarkHolder?.isVisible = data.first
|
||||
bookmarkAdapter?.updateList(data.second)
|
||||
if (!isTvSettings()) {
|
||||
itemView.home_bookmark_parent_item_title?.setOnClickListener {
|
||||
val items = toggleList.mapNotNull { it.first }.filter { it.isChecked }
|
||||
if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog
|
||||
val textSum = items
|
||||
.mapNotNull { it.text }.joinToString()
|
||||
|
||||
moreInfoClickCallback.invoke(
|
||||
HomeViewModel.ExpandableHomepageList(
|
||||
HomePageList(
|
||||
textSum,
|
||||
data.second,
|
||||
false
|
||||
), 1, false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAvailableWatchStatusTypes(availableWatchStatusTypes: Pair<Set<WatchType>, Set<WatchType>>) {
|
||||
for ((chip, watch) in toggleList) {
|
||||
chip?.apply {
|
||||
isVisible = availableWatchStatusTypes.second.contains(watch)
|
||||
isChecked = availableWatchStatusTypes.first.contains(watch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,22 +1,29 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.view.isGone
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.*
|
||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
|
||||
import kotlinx.android.synthetic.main.home_scroll_view.view.*
|
||||
|
||||
|
||||
class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class HomeScrollAdapter(
|
||||
@LayoutRes val layout: Int = R.layout.home_scroll_view,
|
||||
private val forceHorizontalPosters: Boolean? = null
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private var items: MutableList<LoadResponse> = mutableListOf()
|
||||
var hasMoreItems: Boolean = false
|
||||
|
||||
fun getItem(position: Int) : LoadResponse? {
|
||||
fun getItem(position: Int): LoadResponse? {
|
||||
return items.getOrNull(position)
|
||||
}
|
||||
|
||||
|
@ -39,7 +46,8 @@ class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return CardViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.home_scroll_view, parent, false),
|
||||
LayoutInflater.from(parent.context).inflate(layout, parent, false),
|
||||
forceHorizontalPosters
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -54,11 +62,17 @@ class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||
class CardViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
private val forceHorizontalPosters: Boolean? = null
|
||||
) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
fun bind(card: LoadResponse) {
|
||||
card.apply {
|
||||
val isHorizontal =
|
||||
(forceHorizontalPosters == true) || ((forceHorizontalPosters != false) && itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
|
||||
|
||||
val posterUrl = if (isHorizontal) backgroundPosterUrl ?: posterUrl else posterUrl
|
||||
?: backgroundPosterUrl
|
||||
itemView.home_scroll_preview_tags?.text = tags?.joinToString(" • ") ?: ""
|
||||
itemView.home_scroll_preview_tags?.isGone = tags.isNullOrEmpty()
|
||||
itemView.home_scroll_preview?.setImage(posterUrl, posterHeaders)
|
||||
|
|
|
@ -36,6 +36,42 @@ import java.util.*
|
|||
import kotlin.collections.set
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
companion object {
|
||||
suspend fun getResumeWatching(): List<DataStoreHelper.ResumeWatchingResult>? {
|
||||
val resumeWatching = withContext(Dispatchers.IO) {
|
||||
getAllResumeStateIds()?.mapNotNull { id ->
|
||||
getLastWatched(id)
|
||||
}?.sortedBy { -it.updateTime }
|
||||
}
|
||||
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
||||
resumeWatching?.mapNotNull { resume ->
|
||||
|
||||
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
resume.parentId.toString()
|
||||
) ?: return@mapNotNull null
|
||||
|
||||
val watchPos = getViewPos(resume.episodeId)
|
||||
|
||||
DataStoreHelper.ResumeWatchingResult(
|
||||
data.name,
|
||||
data.url,
|
||||
data.apiName,
|
||||
data.type,
|
||||
data.poster,
|
||||
watchPos,
|
||||
resume.episodeId,
|
||||
resume.parentId,
|
||||
resume.episode,
|
||||
resume.season,
|
||||
resume.isFromDownload
|
||||
)
|
||||
}
|
||||
}
|
||||
return resumeWatchingResult
|
||||
}
|
||||
}
|
||||
|
||||
private var repo: APIRepository? = null
|
||||
|
||||
private val _apiName = MutableLiveData<String>()
|
||||
|
@ -66,36 +102,7 @@ class HomeViewModel : ViewModel() {
|
|||
val preview: LiveData<Resource<Pair<Boolean, List<LoadResponse>>>> = _preview
|
||||
|
||||
fun loadResumeWatching() = viewModelScope.launchSafe {
|
||||
val resumeWatching = withContext(Dispatchers.IO) {
|
||||
getAllResumeStateIds()?.mapNotNull { id ->
|
||||
getLastWatched(id)
|
||||
}?.sortedBy { -it.updateTime }
|
||||
}
|
||||
|
||||
// val resumeWatchingResult = ArrayList<DataStoreHelper.ResumeWatchingResult>()
|
||||
|
||||
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
||||
resumeWatching?.map { resume ->
|
||||
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
resume.parentId.toString()
|
||||
) ?: return@map null
|
||||
val watchPos = getViewPos(resume.episodeId)
|
||||
DataStoreHelper.ResumeWatchingResult(
|
||||
data.name,
|
||||
data.url,
|
||||
data.apiName,
|
||||
data.type,
|
||||
data.poster,
|
||||
watchPos,
|
||||
resume.episodeId,
|
||||
resume.parentId,
|
||||
resume.episode,
|
||||
resume.season,
|
||||
resume.isFromDownload
|
||||
)
|
||||
}?.filterNotNull()
|
||||
}
|
||||
val resumeWatchingResult = getResumeWatching()
|
||||
resumeWatchingResult?.let {
|
||||
_resumeWatching.postValue(it)
|
||||
}
|
||||
|
@ -121,6 +128,7 @@ class HomeViewModel : ViewModel() {
|
|||
currentWatchTypes.remove(WatchType.NONE)
|
||||
|
||||
if (currentWatchTypes.size <= 0) {
|
||||
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
|
||||
_bookmarks.postValue(Pair(false, ArrayList()))
|
||||
return@launchSafe
|
||||
}
|
||||
|
@ -257,80 +265,83 @@ class HomeViewModel : ViewModel() {
|
|||
_apiName.postValue(repo?.name)
|
||||
_randomItems.postValue(listOf())
|
||||
|
||||
if (repo?.hasMainPage == true) {
|
||||
_page.postValue(Resource.Loading())
|
||||
_preview.postValue(Resource.Loading())
|
||||
addJob?.cancel()
|
||||
|
||||
when (val data = repo?.getMainPage(1, null)) {
|
||||
is Resource.Success -> {
|
||||
try {
|
||||
expandable.clear()
|
||||
data.value.forEach { home ->
|
||||
home?.items?.forEach { list ->
|
||||
val filteredList =
|
||||
context?.filterHomePageListByFilmQuality(list) ?: list
|
||||
expandable[list.name] =
|
||||
ExpandableHomepageList(filteredList, 1, home.hasNext)
|
||||
}
|
||||
}
|
||||
|
||||
val items = data.value.mapNotNull { it?.items }.flatten()
|
||||
|
||||
|
||||
previewResponses.clear()
|
||||
previewResponsesAdded.clear()
|
||||
|
||||
//val home = data.value
|
||||
if (items.isNotEmpty()) {
|
||||
val currentList =
|
||||
items.shuffled().filter { it.list.isNotEmpty() }
|
||||
.flatMap { it.list }
|
||||
.distinctBy { it.url }
|
||||
.toList()
|
||||
|
||||
if (currentList.isNotEmpty()) {
|
||||
val randomItems =
|
||||
context?.filterSearchResultByFilmQuality(currentList.shuffled())
|
||||
?: currentList.shuffled()
|
||||
|
||||
updatePreviewResponses(
|
||||
previewResponses,
|
||||
previewResponsesAdded,
|
||||
randomItems,
|
||||
3
|
||||
)
|
||||
|
||||
_randomItems.postValue(randomItems)
|
||||
currentShuffledList = randomItems
|
||||
}
|
||||
}
|
||||
if (previewResponses.isEmpty()) {
|
||||
_preview.postValue(
|
||||
Resource.Failure(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
"No homepage responses"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
_preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses))
|
||||
}
|
||||
_page.postValue(Resource.Success(expandable))
|
||||
} catch (e: Exception) {
|
||||
_randomItems.postValue(emptyList())
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
is Resource.Failure -> {
|
||||
_page.postValue(data!!)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
if (repo?.hasMainPage != true) {
|
||||
_page.postValue(Resource.Success(emptyMap()))
|
||||
_preview.postValue(Resource.Failure(false, null, null, "No homepage"))
|
||||
return@ioSafe
|
||||
}
|
||||
|
||||
|
||||
_page.postValue(Resource.Loading())
|
||||
_preview.postValue(Resource.Loading())
|
||||
addJob?.cancel()
|
||||
|
||||
when (val data = repo?.getMainPage(1, null)) {
|
||||
is Resource.Success -> {
|
||||
try {
|
||||
expandable.clear()
|
||||
data.value.forEach { home ->
|
||||
home?.items?.forEach { list ->
|
||||
val filteredList =
|
||||
context?.filterHomePageListByFilmQuality(list) ?: list
|
||||
expandable[list.name] =
|
||||
ExpandableHomepageList(filteredList, 1, home.hasNext)
|
||||
}
|
||||
}
|
||||
|
||||
val items = data.value.mapNotNull { it?.items }.flatten()
|
||||
|
||||
|
||||
previewResponses.clear()
|
||||
previewResponsesAdded.clear()
|
||||
|
||||
//val home = data.value
|
||||
if (items.isNotEmpty()) {
|
||||
val currentList =
|
||||
items.shuffled().filter { it.list.isNotEmpty() }
|
||||
.flatMap { it.list }
|
||||
.distinctBy { it.url }
|
||||
.toList()
|
||||
|
||||
if (currentList.isNotEmpty()) {
|
||||
val randomItems =
|
||||
context?.filterSearchResultByFilmQuality(currentList.shuffled())
|
||||
?: currentList.shuffled()
|
||||
|
||||
updatePreviewResponses(
|
||||
previewResponses,
|
||||
previewResponsesAdded,
|
||||
randomItems,
|
||||
3
|
||||
)
|
||||
|
||||
_randomItems.postValue(randomItems)
|
||||
currentShuffledList = randomItems
|
||||
}
|
||||
}
|
||||
if (previewResponses.isEmpty()) {
|
||||
_preview.postValue(
|
||||
Resource.Failure(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
"No homepage responses"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
_preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses))
|
||||
}
|
||||
_page.postValue(Resource.Success(expandable))
|
||||
} catch (e: Exception) {
|
||||
_randomItems.postValue(emptyList())
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
is Resource.Failure -> {
|
||||
_page.postValue(data!!)
|
||||
_preview.postValue(data!!)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -38,6 +38,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
|||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.UIHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
||||
|
@ -103,6 +104,14 @@ abstract class AbstractPlayerFragment(
|
|||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
||||
|
||||
}
|
||||
|
||||
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
|
||||
|
||||
}
|
||||
|
||||
open fun exitedPipMode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
@ -373,7 +382,9 @@ abstract class AbstractPlayerFragment(
|
|||
),
|
||||
subtitlesUpdates = ::subtitlesChanged,
|
||||
embeddedSubtitlesFetched = ::embeddedSubtitlesFetched,
|
||||
onTracksInfoChanged = ::onTracksInfoChanged
|
||||
onTracksInfoChanged = ::onTracksInfoChanged,
|
||||
onTimestampInvoked = ::onTimestamp,
|
||||
onTimestampSkipped = ::onTimestampSkipped
|
||||
)
|
||||
|
||||
if (player is CS3IPlayer) {
|
||||
|
|
|
@ -8,17 +8,22 @@ import android.util.Log
|
|||
import android.widget.FrameLayout
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.TRACK_TYPE_AUDIO
|
||||
import com.google.android.exoplayer2.C.TRACK_TYPE_VIDEO
|
||||
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
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelector
|
||||
import com.google.android.exoplayer2.ui.SubtitleView
|
||||
import com.google.android.exoplayer2.upstream.*
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
|
@ -32,11 +37,14 @@ import com.lagradost.cloudstream3.app
|
|||
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
|
||||
|
@ -85,10 +93,10 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
/**
|
||||
* Tracks reported to be used by exoplayer, since sometimes it has a mind of it's own when selecting subs.
|
||||
* String = lowercase language as set by .setLanguage("_$langId")
|
||||
* String = id
|
||||
* Boolean = if it's active
|
||||
* */
|
||||
private var exoPlayerSelectedTracks = listOf<Pair<String, Boolean>>()
|
||||
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
|
||||
|
||||
/** isPlaying */
|
||||
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null
|
||||
|
@ -113,6 +121,8 @@ class CS3IPlayer : IPlayer {
|
|||
private var playerUpdated: ((Any?) -> Unit)? = null
|
||||
private var embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)? = null
|
||||
private var onTracksInfoChanged: (() -> Unit)? = null
|
||||
private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null
|
||||
private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null
|
||||
|
||||
override fun releaseCallbacks() {
|
||||
playerUpdated = null
|
||||
|
@ -126,7 +136,9 @@ class CS3IPlayer : IPlayer {
|
|||
prevEpisode = null
|
||||
subtitlesUpdates = null
|
||||
onTracksInfoChanged = null
|
||||
onTimestampInvoked = null
|
||||
requestSubtitleUpdate = null
|
||||
onTimestampSkipped = null
|
||||
}
|
||||
|
||||
override fun initCallbacks(
|
||||
|
@ -142,6 +154,8 @@ class CS3IPlayer : IPlayer {
|
|||
subtitlesUpdates: (() -> Unit)?,
|
||||
embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)?,
|
||||
onTracksInfoChanged: (() -> Unit)?,
|
||||
onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?,
|
||||
onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?,
|
||||
) {
|
||||
this.playerUpdated = playerUpdated
|
||||
this.updateIsPlaying = updateIsPlaying
|
||||
|
@ -155,6 +169,8 @@ class CS3IPlayer : IPlayer {
|
|||
this.subtitlesUpdates = subtitlesUpdates
|
||||
this.embeddedSubtitlesFetched = embeddedSubtitlesFetched
|
||||
this.onTracksInfoChanged = onTracksInfoChanged
|
||||
this.onTimestampInvoked = onTimestampInvoked
|
||||
this.onTimestampSkipped = onTimestampSkipped
|
||||
}
|
||||
|
||||
// I know, this is not a perfect solution, however it works for fixing subs
|
||||
|
@ -299,14 +315,18 @@ class CS3IPlayer : IPlayer {
|
|||
* */
|
||||
private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> {
|
||||
return this.map {
|
||||
(0 until it.mediaTrackGroup.length).mapNotNull { i ->
|
||||
if (it.isSupported)
|
||||
it.mediaTrackGroup.getFormat(i) to i
|
||||
else null
|
||||
}
|
||||
it.getFormats()
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
|
||||
return (0 until this.mediaTrackGroup.length).mapNotNull { i ->
|
||||
if (this.isSupported)
|
||||
this.mediaTrackGroup.getFormat(i) to i
|
||||
else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Format.toAudioTrack(): AudioTrack {
|
||||
return AudioTrack(
|
||||
this.id,
|
||||
|
@ -349,12 +369,17 @@ class CS3IPlayer : IPlayer {
|
|||
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
|
||||
Log.i(TAG, "setPreferredSubtitles init $subtitle")
|
||||
currentSubtitles = subtitle
|
||||
|
||||
fun getTextTrack(id: String) =
|
||||
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT }
|
||||
?.getTrack(id)
|
||||
|
||||
return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector ->
|
||||
val name = subtitle?.name
|
||||
if (name.isNullOrBlank()) {
|
||||
if (subtitle == null) {
|
||||
trackSelector.setParameters(
|
||||
trackSelector.buildUponParameters()
|
||||
.setPreferredTextLanguage(null)
|
||||
.clearOverridesOfType(TRACK_TYPE_TEXT)
|
||||
)
|
||||
} else {
|
||||
when (subtitleHelper.subtitleStatus(subtitle)) {
|
||||
|
@ -368,12 +393,15 @@ class CS3IPlayer : IPlayer {
|
|||
trackSelector.setParameters(
|
||||
trackSelector.buildUponParameters()
|
||||
.apply {
|
||||
if (subtitle.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO)
|
||||
// The real Language (two letter) is in the url
|
||||
// No underscore as the .url is the actual exoplayer designated language
|
||||
setPreferredTextLanguage(subtitle.url)
|
||||
else
|
||||
setPreferredTextLanguage("_$name")
|
||||
val track = getTextTrack(subtitle.getId())
|
||||
if (track != null) {
|
||||
setOverrideForType(
|
||||
TrackSelectionOverride(
|
||||
track.first,
|
||||
track.second
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -407,17 +435,8 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
override fun getCurrentPreferredSubtitle(): SubtitleData? {
|
||||
return subtitleHelper.getAllSubtitles().firstOrNull { sub ->
|
||||
exoPlayerSelectedTracks.any {
|
||||
// When embedded the real language is in .url as the real name is a two letter code
|
||||
val realName =
|
||||
if (sub.origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) sub.url else sub.name
|
||||
|
||||
// The replace is needed as exoplayer translates _ to -
|
||||
// Also we prefix the languages with _
|
||||
it.second && it.first.replace("-", "").equals(
|
||||
realName.replace("-", ""),
|
||||
ignoreCase = true
|
||||
)
|
||||
playerSelectedSubtitleTracks.any { (id, isSelected) ->
|
||||
isSelected && sub.getId() == id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -521,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)
|
||||
|
@ -652,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(
|
||||
|
@ -676,6 +701,8 @@ class CS3IPlayer : IPlayer {
|
|||
maxVideoHeight
|
||||
)
|
||||
)
|
||||
// Allows any seeking to be +- 0.3s to allow for faster seeking
|
||||
.setSeekParameters(SeekParameters(300_000, 300_000))
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setTargetBufferBytes(
|
||||
|
@ -719,7 +746,7 @@ class CS3IPlayer : IPlayer {
|
|||
source
|
||||
}
|
||||
|
||||
println("PLAYBACK POS $playbackPosition")
|
||||
//println("PLAYBACK POS $playbackPosition")
|
||||
return exoPlayerBuilder.build().apply {
|
||||
setPlayWhenReady(playWhenReady)
|
||||
seekTo(currentWindow, playbackPosition)
|
||||
|
@ -735,8 +762,22 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
fun updatedTime() {
|
||||
val position = exoPlayer?.currentPosition
|
||||
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? {
|
||||
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
|
||||
for (lastTimeStamp in lastTimeStamps) {
|
||||
if (lastTimeStamp.startMs <= position && position < lastTimeStamp.endMs) {
|
||||
return lastTimeStamp
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun updatedTime(writePosition: Long? = null) {
|
||||
getCurrentTimestamp(writePosition)?.let { timestamp ->
|
||||
onTimestampInvoked?.invoke(timestamp)
|
||||
}
|
||||
|
||||
val position = writePosition ?: exoPlayer?.currentPosition
|
||||
val duration = exoPlayer?.contentDuration
|
||||
if (duration != null && position != null) {
|
||||
playerPositionChanged?.invoke(Pair(position, duration))
|
||||
|
@ -748,12 +789,12 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
|
||||
override fun seekTo(time: Long) {
|
||||
updatedTime()
|
||||
updatedTime(time)
|
||||
exoPlayer?.seekTo(time)
|
||||
}
|
||||
|
||||
private fun ExoPlayer.seekTime(time: Long) {
|
||||
updatedTime()
|
||||
updatedTime(currentPosition + time)
|
||||
seekTo(currentPosition + time)
|
||||
}
|
||||
|
||||
|
@ -789,6 +830,17 @@ class CS3IPlayer : IPlayer {
|
|||
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime)
|
||||
CSPlayerEvent.NextEpisode -> nextEpisode?.invoke()
|
||||
CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke()
|
||||
CSPlayerEvent.SkipCurrentChapter -> {
|
||||
//val dur = this@CS3IPlayer.getDuration() ?: return@apply
|
||||
getCurrentTimestamp()?.let { lastTimeStamp ->
|
||||
if (lastTimeStamp.skipToNextEpisode) {
|
||||
handleEvent(CSPlayerEvent.NextEpisode)
|
||||
} else {
|
||||
seekTo(lastTimeStamp.endMs + 1L)
|
||||
}
|
||||
onTimestampSkipped?.invoke(lastTimeStamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -806,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
|
||||
)
|
||||
|
||||
|
@ -846,43 +898,36 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
exoPlayer?.addListener(object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
fun Format.isSubtitle(): Boolean {
|
||||
return this.sampleMimeType?.contains("video/") == false &&
|
||||
this.sampleMimeType?.contains("audio/") == false
|
||||
}
|
||||
|
||||
normalSafeApiCall {
|
||||
exoPlayerSelectedTracks =
|
||||
tracks.groups.mapNotNull {
|
||||
val format = it.mediaTrackGroup.getFormat(0)
|
||||
if (format.isSubtitle())
|
||||
format.language?.let { lang -> lang to it.isSelected }
|
||||
else null
|
||||
}
|
||||
val textTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT }
|
||||
|
||||
val exoPlayerReportedTracks = tracks.groups.mapNotNull {
|
||||
// Filter out unsupported tracks
|
||||
if (it.isSupported)
|
||||
it.mediaTrackGroup.getFormat(0)
|
||||
else
|
||||
null
|
||||
}.mapNotNull {
|
||||
// Filter out non subs, already used subs and subs without languages
|
||||
if (!it.isSubtitle() ||
|
||||
// Anything starting with - is not embedded
|
||||
it.language?.startsWith("-") == true ||
|
||||
it.language == null
|
||||
) return@mapNotNull null
|
||||
return@mapNotNull SubtitleData(
|
||||
// Nicer looking displayed names
|
||||
fromTwoLettersToLanguage(it.language!!) ?: it.language!!,
|
||||
// See setPreferredTextLanguage
|
||||
it.language!!,
|
||||
SubtitleOrigin.EMBEDDED_IN_VIDEO,
|
||||
it.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
|
||||
emptyMap()
|
||||
)
|
||||
}
|
||||
playerSelectedSubtitleTracks =
|
||||
textTracks.map { group ->
|
||||
group.getFormats().mapNotNull { (format, _) ->
|
||||
(format.id ?: return@mapNotNull null) to group.isSelected
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
val exoPlayerReportedTracks =
|
||||
tracks.groups.filter { it.type == TRACK_TYPE_TEXT }.getFormats()
|
||||
.mapNotNull { (format, _) ->
|
||||
// Filter out non subs, already used subs and subs without languages
|
||||
if (format.id == null ||
|
||||
format.language == null ||
|
||||
format.language?.startsWith("-") == true
|
||||
) return@mapNotNull null
|
||||
|
||||
return@mapNotNull SubtitleData(
|
||||
// Nicer looking displayed names
|
||||
fromTwoLettersToLanguage(format.language!!)
|
||||
?: format.language!!,
|
||||
// See setPreferredTextLanguage
|
||||
format.id!!,
|
||||
SubtitleOrigin.EMBEDDED_IN_VIDEO,
|
||||
format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
|
||||
emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks)
|
||||
onTracksInfoChanged?.invoke()
|
||||
|
@ -941,7 +986,7 @@ class CS3IPlayer : IPlayer {
|
|||
// This is to switch mirrors automatically if the stream has not been fetched, but
|
||||
// allow playing the buffer without internet as then the duration is fetched.
|
||||
if (error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
|
||||
&& exoPlayer?.duration != C.TIME_UNSET
|
||||
&& exoPlayer?.duration != TIME_UNSET
|
||||
) {
|
||||
exoPlayer?.prepare()
|
||||
} else {
|
||||
|
@ -1007,6 +1052,24 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
|
||||
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
|
||||
lastTimeStamps = timeStamps
|
||||
timeStamps.forEach { timestamp ->
|
||||
exoPlayer?.createMessage { _, _ ->
|
||||
updatedTime()
|
||||
//if (payload is EpisodeSkip.SkipStamp) // this should always be true
|
||||
// onTimestampInvoked?.invoke(payload)
|
||||
}
|
||||
?.setLooper(Looper.getMainLooper())
|
||||
?.setPosition(timestamp.startMs)
|
||||
//?.setPayload(timestamp)
|
||||
?.setDeleteAfterDelivery(false)
|
||||
?.send()
|
||||
}
|
||||
updatedTime()
|
||||
}
|
||||
|
||||
fun onRenderFirst() {
|
||||
if (!hasUsedFirstRender) { // this insures that we only call this once per player load
|
||||
Log.i(TAG, "Rendered first frame")
|
||||
|
@ -1082,14 +1145,15 @@ class CS3IPlayer : IPlayer {
|
|||
val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url))
|
||||
.setMimeType(sub.mimeType)
|
||||
.setLanguage("_${sub.name}")
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.setId(sub.getId())
|
||||
.setSelectionFlags(SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
when (sub.origin) {
|
||||
SubtitleOrigin.DOWNLOADED_FILE -> {
|
||||
if (offlineSourceFactory != null) {
|
||||
activeSubtitles.add(sub)
|
||||
SingleSampleMediaSource.Factory(offlineSourceFactory)
|
||||
.createMediaSource(subConfig, C.TIME_UNSET)
|
||||
.createMediaSource(subConfig, TIME_UNSET)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -1101,7 +1165,7 @@ class CS3IPlayer : IPlayer {
|
|||
if (sub.headers.isNotEmpty())
|
||||
this.setDefaultRequestProperties(sub.headers)
|
||||
})
|
||||
.createMediaSource(subConfig, C.TIME_UNSET)
|
||||
.createMediaSource(subConfig, TIME_UNSET)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -1110,7 +1174,7 @@ class CS3IPlayer : IPlayer {
|
|||
if (offlineSourceFactory != null) {
|
||||
activeSubtitles.add(sub)
|
||||
SingleSampleMediaSource.Factory(offlineSourceFactory)
|
||||
.createMediaSource(subConfig, C.TIME_UNSET)
|
||||
.createMediaSource(subConfig, TIME_UNSET)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -1140,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) {
|
||||
|
@ -1193,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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||||
|
||||
class ExtractorLinkGenerator(
|
||||
private val links: List<ExtractorLink>,
|
||||
private val subtitles: List<SubtitleData>,
|
||||
) : IGenerator {
|
||||
override val hasCache = false
|
||||
|
||||
override fun getCurrentId(): Int? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getAll(): List<Any>? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun hasPrev(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getCurrent(offset: Int): Any? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun goto(index: Int) {}
|
||||
|
||||
override fun next() {}
|
||||
|
||||
override fun prev() {}
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
isCasting: Boolean,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset: Int
|
||||
): Boolean {
|
||||
subtitles.forEach(subtitleCallback)
|
||||
links.forEach {
|
||||
callback.invoke(it to null)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -605,13 +609,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
player_top_holder?.isGone = isGone
|
||||
//player_episodes_button?.isVisible = !isGone && hasEpisodes
|
||||
player_video_title?.isGone = togglePlayerTitleGone
|
||||
player_video_title_rez?.isGone = isGone
|
||||
// player_video_title_rez?.isGone = isGone
|
||||
player_episode_filler?.isGone = isGone
|
||||
player_center_menu?.isGone = isGone
|
||||
player_lock?.isGone = !isShowing
|
||||
//player_media_route_button?.isClickable = !isGone
|
||||
player_go_back_holder?.isGone = isGone
|
||||
player_sources_btt?.isGone = isGone
|
||||
player_skip_episode?.isClickable = !isGone
|
||||
}
|
||||
|
||||
private fun updateLockUI() {
|
||||
|
@ -1050,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
|
||||
}
|
||||
}
|
||||
|
@ -1101,7 +1106,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
}
|
||||
|
||||
protected fun uiReset() {
|
||||
isLocked = false
|
||||
isShowing = false
|
||||
|
||||
// if nothing has loaded these buttons should not be visible
|
||||
|
@ -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 ->
|
||||
|
@ -1141,6 +1154,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
PlayerEventType.Play -> {
|
||||
player.handleEvent(CSPlayerEvent.Play)
|
||||
}
|
||||
PlayerEventType.SkipCurrentChapter -> {
|
||||
player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
|
||||
}
|
||||
PlayerEventType.Resize -> {
|
||||
nextResize()
|
||||
}
|
||||
|
@ -1204,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()
|
||||
|
||||
|
@ -1234,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 =
|
||||
|
@ -1254,6 +1276,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
|||
player.handleEvent(CSPlayerEvent.PlayPauseToggle)
|
||||
}
|
||||
|
||||
skip_chapter_button?.setOnClickListener {
|
||||
player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
|
||||
}
|
||||
|
||||
// init clicks
|
||||
player_resize_btt?.setOnClickListener {
|
||||
autoHide()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
|
@ -12,7 +13,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
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
|
||||
import androidx.core.view.isVisible
|
||||
|
@ -24,20 +25,14 @@ import com.hippo.unifile.UniFile
|
|||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.mvvm.*
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
|
||||
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage
|
||||
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
||||
import com.lagradost.cloudstream3.ui.result.SyncViewModel
|
||||
import com.lagradost.cloudstream3.ui.result.*
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
|
@ -49,6 +44,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
|||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlinx.android.synthetic.main.dialog_online_subtitles.*
|
||||
import kotlinx.android.synthetic.main.dialog_online_subtitles.apply_btt
|
||||
import kotlinx.android.synthetic.main.dialog_online_subtitles.cancel_btt
|
||||
|
@ -58,7 +54,9 @@ import kotlinx.android.synthetic.main.player_select_source_and_subs.*
|
|||
import kotlinx.android.synthetic.main.player_select_source_and_subs.subtitles_click_settings
|
||||
import kotlinx.android.synthetic.main.player_select_tracks.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class GeneratorPlayer : FullScreenPlayer() {
|
||||
companion object {
|
||||
|
@ -67,8 +65,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
Log.i(TAG, "newInstance = $syncData")
|
||||
lastUsedGenerator = generator
|
||||
return Bundle().apply {
|
||||
if (syncData != null)
|
||||
putSerializable("syncData", syncData)
|
||||
if (syncData != null) putSerializable("syncData", syncData)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -165,6 +162,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
isActive = true
|
||||
setPlayerDimen(null)
|
||||
setTitle()
|
||||
if (!sameEpisode)
|
||||
hasRequestedStamps = false
|
||||
|
||||
loadExtractorJob(link.first)
|
||||
// load player
|
||||
|
@ -180,12 +179,13 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
},
|
||||
currentSubs,
|
||||
(if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle(
|
||||
currentSubs,
|
||||
settings = true,
|
||||
downloads = true
|
||||
currentSubs, settings = true, downloads = true
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!sameEpisode)
|
||||
player.addTimeStamps(listOf()) // clear stamps
|
||||
}
|
||||
|
||||
private fun sortLinks(useQualitySettings: Boolean = true): List<Pair<ExtractorLink?, ExtractorUri?>> {
|
||||
|
@ -231,9 +231,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
override fun openOnlineSubPicker(
|
||||
context: Context,
|
||||
imdbId: Long?,
|
||||
dismissCallback: (() -> Unit)
|
||||
context: Context, imdbId: Long?, dismissCallback: (() -> Unit)
|
||||
) {
|
||||
val providers = subsProviders
|
||||
val isSingleProvider = subsProviders.size == 1
|
||||
|
@ -256,8 +254,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
val arrayAdapter =
|
||||
object : ArrayAdapter<AbstractSubtitleEntities.SubtitleEntity>(dialog.context, layout) {
|
||||
fun setHearingImpairedIcon(
|
||||
imageViewEnd: ImageView?,
|
||||
position: Int
|
||||
imageViewEnd: ImageView?, position: Int
|
||||
) {
|
||||
if (imageViewEnd == null) return
|
||||
val isHearingImpaired =
|
||||
|
@ -265,13 +262,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
val drawableEnd = if (isHearingImpaired) {
|
||||
ContextCompat.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_baseline_hearing_24
|
||||
context, R.drawable.ic_baseline_hearing_24
|
||||
)?.apply {
|
||||
setTint(
|
||||
ContextCompat.getColor(
|
||||
context,
|
||||
R.color.textColor
|
||||
context, R.color.textColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -281,8 +276,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context)
|
||||
.inflate(layout, null)
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(layout, null)
|
||||
|
||||
val item = getItem(position)
|
||||
|
||||
|
@ -332,18 +326,54 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
dialog.search_loading_bar.progressTintList = color
|
||||
dialog.search_loading_bar.indeterminateTintList = color
|
||||
|
||||
observeNullable(viewModel.currentSubtitleYear) {
|
||||
// When year is changed search again
|
||||
dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true)
|
||||
dialog.year_btt.text = it?.toString() ?: txt(R.string.none).asString(context)
|
||||
}
|
||||
|
||||
dialog.year_btt?.setOnClickListener {
|
||||
val none = txt(R.string.none).asString(context)
|
||||
val currentYear = Calendar.getInstance().get(Calendar.YEAR)
|
||||
val earliestYear = 1900
|
||||
|
||||
val years = (currentYear downTo earliestYear).toList()
|
||||
val options = listOf(none) + years.map {
|
||||
it.toString()
|
||||
}
|
||||
|
||||
val selectedIndex = viewModel.currentSubtitleYear.value
|
||||
?.let {
|
||||
// + 1 since none also takes a space
|
||||
years.indexOf(it) + 1
|
||||
}
|
||||
?.takeIf { it >= 0 } ?: 0
|
||||
|
||||
activity?.showDialog(
|
||||
options,
|
||||
selectedIndex,
|
||||
txt(R.string.year).asString(context),
|
||||
true, {
|
||||
}, { index ->
|
||||
viewModel.setSubtitleYear(years.getOrNull(index - 1))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dialog.subtitles_search.setOnQueryTextListener(object :
|
||||
androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
dialog.search_loading_bar?.show()
|
||||
ioSafe {
|
||||
val search = AbstractSubtitleEntities.SubtitleSearch(
|
||||
query = query ?: return@ioSafe,
|
||||
imdb = imdbId,
|
||||
epNumber = currentTempMeta.episode,
|
||||
seasonNumber = currentTempMeta.season,
|
||||
lang = currentLanguageTwoLetters.ifBlank { null }
|
||||
)
|
||||
val search =
|
||||
AbstractSubtitleEntities.SubtitleSearch(
|
||||
query = query ?: return@ioSafe,
|
||||
imdb = imdbId,
|
||||
epNumber = currentTempMeta.episode,
|
||||
seasonNumber = currentTempMeta.season,
|
||||
lang = currentLanguageTwoLetters.ifBlank { null },
|
||||
year = viewModel.currentSubtitleYear.value
|
||||
)
|
||||
val results = providers.amap {
|
||||
try {
|
||||
it.search(search)
|
||||
|
@ -351,7 +381,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
null
|
||||
}
|
||||
}.filterNotNull()
|
||||
val max = results.map { it.size }.maxOrNull() ?: return@ioSafe
|
||||
val max = results.maxOfOrNull { it.size } ?: return@ioSafe
|
||||
|
||||
// very ugly
|
||||
val items = ArrayList<AbstractSubtitleEntities.SubtitleEntity>()
|
||||
|
@ -379,14 +409,12 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
dialog.search_filter.setOnClickListener { view ->
|
||||
val lang639_1 = languages.map { it.ISO_639_1 }
|
||||
activity?.showDialog(
|
||||
languages.map { it.languageName },
|
||||
activity?.showDialog(languages.map { it.languageName },
|
||||
lang639_1.indexOf(currentLanguageTwoLetters),
|
||||
view?.context?.getString(R.string.subs_subtitle_languages)
|
||||
?: return@setOnClickListener,
|
||||
true,
|
||||
{ }
|
||||
) { index ->
|
||||
{ }) { index ->
|
||||
currentLanguageTwoLetters = lang639_1[index]
|
||||
dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true)
|
||||
}
|
||||
|
@ -419,6 +447,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
dialog.show()
|
||||
dialog.subtitles_search.setQuery(currentTempMeta.name, true)
|
||||
//TODO: Set year text from currently loaded movie on Player
|
||||
//dialog.subtitles_search_year?.setText(currentTempMeta.year)
|
||||
}
|
||||
|
||||
private fun openSubPicker() {
|
||||
|
@ -443,16 +473,17 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
private fun addAndSelectSubtitles(subtitleData: SubtitleData) {
|
||||
val ctx = context ?: return
|
||||
setSubtitles(subtitleData)
|
||||
|
||||
// this is used instead of observe, because observe is too slow
|
||||
val subs = currentSubs + subtitleData
|
||||
|
||||
// this is used instead of observe(viewModel._currentSubs), because observe is too slow
|
||||
player.setActiveSubtitles(subs)
|
||||
|
||||
// Save current time as to not reset player to 00:00
|
||||
player.saveData()
|
||||
player.setActiveSubtitles(subs)
|
||||
player.reloadPlayer(ctx)
|
||||
|
||||
setSubtitles(subtitleData)
|
||||
viewModel.addSubtitles(setOf(subtitleData))
|
||||
|
||||
selectSourceDialog?.dismissSafe()
|
||||
|
@ -472,8 +503,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
if (uri == null) return@normalSafeApiCall
|
||||
val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall
|
||||
// RW perms for the path
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
val flags =
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
ctx.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
|
||||
|
@ -494,7 +525,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
}
|
||||
|
||||
var selectSourceDialog: AlertDialog? = null
|
||||
var selectSourceDialog: Dialog? = null
|
||||
// var selectTracksDialog: AlertDialog? = null
|
||||
|
||||
override fun showMirrorsDialogue() {
|
||||
|
@ -506,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
|
||||
|
||||
|
@ -536,11 +565,9 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
if (subsProvidersIsActive) {
|
||||
val loadFromOpenSubsFooter: TextView =
|
||||
layoutInflater.inflate(
|
||||
R.layout.sort_bottom_footer_add_choice,
|
||||
null
|
||||
) as TextView
|
||||
val loadFromOpenSubsFooter: TextView = layoutInflater.inflate(
|
||||
R.layout.sort_bottom_footer_add_choice, null
|
||||
) as TextView
|
||||
|
||||
loadFromOpenSubsFooter.text =
|
||||
ctx.getString(R.string.player_load_subtitles_online)
|
||||
|
@ -592,8 +619,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
val subtitleIndexStart = currentSubtitles.indexOf(currentSelectedSubtitles) + 1
|
||||
var subtitleIndex = subtitleIndexStart
|
||||
|
||||
val subsArrayAdapter =
|
||||
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||
val subsArrayAdapter = ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||
subsArrayAdapter.add(ctx.getString(R.string.no_subtitles))
|
||||
subsArrayAdapter.addAll(currentSubtitles.map { it.name })
|
||||
|
||||
|
@ -631,8 +657,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values)
|
||||
|
||||
val value = settingsManager.getString(
|
||||
ctx.getString(R.string.subtitles_encoding_key),
|
||||
null
|
||||
ctx.getString(R.string.subtitles_encoding_key), null
|
||||
)
|
||||
val index = prefValues.indexOf(value)
|
||||
text = prefNames[if (index == -1) 0 else index]
|
||||
|
@ -644,28 +669,22 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list)
|
||||
val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values)
|
||||
|
||||
val currentPrefMedia =
|
||||
settingsManager.getString(
|
||||
ctx.getString(R.string.subtitles_encoding_key),
|
||||
null
|
||||
)
|
||||
val currentPrefMedia = settingsManager.getString(
|
||||
ctx.getString(R.string.subtitles_encoding_key), null
|
||||
)
|
||||
|
||||
shouldDismiss = false
|
||||
sourceDialog.dismissSafe(activity)
|
||||
|
||||
val index = prefValues.indexOf(currentPrefMedia)
|
||||
activity?.showDialog(
|
||||
prefNames.toList(),
|
||||
activity?.showDialog(prefNames.toList(),
|
||||
if (index == -1) 0 else index,
|
||||
ctx.getString(R.string.subtitles_encoding),
|
||||
true,
|
||||
{}) {
|
||||
settingsManager.edit()
|
||||
.putString(
|
||||
ctx.getString(R.string.subtitles_encoding_key),
|
||||
prefValues[it]
|
||||
)
|
||||
.apply()
|
||||
settingsManager.edit().putString(
|
||||
ctx.getString(R.string.subtitles_encoding_key), prefValues[it]
|
||||
).apply()
|
||||
|
||||
updateForcedEncoding(ctx)
|
||||
dismiss()
|
||||
|
@ -714,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) {
|
||||
|
@ -761,7 +778,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
videosList.setItemChecked(which, true)
|
||||
}
|
||||
|
||||
tracksDialog.setOnDismissListener {
|
||||
trackDialog.setOnDismissListener {
|
||||
dismiss()
|
||||
// selectTracksDialog = null
|
||||
}
|
||||
|
@ -791,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
|
||||
|
@ -808,7 +825,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
player.setMaxVideoSize(width, height, currentVideo?.id)
|
||||
}
|
||||
|
||||
tracksDialog.dismissSafe(activity)
|
||||
trackDialog.dismissSafe(activity)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -878,7 +895,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
var maxEpisodeSet: Int? = null
|
||||
|
||||
var hasRequestedStamps: Boolean = false
|
||||
override fun playerPositionChanged(posDur: Pair<Long, Long>) {
|
||||
// Don't save livestream data
|
||||
if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return
|
||||
|
@ -887,11 +904,24 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return
|
||||
|
||||
val (position, duration) = posDur
|
||||
if (duration == 0L) return // idk how you achieved this, but div by zero crash
|
||||
if (duration <= 0L) return // idk how you achieved this, but div by zero crash
|
||||
if (!hasRequestedStamps) {
|
||||
hasRequestedStamps = true
|
||||
val fetchStamps = context?.let { ctx ->
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
settingsManager.getBoolean(
|
||||
ctx.getString(R.string.enable_skip_op_from_database),
|
||||
true
|
||||
)
|
||||
} ?: true
|
||||
if (fetchStamps)
|
||||
viewModel.loadStamps(duration)
|
||||
}
|
||||
|
||||
viewModel.getId()?.let {
|
||||
DataStoreHelper.setViewPos(it, position, duration)
|
||||
}
|
||||
|
||||
val percentage = position * 100L / duration
|
||||
|
||||
val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE
|
||||
|
@ -939,17 +969,14 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
context?.let { ctx ->
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
if (settingsManager.getBoolean(
|
||||
ctx.getString(R.string.episode_sync_enabled_key),
|
||||
true
|
||||
ctx.getString(R.string.episode_sync_enabled_key), true
|
||||
)
|
||||
)
|
||||
maxEpisodeSet = meta.episode
|
||||
) maxEpisodeSet = meta.episode
|
||||
sync.modifyMaxEpisode(meta.episode)
|
||||
}
|
||||
}
|
||||
|
||||
if (meta.tvType.isAnimeOp())
|
||||
isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE
|
||||
if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE
|
||||
}
|
||||
}
|
||||
player_skip_op?.isVisible = isOpVisible
|
||||
|
@ -961,12 +988,10 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
private fun getAutoSelectSubtitle(
|
||||
subtitles: Set<SubtitleData>,
|
||||
settings: Boolean,
|
||||
downloads: Boolean
|
||||
subtitles: Set<SubtitleData>, settings: Boolean, downloads: Boolean
|
||||
): SubtitleData? {
|
||||
val langCode = preferredAutoSelectSubtitles ?: return null
|
||||
val lang = SubtitleHelper.fromTwoLettersToLanguage(langCode) ?: return null
|
||||
val lang = fromTwoLettersToLanguage(langCode) ?: return null
|
||||
if (downloads) {
|
||||
return subtitles.firstOrNull { sub ->
|
||||
(sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString(
|
||||
|
@ -977,22 +1002,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
sortSubs(subtitles).firstOrNull { sub ->
|
||||
val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim()
|
||||
(settings || (downloads && sub.origin == SubtitleOrigin.DOWNLOADED_FILE)) && t == lang || t.startsWith(
|
||||
"$lang "
|
||||
) || t == langCode
|
||||
(settings) && t == lang || t.startsWith(lang) || t == langCode
|
||||
}?.let { sub ->
|
||||
return sub
|
||||
}
|
||||
|
||||
// post check in case both did not catch anything
|
||||
if (downloads) {
|
||||
return subtitles.firstOrNull { sub ->
|
||||
(sub.origin == SubtitleOrigin.DOWNLOADED_FILE || sub.name == context?.getString(
|
||||
R.string.default_subtitles
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -1009,23 +1023,18 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
player.handleEvent(CSPlayerEvent.Play)
|
||||
return true
|
||||
}
|
||||
} else
|
||||
if (!langCode.isNullOrEmpty()) {
|
||||
getAutoSelectSubtitle(
|
||||
currentSubs,
|
||||
settings = true,
|
||||
downloads = false
|
||||
)?.let { sub ->
|
||||
|
||||
if (setSubtitles(sub)) {
|
||||
player.saveData()
|
||||
player.reloadPlayer(ctx)
|
||||
player.handleEvent(CSPlayerEvent.Play)
|
||||
return true
|
||||
}
|
||||
|
||||
} else if (!langCode.isNullOrEmpty()) {
|
||||
getAutoSelectSubtitle(
|
||||
currentSubs, settings = true, downloads = false
|
||||
)?.let { sub ->
|
||||
if (setSubtitles(sub)) {
|
||||
player.saveData()
|
||||
player.reloadPlayer(ctx)
|
||||
player.handleEvent(CSPlayerEvent.Play)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -1081,17 +1090,17 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
context?.let { ctx ->
|
||||
//Generate video title
|
||||
val playerVideoTitle = if (headerName != null) {
|
||||
(headerName +
|
||||
if (tvType.isEpisodeBased() && episode != null)
|
||||
if (season == null)
|
||||
" - ${ctx.getString(R.string.episode)} $episode"
|
||||
else
|
||||
" \"${ctx.getString(R.string.season_short)}${season}:${
|
||||
ctx.getString(
|
||||
R.string.episode_short
|
||||
)
|
||||
}${episode}\""
|
||||
else "") + if (subName.isNullOrBlank() || subName == headerName) "" else " - $subName"
|
||||
(headerName + if (tvType.isEpisodeBased() && episode != null) if (season == null) " - ${
|
||||
ctx.getString(
|
||||
R.string.episode
|
||||
)
|
||||
} $episode"
|
||||
else " \"${ctx.getString(R.string.season_short)}${season}:${
|
||||
ctx.getString(
|
||||
R.string.episode_short
|
||||
)
|
||||
}${episode}\""
|
||||
else "") + if (subName.isNullOrBlank() || subName == headerName) "" else " - $subName"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
@ -1131,16 +1140,17 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
""
|
||||
}
|
||||
|
||||
val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name
|
||||
?: "NULL"
|
||||
val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL"
|
||||
|
||||
player_video_title_rez?.text = when (titleRez) {
|
||||
val title = when (titleRez) {
|
||||
0 -> ""
|
||||
1 -> extra
|
||||
2 -> source
|
||||
3 -> "$source - $extra"
|
||||
else -> ""
|
||||
}
|
||||
player_video_title_rez?.text = title
|
||||
player_video_title_rez?.isVisible = title.isNotBlank()
|
||||
}
|
||||
|
||||
override fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
|
||||
|
@ -1155,14 +1165,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason
|
||||
isTv = isTvSettings()
|
||||
layout =
|
||||
if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player
|
||||
layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player
|
||||
|
||||
viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java]
|
||||
sync = ViewModelProvider(this)[SyncViewModel::class.java]
|
||||
|
@ -1174,6 +1181,68 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
var timestampShowState = false
|
||||
|
||||
var skipAnimator: ValueAnimator? = null
|
||||
var skipIndex = 0
|
||||
|
||||
private fun displayTimeStamp(show: Boolean) {
|
||||
if (timestampShowState == show) return
|
||||
skipIndex++
|
||||
println("displayTimeStamp = $show")
|
||||
timestampShowState = show
|
||||
skip_chapter_button?.apply {
|
||||
val showWidth = 170.toPx
|
||||
val noShowWidth = 10.toPx
|
||||
//if((show && width == showWidth) || (!show && width == noShowWidth)) {
|
||||
// return
|
||||
//}
|
||||
val to = if (show) showWidth else noShowWidth
|
||||
val from = if (!show) showWidth else noShowWidth
|
||||
|
||||
skipAnimator?.cancel()
|
||||
isVisible = true
|
||||
|
||||
// just in case
|
||||
val lay = layoutParams
|
||||
lay.width = from
|
||||
layoutParams = lay
|
||||
skipAnimator = ValueAnimator.ofInt(
|
||||
from, to
|
||||
).apply {
|
||||
addListener(onEnd = {
|
||||
if (!show) skip_chapter_button?.isVisible = false
|
||||
})
|
||||
addUpdateListener { valueAnimator ->
|
||||
val value = valueAnimator.animatedValue as Int
|
||||
val layoutParams: ViewGroup.LayoutParams = layoutParams
|
||||
layoutParams.width = value
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
duration = 500
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
|
||||
displayTimeStamp(false)
|
||||
}
|
||||
|
||||
override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
||||
if (timestamp != null) {
|
||||
skip_chapter_button.setText(timestamp.uiText)
|
||||
displayTimeStamp(true)
|
||||
val currentIndex = skipIndex
|
||||
skip_chapter_button?.handler?.postDelayed({
|
||||
if (skipIndex == currentIndex)
|
||||
displayTimeStamp(false)
|
||||
}, 6000)
|
||||
} else {
|
||||
displayTimeStamp(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
var langFilterList = listOf<String>()
|
||||
|
@ -1189,8 +1258,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false)
|
||||
if (filterSubByLang) {
|
||||
val langFromPrefMedia = settingsManager.getStringSet(
|
||||
this.getString(R.string.provider_lang_key),
|
||||
mutableSetOf("en")
|
||||
this.getString(R.string.provider_lang_key), mutableSetOf("en")
|
||||
)
|
||||
langFilterList = langFromPrefMedia?.mapNotNull {
|
||||
fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null
|
||||
|
@ -1203,7 +1271,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
sync.updateUserData()
|
||||
|
||||
preferredAutoSelectSubtitles = SubtitlesFragment.getAutoSelectLanguageISO639_1()
|
||||
preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1()
|
||||
|
||||
if (currentSelectedLink == null) {
|
||||
viewModel.loadLinks()
|
||||
|
@ -1218,6 +1286,10 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
activity?.popCurrentPage()
|
||||
}
|
||||
|
||||
observe(viewModel.currentStamps) { stamps ->
|
||||
player.addTimeStamps(stamps)
|
||||
}
|
||||
|
||||
observe(viewModel.loadingLinks) {
|
||||
when (it) {
|
||||
is Resource.Loading -> {
|
||||
|
@ -1253,8 +1325,10 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
Log.i("subfilter", "Filtering subtitle")
|
||||
langFilterList.forEach { lang ->
|
||||
Log.i("subfilter", "Lang: $lang")
|
||||
setOfSub += set.filter { it.name.contains(lang, ignoreCase = true) }
|
||||
.toMutableSet()
|
||||
setOfSub += set.filter {
|
||||
it.name.contains(lang, ignoreCase = true) ||
|
||||
it.origin != SubtitleOrigin.URL
|
||||
}
|
||||
}
|
||||
currentSubs = setOfSub
|
||||
} else {
|
||||
|
@ -1262,7 +1336,13 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
player.setActiveSubtitles(set)
|
||||
|
||||
autoSelectSubtitles()
|
||||
// If the file is downloaded then do not select auto select the subtitles
|
||||
// Downloaded subtitles cannot be selected immediately after loading since
|
||||
// player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles
|
||||
// Resulting in unselecting the downloaded subtitle
|
||||
if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) {
|
||||
autoSelectSubtitles()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue