mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-06-19 20:05:41 +00:00
Compare commits
258 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6bf2984e |
||
|
|
6f458fc9b5 |
||
|
|
b4100dbfca |
||
|
|
943bc551e9 |
||
|
|
c045bfdc0d |
||
|
|
2c03a3d976 |
||
|
|
3417fe0160 |
||
|
|
55450a02fa |
||
|
|
6f9646e52f |
||
|
|
b222911e29 |
||
|
|
5667f52648 |
||
|
|
b2a02a174f |
||
|
|
18a857723b |
||
|
|
292d3f1442 |
||
|
|
8012c58069 |
||
|
|
4f8a79669c |
||
|
|
2181243dd1 |
||
|
|
eae18bb50d |
||
|
|
f7cbf25b30 |
||
|
|
fd579fcc18 |
||
|
|
79cc3fb501 |
||
|
|
d78b991d66 |
||
|
|
70053ebbae |
||
|
|
a4a4c31f8d |
||
|
|
3844c896f1 |
||
|
|
11f77fbe11 |
||
|
|
62662cb064 |
||
|
|
8e0c664b1e |
||
|
|
4836e2b371 |
||
|
|
0e16f429af |
||
|
|
5de7f207f2 |
||
|
|
e1aacce93d |
||
|
|
8e1b41ea61 |
||
|
|
b7f5826a19 |
||
|
|
8e7569df53 |
||
|
|
0728dd06a1 |
||
|
|
041d21a486 |
||
|
|
a124450ddc |
||
|
|
028a794ea5 |
||
|
|
c1b6fc2eeb |
||
|
|
647c274944 |
||
|
|
22be73619e |
||
|
|
a3c100e75b |
||
|
|
d24f8bca0f |
||
|
|
4c3c463a19 |
||
|
|
007c0ff9bc |
||
|
|
c8bc999d22 |
||
|
|
b353cf2017 |
||
|
|
70ed1c753d |
||
|
|
00e943ebc4 |
||
|
|
0afb23eb2e |
||
|
|
0b642bb47f |
||
|
|
c6c70d5751 |
||
|
|
c1b49d0dcb |
||
|
|
85cc10c2e0 |
||
|
|
dd016341c0 |
||
|
|
ac0a0d2941 |
||
|
|
4ab97e4605 |
||
|
|
f894b8f7ec |
||
|
|
72386cb98c |
||
|
|
419b902ead |
||
|
|
638d749945 |
||
|
|
0f41ca2641 |
||
|
|
a6000fbe04 |
||
|
|
862e2590d2 |
||
|
|
9bc5027ea7 |
||
|
|
7e406cb5eb |
||
|
|
a24dc2600e |
||
|
|
89cc63673b |
||
|
|
ab85737637 |
||
|
|
9a53e267ac |
||
|
|
03eb6ccd45 |
||
|
|
7425d283cd |
||
|
|
6eb833130d |
||
|
|
3d1852ba04 | ||
|
|
59ae579b7c |
||
|
|
aa6e702b59 |
||
|
|
0d16a636e2 |
||
|
|
bfc926814c |
||
|
|
a45f1d9ab1 |
||
|
|
948a2c1725 |
||
|
|
4e24aa5db1 |
||
|
|
7476d24db3 |
||
|
|
c82fec0862 |
||
|
|
e36e9e8d24 |
||
|
|
e64136db8a |
||
|
|
104ab26790 |
||
|
|
2400e6ab45 |
||
|
|
4cc76ee6c5 |
||
|
|
8523a4bd90 |
||
|
|
58f45c7bda |
||
|
|
d30d71f39e |
||
|
|
4610d6aae7 |
||
|
|
9257d31090 |
||
|
|
2755385fa6 |
||
|
|
18d9f5c317 |
||
|
|
53345a804f |
||
|
|
71306d4e98 |
||
|
|
6d79b0e5d0 |
||
|
|
3bdda7d380 |
||
|
|
659f639acd |
||
|
|
1d750340a0 |
||
|
|
d4899536d3 |
||
|
|
18ee71664f |
||
|
|
f7494f20e1 |
||
|
|
590a94e318 |
||
|
|
2264b90396 |
||
|
|
0ed6fd8fef |
||
|
|
e3e995b222 |
||
|
|
7c1554a479 |
||
|
|
7926e60fb0 |
||
|
|
68a1d0856c |
||
|
|
ee6a9af217 |
||
|
|
c1eef1de1d |
||
|
|
f175beb51b |
||
|
|
e55794c200 |
||
|
|
c67ba2b485 |
||
|
|
6336837903 |
||
|
|
636d2507f7 |
||
|
|
cd03392364 |
||
|
|
c31c5764ea |
||
|
|
7925e714e7 |
||
|
|
8d416fa2fc |
||
|
|
0bb9322276 |
||
|
|
cfce80e93e |
||
|
|
fb54d02979 |
||
|
|
788189c80c |
||
|
|
1b0fdb57a8 |
||
|
|
2eb63dc334 |
||
|
|
bd7db6c20a |
||
|
|
14d56de61e |
||
|
|
adf2ed6df3 |
||
|
|
b89f36c9bc |
||
|
|
0f1cb3a773 |
||
|
|
c304e8556e |
||
|
|
a7f5f9a35a |
||
|
|
d7b030e7ef |
||
|
|
fe0829ff64 |
||
|
|
bb4e5da5c9 |
||
|
|
c9a24e198c |
||
|
|
ca96aa6891 |
||
|
|
8bdc1a83d7 |
||
|
|
be69ec938e |
||
|
|
04b22ba4df |
||
|
|
e22a596d0c |
||
|
|
f6920fb05d |
||
|
|
f28924f704 |
||
|
|
b510942027 |
||
|
|
1d03b05a7c |
||
|
|
f51885fb6e |
||
|
|
31165a87c1 |
||
|
|
9f792f5b1a |
||
|
|
418cf08ad4 |
||
|
|
d495cbe32d |
||
|
|
08b1d97152 |
||
|
|
62e6895d8e |
||
|
|
ae9a374a83 |
||
|
|
154cd7500b |
||
|
|
76e30d2e75 |
||
|
|
b2cd9612ea |
||
|
|
562a5d8192 |
||
|
|
bb295ded09 |
||
|
|
ba9413e972 |
||
|
|
db154a8cd2 |
||
|
|
736c6374a6 |
||
|
|
9fe7662f95 |
||
|
|
d23fb0ac4c |
||
|
|
c26f236202 |
||
|
|
673569a747 | ||
|
|
7a2222b252 |
||
|
|
76a2feb79c |
||
|
|
81c7d90a5f |
||
|
|
235863f9d2 |
||
|
|
07eb9973f8 |
||
|
|
a23c136d81 |
||
|
|
89400be5e5 |
||
|
|
d06afa32fd |
||
|
|
f674b427ac |
||
|
|
f5b46949ec |
||
|
|
dce70ac229 |
||
|
|
45699b72a8 |
||
|
|
a74a0840d6 |
||
|
|
73e19212cc |
||
|
|
ee1e90e0f4 |
||
|
|
51bd1c4a6c |
||
|
|
19efb1ffc3 |
||
|
|
86cca03dd7 |
||
|
|
ef07f761d7 |
||
|
|
904dda0c60 |
||
|
|
8d3846d2a3 |
||
|
|
ccc0a45065 |
||
|
|
06907bed05 |
||
|
|
b0d3731faa |
||
|
|
618f9cde65 |
||
|
|
1fb6ce310d |
||
|
|
2ca21051d5 |
||
|
|
a8f6ef0ea5 |
||
|
|
809b66af81 |
||
|
|
ecc3e506f9 |
||
|
|
ef9e49d955 |
||
|
|
a65828e2b0 |
||
|
|
9f2067bbff |
||
|
|
514a808218 |
||
|
|
8e71baeb84 |
||
|
|
543d1b4478 |
||
|
|
6e423ba24e |
||
|
|
46f9c95376 |
||
|
|
1d1a7fb6fe |
||
|
|
eaf2b7bd0d |
||
|
|
6806a4e2e6 |
||
|
|
8baee7ee78 |
||
|
|
ea4ef5c2f3 |
||
|
|
ad2168c5bc |
||
|
|
76728d858f |
||
|
|
88d42613d3 |
||
|
|
c862d119fb |
||
|
|
443c1c81c9 |
||
|
|
8796a73f06 |
||
|
|
960658df61 |
||
|
|
3da9b2ec7b |
||
|
|
b58e0b893f |
||
|
|
f6339e44e1 |
||
|
|
32f1a2e6c3 |
||
|
|
cf084ac2eb |
||
|
|
2c62f3fa46 |
||
|
|
8fcce6b5fd |
||
| 4530c00a71 | |||
|
|
2766ac86a1 |
||
|
|
838989beaa |
||
|
|
b370b5b9e7 |
||
|
|
30b5a4e649 |
||
|
|
a45593283d |
||
|
|
60244d86a4 |
||
|
|
c25a9dc56b |
||
|
|
6acc3d8f65 |
||
|
|
47b568c289 |
||
|
|
045fc2770f |
||
|
|
bef80875b1 |
||
|
|
c44d07b4e5 |
||
|
|
4e2bfd3d43 |
||
|
|
06456bc548 |
||
|
|
af1e0757f4 |
||
|
|
4271b8104e |
||
|
|
5e039a80ba |
||
|
|
c618e4e505 |
||
|
|
cbad2cfdaf |
||
|
|
290283dc15 |
||
|
|
7ecb7785c2 |
||
|
|
f82fe7b0ce |
||
|
|
4b28140f8b |
||
|
|
6f1e4a959f |
||
|
|
0c25630f0b |
||
|
|
f6f3e3ff73 |
||
|
|
c1a2ae8704 |
||
|
|
fda9f0f8c0 |
||
|
|
58ca69c284 |
||
|
|
663c8a93cb |
||
|
|
c28ee05bde |
381 changed files with 19885 additions and 10958 deletions
7
.github/workflows/build_to_archive.yml
vendored
7
.github/workflows/build_to_archive.yml
vendored
|
|
@ -9,6 +9,9 @@ on:
|
||||||
- '**/wcokey.txt'
|
- '**/wcokey.txt'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "Archive-build"
|
group: "Archive-build"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
@ -61,13 +64,15 @@ jobs:
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrerelease
|
run: ./gradlew assemblePrereleaseRelease
|
||||||
env:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
|
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||||
|
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
6
.github/workflows/generate_dokka.yml
vendored
6
.github/workflows/generate_dokka.yml
vendored
|
|
@ -6,6 +6,9 @@ on:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '*.md'
|
- '*.md'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "dokka"
|
group: "dokka"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
@ -51,9 +54,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
- name: Set up Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Generate Dokka
|
- name: Generate Dokka
|
||||||
run: |
|
run: |
|
||||||
cd $GITHUB_WORKSPACE/src/
|
cd $GITHUB_WORKSPACE/src/
|
||||||
|
|
|
||||||
94
.github/workflows/issue_action.yml
vendored
94
.github/workflows/issue_action.yml
vendored
|
|
@ -1,94 +0,0 @@
|
||||||
name: Issue automatic actions
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
issue-moderator:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Generate access token
|
|
||||||
id: generate_token
|
|
||||||
uses: tibdex/github-app-token@v2
|
|
||||||
with:
|
|
||||||
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 }}
|
|
||||||
filter-threshold: 0.60
|
|
||||||
title-excludes: ''
|
|
||||||
comment-title: |
|
|
||||||
### 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@v8
|
|
||||||
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@v6
|
|
||||||
|
|
||||||
- name: Automatically close issues that dont follow the issue template
|
|
||||||
uses: lucasbento/auto-close-issues@v1.0.2
|
|
||||||
with:
|
|
||||||
github-token: ${{ steps.generate_token.outputs.token }}
|
|
||||||
issue-close-message: |
|
|
||||||
@${issue.user.login}: hello! :wave:
|
|
||||||
This issue is being automatically closed because it does not follow the issue template."
|
|
||||||
closed-issues-label: "invalid"
|
|
||||||
|
|
||||||
- name: Check if issue mentions a provider
|
|
||||||
id: provider_check
|
|
||||||
env:
|
|
||||||
GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
|
|
||||||
run: |
|
|
||||||
wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
|
|
||||||
pip3 install httpx
|
|
||||||
RES="$(python3 ./check_issue.py)"
|
|
||||||
echo "name=${RES}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Comment if issue mentions a provider
|
|
||||||
if: steps.provider_check.outputs.name != 'none'
|
|
||||||
uses: actions-cool/issues-helper@v3
|
|
||||||
with:
|
|
||||||
actions: 'create-comment'
|
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
|
||||||
body: |
|
|
||||||
Hello ${{ github.event.issue.user.login }}.
|
|
||||||
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@v8
|
|
||||||
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:
|
|
||||||
type: 'issue'
|
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
|
||||||
emoji: 'eyes'
|
|
||||||
6
.github/workflows/prerelease.yml
vendored
6
.github/workflows/prerelease.yml
vendored
|
|
@ -12,6 +12,9 @@ concurrency:
|
||||||
group: "pre-release"
|
group: "pre-release"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -52,13 +55,14 @@ jobs:
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrerelease build androidSourcesJar makeJar
|
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
|
||||||
env:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
|
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
|
|
|
||||||
7
.github/workflows/pull_request.yml
vendored
7
.github/workflows/pull_request.yml
vendored
|
|
@ -2,6 +2,9 @@ name: Artifact Build
|
||||||
|
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -24,10 +27,10 @@ jobs:
|
||||||
cache-read-only: false
|
cache-read-only: false
|
||||||
|
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseDebug
|
run: ./gradlew assemblePrereleaseDebug lint check
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: pull-request-build
|
name: pull-request-build
|
||||||
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
||||||
|
|
|
||||||
3
.github/workflows/update_locales.yml
vendored
3
.github/workflows/update_locales.yml
vendored
|
|
@ -11,6 +11,9 @@ concurrency:
|
||||||
group: "locale"
|
group: "locale"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create:
|
create:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -8,47 +8,89 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.dokka)
|
alias(libs.plugins.dokka)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
|
||||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
|
||||||
|
|
||||||
fun getGitCommitHash(): String {
|
abstract class GenerateGitHashTask : DefaultTask() {
|
||||||
return try {
|
|
||||||
val headFile = file("${project.rootDir}/.git/HEAD")
|
|
||||||
|
|
||||||
// Read the commit hash from .git/HEAD
|
@get:InputFile
|
||||||
if (headFile.exists()) {
|
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||||
val headContent = headFile.readText().trim()
|
abstract val headFile: RegularFileProperty
|
||||||
if (headContent.startsWith("ref:")) {
|
|
||||||
val refPath = headContent.substring(5) // e.g., refs/heads/main
|
@get:InputDirectory
|
||||||
val commitFile = file("${project.rootDir}/.git/$refPath")
|
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||||
if (commitFile.exists()) commitFile.readText().trim() else ""
|
abstract val headsDir: DirectoryProperty
|
||||||
} else headContent // If it's a detached HEAD (commit hash directly)
|
|
||||||
} else {
|
@get:OutputDirectory
|
||||||
"" // If .git/HEAD doesn't exist
|
abstract val outputDir: DirectoryProperty
|
||||||
}.take(7) // Return the short commit hash
|
|
||||||
} catch (_: Throwable) {
|
@TaskAction
|
||||||
"" // Just return an empty string if any exception occurs
|
fun generate() {
|
||||||
|
val head = headFile.get().asFile
|
||||||
|
|
||||||
|
val hash = try {
|
||||||
|
if (head.exists()) {
|
||||||
|
// Read the commit hash from .git/HEAD
|
||||||
|
val headContent = head.readText().trim()
|
||||||
|
if (headContent.startsWith("ref:")) {
|
||||||
|
val refPath = headContent.substring(5) // e.g., refs/heads/main
|
||||||
|
val commitFile = File(head.parentFile, refPath)
|
||||||
|
if (commitFile.exists()) commitFile.readText().trim() else ""
|
||||||
|
} else headContent // If it's a detached HEAD (commit hash directly)
|
||||||
|
} else "" // If .git/HEAD doesn't exist
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
"" // Just set to an empty string if any exception occurs
|
||||||
|
}.take(7) // Get the short commit hash
|
||||||
|
|
||||||
|
val outFile = outputDir.file("git-hash.txt").get().asFile
|
||||||
|
outFile.parentFile.mkdirs()
|
||||||
|
outFile.writeText(hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val generateGitHash = tasks.register<GenerateGitHashTask>("generateGitHash") {
|
||||||
|
val gitDir = layout.projectDirectory.dir("../.git")
|
||||||
|
|
||||||
|
headFile.set(gitDir.file("HEAD"))
|
||||||
|
headsDir.set(gitDir.dir("refs/heads"))
|
||||||
|
|
||||||
|
outputDir.set(layout.buildDirectory.dir("generated/git"))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@Suppress("UnstableApiUsage")
|
@Suppress("UnstableApiUsage")
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
viewBinding {
|
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
|
||||||
enable = true
|
dependenciesInfo {
|
||||||
|
// Disables dependency metadata when building APKs.
|
||||||
|
includeInApk = false
|
||||||
|
// Disables dependency metadata when building Android App Bundles.
|
||||||
|
includeInBundle = false
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||||
|
generateGitHash,
|
||||||
|
GenerateGitHashTask::outputDir
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (prereleaseStoreFile != null) {
|
// We just use SIGNING_KEY_ALIAS here since it won't change
|
||||||
|
// so won't kill the configuration cache.
|
||||||
|
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
|
||||||
create("prerelease") {
|
create("prerelease") {
|
||||||
storeFile = file(prereleaseStoreFile)
|
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
||||||
|
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
||||||
|
|
||||||
|
storeFile = prereleaseStoreFile?.let { file(it) }
|
||||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||||
|
|
@ -62,10 +104,8 @@ android {
|
||||||
applicationId = "com.lagradost.cloudstream3"
|
applicationId = "com.lagradost.cloudstream3"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = 67
|
versionCode = libs.versions.versionCode.get().toInt()
|
||||||
versionName = "4.6.1"
|
versionName = libs.versions.versionName.get()
|
||||||
|
|
||||||
resValue("string", "commit_hash", getGitCommitHash())
|
|
||||||
|
|
||||||
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
|
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
|
||||||
|
|
||||||
|
|
@ -135,21 +175,28 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
// Use Java 17 toolchain even if a higher JDK runs the build.
|
// Use Java 17 toolchain even if a higher JDK runs the build.
|
||||||
// We still use Java 8 for now which higher JDKs have deprecated.
|
// We still use Java 8 for now which higher JDKs have deprecated.
|
||||||
toolchain {
|
toolchain {
|
||||||
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
|
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
abortOnError = false
|
|
||||||
checkReleaseBuilds = false
|
checkReleaseBuilds = false
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
resValues = true
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
// Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
|
||||||
|
// Note: This may increase app startup time slightly.
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace = "com.lagradost.cloudstream3"
|
namespace = "com.lagradost.cloudstream3"
|
||||||
|
|
@ -160,17 +207,22 @@ dependencies {
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.json)
|
testImplementation(libs.json)
|
||||||
androidTestImplementation(libs.core)
|
androidTestImplementation(libs.core)
|
||||||
implementation(libs.junit.ktx)
|
|
||||||
androidTestImplementation(libs.ext.junit)
|
|
||||||
androidTestImplementation(libs.espresso.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
|
androidTestImplementation(libs.ext.junit)
|
||||||
|
androidTestImplementation(libs.instancio.core)
|
||||||
|
androidTestImplementation(libs.junit.ktx)
|
||||||
|
androidTestImplementation(libs.kotlin.test)
|
||||||
|
|
||||||
// Android Core & Lifecycle
|
// Android Core & Lifecycle
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
implementation(libs.activity.ktx)
|
implementation(libs.activity.ktx)
|
||||||
|
implementation(libs.annotation)
|
||||||
implementation(libs.appcompat)
|
implementation(libs.appcompat)
|
||||||
implementation(libs.fragment.ktx)
|
implementation(libs.fragment.ktx)
|
||||||
implementation(libs.bundles.lifecycle)
|
implementation(libs.bundles.lifecycle)
|
||||||
implementation(libs.bundles.navigation)
|
implementation(libs.bundles.navigation)
|
||||||
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
|
implementation(libs.kotlinx.serialization.json) // JSON Parser
|
||||||
|
|
||||||
// Design & UI
|
// Design & UI
|
||||||
implementation(libs.preference.ktx)
|
implementation(libs.preference.ktx)
|
||||||
|
|
@ -187,6 +239,9 @@ dependencies {
|
||||||
// FFmpeg Decoding
|
// FFmpeg Decoding
|
||||||
implementation(libs.bundles.nextlib)
|
implementation(libs.bundles.nextlib)
|
||||||
|
|
||||||
|
// Anime-db for filler
|
||||||
|
implementation(libs.anime.db)
|
||||||
|
|
||||||
// PlayBack
|
// PlayBack
|
||||||
implementation(libs.colorpicker) // Subtitle Color Picker
|
implementation(libs.colorpicker) // Subtitle Color Picker
|
||||||
implementation(libs.newpipeextractor) // For Trailers
|
implementation(libs.newpipeextractor) // For Trailers
|
||||||
|
|
@ -204,13 +259,15 @@ dependencies {
|
||||||
// Extensions & Other Libs
|
// Extensions & Other Libs
|
||||||
implementation(libs.jsoup) // HTML Parser
|
implementation(libs.jsoup) // HTML Parser
|
||||||
implementation(libs.rhino) // Run JavaScript
|
implementation(libs.rhino) // Run JavaScript
|
||||||
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
|
|
||||||
implementation(libs.safefile) // To Prevent the URI File Fu*kery
|
implementation(libs.safefile) // To Prevent the URI File Fu*kery
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
|
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
|
||||||
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
|
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
|
||||||
implementation(libs.jackson.module.kotlin) // JSON Parser
|
implementation(libs.jackson.module.kotlin) // JSON Parser
|
||||||
implementation(libs.zipline)
|
implementation(libs.zipline)
|
||||||
|
|
||||||
|
// Deprecated; will be removed once extensions have time to migrate from using it
|
||||||
|
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||||
|
|
||||||
// Torrent Support
|
// Torrent Support
|
||||||
implementation(libs.torrentserver)
|
implementation(libs.torrentserver)
|
||||||
|
|
||||||
|
|
@ -218,18 +275,7 @@ dependencies {
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
implementation(libs.nicehttp) // HTTP Lib
|
implementation(libs.nicehttp) // HTTP Lib
|
||||||
|
|
||||||
implementation(project(":library") {
|
implementation(project(":library"))
|
||||||
// There does not seem to be a good way of getting the android flavor.
|
|
||||||
val isDebug = gradle.startParameter.taskRequests.any { task ->
|
|
||||||
task.args.any { arg ->
|
|
||||||
arg.contains("debug", true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.extra.set("isDebug", isDebug)
|
|
||||||
})
|
|
||||||
// Extra brightness video filters
|
|
||||||
implementation(libs.gpuv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Jar>("androidSourcesJar") {
|
tasks.register<Jar>("androidSourcesJar") {
|
||||||
|
|
@ -266,16 +312,22 @@ tasks.withType<KotlinJvmCompile> {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(javaTarget)
|
jvmTarget.set(javaTarget)
|
||||||
jvmDefault.set(JvmDefaultMode.ENABLE)
|
jvmDefault.set(JvmDefaultMode.ENABLE)
|
||||||
optIn.add("com.lagradost.cloudstream3.Prerelease")
|
|
||||||
freeCompilerArgs.add("-Xannotation-default-target=param-property")
|
freeCompilerArgs.add("-Xannotation-default-target=param-property")
|
||||||
|
optIn.addAll(
|
||||||
|
"com.lagradost.cloudstream3.InternalAPI",
|
||||||
|
"com.lagradost.cloudstream3.Prerelease",
|
||||||
|
"kotlin.uuid.ExperimentalUuidApi",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dokka {
|
dokka {
|
||||||
moduleName = "App"
|
moduleName = "App"
|
||||||
dokkaSourceSets {
|
dokkaSourceSets {
|
||||||
main {
|
configureEach {
|
||||||
|
suppress = name != "prereleaseDebug"
|
||||||
analysisPlatform = KotlinPlatform.JVM
|
analysisPlatform = KotlinPlatform.JVM
|
||||||
|
displayName = "JVM"
|
||||||
documentedVisibilities(
|
documentedVisibilities(
|
||||||
VisibilityModifier.Public,
|
VisibilityModifier.Public,
|
||||||
VisibilityModifier.Protected
|
VisibilityModifier.Protected
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,9 @@
|
||||||
|
|
||||||
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
|
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
|
||||||
<issue id="MissingTranslation" severity="ignore" />
|
<issue id="MissingTranslation" severity="ignore" />
|
||||||
|
|
||||||
|
<!-- We only care about the source language here. -->
|
||||||
|
<issue id="StringFormatInvalid">
|
||||||
|
<ignore path="**/res/values-*/**" />
|
||||||
|
</issue>
|
||||||
</lint>
|
</lint>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import dalvik.system.DexFile
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.InternalSerializationApi
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import kotlinx.serialization.serializerOrNull
|
||||||
|
import org.instancio.Instancio
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
import kotlin.reflect.jvm.jvmName
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SerializationClassTester {
|
||||||
|
// Same as app, or using app reference
|
||||||
|
val jacksonMapper = mapper
|
||||||
|
val kotlinxMapper = json
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isIdenticalSerialization() {
|
||||||
|
val serializableClasses = findSerializableClasses("com.lagradost")
|
||||||
|
println("Number of serializable classes: ${serializableClasses.size}")
|
||||||
|
|
||||||
|
serializableClasses.forEach { kClass ->
|
||||||
|
val instance = Instancio.create(kClass.java)
|
||||||
|
|
||||||
|
val jacksonJson = jacksonMapper.writeValueAsString(instance)
|
||||||
|
val kotlinxJson = serializeWithKotlinx(kClass, instance)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
jacksonJson,
|
||||||
|
kotlinxJson,
|
||||||
|
"""
|
||||||
|
Serialization mismatch for:
|
||||||
|
${kClass.qualifiedName}
|
||||||
|
|
||||||
|
Jackson:
|
||||||
|
$jacksonJson
|
||||||
|
|
||||||
|
Kotlinx:
|
||||||
|
$kotlinxJson
|
||||||
|
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
println("Identical serialization for: ${kClass.jvmName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
||||||
|
@Test
|
||||||
|
fun isIdenticalDeserialization() {
|
||||||
|
val serializableClasses = findSerializableClasses("com.lagradost")
|
||||||
|
println("Number of serializable classes: ${serializableClasses.size}")
|
||||||
|
|
||||||
|
serializableClasses.forEach { kClass ->
|
||||||
|
val instance = Instancio.create(kClass.java)
|
||||||
|
// Convert to JSON to get example JSON object
|
||||||
|
// We prefer jackson here because the app may have many jackson JSON strings in local storage
|
||||||
|
val originalJson = jacksonMapper.writeValueAsString(instance)
|
||||||
|
|
||||||
|
// Create an object from the JSON using kotlinx
|
||||||
|
val serializer =
|
||||||
|
kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
|
||||||
|
assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
|
||||||
|
val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
|
||||||
|
|
||||||
|
// Create an object from the JSON using jackson
|
||||||
|
val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
|
||||||
|
|
||||||
|
|
||||||
|
// Deep inspect both object using the mapper toJson function.
|
||||||
|
// This deep equality check can be performed using other methods, but this just works.
|
||||||
|
val jacksonJson = mapperDecoded.toJson()
|
||||||
|
val kotlinxJson = kotlinxDecoded.toJson()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
jacksonJson,
|
||||||
|
kotlinxJson,
|
||||||
|
"""
|
||||||
|
Serialization mismatch for:
|
||||||
|
${kClass.qualifiedName}
|
||||||
|
|
||||||
|
Jackson:
|
||||||
|
$jacksonJson
|
||||||
|
|
||||||
|
Kotlinx:
|
||||||
|
$kotlinxJson
|
||||||
|
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
println("Identical deserialization for: ${kClass.jvmName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEX files are the best solution to read all our classes dynamically.
|
||||||
|
// classgraph could be used instead, but it only gives results on the JVM, not Android.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun findSerializableClasses(packageName: String): List<KClass<*>> {
|
||||||
|
val context = InstrumentationRegistry
|
||||||
|
.getInstrumentation()
|
||||||
|
.targetContext
|
||||||
|
|
||||||
|
val dexFile = DexFile(context.packageCodePath)
|
||||||
|
return dexFile.entries()
|
||||||
|
.toList()
|
||||||
|
.filter { it.startsWith(packageName) }
|
||||||
|
.mapNotNull {
|
||||||
|
runCatching { Class.forName(it).kotlin }.getOrNull()
|
||||||
|
}.filter { kClass ->
|
||||||
|
// Not possible to use .hasAnnotation() on newer Android versions.
|
||||||
|
kClass.java.annotations.any {
|
||||||
|
it is Serializable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(InternalSerializationApi::class)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun serializeWithKotlinx(
|
||||||
|
kClass: KClass<*>,
|
||||||
|
value: Any
|
||||||
|
): String {
|
||||||
|
val serializer = kClass.serializer() as KSerializer<Any>
|
||||||
|
return kotlinxMapper.encodeToString(serializer, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package com.lagradost.cloudstream3.utils.serializers
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.KeepGeneratedSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
@KeepGeneratedSerializer
|
||||||
|
@Serializable(with = NonEmptyData.Serializer::class)
|
||||||
|
data class NonEmptyData(
|
||||||
|
val title: String = "",
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val meta: Map<String, String> = emptyMap(),
|
||||||
|
val name: String = "hello",
|
||||||
|
) {
|
||||||
|
object Serializer : NonEmptySerializer<NonEmptyData>(NonEmptyData.generatedSerializer())
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
@KeepGeneratedSerializer
|
||||||
|
@Serializable(with = WriteOnlyData.Serializer::class)
|
||||||
|
data class WriteOnlyData(
|
||||||
|
val fieldA: String = "",
|
||||||
|
val fieldB: String = "",
|
||||||
|
) {
|
||||||
|
object Serializer : WriteOnlySerializer<WriteOnlyData>(
|
||||||
|
WriteOnlyData.generatedSerializer(),
|
||||||
|
setOf("fieldB"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
@KeepGeneratedSerializer
|
||||||
|
@Serializable(with = MultiWriteOnly.Serializer::class)
|
||||||
|
data class MultiWriteOnly(
|
||||||
|
val fieldA: String = "",
|
||||||
|
val fieldB: String = "",
|
||||||
|
val fieldC: String = "",
|
||||||
|
) {
|
||||||
|
object Serializer : WriteOnlySerializer<MultiWriteOnly>(
|
||||||
|
MultiWriteOnly.generatedSerializer(),
|
||||||
|
setOf("fieldB", "fieldC"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UriData(
|
||||||
|
@Serializable(with = UriSerializer::class)
|
||||||
|
val uri: Uri = Uri.EMPTY,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SerializerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonEmptySerializerOmitsEmptyStrings() {
|
||||||
|
val data = NonEmptyData(title = "", name = "hello")
|
||||||
|
val result = data.toJson()
|
||||||
|
assertFalse(result.contains("title"))
|
||||||
|
assertTrue(result.contains("name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonEmptySerializerOmitsEmptyLists() {
|
||||||
|
val data = NonEmptyData(tags = emptyList(), name = "hello")
|
||||||
|
val result = data.toJson()
|
||||||
|
assertFalse(result.contains("tags"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonEmptySerializerOmitsEmptyMaps() {
|
||||||
|
val data = NonEmptyData(meta = emptyMap(), name = "hello")
|
||||||
|
val result = data.toJson()
|
||||||
|
assertFalse(result.contains("meta"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonEmptySerializerKeepsNonEmptyFields() {
|
||||||
|
val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
|
||||||
|
val result = data.toJson()
|
||||||
|
assertTrue(result.contains("title"))
|
||||||
|
assertTrue(result.contains("tags"))
|
||||||
|
assertTrue(result.contains("meta"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonEmptySerializerDoesNotAffectDeserialization() {
|
||||||
|
val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
|
||||||
|
val result = parseJson<NonEmptyData>(input)
|
||||||
|
assertEquals("hello", result.title)
|
||||||
|
assertEquals(listOf("a"), result.tags)
|
||||||
|
assertEquals(mapOf("k" to "v"), result.meta)
|
||||||
|
assertEquals("world", result.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writeOnlySerializerOmitsFieldOnSerialize() {
|
||||||
|
val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
|
||||||
|
val result = data.toJson()
|
||||||
|
assertTrue(result.contains("fieldA"))
|
||||||
|
assertFalse(result.contains("fieldB"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writeOnlySerializerDeserializesNormally() {
|
||||||
|
val input = """{"fieldA":"hello","fieldB":"secret"}"""
|
||||||
|
val result = parseJson<WriteOnlyData>(input)
|
||||||
|
assertEquals("hello", result.fieldA)
|
||||||
|
assertEquals("secret", result.fieldB)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writeOnlySerializerDeserializesMissingAsDefault() {
|
||||||
|
val input = """{"fieldA":"hello"}"""
|
||||||
|
val result = parseJson<WriteOnlyData>(input)
|
||||||
|
assertEquals("hello", result.fieldA)
|
||||||
|
assertEquals("", result.fieldB)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writeOnlySerializerHandlesMultipleKeys() {
|
||||||
|
val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
|
||||||
|
val result = data.toJson()
|
||||||
|
assertTrue(result.contains("fieldA"))
|
||||||
|
assertFalse(result.contains("fieldB"))
|
||||||
|
assertFalse(result.contains("fieldC"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uriSerializerSerializesUriToString() {
|
||||||
|
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
|
||||||
|
val result = data.toJson()
|
||||||
|
assertTrue(result.contains("https://example.com/path?query=1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uriSerializerDeserializesStringToUri() {
|
||||||
|
val input = """{"uri":"https://example.com/path?query=1"}"""
|
||||||
|
val result = parseJson<UriData>(input)
|
||||||
|
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uriSerializerRoundtripsCorrectly() {
|
||||||
|
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
|
||||||
|
val encoded = data.toJson()
|
||||||
|
val decoded = parseJson<UriData>(encoded)
|
||||||
|
assertEquals(data.uri, decoded.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,47 @@
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<!--
|
||||||
|
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
|
||||||
|
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
|
||||||
|
-->
|
||||||
|
<!-- For external video players -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:mimeType="application/x-mpegURL" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:mimeType="application/vnd.apple.mpegurl" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="magnet" />
|
||||||
|
</intent>
|
||||||
|
|
||||||
|
<!-- Common players supported in actions/temp -->
|
||||||
|
<package android:name="org.videolan.vlc" />
|
||||||
|
<package android:name="org.videolan.vlc.debug" />
|
||||||
|
<package android:name="is.xyz.mpv" />
|
||||||
|
<package android:name="is.xyz.mpv.ytdl" />
|
||||||
|
<package android:name="app.marlboroadvance.mpvex" />
|
||||||
|
<package android:name="live.mehiz.mpvkt" />
|
||||||
|
<package android:name="live.mehiz.mpvkt.preview" />
|
||||||
|
<package android:name="com.brouken.player" />
|
||||||
|
<package android:name="dev.anilbeesetti.nextplayer" />
|
||||||
|
<package android:name="com.instantbits.cast.webvideo" />
|
||||||
|
<package android:name="com.gianlu.aria2android" />
|
||||||
|
|
||||||
|
<!-- Torrent clients -->
|
||||||
|
<package android:name="org.proninyaroslav.libretorrent" />
|
||||||
|
<package android:name="com.biglybt.android.client" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<!-- Fixes android tv fuckery -->
|
<!-- Fixes android tv fuckery -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -108,14 +149,31 @@
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
||||||
no idea about side effects
|
no idea about side effects
|
||||||
|
|
||||||
|
Not exported to prevent bypassing the AccountSelectActivity
|
||||||
-->
|
-->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
||||||
android:exported="true"
|
android:exported="false"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.account.AccountSelectActivity"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:exported="true">
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
@ -173,7 +231,7 @@
|
||||||
<data android:scheme="cloudstreamcontinuewatching" />
|
<data android:scheme="cloudstreamcontinuewatching" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="false">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
@ -186,21 +244,6 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.account.AccountSelectActivity"
|
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter android:exported="true">
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
|
|
@ -216,6 +259,12 @@
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".services.DownloadQueueService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
|
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,78 @@
|
||||||
package com.lagradost.cloudstream3
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.lagradost.api.setContext
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
|
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
|
||||||
* Use CloudStreamApp instead.
|
* Use CloudStreamApp instead.
|
||||||
*/
|
*/
|
||||||
// Deprecate after next stable
|
@Deprecated(
|
||||||
/*@Deprecated(
|
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
class AcraApplication {
|
class AcraApplication {
|
||||||
// All methods here can be changed to be a wrapper around CloudStream app
|
|
||||||
// without a seperate deprecation after next stable. All methods should
|
|
||||||
// also be deprecated at that time.
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
// This can be removed without deprecation after next stable
|
@Deprecated(
|
||||||
private var _context: WeakReference<Context>? = null
|
|
||||||
/*@Deprecated(
|
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
var context
|
val context get() = CloudStreamApp.context
|
||||||
get() = _context?.get()
|
|
||||||
internal set(value) {
|
|
||||||
_context = WeakReference(value)
|
|
||||||
setContext(WeakReference(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
fun removeKeys(folder: String): Int? {
|
fun removeKeys(folder: String): Int? =
|
||||||
return context?.removeKeys(folder)
|
CloudStreamApp.removeKeys(folder)
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
fun <T> setKey(path: String, value: T) {
|
fun <T> setKey(path: String, value: T) =
|
||||||
context?.setKey(path, value)
|
CloudStreamApp.setKey(path, value)
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
fun <T> setKey(folder: String, path: String, value: T) {
|
fun <T> setKey(folder: String, path: String, value: T) =
|
||||||
context?.setKey(folder, path, value)
|
CloudStreamApp.setKey(folder, path, value)
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
|
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
|
||||||
return context?.getKey(path, defVal)
|
CloudStreamApp.getKey(path, defVal)
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
inline fun <reified T : Any> getKey(path: String): T? {
|
inline fun <reified T : Any> getKey(path: String): T? =
|
||||||
return context?.getKey(path)
|
CloudStreamApp.getKey(path)
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
|
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
|
||||||
return context?.getKey(folder, path)
|
CloudStreamApp.getKey(folder, path)
|
||||||
}
|
|
||||||
|
|
||||||
/*@Deprecated(
|
@Deprecated(
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
|
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
|
||||||
level = DeprecationLevel.WARNING
|
level = DeprecationLevel.WARNING
|
||||||
)*/
|
)
|
||||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
|
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
|
||||||
return context?.getKey(folder, path, defVal)
|
CloudStreamApp.getKey(folder, path, defVal)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import coil3.ImageLoader
|
||||||
import coil3.PlatformContext
|
import coil3.PlatformContext
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import com.lagradost.api.setContext
|
import com.lagradost.api.setContext
|
||||||
|
import com.lagradost.cloudstream3.BuildConfig
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.mvvm.safeAsync
|
import com.lagradost.cloudstream3.mvvm.safeAsync
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
|
|
@ -20,6 +21,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
|
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
|
||||||
|
import com.lagradost.cloudstream3.utils.AppDebug
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||||
|
|
@ -65,7 +67,6 @@ class ExceptionHandler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Prerelease
|
|
||||||
class CloudStreamApp : Application(), SingletonImageLoader.Factory {
|
class CloudStreamApp : Application(), SingletonImageLoader.Factory {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|
@ -81,13 +82,13 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory {
|
||||||
exceptionHandler = it
|
exceptionHandler = it
|
||||||
Thread.setDefaultUncaughtExceptionHandler(it)
|
Thread.setDefaultUncaughtExceptionHandler(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppDebug.isDebug = BuildConfig.DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
context = base
|
context = base
|
||||||
// This can be removed without deprecation after next stable
|
|
||||||
AcraApplication.context = context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
|
@ -39,7 +41,6 @@ import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
|
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
|
||||||
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
|
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
|
||||||
import com.lagradost.cloudstream3.ui.player.Torrent
|
import com.lagradost.cloudstream3.ui.player.Torrent
|
||||||
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
|
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
|
||||||
|
|
@ -115,7 +116,6 @@ object CommonActivity {
|
||||||
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
||||||
val onDialogDismissedEvent = Event<Int>()
|
val onDialogDismissedEvent = Event<Int>()
|
||||||
|
|
||||||
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
|
||||||
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
||||||
var appliedTheme: Int = 0
|
var appliedTheme: Int = 0
|
||||||
var appliedColor: Int = 0
|
var appliedColor: Int = 0
|
||||||
|
|
@ -191,6 +191,16 @@ object CommonActivity {
|
||||||
currentToast = toast
|
currentToast = toast
|
||||||
toast.show()
|
toast.show()
|
||||||
|
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
val ref = WeakReference(toast)
|
||||||
|
|
||||||
|
/* Clean up activity leak */
|
||||||
|
handler.postDelayed({
|
||||||
|
if (ref.get() == currentToast) {
|
||||||
|
currentToast = null
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
|
@ -234,19 +244,8 @@ object CommonActivity {
|
||||||
fun init(act: Activity) {
|
fun init(act: Activity) {
|
||||||
setActivityInstance(act)
|
setActivityInstance(act)
|
||||||
ioSafe { Torrent.deleteAllFiles() }
|
ioSafe { Torrent.deleteAllFiles() }
|
||||||
|
|
||||||
// Clear all pools to apply the correct theme
|
|
||||||
for (pool in arrayOf(
|
|
||||||
PluginAdapter.sharedPool, HomeChildItemAdapter.sharedPool,
|
|
||||||
ParentItemAdapter.sharedPool, ActorAdaptor.sharedPool, EpisodeAdapter.sharedPool,
|
|
||||||
SearchAdapter.sharedPool, ImageAdapter.sharedPool
|
|
||||||
)) {
|
|
||||||
pool.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
val componentActivity = activity as? ComponentActivity ?: return
|
val componentActivity = activity as? ComponentActivity ?: return
|
||||||
|
|
||||||
|
|
||||||
componentActivity.updateLocale()
|
componentActivity.updateLocale()
|
||||||
componentActivity.updateTv()
|
componentActivity.updateTv()
|
||||||
AccountManager.initMainAPI()
|
AccountManager.initMainAPI()
|
||||||
|
|
@ -533,87 +532,7 @@ object CommonActivity {
|
||||||
|
|
||||||
|
|
||||||
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
|
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
|
||||||
|
|
||||||
// 149 keycode_numpad 5
|
|
||||||
val playerEvent = when (keyCode) {
|
|
||||||
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
|
||||||
PlayerEventType.SeekForward
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
|
||||||
PlayerEventType.SeekBack
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
|
|
||||||
PlayerEventType.NextEpisode
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
|
|
||||||
PlayerEventType.PrevEpisode
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
|
||||||
PlayerEventType.Pause
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
|
|
||||||
PlayerEventType.Play
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
|
|
||||||
PlayerEventType.Lock
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
|
|
||||||
PlayerEventType.ToggleHide
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
|
||||||
PlayerEventType.ToggleMute
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
|
|
||||||
PlayerEventType.ShowMirrors
|
|
||||||
}
|
|
||||||
// OpenSubtitles shortcut
|
|
||||||
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
|
|
||||||
PlayerEventType.SearchSubtitlesOnline
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
|
|
||||||
PlayerEventType.ShowSpeed
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
|
|
||||||
PlayerEventType.Resize
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
val listener = playerEventListener
|
|
||||||
if (listener != null) {
|
|
||||||
listener.invoke(playerEvent)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
|
|
||||||
//when (keyCode) {
|
|
||||||
// KeyEvent.KEYCODE_DPAD_CENTER -> {
|
|
||||||
// println("DPAD PRESSED")
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** overrides focus and custom key events */
|
/** overrides focus and custom key events */
|
||||||
|
|
@ -660,8 +579,10 @@ object CommonActivity {
|
||||||
|
|
||||||
// TODO: Figure out why removing the check for SearchAutoComplete seems
|
// TODO: Figure out why removing the check for SearchAutoComplete seems
|
||||||
// to break focus on TV as it shouldn't need to be used.
|
// to break focus on TV as it shouldn't need to be used.
|
||||||
|
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
|
||||||
|
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
|
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
|
||||||
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
||||||
) {
|
) {
|
||||||
showInputMethod(act.currentFocus?.findFocus())
|
showInputMethod(act.currentFocus?.findFocus())
|
||||||
|
|
@ -682,4 +603,4 @@ object CommonActivity {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import androidx.annotation.MainThread
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.cardview.widget.CardView
|
import androidx.cardview.widget.CardView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
|
|
@ -106,7 +105,6 @@ import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
|
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
||||||
import com.lagradost.cloudstream3.ui.home.HomeFragment
|
|
||||||
import com.lagradost.cloudstream3.ui.home.HomeViewModel
|
import com.lagradost.cloudstream3.ui.home.HomeViewModel
|
||||||
import com.lagradost.cloudstream3.ui.library.LibraryViewModel
|
import com.lagradost.cloudstream3.ui.library.LibraryViewModel
|
||||||
import com.lagradost.cloudstream3.ui.player.BasicLink
|
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||||
|
|
@ -190,11 +188,9 @@ import java.nio.charset.Charset
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
import androidx.core.net.toUri
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
import androidx.tvprovider.media.tv.Channel
|
import kotlinx.coroutines.Job
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import kotlinx.coroutines.cancel
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.ContentUris
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
|
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -204,6 +200,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
const val ANIMATED_OUTLINE: Boolean = false
|
const val ANIMATED_OUTLINE: Boolean = false
|
||||||
var lastError: String? = null
|
var lastError: String? = null
|
||||||
|
|
||||||
|
/** Update lastError variable based on error file, to check if app crashed.
|
||||||
|
* Can be called multiple times without changing the lastError variable changing.
|
||||||
|
**/
|
||||||
|
fun setLastError(context: Context) {
|
||||||
|
if (lastError != null) return
|
||||||
|
|
||||||
|
val errorFile = context.filesDir.resolve("last_error")
|
||||||
|
if (errorFile.exists() && errorFile.isFile) {
|
||||||
|
lastError = errorFile.readText(Charset.defaultCharset())
|
||||||
|
errorFile.delete()
|
||||||
|
} else {
|
||||||
|
lastError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
|
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
|
||||||
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
|
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
|
||||||
|
|
||||||
|
|
@ -265,7 +276,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
* @return true if the str has launched an app task (be it successful or not)
|
* @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.
|
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
|
||||||
* */
|
* */
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
fun handleAppIntentUrl(
|
fun handleAppIntentUrl(
|
||||||
activity: FragmentActivity?,
|
activity: FragmentActivity?,
|
||||||
str: String?,
|
str: String?,
|
||||||
|
|
@ -352,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
LinkGenerator(
|
LinkGenerator(
|
||||||
listOf(BasicLink(url, name)),
|
listOf(BasicLink(url, name)),
|
||||||
extract = true,
|
extract = true,
|
||||||
)
|
id = url.hashCode()
|
||||||
|
), 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
|
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
|
||||||
|
|
@ -397,13 +408,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized(apis) {
|
val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
|
||||||
for (api in apis) {
|
if (matchedApi != null) {
|
||||||
if (str.startsWith(api.mainUrl)) {
|
loadResult(str, matchedApi.name, "")
|
||||||
loadResult(str, api.name, "")
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -432,6 +440,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
|
|
||||||
|
|
||||||
var lastPopup: SearchResponse? = null
|
var lastPopup: SearchResponse? = null
|
||||||
|
var lastPopupJob: Job? = null
|
||||||
fun loadPopup(result: SearchResponse, load: Boolean = true) {
|
fun loadPopup(result: SearchResponse, load: Boolean = true) {
|
||||||
lastPopup = result
|
lastPopup = result
|
||||||
val syncName = syncViewModel.syncName(result.apiName)
|
val syncName = syncViewModel.syncName(result.apiName)
|
||||||
|
|
@ -447,7 +456,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
syncViewModel.clear()
|
syncViewModel.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (load) {
|
lastPopupJob?.cancel()
|
||||||
|
lastPopupJob = if (load) {
|
||||||
viewModel.load(
|
viewModel.load(
|
||||||
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
||||||
.contains(DubStatus.Dubbed)
|
.contains(DubStatus.Dubbed)
|
||||||
|
|
@ -494,6 +504,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
R.id.navigation_downloads,
|
R.id.navigation_downloads,
|
||||||
R.id.navigation_settings,
|
R.id.navigation_settings,
|
||||||
R.id.navigation_download_child,
|
R.id.navigation_download_child,
|
||||||
|
R.id.navigation_download_queue,
|
||||||
R.id.navigation_subtitles,
|
R.id.navigation_subtitles,
|
||||||
R.id.navigation_chrome_subtitles,
|
R.id.navigation_chrome_subtitles,
|
||||||
R.id.navigation_settings_player,
|
R.id.navigation_settings_player,
|
||||||
|
|
@ -546,9 +557,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
navView.isVisible = isNavVisible && !isLandscape()
|
navView.isVisible = isNavVisible && !isLandscape()
|
||||||
navHostFragment.apply {
|
navHostFragment.apply {
|
||||||
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
|
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
|
||||||
layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
|
layoutParams =
|
||||||
marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
|
(navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
}
|
marginStart =
|
||||||
|
if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -557,7 +570,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
* highlight the wrong one in UI.
|
* highlight the wrong one in UI.
|
||||||
*/
|
*/
|
||||||
when (destination.id) {
|
when (destination.id) {
|
||||||
in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
|
in listOf(
|
||||||
|
R.id.navigation_downloads,
|
||||||
|
R.id.navigation_download_child,
|
||||||
|
R.id.navigation_download_queue
|
||||||
|
) -> {
|
||||||
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||||
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||||
}
|
}
|
||||||
|
|
@ -789,12 +806,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private val pluginsLock = Mutex()
|
private val pluginsLock = Mutex()
|
||||||
private fun onAllPluginsLoaded(success: Boolean = false) {
|
private fun onAllPluginsLoaded(success: Boolean = false) {
|
||||||
ioSafe {
|
ioSafe {
|
||||||
pluginsLock.withLock {
|
pluginsLock.withLock {
|
||||||
synchronized(allProviders) {
|
allProviders.withLock {
|
||||||
// Load cloned sites after plugins have been loaded since clones depend on plugins.
|
// Load cloned sites after plugins have been loaded since clones depend on plugins.
|
||||||
try {
|
try {
|
||||||
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
|
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
|
||||||
|
|
@ -840,6 +856,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
|
|
||||||
private fun hidePreviewPopupDialog() {
|
private fun hidePreviewPopupDialog() {
|
||||||
bottomPreviewPopup.dismissSafe(this)
|
bottomPreviewPopup.dismissSafe(this)
|
||||||
|
lastPopupJob?.cancel()
|
||||||
|
lastPopupJob = null
|
||||||
bottomPreviewPopup = null
|
bottomPreviewPopup = null
|
||||||
bottomPreviewBinding = null
|
bottomPreviewBinding = null
|
||||||
}
|
}
|
||||||
|
|
@ -1159,18 +1177,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
app.initClient(this)
|
app.initClient(this, ignoreSSL = false)
|
||||||
|
@OptIn(UnsafeSSL::class)
|
||||||
|
insecureApp.initClient(this, ignoreSSL = true)
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
|
|
||||||
val errorFile = filesDir.resolve("last_error")
|
setLastError(this)
|
||||||
if (errorFile.exists() && errorFile.isFile) {
|
|
||||||
lastError = errorFile.readText(Charset.defaultCharset())
|
|
||||||
errorFile.delete()
|
|
||||||
} else {
|
|
||||||
lastError = null
|
|
||||||
}
|
|
||||||
|
|
||||||
val settingsForProvider = SettingsJson()
|
val settingsForProvider = SettingsJson()
|
||||||
settingsForProvider.enableAdult =
|
settingsForProvider.enableAdult =
|
||||||
|
|
@ -1639,9 +1653,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
ioSafe {
|
ioSafe {
|
||||||
initAll()
|
initAll()
|
||||||
// No duplicates (which can happen by registerMainAPI)
|
// No duplicates (which can happen by registerMainAPI)
|
||||||
apis = synchronized(allProviders) {
|
apis = allProviders.distinctBy { it }
|
||||||
allProviders.distinctBy { it }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
|
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
|
||||||
|
|
@ -1949,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
var providersAndroidManifestString = "Current androidmanifest should be:\n"
|
var providersAndroidManifestString = "Current androidmanifest should be:\n"
|
||||||
synchronized(allProviders) {
|
allProviders.withLock {
|
||||||
for (api in allProviders) {
|
for (api in allProviders) {
|
||||||
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
|
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
|
||||||
api.mainUrl.removePrefix(
|
api.mainUrl.removePrefix(
|
||||||
|
|
@ -2032,6 +2044,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
updateLocale()
|
updateLocale()
|
||||||
runDefault()
|
runDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the download queue
|
||||||
|
DownloadQueueManager.init(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Biometric stuff **/
|
/** Biometric stuff **/
|
||||||
|
|
@ -2054,4 +2069,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ import com.lagradost.cloudstream3.actions.temp.MpvExPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
||||||
|
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
|
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
|
||||||
|
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
|
||||||
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
||||||
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
|
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
|
||||||
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
||||||
|
|
@ -32,8 +34,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -43,7 +45,7 @@ import java.util.concurrent.FutureTask
|
||||||
import kotlin.reflect.jvm.jvmName
|
import kotlin.reflect.jvm.jvmName
|
||||||
|
|
||||||
object VideoClickActionHolder {
|
object VideoClickActionHolder {
|
||||||
val allVideoClickActions = threadSafeListOf(
|
val allVideoClickActions = atomicListOf(
|
||||||
// Default
|
// Default
|
||||||
PlayInBrowserAction(),
|
PlayInBrowserAction(),
|
||||||
CopyClipboardAction(),
|
CopyClipboardAction(),
|
||||||
|
|
@ -64,6 +66,8 @@ object VideoClickActionHolder {
|
||||||
MpvYTDLPackage(),
|
MpvYTDLPackage(),
|
||||||
MpvKtPackage(),
|
MpvKtPackage(),
|
||||||
MpvKtPreviewPackage(),
|
MpvKtPreviewPackage(),
|
||||||
|
OnlyPlayer(),
|
||||||
|
MpvRxPackage(),
|
||||||
// Always Ask option
|
// Always Ask option
|
||||||
AlwaysAskAction(),
|
AlwaysAskAction(),
|
||||||
// added by plugins
|
// added by plugins
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.lagradost.cloudstream3.actions.temp
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.lagradost.api.Log
|
||||||
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
|
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||||
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
|
||||||
|
/** https://github.com/Riteshp2001/mpvRx
|
||||||
|
*
|
||||||
|
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132
|
||||||
|
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56
|
||||||
|
* */
|
||||||
|
class MpvRxPackage : OpenInAppAction(
|
||||||
|
appName = txt("mpvRx"),
|
||||||
|
packageName = "app.gyrolet.mpvrx",
|
||||||
|
intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity"
|
||||||
|
) {
|
||||||
|
override val oneSource = true
|
||||||
|
override suspend fun putExtra(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent,
|
||||||
|
video: ResultEpisode,
|
||||||
|
result: LinkLoadingResult,
|
||||||
|
index: Int?
|
||||||
|
) {
|
||||||
|
intent.apply {
|
||||||
|
putExtra("title", video.name)
|
||||||
|
val link = result.links[index!!]
|
||||||
|
val headers = link.headers
|
||||||
|
|
||||||
|
setData(link.url.toUri())
|
||||||
|
if (headers.isNotEmpty()) {
|
||||||
|
// PlayerActivity expects a flat array: [key1, value1, key2, value2, ...]
|
||||||
|
val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray()
|
||||||
|
intent.putExtra("headers", flat)
|
||||||
|
}
|
||||||
|
/*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146
|
||||||
|
intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray())
|
||||||
|
intent.putExtra(
|
||||||
|
"subs.titles",
|
||||||
|
subs.map { it.name }.toTypedArray(),
|
||||||
|
)
|
||||||
|
intent.putExtra(
|
||||||
|
"subs.langs",
|
||||||
|
subs.map { it.languageCode }.toTypedArray(),
|
||||||
|
)
|
||||||
|
val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri()
|
||||||
|
intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf<Uri>() )*/
|
||||||
|
|
||||||
|
if (video.tvType.isEpisodeBased()) {
|
||||||
|
video.season?.let { intent.putExtra("introdb_season", it) }
|
||||||
|
video.episode.let { intent.putExtra("introdb_episode", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val position = getViewPos(video.id)?.position
|
||||||
|
if (position != null)
|
||||||
|
putExtra("position", position.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResult(activity: Activity, intent: Intent?) {
|
||||||
|
val position = intent?.getIntExtra("position", -1) ?: -1
|
||||||
|
val duration = intent?.getIntExtra("duration", -1) ?: -1
|
||||||
|
Log.d("MPV", "Position: $position, Duration: $duration")
|
||||||
|
updateDurationAndPosition(position.toLong(), duration.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.lagradost.cloudstream3.actions.temp
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
|
||||||
|
/** https://github.com/Kindness-Kismet/only_player/tree/main
|
||||||
|
* https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */
|
||||||
|
class OnlyPlayer : OpenInAppAction(
|
||||||
|
txt("Only Player"),
|
||||||
|
"one.only.player",
|
||||||
|
intentClass = "one.only.player.feature.player.PlayerActivity"
|
||||||
|
) {
|
||||||
|
override val oneSource = true
|
||||||
|
override suspend fun putExtra(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent,
|
||||||
|
video: ResultEpisode,
|
||||||
|
result: LinkLoadingResult,
|
||||||
|
index: Int?
|
||||||
|
) {
|
||||||
|
/** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */
|
||||||
|
intent.apply {
|
||||||
|
val link = result.links[index!!]
|
||||||
|
setData(link.url.toUri())
|
||||||
|
|
||||||
|
putExtra("headers", Bundle().apply {
|
||||||
|
for ((key, value) in link.headers) {
|
||||||
|
putExtra(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResult(activity: Activity, intent: Intent?) {
|
||||||
|
/* onResult does not get called */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,9 +35,11 @@ class PlayMirrorAction : VideoClickAction() {
|
||||||
) {
|
) {
|
||||||
//Implemented a generator to handle the single
|
//Implemented a generator to handle the single
|
||||||
val activity = context as? Activity ?: return
|
val activity = context as? Activity ?: return
|
||||||
|
val link = index?.let { result.links[it] }
|
||||||
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
|
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
|
||||||
override val hasCache: Boolean = false
|
override val hasCache: Boolean = false
|
||||||
override val canSkipLoading: Boolean = false
|
override val canSkipLoading: Boolean = false
|
||||||
|
override fun getId(index: Int): Int = video.id
|
||||||
|
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
|
|
@ -47,7 +49,7 @@ class PlayMirrorAction : VideoClickAction() {
|
||||||
offset: Int,
|
offset: Int,
|
||||||
isCasting: Boolean
|
isCasting: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
index?.let { callback(result.links[it] to null) }
|
index?.let { callback(link to null) }
|
||||||
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
|
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +58,7 @@ class PlayMirrorAction : VideoClickAction() {
|
||||||
activity.navigate(
|
activity.navigate(
|
||||||
R.id.global_to_navigation_player,
|
R.id.global_to_navigation_player,
|
||||||
GeneratorPlayer.newInstance(
|
GeneratorPlayer.newInstance(
|
||||||
generatorMirror, result.syncData
|
generatorMirror, 0, result.syncData
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,68 @@
|
||||||
package com.lagradost.cloudstream3.mvvm
|
package com.lagradost.cloudstream3.mvvm
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.core.view.doOnAttach
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
/** NOTE: Only one observer at a time per value */
|
||||||
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||||
liveData.removeObservers(this)
|
observeNullable(liveData) { t -> t?.run(action) }
|
||||||
liveData.observe(this) { it?.let { t -> action(t) } }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
/** NOTE: Only one observer at a time per value */
|
||||||
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
||||||
liveData.removeObservers(this)
|
liveData.removeObservers(this)
|
||||||
liveData.observe(this) { action(it) }
|
liveData.observe(this, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** NOTE: Only one observer at a time per value */
|
||||||
|
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||||
|
observeNullable(liveData) { t -> t?.run(action) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches an observable to the root binding, instead of the fragment. This is more efficient as
|
||||||
|
* it will not call observe if the view is in the background.
|
||||||
|
*
|
||||||
|
* NOTE: Only one observer at a time per value
|
||||||
|
* */
|
||||||
|
fun <T, V : ViewBinding> BaseFragment<V>.observeNullable(
|
||||||
|
liveData: LiveData<T>, action: (T?) -> Unit
|
||||||
|
) {
|
||||||
|
val root = this.binding?.root
|
||||||
|
if (root == null) {
|
||||||
|
liveData.removeObservers(this)
|
||||||
|
liveData.observe(this, action)
|
||||||
|
} else {
|
||||||
|
root.doOnAttach { view ->
|
||||||
|
// On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
|
||||||
|
val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
|
||||||
|
liveData.removeObservers(owner)
|
||||||
|
liveData.observe(owner, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NOTE: Only one observer at a time per value */
|
||||||
|
fun <T> View.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||||
|
observeNullable(liveData) { t -> t?.run(action) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NOTE: Only one observer at a time per value */
|
||||||
|
fun <T> View.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
||||||
|
doOnAttach { view ->
|
||||||
|
// On attach should make findViewTreeLifecycleOwner non-null
|
||||||
|
val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
|
||||||
|
if(owner == null) {
|
||||||
|
debugException { "Expected non-null findViewTreeLifecycleOwner" }
|
||||||
|
return@doOnAttach
|
||||||
|
}
|
||||||
|
liveData.removeObservers(owner)
|
||||||
|
liveData.observe(owner, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.lagradost.cloudstream3.Prerelease
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
|
|
@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
|
|
||||||
|
// Backwards compatible constructor, mark as deprecated later
|
||||||
fun Requests.initClient(context: Context) {
|
fun Requests.initClient(context: Context) {
|
||||||
this.baseClient = buildDefaultClient(context)
|
this.baseClient = buildDefaultClient(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only use ignoreSSL if you know what you are doing*/
|
||||||
|
@Prerelease
|
||||||
|
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
|
||||||
|
this.baseClient = buildDefaultClient(context, ignoreSSL)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Backwards compatible constructor, mark as deprecated later
|
||||||
fun buildDefaultClient(context: Context): OkHttpClient {
|
fun buildDefaultClient(context: Context): OkHttpClient {
|
||||||
|
return buildDefaultClient(context, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only use ignoreSSL if you know what you are doing*/
|
||||||
|
@Prerelease
|
||||||
|
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
|
||||||
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient {
|
||||||
val baseClient = OkHttpClient.Builder()
|
val baseClient = OkHttpClient.Builder()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
.followSslRedirects(true)
|
.followSslRedirects(true)
|
||||||
.ignoreAllSSLErrors()
|
.apply {
|
||||||
|
if (ignoreSSL) {
|
||||||
|
ignoreAllSSLErrors()
|
||||||
|
}
|
||||||
|
}
|
||||||
.cache(
|
.cache(
|
||||||
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
||||||
// The server response dictates if and when stuff should be cached.
|
// The server response dictates if and when stuff should be cached.
|
||||||
|
|
@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient {
|
||||||
return baseClient
|
return baseClient
|
||||||
}
|
}
|
||||||
|
|
||||||
//val Request.cookies: Map<String, String>
|
|
||||||
// get() {
|
|
||||||
// return this.headers.getCookies("Cookie")
|
|
||||||
// }
|
|
||||||
|
|
||||||
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
import kotlin.Throws
|
import kotlin.Throws
|
||||||
|
|
||||||
|
|
||||||
abstract class Plugin : BasePlugin() {
|
abstract class Plugin : BasePlugin() {
|
||||||
/**
|
/**
|
||||||
* Called when your Plugin is loaded
|
* Called when your Plugin is loaded
|
||||||
|
|
@ -26,9 +25,7 @@ abstract class Plugin : BasePlugin() {
|
||||||
fun registerVideoClickAction(element: VideoClickAction) {
|
fun registerVideoClickAction(element: VideoClickAction) {
|
||||||
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
||||||
element.sourcePlugin = this.filename
|
element.sourcePlugin = this.filename
|
||||||
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
VideoClickActionHolder.allVideoClickActions.add(element)
|
||||||
VideoClickActionHolder.allVideoClickActions.add(element)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,4 +37,4 @@ abstract class Plugin : BasePlugin() {
|
||||||
* This will add a button in the settings allowing you to add custom settings
|
* This will add a button in the settings allowing you to add custom settings
|
||||||
*/
|
*/
|
||||||
var openSettings: ((context: Context) -> Unit)? = null
|
var openSettings: ((context: Context) -> Unit)? = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
@ -26,9 +27,11 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
|
import com.lagradost.cloudstream3.InternalAPI
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
|
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
|
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -43,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||||
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||||
|
|
@ -51,7 +55,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
|
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
|
||||||
import com.lagradost.cloudstream3.utils.extractorApis
|
import com.lagradost.cloudstream3.utils.extractorApis
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import dalvik.system.PathClassLoader
|
import dalvik.system.PathClassLoader
|
||||||
|
|
@ -76,6 +80,7 @@ data class PluginData(
|
||||||
@JsonProperty("filePath") val filePath: String,
|
@JsonProperty("filePath") val filePath: String,
|
||||||
@JsonProperty("version") val version: Int,
|
@JsonProperty("version") val version: Int,
|
||||||
) {
|
) {
|
||||||
|
@WorkerThread
|
||||||
fun toSitePlugin(): SitePlugin {
|
fun toSitePlugin(): SitePlugin {
|
||||||
return SitePlugin(
|
return SitePlugin(
|
||||||
this.filePath,
|
this.filePath,
|
||||||
|
|
@ -90,7 +95,9 @@ data class PluginData(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
File(this.filePath).length()
|
File(this.filePath).length(),
|
||||||
|
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -258,12 +265,8 @@ object PluginManager {
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
@Suppress("FunctionName")
|
||||||
@Deprecated(
|
@InternalAPI
|
||||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
|
||||||
replaceWith = ReplaceWith("loadPlugin"),
|
|
||||||
level = DeprecationLevel.ERROR
|
|
||||||
)
|
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
@ -304,6 +307,7 @@ object PluginManager {
|
||||||
downloadPlugin(
|
downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
|
pluginData.onlineData.second.fileHash,
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
File(pluginData.savedData.filePath),
|
File(pluginData.savedData.filePath),
|
||||||
true
|
true
|
||||||
|
|
@ -339,12 +343,8 @@ object PluginManager {
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
@Suppress("FunctionName")
|
||||||
@Deprecated(
|
@InternalAPI
|
||||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
|
||||||
replaceWith = ReplaceWith("loadPlugin"),
|
|
||||||
level = DeprecationLevel.ERROR
|
|
||||||
)
|
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
|
|
@ -419,6 +419,7 @@ object PluginManager {
|
||||||
downloadPlugin(
|
downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
|
pluginData.onlineData.second.fileHash,
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
pluginData.onlineData.first,
|
pluginData.onlineData.first,
|
||||||
!pluginData.isDisabled
|
!pluginData.isDisabled
|
||||||
|
|
@ -453,12 +454,8 @@ object PluginManager {
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
@Suppress("FunctionName")
|
||||||
@Deprecated(
|
@InternalAPI
|
||||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
|
||||||
replaceWith = ReplaceWith("loadPlugin"),
|
|
||||||
level = DeprecationLevel.ERROR
|
|
||||||
)
|
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
@ -479,13 +476,9 @@ object PluginManager {
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
@Suppress("FunctionName")
|
||||||
|
@InternalAPI
|
||||||
@Throws
|
@Throws
|
||||||
@Deprecated(
|
|
||||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
|
||||||
replaceWith = ReplaceWith("loadPlugin"),
|
|
||||||
level = DeprecationLevel.ERROR
|
|
||||||
)
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
||||||
|
|
@ -504,12 +497,8 @@ object PluginManager {
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
@Suppress("FunctionName")
|
||||||
@Deprecated(
|
@InternalAPI
|
||||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
|
||||||
replaceWith = ReplaceWith("loadPlugin"),
|
|
||||||
level = DeprecationLevel.ERROR
|
|
||||||
)
|
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
@ -572,6 +561,11 @@ object PluginManager {
|
||||||
afterPluginsLoadedEvent.invoke(forceReload)
|
afterPluginsLoadedEvent.invoke(forceReload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return true if safe mode is enabled in any possible way. */
|
||||||
|
fun isSafeMode(): Boolean {
|
||||||
|
return checkSafeModeFile() || lastError != null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This can be used to override any extension loading to fix crashes!
|
* This can be used to override any extension loading to fix crashes!
|
||||||
* @return true if safe mode file is present
|
* @return true if safe mode file is present
|
||||||
|
|
@ -616,7 +610,7 @@ object PluginManager {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
InputStreamReader(stream).use { reader ->
|
InputStreamReader(stream).use { reader ->
|
||||||
manifest = parseJson(reader, BasePlugin.Manifest::class.java)
|
manifest = parseJson<BasePlugin.Manifest>(reader.readText())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,9 +651,15 @@ object PluginManager {
|
||||||
context.resources.configuration
|
context.resources.configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
plugins[filePath] = pluginInstance
|
synchronized(plugins) {
|
||||||
classLoaders[loader] = pluginInstance
|
plugins[filePath] = pluginInstance
|
||||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
}
|
||||||
|
synchronized(classLoaders) {
|
||||||
|
classLoaders[loader] = pluginInstance
|
||||||
|
}
|
||||||
|
synchronized(urlPlugins) {
|
||||||
|
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||||
|
}
|
||||||
if (pluginInstance is Plugin) {
|
if (pluginInstance is Plugin) {
|
||||||
pluginInstance.load(context)
|
pluginInstance.load(context)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -695,25 +695,33 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove all registered apis
|
// remove all registered apis
|
||||||
synchronized(APIHolder.apis) {
|
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
||||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
removePluginMapping(it)
|
||||||
removePluginMapping(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
synchronized(APIHolder.allProviders) {
|
|
||||||
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
|
APIHolder.allProviders.withLock {
|
||||||
|
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
||||||
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
|
||||||
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
classLoaders.values.removeIf { v -> v == plugin }
|
extractorApis.withLock {
|
||||||
|
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
||||||
|
}
|
||||||
|
|
||||||
plugins.remove(absolutePath)
|
VideoClickActionHolder.allVideoClickActions.withLock {
|
||||||
urlPlugins.values.removeIf { v -> v == plugin }
|
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(classLoaders) {
|
||||||
|
classLoaders.values.removeIf { v -> v == plugin }
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(plugins) {
|
||||||
|
plugins.remove(absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(urlPlugins) {
|
||||||
|
urlPlugins.values.removeIf { v -> v == plugin }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -743,25 +751,27 @@ object PluginManager {
|
||||||
suspend fun downloadPlugin(
|
suspend fun downloadPlugin(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
|
pluginHash: String?,
|
||||||
internalName: String,
|
internalName: String,
|
||||||
repositoryUrl: String,
|
repositoryUrl: String,
|
||||||
loadPlugin: Boolean
|
loadPlugin: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val file = getPluginPath(activity, internalName, repositoryUrl)
|
val file = getPluginPath(activity, internalName, repositoryUrl)
|
||||||
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
|
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadPlugin(
|
suspend fun downloadPlugin(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
|
pluginHash: String?,
|
||||||
internalName: String,
|
internalName: String,
|
||||||
file: File,
|
file: File,
|
||||||
loadPlugin: Boolean
|
loadPlugin: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Downloading plugin: $pluginUrl to ${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
|
// 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 false
|
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
|
||||||
|
|
||||||
val data = PluginData(
|
val data = PluginData(
|
||||||
internalName,
|
internalName,
|
||||||
|
|
@ -808,13 +818,9 @@ object PluginManager {
|
||||||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
@Suppress("FunctionName")
|
||||||
|
@InternalAPI
|
||||||
@Throws
|
@Throws
|
||||||
@Deprecated(
|
|
||||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
|
||||||
replaceWith = ReplaceWith("loadPlugin"),
|
|
||||||
level = DeprecationLevel.ERROR
|
|
||||||
)
|
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
||||||
|
|
@ -853,6 +859,7 @@ object PluginManager {
|
||||||
if (downloadPlugin(
|
if (downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
|
pluginData.onlineData.second.fileHash,
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
existingFile,
|
existingFile,
|
||||||
true
|
true
|
||||||
|
|
@ -951,4 +958,4 @@ object PluginManager {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.lagradost.cloudstream3.plugins
|
package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
|
|
@ -18,10 +19,12 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.BufferedInputStream
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
import java.io.OutputStream
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comes with the app, always available in the app, non removable.
|
* Comes with the app, always available in the app, non removable.
|
||||||
|
|
@ -67,6 +70,7 @@ data class SitePlugin(
|
||||||
@JsonProperty("iconUrl") val iconUrl: String?,
|
@JsonProperty("iconUrl") val iconUrl: String?,
|
||||||
// Automatically generated by the gradle plugin
|
// Automatically generated by the gradle plugin
|
||||||
@JsonProperty("fileSize") val fileSize: Long?,
|
@JsonProperty("fileSize") val fileSize: Long?,
|
||||||
|
@JsonProperty("fileHash") val fileHash: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,7 +79,26 @@ object RepositoryManager {
|
||||||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||||
}
|
}
|
||||||
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
private val GH_REGEX =
|
||||||
|
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
/** Returns a SHA-256 string of the file content.
|
||||||
|
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
|
||||||
|
@WorkerThread
|
||||||
|
fun sha256(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
file.inputStream().use { fis ->
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var read = fis.read(buffer)
|
||||||
|
while (read != -1) {
|
||||||
|
digest.update(buffer, 0, read)
|
||||||
|
read = fis.read(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
||||||
fun convertRawGitUrl(url: String): String {
|
fun convertRawGitUrl(url: String): String {
|
||||||
|
|
@ -140,21 +163,52 @@ object RepositoryManager {
|
||||||
}.flatten()
|
}.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun downloadPluginToFile(
|
suspend fun downloadPluginToFile(
|
||||||
|
context: Context,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
file: File
|
file: File,
|
||||||
|
expectedFileHash: String?
|
||||||
): File? {
|
): File? {
|
||||||
return safeAsync {
|
return safeAsync {
|
||||||
file.mkdirs()
|
val parentDir = file.parentFile ?: return@safeAsync null
|
||||||
|
parentDir.mkdirs()
|
||||||
|
|
||||||
// Overwrite if exists
|
// Prevent corrupting the plugin file if the operation fails
|
||||||
if (file.exists()) {
|
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
file.createNewFile()
|
|
||||||
|
|
||||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||||
write(body.byteStream(), file.outputStream())
|
|
||||||
|
body.byteStream().use { body ->
|
||||||
|
tempFile.outputStream().use { fileSteam ->
|
||||||
|
body.copyTo(fileSteam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedFileHash != null) {
|
||||||
|
val downloadHash = sha256(tempFile)
|
||||||
|
if (expectedFileHash != downloadHash) {
|
||||||
|
tempFile.delete()
|
||||||
|
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We prefer the operation to be atomic
|
||||||
|
try {
|
||||||
|
Files.move(
|
||||||
|
tempFile.toPath(),
|
||||||
|
file.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE
|
||||||
|
)
|
||||||
|
} catch (_: AtomicMoveNotSupportedException) {
|
||||||
|
Files.move(
|
||||||
|
tempFile.toPath(),
|
||||||
|
file.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +256,4 @@ object RepositoryManager {
|
||||||
|
|
||||||
PluginManager.deleteRepositoryData(file.absolutePath)
|
PluginManager.deleteRepositoryData(file.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun write(stream: InputStream, output: OutputStream) {
|
|
||||||
val input = BufferedInputStream(stream)
|
|
||||||
val dataBuffer = ByteArray(512)
|
|
||||||
var readBytes: Int
|
|
||||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
|
||||||
output.write(dataBuffer, 0, readBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,87 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
object VotingApi { // please do not cheat the votes lol
|
object VotingApi {
|
||||||
|
|
||||||
private const val LOGKEY = "VotingApi"
|
private const val LOGKEY = "VotingApi"
|
||||||
|
private const val API_DOMAIN = "https://api.countify.xyz"
|
||||||
|
|
||||||
private const val API_DOMAIN = "https://counterapi.com/api"
|
private fun transformUrl(url: String): String =
|
||||||
|
|
||||||
private fun transformUrl(url: String): String = // dont touch or all votes get reset
|
|
||||||
MessageDigest
|
MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
.digest("${url}#funny-salt".toByteArray())
|
.digest("${url}#funny-salt".toByteArray())
|
||||||
.fold("") { str, it -> str + "%02x".format(it) }
|
.fold("") { str, it -> str + "%02x".format(it) }
|
||||||
|
|
||||||
suspend fun SitePlugin.getVotes(): Int {
|
suspend fun SitePlugin.getVotes(): Int = getVotes(url)
|
||||||
return getVotes(url)
|
fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
|
||||||
}
|
suspend fun SitePlugin.vote(): Int = vote(url)
|
||||||
|
fun SitePlugin.canVote(): Boolean = canVote(this.url)
|
||||||
|
|
||||||
fun SitePlugin.hasVoted(): Boolean {
|
|
||||||
return hasVoted(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun SitePlugin.vote(): Int {
|
|
||||||
return vote(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun SitePlugin.canVote(): Boolean {
|
|
||||||
return canVote(this.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin url to Int
|
|
||||||
private val votesCache = mutableMapOf<String, Int>()
|
private val votesCache = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
private fun getRepository(pluginUrl: String) = pluginUrl
|
|
||||||
.split("/")
|
|
||||||
.drop(2)
|
|
||||||
.take(3)
|
|
||||||
.joinToString("-")
|
|
||||||
|
|
||||||
private suspend fun readVote(pluginUrl: String): Int {
|
private suspend fun readVote(pluginUrl: String): Int {
|
||||||
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
|
val id = transformUrl(pluginUrl)
|
||||||
Log.d(LOGKEY, "Requesting: $url")
|
val url = "$API_DOMAIN/get-total/$id"
|
||||||
return app.get(url).parsedSafe<Result>()?.value ?: 0
|
Log.d(LOGKEY, "Requesting GET: $url")
|
||||||
|
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeVote(pluginUrl: String): Boolean {
|
private suspend fun writeVote(pluginUrl: String): Boolean {
|
||||||
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
|
val id = transformUrl(pluginUrl)
|
||||||
Log.d(LOGKEY, "Requesting: $url")
|
val url = "$API_DOMAIN/increment/$id"
|
||||||
return app.get(url).parsedSafe<Result>()?.value != null
|
Log.d(LOGKEY, "Requesting POST: $url")
|
||||||
|
return app.post(url, emptyMap<String, String>())
|
||||||
|
.parsedSafe<CountifyResult>()?.count != null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getVotes(pluginUrl: String): Int =
|
suspend fun getVotes(pluginUrl: String): Int =
|
||||||
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
|
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
|
||||||
votesCache[pluginUrl] = it
|
votesCache[pluginUrl] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasVoted(pluginUrl: String) =
|
fun hasVoted(pluginUrl: String) =
|
||||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||||
|
|
||||||
fun canVote(pluginUrl: String): Boolean {
|
fun canVote(pluginUrl: String): Boolean =
|
||||||
return PluginManager.urlPlugins.contains(pluginUrl)
|
PluginManager.urlPlugins.contains(pluginUrl)
|
||||||
}
|
|
||||||
|
|
||||||
private val voteLock = Mutex()
|
private val voteLock = Mutex()
|
||||||
|
|
||||||
suspend fun vote(pluginUrl: String): Int {
|
suspend fun vote(pluginUrl: String): Int {
|
||||||
// Prevent multiple requests at the same time.
|
|
||||||
voteLock.withLock {
|
voteLock.withLock {
|
||||||
if (!canVote(pluginUrl)) {
|
if (!canVote(pluginUrl)) {
|
||||||
main {
|
main {
|
||||||
Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
|
Toast.makeText(
|
||||||
.show()
|
context,
|
||||||
|
R.string.extension_install_first,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVoted(pluginUrl)) {
|
if (hasVoted(pluginUrl)) {
|
||||||
main {
|
main {
|
||||||
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
|
Toast.makeText(
|
||||||
.show()
|
context,
|
||||||
|
R.string.already_voted,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (writeVote(pluginUrl)) {
|
if (writeVote(pluginUrl)) {
|
||||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
||||||
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
||||||
|
|
@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class Result(
|
private data class CountifyResult(
|
||||||
val value: Int?
|
val id: String? = null,
|
||||||
|
val count: Int? = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
package com.lagradost.cloudstream3.services
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
import android.os.Build.VERSION.SDK_INT
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.setLastError
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugWarning
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class DownloadQueueService : Service() {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "DownloadQueueService"
|
||||||
|
const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue"
|
||||||
|
const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service"
|
||||||
|
const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification."
|
||||||
|
const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique
|
||||||
|
@Volatile
|
||||||
|
var isRunning = false
|
||||||
|
|
||||||
|
fun getIntent(
|
||||||
|
context: Context,
|
||||||
|
): Intent {
|
||||||
|
return Intent(context, DownloadQueueService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _downloadInstances: MutableStateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
|
||||||
|
MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
/** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances.
|
||||||
|
* Completed or failed instances are automatically removed by the download queue service.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
val downloadInstances: StateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
|
||||||
|
_downloadInstances
|
||||||
|
|
||||||
|
private val totalDownloadFlow =
|
||||||
|
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
|
||||||
|
instances to queue
|
||||||
|
}
|
||||||
|
.combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads ->
|
||||||
|
Triple(instances, queue, currentDownloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private val baseNotification by lazy {
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntentCompat.getActivity(this, 0, intent, 0, false)
|
||||||
|
|
||||||
|
val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0)
|
||||||
|
val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0)
|
||||||
|
|
||||||
|
NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID)
|
||||||
|
.setOngoing(true) // Make it persistent
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setColorized(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
// If low priority then the notification might not show :(
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setColor(this.colorFromAttribute(R.attr.colorPrimary))
|
||||||
|
.setContentText(activeDownloads)
|
||||||
|
.setSubText(activeQueue)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setSmallIcon(R.drawable.download_icon_load)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun updateNotification(context: Context, downloads: Int, queued: Int) {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) return
|
||||||
|
|
||||||
|
val activeDownloads =
|
||||||
|
resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
|
||||||
|
val activeQueue =
|
||||||
|
resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued)
|
||||||
|
|
||||||
|
val newNotification = baseNotification
|
||||||
|
.setContentText(activeDownloads)
|
||||||
|
.setSubText(activeQueue)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
safe {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We always need to listen to events, even before the download is launched.
|
||||||
|
// Stopping link loading is an event which can trigger before downloading.
|
||||||
|
val downloadEventListener = { event: Pair<Int, VideoDownloadManager.DownloadActionType> ->
|
||||||
|
when (event.second) {
|
||||||
|
VideoDownloadManager.DownloadActionType.Stop -> {
|
||||||
|
removeKey(KEY_RESUME_PACKAGES, event.first.toString())
|
||||||
|
removeKey(KEY_RESUME_IN_QUEUE, event.first.toString())
|
||||||
|
DownloadQueueManager.cancelDownload(event.first)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
|
override fun onCreate() {
|
||||||
|
isRunning = true
|
||||||
|
val context: Context = this // To make code more readable
|
||||||
|
|
||||||
|
Log.d(TAG, "Download queue service started.")
|
||||||
|
this.createNotificationChannel(
|
||||||
|
DOWNLOAD_QUEUE_CHANNEL_ID,
|
||||||
|
DOWNLOAD_QUEUE_CHANNEL_NAME,
|
||||||
|
DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION
|
||||||
|
)
|
||||||
|
if (SDK_INT >= 29) {
|
||||||
|
startForeground(
|
||||||
|
DOWNLOAD_QUEUE_NOTIFICATION_ID,
|
||||||
|
baseNotification.build(),
|
||||||
|
FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadEvent += downloadEventListener
|
||||||
|
|
||||||
|
val queueJob = ioSafe {
|
||||||
|
// Ensure this is up to date to prevent race conditions with MainActivity launches
|
||||||
|
setLastError(context)
|
||||||
|
// Early return, to prevent waiting for plugins in safe mode
|
||||||
|
if (lastError != null) return@ioSafe
|
||||||
|
|
||||||
|
// Try to ensure all plugins are loaded before starting the downloader.
|
||||||
|
// To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough
|
||||||
|
val timeout = 15.seconds
|
||||||
|
val timeTaken = withTimeoutOrNull(timeout) {
|
||||||
|
measureTimeMillis {
|
||||||
|
while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) {
|
||||||
|
delay(100.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugWarning({ timeTaken == null || timeTaken > 3_000 }, {
|
||||||
|
"Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms"
|
||||||
|
})
|
||||||
|
debugAssert({ timeTaken == null }, { "Downloader startup should not time out" })
|
||||||
|
|
||||||
|
totalDownloadFlow
|
||||||
|
.debounce { (instances, queue) ->
|
||||||
|
// Filter away incorrect transient queue states.
|
||||||
|
// For example when we pop the queue and add a download instance there exists a transient state where
|
||||||
|
// there is no queue and no download instances (leading to an early exit)
|
||||||
|
if (instances.isEmpty() && queue.isEmpty()) {
|
||||||
|
500.milliseconds
|
||||||
|
} else {
|
||||||
|
0.milliseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.takeWhile { (instances, queue) ->
|
||||||
|
// Stop if destroyed
|
||||||
|
isRunning
|
||||||
|
// Run as long as there is a queue to process
|
||||||
|
&& (instances.isNotEmpty() || queue.isNotEmpty())
|
||||||
|
// Run as long as there are no app crashes
|
||||||
|
&& lastError == null
|
||||||
|
}
|
||||||
|
.collect { (_, queue, currentDownloads) ->
|
||||||
|
// Remove completed or failed
|
||||||
|
val newInstances = _downloadInstances.updateAndGet { currentInstances ->
|
||||||
|
currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled }
|
||||||
|
}
|
||||||
|
|
||||||
|
val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context)
|
||||||
|
val currentInstanceCount = newInstances.size
|
||||||
|
|
||||||
|
val newDownloads = minOf(
|
||||||
|
// Cannot exceed the max downloads
|
||||||
|
maxOf(0, maxDownloads - currentInstanceCount),
|
||||||
|
// Cannot start more downloads than the queue size
|
||||||
|
queue.size
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cant start multiple downloads at once. If this is rerun it may start too many downloads.
|
||||||
|
if (newDownloads > 0) {
|
||||||
|
_downloadInstances.update { instances ->
|
||||||
|
val downloadInstance = DownloadQueueManager.popQueue(context)
|
||||||
|
if (downloadInstance != null) {
|
||||||
|
downloadInstance.startDownload()
|
||||||
|
instances + downloadInstance
|
||||||
|
} else {
|
||||||
|
instances
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The downloads actually displayed to the user with a notification
|
||||||
|
val currentVisualDownloads =
|
||||||
|
currentDownloads.size + newInstances.count {
|
||||||
|
currentDownloads.contains(it.downloadQueueWrapper.id)
|
||||||
|
.not()
|
||||||
|
}
|
||||||
|
// Just the queue
|
||||||
|
val currentVisualQueue = queue.size
|
||||||
|
|
||||||
|
updateNotification(context, currentVisualDownloads, currentVisualQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop self regardless of job outcome
|
||||||
|
queueJob.invokeOnCompletion { throwable ->
|
||||||
|
if (throwable != null) {
|
||||||
|
logError(throwable)
|
||||||
|
}
|
||||||
|
safe {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.d(TAG, "Download queue service stopped.")
|
||||||
|
downloadEvent -= downloadEventListener
|
||||||
|
isRunning = false
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
return START_STICKY // We want the service restarted if its killed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onTimeout(reason: Int) {
|
||||||
|
stopSelf()
|
||||||
|
Log.e(TAG, "Service stopped due to timeout: $reason")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
|
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
try {
|
try {
|
||||||
// println("Update subscriptions!")
|
// println("Update subscriptions!")
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Handle notification actions such as pause/resume downloads */
|
||||||
class VideoDownloadService : Service() {
|
class VideoDownloadService : Service() {
|
||||||
|
|
||||||
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
@ -42,19 +43,3 @@ class VideoDownloadService : Service() {
|
||||||
super.onDestroy()
|
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))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
||||||
|
|
@ -12,12 +13,14 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class AccountManager {
|
abstract class AccountManager {
|
||||||
companion object {
|
companion object {
|
||||||
const val NONE_ID: Int = -1
|
const val NONE_ID: Int = -1
|
||||||
val malApi = MALApi()
|
val malApi = MALApi()
|
||||||
|
val kitsuApi = KitsuApi()
|
||||||
val aniListApi = AniListApi()
|
val aniListApi = AniListApi()
|
||||||
val simklApi = SimklApi()
|
val simklApi = SimklApi()
|
||||||
val localListApi = LocalList()
|
val localListApi = LocalList()
|
||||||
|
|
@ -26,6 +29,7 @@ abstract class AccountManager {
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
val subDlApi = SubDlApi()
|
val subDlApi = SubDlApi()
|
||||||
val subSourceApi = SubSourceApi()
|
val subSourceApi = SubSourceApi()
|
||||||
|
val animeSkipApi = AnimeSkipAuth()
|
||||||
|
|
||||||
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
||||||
var cachedAccountIds: MutableMap<String, Int>
|
var cachedAccountIds: MutableMap<String, Int>
|
||||||
|
|
@ -59,13 +63,14 @@ abstract class AccountManager {
|
||||||
|
|
||||||
val allApis = arrayOf(
|
val allApis = arrayOf(
|
||||||
SyncRepo(malApi),
|
SyncRepo(malApi),
|
||||||
|
SyncRepo(kitsuApi),
|
||||||
SyncRepo(aniListApi),
|
SyncRepo(aniListApi),
|
||||||
SyncRepo(simklApi),
|
SyncRepo(simklApi),
|
||||||
SyncRepo(localListApi),
|
SyncRepo(localListApi),
|
||||||
|
|
||||||
SubtitleRepo(openSubtitlesApi),
|
SubtitleRepo(openSubtitlesApi),
|
||||||
SubtitleRepo(addic7ed),
|
SubtitleRepo(addic7ed),
|
||||||
SubtitleRepo(subDlApi)
|
SubtitleRepo(subDlApi),
|
||||||
|
PlainAuthRepo(animeSkipApi)
|
||||||
)
|
)
|
||||||
|
|
||||||
fun updateAccountIds() {
|
fun updateAccountIds() {
|
||||||
|
|
@ -107,6 +112,7 @@ abstract class AccountManager {
|
||||||
// accessing other classes
|
// accessing other classes
|
||||||
fun initMainAPI() {
|
fun initMainAPI() {
|
||||||
LoadResponse.malIdPrefix = malApi.idPrefix
|
LoadResponse.malIdPrefix = malApi.idPrefix
|
||||||
|
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
|
||||||
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
||||||
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +124,7 @@ abstract class AccountManager {
|
||||||
)
|
)
|
||||||
val syncApis = arrayOf(
|
val syncApis = arrayOf(
|
||||||
SyncRepo(malApi),
|
SyncRepo(malApi),
|
||||||
|
SyncRepo(kitsuApi),
|
||||||
SyncRepo(aniListApi),
|
SyncRepo(aniListApi),
|
||||||
SyncRepo(simklApi),
|
SyncRepo(simklApi),
|
||||||
SyncRepo(localListApi)
|
SyncRepo(localListApi)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
||||||
|
|
@ -35,11 +36,9 @@ import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
|
||||||
|
/** General-purpose repo */
|
||||||
|
class PlainAuthRepo(api: AuthAPI) : AuthRepo(api)
|
||||||
|
|
||||||
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
|
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
|
||||||
abstract class AuthRepo(open val api: AuthAPI) {
|
abstract class AuthRepo(open val api: AuthAPI) {
|
||||||
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||||
|
|
||||||
/** Stateless safe abstraction of SubtitleAPI */
|
/** Stateless safe abstraction of SubtitleAPI */
|
||||||
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
|
|
@ -24,26 +24,30 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// maybe make this a generic struct? right now there is a lot of boilerplate
|
// maybe make this a generic struct? right now there is a lot of boilerplate
|
||||||
private val searchCache = threadSafeListOf<SavedSearchResponse>()
|
private val searchCache = atomicListOf<SavedSearchResponse>()
|
||||||
private var searchCacheIndex: Int = 0
|
private var searchCacheIndex: Int = 0
|
||||||
private val resourceCache = threadSafeListOf<SavedResourceResponse>()
|
private val resourceCache = atomicListOf<SavedResourceResponse>()
|
||||||
private var resourceCacheIndex: Int = 0
|
private var resourceCacheIndex: Int = 0
|
||||||
const val CACHE_SIZE = 20
|
const val CACHE_SIZE = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
|
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
|
||||||
synchronized(resourceCache) {
|
val cached = resourceCache.withLock {
|
||||||
|
var found: SubtitleResource? = null
|
||||||
for (item in resourceCache) {
|
for (item in resourceCache) {
|
||||||
// 20 min save
|
// 20 min save
|
||||||
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
||||||
return@runCatching item.response
|
found = item.response
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
found
|
||||||
}
|
}
|
||||||
|
if (cached != null) return@runCatching cached
|
||||||
|
|
||||||
val returnValue = api.resource(freshAuth(), data)
|
val returnValue = api.resource(freshAuth(), data)
|
||||||
synchronized(resourceCache) {
|
resourceCache.withLock {
|
||||||
val add = SavedResourceResponse(unixTime, returnValue, data)
|
val add = SavedResourceResponse(unixTime, returnValue, data)
|
||||||
if (resourceCache.size > CACHE_SIZE) {
|
if (resourceCache.size > CACHE_SIZE) {
|
||||||
resourceCache[resourceCacheIndex] = add // rolling cache
|
resourceCache[resourceCacheIndex] = add // rolling cache
|
||||||
|
|
@ -58,22 +62,25 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
|
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
synchronized(searchCache) {
|
val cached = searchCache.withLock {
|
||||||
|
var found: List<SubtitleEntity>? = null
|
||||||
for (item in searchCache) {
|
for (item in searchCache) {
|
||||||
// 120 min save
|
// 120 min save
|
||||||
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
||||||
return@runCatching item.response
|
found = item.response
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
val returnValue =
|
if (cached != null) return@runCatching cached
|
||||||
api.search(freshAuth(), query) ?: emptyList()
|
val returnValue = api.search(freshAuth(), query) ?: emptyList()
|
||||||
|
|
||||||
// only cache valid return values
|
// only cache valid return values
|
||||||
if (returnValue.isNotEmpty()) {
|
if (returnValue.isNotEmpty()) {
|
||||||
val add = SavedSearchResponse(unixTime, returnValue, query)
|
val add = SavedSearchResponse(unixTime, returnValue, query)
|
||||||
synchronized(searchCache) {
|
searchCache.withLock {
|
||||||
if (searchCache.size > CACHE_SIZE) {
|
if (searchCache.size > CACHE_SIZE) {
|
||||||
searchCache[searchCacheIndex] = add // rolling cache
|
searchCache[searchCacheIndex] = add // rolling cache
|
||||||
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
||||||
|
|
@ -86,4 +93,3 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
|
import com.lagradost.cloudstream3.utils.Levenshtein
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
ListSorting.Query ->
|
ListSorting.Query ->
|
||||||
if (query != null) {
|
if (query != null) {
|
||||||
items.sortedBy {
|
items.sortedBy {
|
||||||
-FuzzySearch.partialRatio(
|
-Levenshtein.partialRatio(
|
||||||
query.lowercase(), it.name.lowercase()
|
query.lowercase(), it.name.lowercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -191,4 +191,4 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
override var score: Score? = null,
|
override var score: Score? = null,
|
||||||
val tags: List<String>? = null
|
val tags: List<String>? = null
|
||||||
) : SearchResponse
|
) : SearchResponse
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ class AniListApi : SyncAPI() {
|
||||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
||||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
val sanitizer = splitRedirectUrl(redirectUrl)
|
||||||
val token = AuthToken(
|
val token = AuthToken(
|
||||||
accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"),
|
accessToken = sanitizer["access_token"]
|
||||||
|
?: throw ErrorLoadingException("No access token"),
|
||||||
//refreshToken = sanitizer["refresh_token"],
|
//refreshToken = sanitizer["refresh_token"],
|
||||||
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
|
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
|
||||||
)
|
)
|
||||||
|
|
@ -83,8 +84,8 @@ class AniListApi : SyncAPI() {
|
||||||
return "$mainUrl/anime/$id"
|
return "$mainUrl/anime/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
val data = searchShows(name) ?: return null
|
val data = searchShows(query) ?: return null
|
||||||
return data.data?.page?.media?.map {
|
return data.data?.page?.media?.map {
|
||||||
SyncAPI.SyncSearchResult(
|
SyncAPI.SyncSearchResult(
|
||||||
it.title.romaji ?: return null,
|
it.title.romaji ?: return null,
|
||||||
|
|
@ -96,7 +97,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
|
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
||||||
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
||||||
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
||||||
val season = getSeason(internalId).data.media
|
val season = getSeason(internalId).data.media
|
||||||
|
|
@ -158,7 +159,7 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
||||||
|
|
||||||
|
|
@ -459,7 +460,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? {
|
private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? {
|
||||||
val q =
|
val q =
|
||||||
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
||||||
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
||||||
|
|
@ -506,7 +507,7 @@ class AniListApi : SyncAPI() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? {
|
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
|
||||||
return app.post(
|
return app.post(
|
||||||
"https://graphql.anilist.co/",
|
"https://graphql.anilist.co/",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
|
|
@ -638,7 +639,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
|
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
|
||||||
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
||||||
convertAniListStringToStatus(it.status ?: "").stringRes
|
convertAniListStringToStatus(it.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
|
|
@ -666,7 +667,7 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? {
|
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
|
||||||
val userID = auth.user.id
|
val userID = auth.user.id
|
||||||
val mediaType = "ANIME"
|
val mediaType = "ANIME"
|
||||||
|
|
||||||
|
|
@ -714,7 +715,7 @@ class AniListApi : SyncAPI() {
|
||||||
return text?.toKotlinObject()
|
return text?.toKotlinObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toggleLike(auth : AuthData, id: Int): Boolean {
|
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
|
||||||
val q = """mutation (${'$'}animeId: Int = $id) {
|
val q = """mutation (${'$'}animeId: Int = $id) {
|
||||||
ToggleFavourite (animeId: ${'$'}animeId) {
|
ToggleFavourite (animeId: ${'$'}animeId) {
|
||||||
anime {
|
anime {
|
||||||
|
|
@ -737,7 +738,7 @@ class AniListApi : SyncAPI() {
|
||||||
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||||
|
|
||||||
private suspend fun postDataAboutId(
|
private suspend fun postDataAboutId(
|
||||||
auth : AuthData,
|
auth: AuthData,
|
||||||
id: Int,
|
id: Int,
|
||||||
type: AniListStatusType,
|
type: AniListStatusType,
|
||||||
score: Score?,
|
score: Score?,
|
||||||
|
|
@ -786,7 +787,7 @@ class AniListApi : SyncAPI() {
|
||||||
return data != ""
|
return data != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getUser(token : AuthToken): AniListUser? {
|
private suspend fun getUser(token: AuthToken): AniListUser? {
|
||||||
val q = """
|
val q = """
|
||||||
{
|
{
|
||||||
Viewer {
|
Viewer {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,676 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.Score
|
||||||
|
import com.lagradost.cloudstream3.ShowStatus
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
const val KITSU_MAX_SEARCH_LIMIT = 20
|
||||||
|
|
||||||
|
class KitsuApi: SyncAPI() {
|
||||||
|
override var name = "Kitsu"
|
||||||
|
override val idPrefix = "kitsu"
|
||||||
|
|
||||||
|
private val apiUrl = "https://kitsu.io/api/edge"
|
||||||
|
private val fallbackApiUrl = "https://kitsu.app/api/edge"
|
||||||
|
private val oauthUrl = "https://kitsu.io/api/oauth"
|
||||||
|
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
|
||||||
|
override val hasInApp = true
|
||||||
|
override val mainUrl = "https://kitsu.app"
|
||||||
|
override val icon = R.drawable.kitsu_icon
|
||||||
|
override val syncIdName = SyncIdName.Kitsu
|
||||||
|
override val createAccountUrl = mainUrl
|
||||||
|
|
||||||
|
override val supportedWatchTypes = setOf(
|
||||||
|
SyncWatchType.WATCHING,
|
||||||
|
SyncWatchType.COMPLETED,
|
||||||
|
SyncWatchType.PLANTOWATCH,
|
||||||
|
SyncWatchType.DROPPED,
|
||||||
|
SyncWatchType.ONHOLD,
|
||||||
|
SyncWatchType.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
override val inAppLoginRequirement = AuthLoginRequirement(
|
||||||
|
password = true,
|
||||||
|
email = true
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request: Request = chain.request()
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
val response = chain.proceed(request);
|
||||||
|
|
||||||
|
if (response.isSuccessful) return response
|
||||||
|
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
val fallbackRequest: Request = request.newBuilder()
|
||||||
|
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(fallbackRequest)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
|
||||||
|
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
|
||||||
|
|
||||||
|
override suspend fun login(form: AuthLoginResponse): AuthToken? {
|
||||||
|
val username = form.email ?: return null
|
||||||
|
val password = form.password ?: return null
|
||||||
|
|
||||||
|
val grantType = "password"
|
||||||
|
|
||||||
|
val token = app.post(
|
||||||
|
"$oauthUrl/token",
|
||||||
|
data = mapOf(
|
||||||
|
"grant_type" to grantType,
|
||||||
|
"username" to username,
|
||||||
|
"password" to password
|
||||||
|
),
|
||||||
|
interceptor = oauthFallbackInterceptor
|
||||||
|
).parsed<ResponseToken>()
|
||||||
|
|
||||||
|
return AuthToken(
|
||||||
|
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
|
||||||
|
refreshToken = token.refreshToken,
|
||||||
|
accessToken = token.accessToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshToken(token: AuthToken): AuthToken {
|
||||||
|
val res = app.post(
|
||||||
|
"$oauthUrl/token",
|
||||||
|
data = mapOf(
|
||||||
|
"grant_type" to "refresh_token",
|
||||||
|
"refresh_token" to token.refreshToken!!
|
||||||
|
),
|
||||||
|
interceptor = oauthFallbackInterceptor
|
||||||
|
).parsed<ResponseToken>()
|
||||||
|
|
||||||
|
return AuthToken(
|
||||||
|
accessToken = res.accessToken,
|
||||||
|
refreshToken = res.refreshToken,
|
||||||
|
accessTokenLifetime = unixTime + res.expiresIn.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun user(token: AuthToken?): AuthUser? {
|
||||||
|
val user = app.get(
|
||||||
|
"$apiUrl/users?filter[self]=true",
|
||||||
|
headers = mapOf(
|
||||||
|
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
|
||||||
|
), cacheTime = 0,
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
).parsed<KitsuResponse>()
|
||||||
|
|
||||||
|
if (user.data.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthUser(
|
||||||
|
id = user.data[0].id.toInt(),
|
||||||
|
name = user.data[0].attributes.name,
|
||||||
|
profilePicture = user.data[0].attributes.avatar?.original
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? {
|
||||||
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
|
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
|
||||||
|
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
|
||||||
|
|
||||||
|
val res = app.get(
|
||||||
|
url, headers = mapOf(
|
||||||
|
"Authorization" to "Bearer $auth",
|
||||||
|
), cacheTime = 0,
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
).parsed<KitsuResponse>()
|
||||||
|
|
||||||
|
return res.data.map {
|
||||||
|
val attributes = it.attributes
|
||||||
|
|
||||||
|
val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title"
|
||||||
|
|
||||||
|
SyncSearchResult(
|
||||||
|
title,
|
||||||
|
this.name,
|
||||||
|
it.id,
|
||||||
|
"$mainUrl/anime/${it.id}/",
|
||||||
|
attributes.posterImage?.large ?: attributes.posterImage?.medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(auth : AuthData?, id: String): SyncResult? {
|
||||||
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
|
if (id.toIntOrNull() == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class KitsuResponse(
|
||||||
|
@field:JsonProperty(value = "data")
|
||||||
|
val data: KitsuNode,
|
||||||
|
)
|
||||||
|
|
||||||
|
val url =
|
||||||
|
"$apiUrl/anime/$id"
|
||||||
|
|
||||||
|
val anime = app.get(
|
||||||
|
url, headers = mapOf(
|
||||||
|
"Authorization" to "Bearer $auth"
|
||||||
|
),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
).parsed<KitsuResponse>().data.attributes
|
||||||
|
|
||||||
|
return SyncResult(
|
||||||
|
id = id,
|
||||||
|
totalEpisodes = anime.episodeCount,
|
||||||
|
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
|
||||||
|
publicScore = Score.from(anime.ratingTwenty, 20),
|
||||||
|
duration = anime.episodeLength,
|
||||||
|
synopsis = anime.synopsis,
|
||||||
|
airStatus = when(anime.status) {
|
||||||
|
"finished" -> ShowStatus.Completed
|
||||||
|
"current" -> ShowStatus.Ongoing
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
nextAiring = null,
|
||||||
|
studio = null,
|
||||||
|
genres = null,
|
||||||
|
trailers = null,
|
||||||
|
startDate = LocalDate.parse(anime.startDate).toEpochDay(),
|
||||||
|
endDate = LocalDate.parse(anime.endDate).toEpochDay(),
|
||||||
|
recommendations = null,
|
||||||
|
nextSeason =null,
|
||||||
|
prevSeason = null,
|
||||||
|
actors = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? {
|
||||||
|
val accessToken = auth?.token?.accessToken ?: return null
|
||||||
|
val userId = auth.user.id
|
||||||
|
|
||||||
|
val selectedFields = arrayOf("status","ratingTwenty", "progress")
|
||||||
|
|
||||||
|
val url =
|
||||||
|
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}"
|
||||||
|
|
||||||
|
val anime = app.get(
|
||||||
|
url, headers = mapOf(
|
||||||
|
"Authorization" to "Bearer $accessToken"
|
||||||
|
),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
|
||||||
|
|
||||||
|
if (anime == null) {
|
||||||
|
return SyncStatus(
|
||||||
|
score = null,
|
||||||
|
status = SyncWatchType.NONE,
|
||||||
|
isFavorite = null,
|
||||||
|
watchedEpisodes = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyncStatus(
|
||||||
|
score = Score.from(anime.ratingTwenty, 20),
|
||||||
|
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
|
||||||
|
isFavorite = null,
|
||||||
|
watchedEpisodes = anime.progress,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
suspend fun getAnimeIdByTitle(title: String): String? {
|
||||||
|
|
||||||
|
val animeSelectedFields = arrayOf("titles","canonicalTitle")
|
||||||
|
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
|
||||||
|
|
||||||
|
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
|
||||||
|
|
||||||
|
return res.data.firstOrNull()?.id
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun urlToId(url: String): String? =
|
||||||
|
Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first()
|
||||||
|
|
||||||
|
override suspend fun updateStatus(
|
||||||
|
auth : AuthData?,
|
||||||
|
id: String,
|
||||||
|
newStatus: AbstractSyncStatus
|
||||||
|
): Boolean {
|
||||||
|
|
||||||
|
return setScoreRequest(
|
||||||
|
auth ?: return false,
|
||||||
|
id.toIntOrNull() ?: return false,
|
||||||
|
fromIntToAnimeStatus(newStatus.status),
|
||||||
|
newStatus.score?.toInt(20),
|
||||||
|
newStatus.watchedEpisodes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setScoreRequest(
|
||||||
|
auth : AuthData,
|
||||||
|
id: Int,
|
||||||
|
status: KitsuStatusType? = null,
|
||||||
|
score: Int? = null,
|
||||||
|
numWatchedEpisodes: Int? = null,
|
||||||
|
): Boolean {
|
||||||
|
|
||||||
|
val libraryEntryId = getAnimeLibraryEntryId(auth, id)
|
||||||
|
|
||||||
|
// Exists entry for anime in library
|
||||||
|
if (libraryEntryId != null) {
|
||||||
|
|
||||||
|
// Delete anime from library
|
||||||
|
if (status == null || status == KitsuStatusType.None) {
|
||||||
|
|
||||||
|
val res = app.delete(
|
||||||
|
"$apiUrl/library-entries/$libraryEntryId",
|
||||||
|
headers = mapOf(
|
||||||
|
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||||
|
),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
return res.isSuccessful
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return setScoreRequest(
|
||||||
|
auth,
|
||||||
|
libraryEntryId,
|
||||||
|
kitsuStatusAsString[maxOf(0, status.value)],
|
||||||
|
score,
|
||||||
|
numWatchedEpisodes
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
val data = mapOf(
|
||||||
|
"data" to mapOf(
|
||||||
|
"type" to "libraryEntries",
|
||||||
|
"attributes" to mapOf(
|
||||||
|
"ratingTwenty" to score,
|
||||||
|
"progress" to numWatchedEpisodes,
|
||||||
|
"status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)],
|
||||||
|
),
|
||||||
|
"relationships" to mapOf(
|
||||||
|
"anime" to mapOf(
|
||||||
|
"data" to mapOf(
|
||||||
|
"type" to "anime",
|
||||||
|
"id" to id.toString()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"user" to mapOf(
|
||||||
|
"data" to mapOf(
|
||||||
|
"type" to "users",
|
||||||
|
"id" to auth.user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val res = app.post(
|
||||||
|
"$apiUrl/library-entries",
|
||||||
|
headers = mapOf(
|
||||||
|
"content-type" to "application/vnd.api+json",
|
||||||
|
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||||
|
),
|
||||||
|
requestBody = data.toJson().toRequestBody(),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.isSuccessful
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private suspend fun setScoreRequest(
|
||||||
|
auth : AuthData,
|
||||||
|
id: Int,
|
||||||
|
status: String? = null,
|
||||||
|
score: Int? = null,
|
||||||
|
numWatchedEpisodes: Int? = null,
|
||||||
|
): Boolean {
|
||||||
|
val data = mapOf(
|
||||||
|
"data" to mapOf(
|
||||||
|
"type" to "libraryEntries",
|
||||||
|
"id" to id.toString(),
|
||||||
|
"attributes" to mapOf(
|
||||||
|
"ratingTwenty" to score,
|
||||||
|
"progress" to numWatchedEpisodes,
|
||||||
|
"status" to status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val res = app.patch(
|
||||||
|
"$apiUrl/library-entries/$id",
|
||||||
|
headers = mapOf(
|
||||||
|
"content-type" to "application/vnd.api+json",
|
||||||
|
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||||
|
),
|
||||||
|
requestBody = data.toJson().toRequestBody(),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
return res.isSuccessful
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? {
|
||||||
|
|
||||||
|
val userId = auth.user.id
|
||||||
|
|
||||||
|
val res = app.get(
|
||||||
|
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id",
|
||||||
|
headers = mapOf(
|
||||||
|
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||||
|
),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
|
||||||
|
|
||||||
|
return res.id.toInt()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun library(auth : AuthData?): LibraryMetadata? {
|
||||||
|
val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy {
|
||||||
|
convertToStatus(it.attributes.status ?: "").stringRes
|
||||||
|
}?.mapValues { group ->
|
||||||
|
group.value.map { it.toLibraryItem() }
|
||||||
|
} ?: emptyMap()
|
||||||
|
|
||||||
|
// To fill empty lists when Kitsu does not return them
|
||||||
|
val baseMap =
|
||||||
|
KitsuStatusType.entries.filter { it.value >= 0 }.associate {
|
||||||
|
it.stringRes to emptyList<LibraryItem>()
|
||||||
|
}
|
||||||
|
|
||||||
|
return LibraryMetadata(
|
||||||
|
(baseMap + list).map { LibraryList(txt(it.key), it.value) },
|
||||||
|
setOf(
|
||||||
|
ListSorting.AlphabeticalA,
|
||||||
|
ListSorting.AlphabeticalZ,
|
||||||
|
ListSorting.UpdatedNew,
|
||||||
|
ListSorting.UpdatedOld,
|
||||||
|
ListSorting.ReleaseDateNew,
|
||||||
|
ListSorting.ReleaseDateOld,
|
||||||
|
ListSorting.RatingHigh,
|
||||||
|
ListSorting.RatingLow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array<KitsuNode>? {
|
||||||
|
return if (requireLibraryRefresh) {
|
||||||
|
val list = getKitsuAnimeList(auth.token, auth.user.id)
|
||||||
|
setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
|
||||||
|
list
|
||||||
|
} else {
|
||||||
|
getKey<Array<KitsuNode>>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array<KitsuNode>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
|
||||||
|
|
||||||
|
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
|
||||||
|
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
|
||||||
|
val limit = 500
|
||||||
|
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
|
||||||
|
|
||||||
|
val fullList = mutableListOf<KitsuNode>()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
|
||||||
|
val data: KitsuResponse = getKitsuAnimeListSlice(token, url)
|
||||||
|
|
||||||
|
data.data.forEachIndexed { index, value ->
|
||||||
|
value.anime = data.included?.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullList.addAll(data.data)
|
||||||
|
|
||||||
|
url = data.links?.next ?: break
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return fullList.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse {
|
||||||
|
val res = app.get(
|
||||||
|
url, headers = mapOf(
|
||||||
|
"Authorization" to "Bearer ${token.accessToken}",
|
||||||
|
),
|
||||||
|
interceptor = apiFallbackInterceptor
|
||||||
|
).parsed<KitsuResponse>()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class ResponseToken(
|
||||||
|
@JsonProperty("token_type") val tokenType: String,
|
||||||
|
@JsonProperty("expires_in") val expiresIn: Int,
|
||||||
|
@JsonProperty("access_token") val accessToken: String,
|
||||||
|
@JsonProperty("refresh_token") val refreshToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuNode(
|
||||||
|
@JsonProperty("id") val id: String,
|
||||||
|
@JsonProperty("attributes") val attributes: KitsuNodeAttributes,
|
||||||
|
/* User list anime node */
|
||||||
|
@JsonProperty("relationships") val relationships: KitsuRelationships?,
|
||||||
|
var anime: KitsuAnimeData?
|
||||||
|
) {
|
||||||
|
fun toLibraryItem(): LibraryItem {
|
||||||
|
|
||||||
|
val animeItem = this.anime
|
||||||
|
|
||||||
|
val numEpisodes = animeItem?.attributes?.episodeCount
|
||||||
|
|
||||||
|
val startDate = animeItem?.attributes?.startDate
|
||||||
|
|
||||||
|
val posterImage = animeItem?.attributes?.posterImage
|
||||||
|
|
||||||
|
val canonicalTitle = animeItem?.attributes?.canonicalTitle
|
||||||
|
val titles = animeItem?.attributes?.titles
|
||||||
|
|
||||||
|
val animeId = animeItem?.id
|
||||||
|
|
||||||
|
val synopsis: String? = animeItem?.attributes?.synopsis
|
||||||
|
|
||||||
|
return LibraryItem(
|
||||||
|
canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(),
|
||||||
|
"https://kitsu.app/anime/${animeId}/",
|
||||||
|
this.id,
|
||||||
|
this.attributes.progress,
|
||||||
|
numEpisodes,
|
||||||
|
Score.from(this.attributes.ratingTwenty, 20),
|
||||||
|
parseDateLong(this.attributes.updatedAt),
|
||||||
|
"Kitsu",
|
||||||
|
TvType.Anime,
|
||||||
|
posterImage?.large ?: posterImage?.medium,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
plot = synopsis,
|
||||||
|
releaseDate = if (startDate == null) null else try {
|
||||||
|
Date.from(LocalDate.parse(startDate).atStartOfDay()
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant())
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
data class KitsuAnimeAttributes(
|
||||||
|
@JsonProperty("titles") val titles: KitsuTitles?,
|
||||||
|
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
|
||||||
|
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
|
||||||
|
@JsonProperty("synopsis") val synopsis: String?,
|
||||||
|
@JsonProperty("startDate") val startDate: String?,
|
||||||
|
@JsonProperty("endDate") val endDate: String?,
|
||||||
|
@JsonProperty("episodeCount") val episodeCount: Int?,
|
||||||
|
@JsonProperty("episodeLength") val episodeLength: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuAnimeData(
|
||||||
|
@JsonProperty("id") val id: String,
|
||||||
|
@JsonProperty("attributes") val attributes: KitsuAnimeAttributes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
data class KitsuNodeAttributes(
|
||||||
|
/* General attributes */
|
||||||
|
@JsonProperty("titles") val titles: KitsuTitles?,
|
||||||
|
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
|
||||||
|
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
|
||||||
|
@JsonProperty("synopsis") val synopsis: String?,
|
||||||
|
@JsonProperty("startDate") val startDate: String?,
|
||||||
|
@JsonProperty("endDate") val endDate: String?,
|
||||||
|
@JsonProperty("episodeCount") val episodeCount: Int?,
|
||||||
|
@JsonProperty("episodeLength") val episodeLength: Int?,
|
||||||
|
/* User attributes */
|
||||||
|
@JsonProperty("name") val name: String?,
|
||||||
|
@JsonProperty("location") val location: String?,
|
||||||
|
@JsonProperty("createdAt") val createdAt: String?,
|
||||||
|
@JsonProperty("avatar") val avatar: KitsuUserAvatar?,
|
||||||
|
/* User list anime attributes */
|
||||||
|
@JsonProperty("progress") val progress: Int?,
|
||||||
|
@JsonProperty("ratingTwenty") val ratingTwenty: Int?,
|
||||||
|
@JsonProperty("updatedAt") val updatedAt: String?,
|
||||||
|
@JsonProperty("status") val status: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuRelationships(
|
||||||
|
@JsonProperty("anime") val anime: KitsuRelationshipsAnime?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuRelationshipsAnime(
|
||||||
|
@JsonProperty("links") val links: KitsuLinks?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuPosterImage(
|
||||||
|
@JsonProperty("large") val large: String?,
|
||||||
|
@JsonProperty("medium") val medium: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuTitles(
|
||||||
|
@JsonProperty("en_jp") val enJp: String?,
|
||||||
|
@JsonProperty("ja_jp") val jaJp: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuUserAvatar(
|
||||||
|
@JsonProperty("original") val original: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuLinks(
|
||||||
|
/* Pagination */
|
||||||
|
@JsonProperty("first") val first: String?,
|
||||||
|
@JsonProperty("next") val next: String?,
|
||||||
|
@JsonProperty("last") val last: String?,
|
||||||
|
/* Relationships */
|
||||||
|
@JsonProperty("related") val related: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KitsuResponse(
|
||||||
|
@JsonProperty("links") val links: KitsuLinks?,
|
||||||
|
@JsonProperty("data") val data: List<KitsuNode>,
|
||||||
|
/* When requesting related info (User library entry -> anime) */
|
||||||
|
@JsonProperty("included") val included: List<KitsuAnimeData>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
|
||||||
|
private fun parseDateLong(string: String?): Long? {
|
||||||
|
return try {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
|
||||||
|
string ?: return null
|
||||||
|
)?.time?.div(1000)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val kitsuStatusAsString =
|
||||||
|
arrayOf("current", "completed", "on_hold", "dropped", "planned")
|
||||||
|
private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType {
|
||||||
|
return when (inp) {
|
||||||
|
SyncWatchType.NONE -> KitsuStatusType.None
|
||||||
|
SyncWatchType.WATCHING -> KitsuStatusType.Watching
|
||||||
|
SyncWatchType.COMPLETED -> KitsuStatusType.Completed
|
||||||
|
SyncWatchType.ONHOLD -> KitsuStatusType.OnHold
|
||||||
|
SyncWatchType.DROPPED -> KitsuStatusType.Dropped
|
||||||
|
SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch
|
||||||
|
SyncWatchType.REWATCHING -> KitsuStatusType.Watching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class KitsuStatusType(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 convertToStatus(string: String): KitsuStatusType {
|
||||||
|
return when (string) {
|
||||||
|
"current" -> KitsuStatusType.Watching
|
||||||
|
"completed" -> KitsuStatusType.Completed
|
||||||
|
"on_hold" -> KitsuStatusType.OnHold
|
||||||
|
"dropped" -> KitsuStatusType.Dropped
|
||||||
|
"planned" -> KitsuStatusType.PlanToWatch
|
||||||
|
else -> KitsuStatusType.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
|
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
|
||||||
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
||||||
|
|
@ -142,4 +810,4 @@ query {
|
||||||
val canonical: String? = null
|
val canonical: String? = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,9 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
|
val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||||
val res = app.get(
|
val res = app.get(
|
||||||
url, headers = mapOf(
|
url, headers = mapOf(
|
||||||
"Authorization" to "Bearer $auth",
|
"Authorization" to "Bearer $auth",
|
||||||
|
|
@ -122,7 +122,7 @@ class MALApi : SyncAPI() {
|
||||||
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||||
|
|
||||||
override suspend fun updateStatus(
|
override suspend fun updateStatus(
|
||||||
auth : AuthData?,
|
auth: AuthData?,
|
||||||
id: String,
|
id: String,
|
||||||
newStatus: SyncAPI.AbstractSyncStatus
|
newStatus: SyncAPI.AbstractSyncStatus
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
|
@ -225,7 +225,7 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
|
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
val url =
|
val url =
|
||||||
|
|
@ -271,7 +271,7 @@ class MALApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
|
|
||||||
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
|
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
|
||||||
|
|
@ -477,7 +477,7 @@ class MALApi : SyncAPI() {
|
||||||
@JsonProperty("start_time") val startTime: String?
|
@JsonProperty("start_time") val startTime: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun library(auth : AuthData?): LibraryMetadata? {
|
override suspend fun library(auth: AuthData?): LibraryMetadata? {
|
||||||
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
||||||
convertToStatus(it.listStatus?.status ?: "").stringRes
|
convertToStatus(it.listStatus?.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
|
|
@ -505,7 +505,7 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getMalAnimeListSmart(auth : AuthData): Array<Data>? {
|
private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
|
||||||
return if (requireLibraryRefresh) {
|
return if (requireLibraryRefresh) {
|
||||||
val list = getMalAnimeList(auth.token)
|
val list = getMalAnimeList(auth.token)
|
||||||
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
|
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import com.lagradost.cloudstream3.Score
|
||||||
import com.lagradost.cloudstream3.SimklSyncServices
|
import com.lagradost.cloudstream3.SimklSyncServices
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mapper
|
|
||||||
import com.lagradost.cloudstream3.mvvm.debugPrint
|
import com.lagradost.cloudstream3.mvvm.debugPrint
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
||||||
|
|
@ -30,6 +29,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
@ -117,13 +117,8 @@ class SimklApi : SyncAPI() {
|
||||||
* Gets cached object, if object is not fresh returns null and removes it from cache
|
* Gets cached object, if object is not fresh returns null and removes it from cache
|
||||||
*/
|
*/
|
||||||
inline fun <reified T : Any> getKey(path: String): T? {
|
inline fun <reified T : Any> getKey(path: String): T? {
|
||||||
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
|
|
||||||
val type = mapper.typeFactory.constructParametricType(
|
|
||||||
SimklCacheWrapper::class.java,
|
|
||||||
T::class.java
|
|
||||||
)
|
|
||||||
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
|
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
|
||||||
mapper.readValue<SimklCacheWrapper<T>>(it, type)
|
tryParseJson<SimklCacheWrapper<T>>(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (cache?.isFresh() == true) {
|
return if (cache?.isFresh() == true) {
|
||||||
|
|
@ -916,7 +911,7 @@ class SimklApi : SyncAPI() {
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
return app.get(
|
return app.get(
|
||||||
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
|
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query)
|
||||||
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
|
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
import com.lagradost.cloudstream3.newSearchResponseList
|
import com.lagradost.cloudstream3.newSearchResponseList
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -55,7 +55,7 @@ class APIRepository(val api: MainAPI) {
|
||||||
val hash: Pair<String, String>
|
val hash: Pair<String, String>
|
||||||
)
|
)
|
||||||
|
|
||||||
private val cache = threadSafeListOf<SavedLoadResponse>()
|
private val cache = atomicListOf<SavedLoadResponse>()
|
||||||
private var cacheIndex: Int = 0
|
private var cacheIndex: Int = 0
|
||||||
const val CACHE_SIZE = 20
|
const val CACHE_SIZE = 20
|
||||||
|
|
||||||
|
|
@ -66,9 +66,7 @@ class APIRepository(val api: MainAPI) {
|
||||||
|
|
||||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||||
if (forceReload) {
|
if (forceReload) {
|
||||||
synchronized(cache) {
|
cache.clear()
|
||||||
cache.clear()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,21 +89,25 @@ class APIRepository(val api: MainAPI) {
|
||||||
val fixedUrl = api.fixUrl(url)
|
val fixedUrl = api.fixUrl(url)
|
||||||
val lookingForHash = Pair(api.name, fixedUrl)
|
val lookingForHash = Pair(api.name, fixedUrl)
|
||||||
|
|
||||||
synchronized(cache) {
|
val cached = cache.withLock {
|
||||||
|
var found: LoadResponse? = null
|
||||||
for (item in cache) {
|
for (item in cache) {
|
||||||
// 10 min save
|
// 10 min save
|
||||||
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
||||||
return@withTimeout item.response
|
found = item.response
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cached != null) return@withTimeout cached
|
||||||
api.load(fixedUrl)?.also { response ->
|
api.load(fixedUrl)?.also { response ->
|
||||||
// Remove all blank tags as early as possible
|
// Remove all blank tags as early as possible
|
||||||
response.tags = response.tags?.filter { it.isNotBlank() }
|
response.tags = response.tags?.filter { it.isNotBlank() }
|
||||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||||
|
|
||||||
synchronized(cache) {
|
cache.withLock {
|
||||||
if (cache.size > CACHE_SIZE) {
|
if (cache.size > CACHE_SIZE) {
|
||||||
cache[cacheIndex] = add // rolling cache
|
cache[cacheIndex] = add // rolling cache
|
||||||
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
||||||
|
|
@ -215,4 +217,4 @@ class APIRepository(val api: MainAPI) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.lagradost.cloudstream3.ui
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
|
@ -11,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import coil3.dispose
|
import coil3.dispose
|
||||||
|
import java.util.WeakHashMap
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
||||||
|
|
@ -22,6 +24,33 @@ abstract class NoStateAdapter<T : Any>(
|
||||||
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
||||||
) : BaseAdapter<T, Any>(0, diffCallback)
|
) : BaseAdapter<T, Any>(0, diffCallback)
|
||||||
|
|
||||||
|
/** Creates a new shared pool, using the supplied lambda as a constructor.
|
||||||
|
*
|
||||||
|
* The reason for this complicated structure is that a pool should not be shared between contexts
|
||||||
|
* as it makes coil fuck up, and theming.
|
||||||
|
* */
|
||||||
|
fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit> =
|
||||||
|
WeakHashMap<Context, RecyclerView.RecycledViewPool>() to lambda
|
||||||
|
|
||||||
|
/** Sets the shared pool of the recyclerview */
|
||||||
|
fun RecyclerView.setRecycledViewPool(pool: Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>) {
|
||||||
|
val ctx = context ?: return
|
||||||
|
synchronized(pool.first) {
|
||||||
|
this.setRecycledViewPool(pool.first.getOrPut(ctx) {
|
||||||
|
RecyclerView.RecycledViewPool().apply(pool.second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the shared pool of views */
|
||||||
|
fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>.clear() {
|
||||||
|
synchronized(this.first) {
|
||||||
|
for (pool in this.first.values) {
|
||||||
|
pool?.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
||||||
* This should be used for restoring eg scroll or focus related to a view when it is recreated.
|
* This should be used for restoring eg scroll or focus related to a view when it is recreated.
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
|
||||||
import com.google.android.gms.cast.MediaLoadOptions
|
import com.google.android.gms.cast.MediaLoadOptions
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
import com.google.android.gms.cast.MediaSeekOptions
|
import com.google.android.gms.cast.MediaSeekOptions
|
||||||
|
|
@ -105,9 +102,6 @@ data class MetadataHolder(
|
||||||
|
|
||||||
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
||||||
UIController() {
|
UIController() {
|
||||||
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
||||||
view.setOnClickListener {
|
view.setOnClickListener {
|
||||||
|
|
@ -334,6 +328,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
}, subtitleCallback = {
|
}, subtitleCallback = {
|
||||||
currentSubs.add(it)
|
currentSubs.add(it)
|
||||||
},
|
},
|
||||||
|
offset = 0,
|
||||||
isCasting = true
|
isCasting = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -448,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() {
|
||||||
SkipNextEpisodeController(skipOpButton)
|
SkipNextEpisodeController(skipOpButton)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
@ -154,10 +155,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
val attrsArray = intArrayOf(android.R.attr.columnWidth)
|
context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) {
|
||||||
val array = context.obtainStyledAttributes(attrs, attrsArray)
|
columnWidth = getDimensionPixelSize(0, -1)
|
||||||
columnWidth = array.getDimensionPixelSize(0, -1)
|
}
|
||||||
array.recycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutManager = manager
|
layoutManager = manager
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ import android.view.View
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
|
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
|
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
|
|
||||||
|
|
||||||
class MyMiniControllerFragment : MiniControllerFragment() {
|
class MyMiniControllerFragment : MiniControllerFragment() {
|
||||||
|
|
@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() {
|
||||||
|
|
||||||
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
|
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
|
||||||
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
|
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
|
||||||
super.onInflate(context, attributeSet, bundle)
|
|
||||||
|
|
||||||
// somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks????
|
|
||||||
if (currentColor == 0) {
|
if (currentColor == 0) {
|
||||||
WeakReference(
|
context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) {
|
||||||
context.obtainStyledAttributes(
|
if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) {
|
||||||
attributeSet,
|
currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0)
|
||||||
R.styleable.CustomCast
|
|
||||||
)
|
|
||||||
).apply {
|
|
||||||
if (get()
|
|
||||||
?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true
|
|
||||||
) {
|
|
||||||
currentColor =
|
|
||||||
get()
|
|
||||||
?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0
|
|
||||||
}
|
}
|
||||||
get()?.recycle()
|
}
|
||||||
}.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
super.onInflate(context, attributeSet, bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
||||||
|
|
||||||
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var hasLoggedIn: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
val accountViewModel: AccountViewModel by viewModels()
|
val accountViewModel: AccountViewModel by viewModels()
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
loadThemes(this)
|
|
||||||
|
|
||||||
enableEdgeToEdgeCompat()
|
|
||||||
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
|
||||||
|
|
||||||
// Are we editing and coming from MainActivity?
|
// Are we editing and coming from MainActivity?
|
||||||
val isEditingFromMainActivity = intent.getBooleanExtra(
|
val isEditingFromMainActivity = intent.getBooleanExtra(
|
||||||
|
|
@ -54,6 +54,19 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sometimes we start this activity when we have already logged in
|
||||||
|
// For example when using cloudstreamsearch://
|
||||||
|
// In those cases we want to just go to the main activity instantly
|
||||||
|
if (hasLoggedIn && !isEditingFromMainActivity) {
|
||||||
|
navigateToMainActivity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThemes(this)
|
||||||
|
|
||||||
|
enableEdgeToEdgeCompat()
|
||||||
|
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
val skipStartup = settingsManager.getBoolean(
|
val skipStartup = settingsManager.getBoolean(
|
||||||
getString(R.string.skip_startup_account_select_key), false
|
getString(R.string.skip_startup_account_select_key), false
|
||||||
|
|
@ -188,8 +201,11 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
askBiometricAuth()
|
askBiometricAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeIntentLaunch")
|
||||||
private fun navigateToMainActivity() {
|
private fun navigateToMainActivity() {
|
||||||
openActivity(MainActivity::class.java)
|
hasLoggedIn = true
|
||||||
|
// We want to propagate any intent we get here to MainActivity since this is just an intermediary
|
||||||
|
openActivity(MainActivity::class.java, baseIntent = intent)
|
||||||
finish() // Finish the account selection activity
|
finish() // Finish the account selection activity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,4 +216,4 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
override fun onAuthenticationError() {
|
override fun onAuthenticationError() {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
|
||||||
const val DOWNLOAD_ACTION_PLAY_FILE = 0
|
const val DOWNLOAD_ACTION_PLAY_FILE = 0
|
||||||
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
||||||
|
|
@ -27,6 +27,7 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
|
||||||
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
|
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
|
||||||
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
||||||
const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
||||||
|
const val DOWNLOAD_ACTION_CANCEL_PENDING = 6
|
||||||
|
|
||||||
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
|
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
|
||||||
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
||||||
|
|
@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
||||||
sealed class VisualDownloadCached {
|
sealed class VisualDownloadCached {
|
||||||
abstract val currentBytes: Long
|
abstract val currentBytes: Long
|
||||||
abstract val totalBytes: Long
|
abstract val totalBytes: Long
|
||||||
abstract val data: VideoDownloadHelper.DownloadCached
|
abstract val data: DownloadObjects.DownloadCached
|
||||||
abstract var isSelected: Boolean
|
abstract var isSelected: Boolean
|
||||||
|
|
||||||
data class Child(
|
data class Child(
|
||||||
override val currentBytes: Long,
|
override val currentBytes: Long,
|
||||||
override val totalBytes: Long,
|
override val totalBytes: Long,
|
||||||
override val data: VideoDownloadHelper.DownloadEpisodeCached,
|
override val data: DownloadObjects.DownloadEpisodeCached,
|
||||||
override var isSelected: Boolean,
|
override var isSelected: Boolean,
|
||||||
) : VisualDownloadCached()
|
) : VisualDownloadCached()
|
||||||
|
|
||||||
data class Header(
|
data class Header(
|
||||||
override val currentBytes: Long,
|
override val currentBytes: Long,
|
||||||
override val totalBytes: Long,
|
override val totalBytes: Long,
|
||||||
override val data: VideoDownloadHelper.DownloadHeaderCached,
|
override val data: DownloadObjects.DownloadHeaderCached,
|
||||||
override var isSelected: Boolean,
|
override var isSelected: Boolean,
|
||||||
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
val child: DownloadObjects.DownloadEpisodeCached?,
|
||||||
val currentOngoingDownloads: Int,
|
val currentOngoingDownloads: Int,
|
||||||
val totalDownloads: Int,
|
val totalDownloads: Int,
|
||||||
) : VisualDownloadCached()
|
) : VisualDownloadCached()
|
||||||
|
|
@ -57,12 +58,12 @@ sealed class VisualDownloadCached {
|
||||||
|
|
||||||
data class DownloadClickEvent(
|
data class DownloadClickEvent(
|
||||||
val action: Int,
|
val action: Int,
|
||||||
val data: VideoDownloadHelper.DownloadEpisodeCached
|
val data: DownloadObjects.DownloadEpisodeCached
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DownloadHeaderClickEvent(
|
data class DownloadHeaderClickEvent(
|
||||||
val action: Int,
|
val action: Int,
|
||||||
val data: VideoDownloadHelper.DownloadHeaderCached
|
val data: DownloadObjects.DownloadHeaderCached
|
||||||
)
|
)
|
||||||
|
|
||||||
class DownloadAdapter(
|
class DownloadAdapter(
|
||||||
|
|
@ -170,6 +171,7 @@ class DownloadAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadButton.resetView()
|
||||||
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
||||||
if (status == DownloadStatusTell.IsDone) {
|
if (status == DownloadStatusTell.IsDone) {
|
||||||
// We do this here instead if we are finished downloading
|
// We do this here instead if we are finished downloading
|
||||||
|
|
@ -187,7 +189,6 @@ class DownloadAdapter(
|
||||||
} else {
|
} else {
|
||||||
// We need to make sure we restore the correct progress
|
// We need to make sure we restore the correct progress
|
||||||
// when we refresh data in the adapter.
|
// when we refresh data in the adapter.
|
||||||
downloadButton.resetView()
|
|
||||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||||
ContextCompat.getDrawable(downloadButton.context, it)
|
ContextCompat.getDrawable(downloadButton.context, it)
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +278,7 @@ class DownloadAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadButton.resetView()
|
||||||
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
||||||
if (status == DownloadStatusTell.IsDone) {
|
if (status == DownloadStatusTell.IsDone) {
|
||||||
// We do this here instead if we are finished downloading
|
// We do this here instead if we are finished downloading
|
||||||
|
|
@ -295,7 +297,6 @@ class DownloadAdapter(
|
||||||
} else {
|
} else {
|
||||||
// We need to make sure we restore the correct progress
|
// We need to make sure we restore the correct progress
|
||||||
// when we refresh data in the adapter.
|
// when we refresh data in the adapter.
|
||||||
downloadButton.resetView()
|
|
||||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||||
ContextCompat.getDrawable(downloadButton.context, it)
|
ContextCompat.getDrawable(downloadButton.context, it)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
|
|
||||||
object DownloadButtonSetup {
|
object DownloadButtonSetup {
|
||||||
|
|
@ -82,7 +83,7 @@ object DownloadButtonSetup {
|
||||||
} else {
|
} else {
|
||||||
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
|
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
|
||||||
if (pkg != null) {
|
if (pkg != null) {
|
||||||
VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
|
DownloadQueueManager.addToQueue(pkg.toWrapper())
|
||||||
} else {
|
} else {
|
||||||
VideoDownloadManager.downloadEvent.invoke(
|
VideoDownloadManager.downloadEvent.invoke(
|
||||||
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
|
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
|
||||||
|
|
@ -95,7 +96,7 @@ object DownloadButtonSetup {
|
||||||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
val length =
|
val length =
|
||||||
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
|
VideoDownloadManager.getDownloadFileInfo(
|
||||||
act,
|
act,
|
||||||
click.data.id
|
click.data.id
|
||||||
)?.fileLength
|
)?.fileLength
|
||||||
|
|
@ -110,24 +111,31 @@ object DownloadButtonSetup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DOWNLOAD_ACTION_CANCEL_PENDING -> {
|
||||||
|
DownloadQueueManager.cancelDownload(id)
|
||||||
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_PLAY_FILE -> {
|
DOWNLOAD_ACTION_PLAY_FILE -> {
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
val parent = getKey<DownloadObjects.DownloadHeaderCached>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
click.data.parentId.toString()
|
click.data.parentId.toString()
|
||||||
) ?: return
|
) ?: return
|
||||||
|
|
||||||
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
|
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
?.mapNotNull {
|
?.mapNotNull {
|
||||||
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
|
getKey<DownloadObjects.DownloadEpisodeCached>(it)
|
||||||
}
|
}
|
||||||
?.filter { it.parentId == click.data.parentId }
|
?.filter { it.parentId == click.data.parentId }
|
||||||
|
|
||||||
val items = mutableListOf<ExtractorUri>()
|
val items = mutableListOf<ExtractorUri>()
|
||||||
val allRelevantEpisodes = episodes?.sortedWith(compareBy<VideoDownloadHelper.DownloadEpisodeCached> { it.season ?: 0 }.thenBy { it.episode })
|
val allRelevantEpisodes =
|
||||||
|
episodes?.sortedWith(compareBy<DownloadObjects.DownloadEpisodeCached> {
|
||||||
|
it.season ?: 0
|
||||||
|
}.thenBy { it.episode })
|
||||||
|
|
||||||
allRelevantEpisodes?.forEach {
|
allRelevantEpisodes?.forEach {
|
||||||
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
|
val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>(
|
||||||
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
||||||
it.id.toString()
|
it.id.toString()
|
||||||
) ?: return@forEach
|
) ?: return@forEach
|
||||||
|
|
@ -154,7 +162,8 @@ object DownloadButtonSetup {
|
||||||
}
|
}
|
||||||
act.navigate(
|
act.navigate(
|
||||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||||
DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) }
|
DownloadFileGenerator(items),
|
||||||
|
items.indexOfFirst { it.id == click.data.id }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||||
|
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel
|
||||||
import com.lagradost.cloudstream3.ui.player.BasicLink
|
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||||
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
||||||
|
|
@ -58,6 +59,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val downloadViewModel: DownloadViewModel by activityViewModels()
|
private val downloadViewModel: DownloadViewModel by activityViewModels()
|
||||||
|
private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
|
||||||
|
|
||||||
private fun View.setLayoutWidth(weight: Long) {
|
private fun View.setLayoutWidth(weight: Long) {
|
||||||
val param = LinearLayout.LayoutParams(
|
val param = LinearLayout.LayoutParams(
|
||||||
|
|
@ -142,6 +144,17 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
binding.downloadApp
|
binding.downloadApp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
observe(downloadQueueViewModel.childCards) { cards ->
|
||||||
|
val size = cards.currentDownloads.size + cards.queue.size
|
||||||
|
val context = binding.root.context
|
||||||
|
val baseText = context.getString(R.string.download_queue)
|
||||||
|
binding.downloadQueueText.text = if (size > 0) {
|
||||||
|
"$baseText (${cards.currentDownloads.size}/$size)"
|
||||||
|
} else {
|
||||||
|
baseText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
observe(downloadViewModel.selectedBytes) {
|
observe(downloadViewModel.selectedBytes) {
|
||||||
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
|
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +226,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
setLinearListLayout(
|
setLinearListLayout(
|
||||||
isHorizontal = false,
|
isHorizontal = false,
|
||||||
nextRight = FOCUS_SELF,
|
nextRight = FOCUS_SELF,
|
||||||
nextDown = FOCUS_SELF,
|
nextDown = R.id.download_queue_button,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +240,10 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
setOnClickListener { showStreamInputDialog(it.context) }
|
setOnClickListener { showStreamInputDialog(it.context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadQueueButton.setOnClickListener {
|
||||||
|
activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
|
||||||
|
}
|
||||||
|
|
||||||
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
|
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
|
||||||
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
|
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
|
||||||
|
|
||||||
|
|
@ -332,7 +349,8 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
listOf(BasicLink(url)),
|
listOf(BasicLink(url)),
|
||||||
extract = true,
|
extract = true,
|
||||||
refererUrl = referer,
|
refererUrl = referer,
|
||||||
)
|
id = url.hashCode()
|
||||||
|
), 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dialog.dismissSafe(activity)
|
dialog.dismissSafe(activity)
|
||||||
|
|
|
||||||
|
|
@ -5,30 +5,46 @@ import android.content.DialogInterface
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.lagradost.api.Log
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.isEpisodeBased
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.services.DownloadQueueService
|
||||||
|
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.ConsistentLiveData
|
import com.lagradost.cloudstream3.utils.ConsistentLiveData
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
||||||
import com.lagradost.cloudstream3.utils.ResourceLiveData
|
import com.lagradost.cloudstream3.utils.ResourceLiveData
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class DownloadViewModel : ViewModel() {
|
class DownloadViewModel : ViewModel() {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "DownloadViewModel"
|
||||||
|
}
|
||||||
|
|
||||||
private val _headerCards =
|
private val _headerCards =
|
||||||
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
|
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
|
||||||
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
|
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
|
||||||
|
|
@ -111,23 +127,109 @@ class DownloadViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun removeRedundantEpisodeKeys(context: Context, keys: List<Pair<Int, Int>>) {
|
||||||
|
val settingsManager = context.getSharedPrefs()
|
||||||
|
ioSafe {
|
||||||
|
settingsManager.edit {
|
||||||
|
keys.forEach { (parentId, childId) ->
|
||||||
|
Log.i(TAG, "Removing download episode key: ${parentId}/${childId}")
|
||||||
|
val oldPath = getFolderName(
|
||||||
|
getFolderName(
|
||||||
|
DOWNLOAD_EPISODE_CACHE,
|
||||||
|
parentId.toString()
|
||||||
|
),
|
||||||
|
childId.toString()
|
||||||
|
)
|
||||||
|
val newPath = getFolderName(
|
||||||
|
getFolderName(
|
||||||
|
DOWNLOAD_EPISODE_CACHE_BACKUP,
|
||||||
|
parentId.toString()
|
||||||
|
),
|
||||||
|
childId.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
val oldPref = settingsManager.getString(oldPath, null)
|
||||||
|
// Cowardly future backup solution in case the key removal fails in some edge case.
|
||||||
|
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
|
||||||
|
this.putString(newPath, oldPref)
|
||||||
|
this.remove(oldPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRedundantHeaderKeys(
|
||||||
|
context: Context,
|
||||||
|
cached: List<DownloadObjects.DownloadHeaderCached>,
|
||||||
|
totalBytesUsedByChild: Map<Int, Long>,
|
||||||
|
totalDownloads: Map<Int, Int>
|
||||||
|
) {
|
||||||
|
val settingsManager = context.getSharedPrefs()
|
||||||
|
ioSafe {
|
||||||
|
// Do not remove headers used by resume watching
|
||||||
|
val resumeWatchingIds =
|
||||||
|
getAllResumeStateIds()?.mapNotNull { id ->
|
||||||
|
getLastWatched(id)?.parentId
|
||||||
|
}?.toSet() ?: emptySet()
|
||||||
|
|
||||||
|
settingsManager.edit {
|
||||||
|
cached.forEach { header ->
|
||||||
|
val downloads = totalDownloads[header.id] ?: 0
|
||||||
|
val bytes = totalBytesUsedByChild[header.id] ?: 0
|
||||||
|
|
||||||
|
if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) {
|
||||||
|
Log.i(TAG, "Removing download header key: ${header.id}")
|
||||||
|
val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString())
|
||||||
|
val newPath =
|
||||||
|
getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString())
|
||||||
|
val oldPref = settingsManager.getString(oldPAth, null)
|
||||||
|
// Cowardly future backup solution in case the key removal fails in some edge case.
|
||||||
|
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
|
||||||
|
this.putString(newPath, oldPref)
|
||||||
|
this.remove(oldPAth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
||||||
// Do not push loading as it interrupts the UI
|
// Do not push loading as it interrupts the UI
|
||||||
//_headerCards.postValue(Resource.Loading())
|
//_headerCards.postValue(Resource.Loading())
|
||||||
|
|
||||||
val visual = withContext(Dispatchers.IO) {
|
val visual = ioWork {
|
||||||
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
|
.mapNotNull { context.getKey<DownloadObjects.DownloadEpisodeCached>(it) }
|
||||||
.distinctBy { it.id } // Remove duplicates
|
.distinctBy { it.id } // Remove duplicates
|
||||||
|
|
||||||
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
|
val isCurrentlyDownloading =
|
||||||
|
DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty()
|
||||||
|
|
||||||
|
val downloadStats =
|
||||||
calculateDownloadStats(context, children)
|
calculateDownloadStats(context, children)
|
||||||
|
|
||||||
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
||||||
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
|
.mapNotNull { context.getKey<DownloadObjects.DownloadHeaderCached>(it) }
|
||||||
|
|
||||||
|
// Download stats and header keys may change when downloading.
|
||||||
|
// To prevent the downloader and key removal from colliding, simply do not prune keys when downloading.
|
||||||
|
if (!isCurrentlyDownloading) {
|
||||||
|
removeRedundantHeaderKeys(
|
||||||
|
context,
|
||||||
|
cached,
|
||||||
|
downloadStats.totalBytesUsedByChild,
|
||||||
|
downloadStats.totalDownloads
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required
|
||||||
|
removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads)
|
||||||
|
|
||||||
createVisualDownloadList(
|
createVisualDownloadList(
|
||||||
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
|
context,
|
||||||
|
cached,
|
||||||
|
downloadStats.totalBytesUsedByChild,
|
||||||
|
downloadStats.currentBytesUsedByChild,
|
||||||
|
downloadStats.totalDownloads
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,20 +261,38 @@ class DownloadViewModel : ViewModel() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class DownloadStats(
|
||||||
|
val totalBytesUsedByChild: Map<Int, Long>,
|
||||||
|
val currentBytesUsedByChild: Map<Int, Long>,
|
||||||
|
val totalDownloads: Map<Int, Int>,
|
||||||
|
/** Parent ID to child ID. Keys to be removed. */
|
||||||
|
val redundantDownloads: List<Pair<Int, Int>>
|
||||||
|
)
|
||||||
|
|
||||||
private fun calculateDownloadStats(
|
private fun calculateDownloadStats(
|
||||||
context: Context,
|
context: Context,
|
||||||
children: List<VideoDownloadHelper.DownloadEpisodeCached>
|
children: List<DownloadObjects.DownloadEpisodeCached>
|
||||||
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
|
): DownloadStats {
|
||||||
// parentId : bytes
|
// parentId : bytes
|
||||||
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||||
// parentId : bytes
|
// parentId : bytes
|
||||||
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||||
// parentId : downloadsCount
|
// parentId : downloadsCount
|
||||||
val totalDownloads = mutableMapOf<Int, Int>()
|
val totalDownloads = mutableMapOf<Int, Int>()
|
||||||
|
val redundantDownloads = mutableListOf<Pair<Int, Int>>()
|
||||||
|
|
||||||
children.forEach { child ->
|
children.forEach { child ->
|
||||||
val childFile =
|
val childFile = getDownloadFileInfo(context, child.id)
|
||||||
getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
|
|
||||||
|
if (childFile == null) {
|
||||||
|
// It may not be a redundant child if something is currently downloading.
|
||||||
|
// DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader
|
||||||
|
// leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE
|
||||||
|
if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) {
|
||||||
|
redundantDownloads.add(child.parentId to child.id)
|
||||||
|
}
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
if (childFile.fileLength <= 1) return@forEach
|
if (childFile.fileLength <= 1) return@forEach
|
||||||
|
|
||||||
val len = childFile.totalBytes
|
val len = childFile.totalBytes
|
||||||
|
|
@ -182,12 +302,17 @@ class DownloadViewModel : ViewModel() {
|
||||||
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
||||||
totalDownloads.merge(child.parentId, 1, Int::plus)
|
totalDownloads.merge(child.parentId, 1, Int::plus)
|
||||||
}
|
}
|
||||||
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
|
return DownloadStats(
|
||||||
|
totalBytesUsedByChild,
|
||||||
|
currentBytesUsedByChild,
|
||||||
|
totalDownloads,
|
||||||
|
redundantDownloads
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createVisualDownloadList(
|
private fun createVisualDownloadList(
|
||||||
context: Context,
|
context: Context,
|
||||||
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
|
cached: List<DownloadObjects.DownloadHeaderCached>,
|
||||||
totalBytesUsedByChild: Map<Int, Long>,
|
totalBytesUsedByChild: Map<Int, Long>,
|
||||||
currentBytesUsedByChild: Map<Int, Long>,
|
currentBytesUsedByChild: Map<Int, Long>,
|
||||||
totalDownloads: Map<Int, Int>
|
totalDownloads: Map<Int, Int>
|
||||||
|
|
@ -196,11 +321,14 @@ class DownloadViewModel : ViewModel() {
|
||||||
val downloads = totalDownloads[it.id] ?: 0
|
val downloads = totalDownloads[it.id] ?: 0
|
||||||
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
||||||
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
||||||
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
|
|
||||||
|
if (bytes <= 0 || downloads <= 0) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||||
val movieEpisode =
|
val movieEpisode =
|
||||||
if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
if (it.type.isEpisodeBased()) null else context.getKey<DownloadObjects.DownloadEpisodeCached>(
|
||||||
DOWNLOAD_EPISODE_CACHE,
|
DOWNLOAD_EPISODE_CACHE,
|
||||||
getFolderName(it.id.toString(), it.id.toString())
|
getFolderName(it.id.toString(), it.id.toString())
|
||||||
)
|
)
|
||||||
|
|
@ -233,11 +361,10 @@ class DownloadViewModel : ViewModel() {
|
||||||
|
|
||||||
val visual = withContext(Dispatchers.IO) {
|
val visual = withContext(Dispatchers.IO) {
|
||||||
context.getKeys(folder).mapNotNull { key ->
|
context.getKeys(folder).mapNotNull { key ->
|
||||||
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
context.getKey<DownloadObjects.DownloadEpisodeCached>(key)
|
||||||
}.mapNotNull {
|
}.mapNotNull {
|
||||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||||
val info =
|
val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null
|
||||||
getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
|
|
||||||
VisualDownloadCached.Child(
|
VisualDownloadCached.Child(
|
||||||
currentBytes = info.fileLength,
|
currentBytes = info.fileLength,
|
||||||
totalBytes = info.totalBytes,
|
totalBytes = info.totalBytes,
|
||||||
|
|
@ -313,7 +440,7 @@ class DownloadViewModel : ViewModel() {
|
||||||
if (item.data.type.isEpisodeBased()) {
|
if (item.data.type.isEpisodeBased()) {
|
||||||
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
.mapNotNull {
|
.mapNotNull {
|
||||||
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
context.getKey<DownloadObjects.DownloadEpisodeCached>(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +464,7 @@ class DownloadViewModel : ViewModel() {
|
||||||
|
|
||||||
is VisualDownloadCached.Child -> {
|
is VisualDownloadCached.Child -> {
|
||||||
ids.add(item.data.id)
|
ids.add(item.data.id)
|
||||||
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
val parent = context.getKey<DownloadObjects.DownloadHeaderCached>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
item.data.parentId.toString()
|
item.data.parentId.toString()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
|
|
||||||
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
|
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
|
||||||
|
|
||||||
|
|
@ -76,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
currentMetaData.id = id
|
currentMetaData.id = id
|
||||||
|
|
||||||
if (!doSetProgress) return
|
if (!doSetProgress) return
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
|
||||||
ioSafe {
|
ioSafe {
|
||||||
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
|
val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
|
||||||
|
|
||||||
mainWork {
|
mainWork {
|
||||||
if (savedData != null) {
|
if (savedData != null) {
|
||||||
val downloadedBytes = savedData.fileLength
|
val downloadedBytes = savedData.fileLength
|
||||||
|
|
@ -87,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
|
|
||||||
setProgress(downloadedBytes, totalBytes)
|
setProgress(downloadedBytes, totalBytes)
|
||||||
applyMetaData(id, downloadedBytes, totalBytes)
|
applyMetaData(id, downloadedBytes, totalBytes)
|
||||||
} else run { resetView() }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,4 +216,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
* Get a clean slate again, might be useful in recyclerview?
|
* Get a clean slate again, might be useful in recyclerview?
|
||||||
* */
|
* */
|
||||||
abstract fun resetView()
|
abstract fun resetView()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import androidx.core.view.isVisible
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
|
||||||
class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
PieFetchButton(context, attributeSet) {
|
PieFetchButton(context, attributeSet) {
|
||||||
|
|
@ -18,6 +18,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
super.onAttachedToWindow()
|
super.onAttachedToWindow()
|
||||||
progressText = findViewById(R.id.result_movie_download_text_precentage)
|
progressText = findViewById(R.id.result_movie_download_text_precentage)
|
||||||
mainText = findViewById(R.id.result_movie_download_text)
|
mainText = findViewById(R.id.result_movie_download_text)
|
||||||
|
setStatus(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStatus(status: DownloadStatusTell?) {
|
override fun setStatus(status: DownloadStatusTell?) {
|
||||||
|
|
@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDefaultClickListener(
|
override fun setDefaultClickListener(
|
||||||
card: VideoDownloadHelper.DownloadEpisodeCached,
|
card: DownloadObjects.DownloadEpisodeCached,
|
||||||
textView: TextView?,
|
textView: TextView?,
|
||||||
callback: (DownloadClickEvent) -> Unit
|
callback: (DownloadClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||||
|
|
@ -23,9 +26,10 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
|
||||||
|
|
||||||
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
BaseFetchButton(context, attributeSet) {
|
BaseFetchButton(context, attributeSet) {
|
||||||
|
|
@ -63,7 +67,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
open fun onInflate() {}
|
open fun onInflate() {}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
|
context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) {
|
||||||
try {
|
try {
|
||||||
inflate(
|
inflate(
|
||||||
overrideLayout ?: getResourceId(
|
overrideLayout ?: getResourceId(
|
||||||
|
|
@ -72,6 +76,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
recycle() // Manually call recycle first to avoid memory leaks
|
||||||
Log.e(
|
Log.e(
|
||||||
"PieFetchButton", "Error inflating PieFetchButton, " +
|
"PieFetchButton", "Error inflating PieFetchButton, " +
|
||||||
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
|
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
|
||||||
|
|
@ -79,11 +84,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
progressBar = findViewById(R.id.progress_downloaded)
|
|
||||||
progressBarBackground = findViewById(R.id.progress_downloaded_background)
|
|
||||||
statusView = findViewById(R.id.image_download_status)
|
|
||||||
|
|
||||||
animateWaiting = getBoolean(
|
animateWaiting = getBoolean(
|
||||||
R.styleable.PieFetchButton_download_animate_waiting,
|
R.styleable.PieFetchButton_download_animate_waiting,
|
||||||
true
|
true
|
||||||
|
|
@ -92,16 +92,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
R.styleable.PieFetchButton_download_hide_when_icon,
|
R.styleable.PieFetchButton_download_hide_when_icon,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
waitingAnimation = getResourceId(
|
waitingAnimation = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_waiting_animation,
|
R.styleable.PieFetchButton_download_waiting_animation,
|
||||||
R.anim.rotate_around_center_point
|
R.anim.rotate_around_center_point
|
||||||
)
|
)
|
||||||
|
|
||||||
activeOutline = getResourceId(
|
activeOutline = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
|
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
|
||||||
)
|
)
|
||||||
|
|
||||||
nonActiveOutline = getResourceId(
|
nonActiveOutline = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_outline_non_active,
|
R.styleable.PieFetchButton_download_outline_non_active,
|
||||||
R.drawable.circle_shape_dotted
|
R.drawable.circle_shape_dotted
|
||||||
|
|
@ -129,19 +126,29 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
)
|
)
|
||||||
|
|
||||||
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
|
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
|
||||||
|
|
||||||
progressDrawable = getResourceId(
|
progressDrawable = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
|
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
|
|
||||||
|
|
||||||
recycle()
|
|
||||||
}
|
}
|
||||||
resetView()
|
|
||||||
|
progressBar = findViewById(R.id.progress_downloaded)
|
||||||
|
progressBarBackground = findViewById(R.id.progress_downloaded_background)
|
||||||
|
statusView = findViewById(R.id.image_download_status)
|
||||||
|
|
||||||
|
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
|
||||||
|
|
||||||
|
// resetView()
|
||||||
onInflate()
|
onInflate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
// Re-run all animations when the view gets visible.
|
||||||
|
// Otherwise views may run without animations after recycled
|
||||||
|
setStatusInternal(currentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
private var currentStatus: DownloadStatusTell? = null
|
private var currentStatus: DownloadStatusTell? = null
|
||||||
/*private fun getActivity(): Activity? {
|
/*private fun getActivity(): Activity? {
|
||||||
var context = context
|
var context = context
|
||||||
|
|
@ -162,16 +169,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
protected fun setDefaultClickListener(
|
protected fun setDefaultClickListener(
|
||||||
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
|
view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached,
|
||||||
callback: (DownloadClickEvent) -> Unit
|
callback: (DownloadClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
this.progressText = textView
|
this.progressText = textView
|
||||||
this.setPersistentId(card.id)
|
this.setPersistentId(card.id)
|
||||||
view.setOnClickListener {
|
view.setOnClickListener {
|
||||||
if (isZeroBytes) {
|
if (isZeroBytes) {
|
||||||
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
|
val localQueue = queue.value
|
||||||
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
|
val localInstances = downloadInstances.value
|
||||||
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
|
val id = card.id
|
||||||
|
|
||||||
|
// If the download is already in queue or active downloads, provide an option to cancel it
|
||||||
|
if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) {
|
||||||
|
it.popupMenuNoIcons(
|
||||||
|
arrayListOf(
|
||||||
|
Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
callback(DownloadClickEvent(itemId, card))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise just start a download instantly
|
||||||
|
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
|
||||||
|
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val list = arrayListOf(
|
val list = arrayListOf(
|
||||||
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
||||||
|
|
@ -212,7 +234,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun setDefaultClickListener(
|
open fun setDefaultClickListener(
|
||||||
card: VideoDownloadHelper.DownloadEpisodeCached,
|
card: DownloadObjects.DownloadEpisodeCached,
|
||||||
textView: TextView?,
|
textView: TextView?,
|
||||||
callback: (DownloadClickEvent) -> Unit
|
callback: (DownloadClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -282,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
override fun setStatus(status: DownloadStatusTell?) {
|
override fun setStatus(status: DownloadStatusTell?) {
|
||||||
currentStatus = status
|
currentStatus = status
|
||||||
|
|
||||||
// Runs on the main thread, but also instant if it already is
|
// Runs on the main thread, but also instant if it already is.
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
if (Looper.getMainLooper().isCurrentThread) {
|
||||||
try {
|
try {
|
||||||
setStatusInternal(status)
|
setStatusInternal(status)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.queue
|
||||||
|
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding
|
||||||
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
|
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO
|
||||||
|
|
||||||
|
/** An item in the adapter can either be a separator or a real item.
|
||||||
|
* isCurrentlyDownloading is used to fully update items as opposed to just moving them. */
|
||||||
|
class DownloadAdapterItem(val item: DownloadQueueWrapper?) {
|
||||||
|
val isSeparator = item == null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter<DownloadAdapterItem, Unit>(
|
||||||
|
diffCallback = BaseDiffCallback(
|
||||||
|
itemSame = { a, b -> a.item?.id == b.item?.id },
|
||||||
|
contentSame = { a, b ->
|
||||||
|
a.item == b.item
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
var currentDownloads = 0
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Unit> {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
val binding = DownloadQueueItemBinding.inflate(inflater, parent, false)
|
||||||
|
return ViewHolderState(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindContent(
|
||||||
|
holder: ViewHolderState<Unit>,
|
||||||
|
item: DownloadAdapterItem,
|
||||||
|
position: Int
|
||||||
|
) {
|
||||||
|
when (val binding = holder.view) {
|
||||||
|
is DownloadQueueItemBinding -> {
|
||||||
|
if (item.item == null) {
|
||||||
|
holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG
|
||||||
|
bindSeparator(binding)
|
||||||
|
} else {
|
||||||
|
holder.itemView.tag = null
|
||||||
|
bind(binding, item.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitQueue(newQueue: DownloadAdapterQueue) {
|
||||||
|
val index = newQueue.currentDownloads.size
|
||||||
|
val current = newQueue.currentDownloads
|
||||||
|
val queue = newQueue.queue
|
||||||
|
currentDownloads = current.size
|
||||||
|
|
||||||
|
val newList =
|
||||||
|
(current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList()
|
||||||
|
.apply {
|
||||||
|
// Only add the separator if it actually separates something
|
||||||
|
if (index < this.size) {
|
||||||
|
add(index, DownloadAdapterItem(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submitList(newList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bindSeparator(binding: DownloadQueueItemBinding) {
|
||||||
|
binding.apply {
|
||||||
|
separatorHolder.isGone = false
|
||||||
|
downloadChildEpisodeHolder.isGone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(
|
||||||
|
binding: DownloadQueueItemBinding,
|
||||||
|
queueWrapper: DownloadQueueWrapper,
|
||||||
|
) {
|
||||||
|
val context = binding.root.context
|
||||||
|
|
||||||
|
binding.apply {
|
||||||
|
separatorHolder.isGone = true
|
||||||
|
downloadChildEpisodeHolder.isGone = false
|
||||||
|
|
||||||
|
// Only set the child-text if child and parent are not the same
|
||||||
|
// This prevents setting movie titles twice
|
||||||
|
if (queueWrapper.id != queueWrapper.parentId) {
|
||||||
|
val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName
|
||||||
|
downloadChildEpisodeTextExtra.text = mainName
|
||||||
|
} else {
|
||||||
|
downloadChildEpisodeTextExtra.text = null
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank()
|
||||||
|
|
||||||
|
val status = VideoDownloadManager.downloadStatus[queueWrapper.id]
|
||||||
|
|
||||||
|
downloadButton.setOnClickListener { view ->
|
||||||
|
val episodeCached =
|
||||||
|
getKey<DownloadObjects.DownloadEpisodeCached>(
|
||||||
|
DOWNLOAD_EPISODE_CACHE,
|
||||||
|
getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString())
|
||||||
|
)
|
||||||
|
|
||||||
|
val downloadInfo = context.getKey<DownloadObjects.DownloadedFileInfo>(
|
||||||
|
KEY_DOWNLOAD_INFO,
|
||||||
|
queueWrapper.id.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading()
|
||||||
|
|
||||||
|
val actionList = arrayListOf<Pair<Int,Int>>()
|
||||||
|
|
||||||
|
if (isCurrentlyDownloading && episodeCached != null) {
|
||||||
|
// KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything
|
||||||
|
if (downloadInfo != null) {
|
||||||
|
actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file))
|
||||||
|
} else {
|
||||||
|
actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel))
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id]
|
||||||
|
|
||||||
|
when (currentStatus) {
|
||||||
|
VideoDownloadManager.DownloadType.IsDownloading -> {
|
||||||
|
actionList.add(
|
||||||
|
Pair(
|
||||||
|
DOWNLOAD_ACTION_PAUSE_DOWNLOAD,
|
||||||
|
R.string.popup_pause_download
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoDownloadManager.DownloadType.IsPaused -> {
|
||||||
|
actionList.add(
|
||||||
|
Pair(
|
||||||
|
DOWNLOAD_ACTION_RESUME_DOWNLOAD,
|
||||||
|
R.string.popup_resume_download
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
view.popupMenuNoIcons(
|
||||||
|
actionList
|
||||||
|
) {
|
||||||
|
handleDownloadClick(DownloadClickEvent(itemId, episodeCached))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel))
|
||||||
|
|
||||||
|
view.popupMenuNoIcons(
|
||||||
|
actionList
|
||||||
|
) {
|
||||||
|
when (itemId) {
|
||||||
|
DOWNLOAD_ACTION_CANCEL_PENDING -> {
|
||||||
|
DownloadQueueManager.cancelDownload(queueWrapper.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadButton.resetView()
|
||||||
|
downloadButton.setStatus(status)
|
||||||
|
downloadButton.setPersistentId(queueWrapper.id)
|
||||||
|
|
||||||
|
downloadChildEpisodeText.apply {
|
||||||
|
val name = queueWrapper.downloadItem?.episode?.name
|
||||||
|
?: queueWrapper.resumePackage?.item?.ep?.name
|
||||||
|
val episode =
|
||||||
|
queueWrapper.downloadItem?.episode?.episode
|
||||||
|
?: queueWrapper.resumePackage?.item?.ep?.episode
|
||||||
|
val season =
|
||||||
|
queueWrapper.downloadItem?.episode?.season
|
||||||
|
?: queueWrapper.resumePackage?.item?.ep?.season
|
||||||
|
text = context.getNameFull(name, episode, season)
|
||||||
|
isSelected = true // Needed for text repeating
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) :
|
||||||
|
ItemTouchHelper(
|
||||||
|
DragAndDropTouchHelperCallback(adapter)
|
||||||
|
)
|
||||||
|
|
||||||
|
private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) :
|
||||||
|
ItemTouchHelper.Callback() {
|
||||||
|
override fun getMovementFlags(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
viewHolder: RecyclerView.ViewHolder
|
||||||
|
): Int {
|
||||||
|
val item = adapter.getItem(viewHolder.absoluteAdapterPosition)
|
||||||
|
val isDownloading = item.item?.isCurrentlyDownloading() == true
|
||||||
|
val dragFlags = if (item.isSeparator || isDownloading) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down
|
||||||
|
}
|
||||||
|
|
||||||
|
val swipeFlags = 0 // Disable swipe functionality
|
||||||
|
return makeMovementFlags(dragFlags, swipeFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMove(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
source: RecyclerView.ViewHolder,
|
||||||
|
target: RecyclerView.ViewHolder
|
||||||
|
): Boolean {
|
||||||
|
val fromPosition = source.absoluteAdapterPosition
|
||||||
|
val toPosition = target.absoluteAdapterPosition
|
||||||
|
val separatorPosition = adapter.currentDownloads
|
||||||
|
|
||||||
|
val toPositionNoSeparator =
|
||||||
|
if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition
|
||||||
|
|
||||||
|
if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
adapter.getItem(fromPosition).item?.let { downloadQueueInfo ->
|
||||||
|
DownloadQueueManager.reorderItem(
|
||||||
|
downloadQueueInfo,
|
||||||
|
toPositionNoSeparator - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLongPressDragEnabled(): Boolean {
|
||||||
|
return true // Enable drag with long press
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isItemViewSwipeEnabled(): Boolean {
|
||||||
|
return false // Disable swipe by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.queue
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding
|
||||||
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
|
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueFragment :
|
||||||
|
BaseFragment<FragmentDownloadQueueBinding>(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) {
|
||||||
|
private val queueViewModel: DownloadQueueViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onBindingCreated(binding: FragmentDownloadQueueBinding) {
|
||||||
|
val adapter = DownloadQueueAdapter(this@DownloadQueueFragment)
|
||||||
|
val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all)
|
||||||
|
|
||||||
|
observe(queueViewModel.childCards) { cards ->
|
||||||
|
val size = cards.queue.size + cards.currentDownloads.size
|
||||||
|
val isEmptyQueue = size == 0
|
||||||
|
binding.downloadQueueList.isGone = isEmptyQueue
|
||||||
|
binding.textNoQueue.isGone = !isEmptyQueue
|
||||||
|
clearQueueItem?.isVisible = !isEmptyQueue
|
||||||
|
|
||||||
|
adapter.submitQueue(cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.apply {
|
||||||
|
downloadQueueToolbar.apply {
|
||||||
|
title = txt(R.string.download_queue).asString(context)
|
||||||
|
if (isLayout(PHONE or EMULATOR)) {
|
||||||
|
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
||||||
|
setNavigationOnClickListener {
|
||||||
|
dispatchBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAppBarNoScrollFlagsOnTV()
|
||||||
|
clearQueueItem?.setOnMenuItemClickListener {
|
||||||
|
AlertDialog.Builder(context, R.style.AlertDialogCustom)
|
||||||
|
.setTitle(R.string.cancel_all)
|
||||||
|
.setMessage(R.string.cancel_queue_message)
|
||||||
|
.setPositiveButton(R.string.yes) { _, _ ->
|
||||||
|
DownloadQueueManager.removeAllFromQueue()
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.no) { _, _ ->
|
||||||
|
}.show()
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadQueueList.adapter = adapter
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
val helper = DragAndDropTouchHelper(adapter)
|
||||||
|
helper.attachToRecyclerView(downloadQueueList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fixLayout(view: View) {
|
||||||
|
fixSystemBarsPadding(
|
||||||
|
view,
|
||||||
|
padBottom = isLandscape(),
|
||||||
|
padLeft = isLayout(TV or EMULATOR)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.queue
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
|
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class DownloadAdapterQueue(
|
||||||
|
val currentDownloads: List<DownloadObjects.DownloadQueueWrapper>,
|
||||||
|
val queue: List<DownloadObjects.DownloadQueueWrapper>,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DownloadQueueViewModel : ViewModel() {
|
||||||
|
private val _childCards = MutableLiveData<DownloadAdapterQueue>()
|
||||||
|
val childCards: LiveData<DownloadAdapterQueue> = _childCards
|
||||||
|
private val totalDownloadFlow =
|
||||||
|
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
|
||||||
|
val current = instances.map { it.downloadQueueWrapper }
|
||||||
|
DownloadAdapterQueue(current, queue.toList())
|
||||||
|
}.combine(VideoDownloadManager.currentDownloads) { total, _ ->
|
||||||
|
// We want to update the flow when currentDownloads updates, but we do not care about its value
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
totalDownloadFlow.collect { queue ->
|
||||||
|
updateChildList(queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateChildList(downloads: DownloadAdapterQueue) {
|
||||||
|
_childCards.postValue(downloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,8 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import coil3.load
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding
|
import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding
|
||||||
|
|
@ -20,6 +16,7 @@ import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
|
||||||
import com.lagradost.cloudstream3.ui.BaseAdapter
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.newSharedPool
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||||
|
|
@ -165,7 +162,7 @@ open class HomeChildItemAdapter(
|
||||||
// The vast majority of the lag comes from creating the view
|
// The vast majority of the lag comes from creating the view
|
||||||
// This simply shares the views between all HomeChildItemAdapter
|
// This simply shares the views between all HomeChildItemAdapter
|
||||||
val sharedPool =
|
val sharedPool =
|
||||||
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 20) }
|
newSharedPool { setMaxRecycledViews(CONTENT, 20) }
|
||||||
|
|
||||||
var minPosterSize: Int = 0
|
var minPosterSize: Int = 0
|
||||||
var maxPosterSize: Int = 0
|
var maxPosterSize: Int = 0
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import android.widget.ImageView
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
|
|
@ -51,6 +52,7 @@ import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
|
@ -64,6 +66,9 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
|
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
|
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.EmptyEvent
|
import com.lagradost.cloudstream3.utils.EmptyEvent
|
||||||
|
|
@ -85,7 +90,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
// Used for configuration changed events to fix any popups that are not attached to a fragment
|
// Used for configuration changed events to fix any popups that are not attached to a fragment
|
||||||
val configEvent = EmptyEvent()
|
val configEvent = EmptyEvent()
|
||||||
var currentSpan = 1
|
var currentSpan = 1
|
||||||
val listHomepageItems = mutableListOf<SearchResponse>()
|
|
||||||
|
|
||||||
private val errorProfilePics = listOf(
|
private val errorProfilePics = listOf(
|
||||||
R.drawable.monke_benene,
|
R.drawable.monke_benene,
|
||||||
|
|
@ -567,6 +571,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
(activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress")
|
||||||
bottomSheetDialog?.ownHide()
|
bottomSheetDialog?.ownHide()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
@ -627,6 +632,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onBindingCreated(binding: FragmentHomeBinding) {
|
override fun onBindingCreated(binding: FragmentHomeBinding) {
|
||||||
context?.let { HomeChildItemAdapter.updatePosterSize(it) }
|
context?.let { HomeChildItemAdapter.updatePosterSize(it) }
|
||||||
|
(activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") {
|
||||||
|
handleTvBackPress(this)
|
||||||
|
}
|
||||||
binding.apply {
|
binding.apply {
|
||||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||||
|
|
@ -642,13 +650,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
activity?.showAccountSelectLinear()
|
activity?.showAccountSelectLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
homeRandom.setOnClickListener {
|
|
||||||
if (listHomepageItems.isNotEmpty()) {
|
|
||||||
activity.loadSearchResult(listHomepageItems.random())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
homeMasterAdapter = HomeParentItemAdapterPreview(
|
homeMasterAdapter = HomeParentItemAdapterPreview(
|
||||||
fragment = this@HomeFragment,
|
|
||||||
homeViewModel, accountViewModel
|
homeViewModel, accountViewModel
|
||||||
)
|
)
|
||||||
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
||||||
|
|
@ -725,8 +727,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
settingsManager.getBoolean(
|
settingsManager.getBoolean(
|
||||||
getString(R.string.random_button_key),
|
getString(R.string.random_button_key),
|
||||||
false
|
false
|
||||||
) && isLayout(PHONE)
|
)
|
||||||
binding.homeRandom.visibility = View.GONE
|
binding.homeRandom.visibility = View.GONE
|
||||||
|
binding.homeRandomButtonTv.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(homeViewModel.apiName) { apiName ->
|
observe(homeViewModel.apiName) { apiName ->
|
||||||
|
|
@ -752,23 +755,28 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
|
|
||||||
saveHomepageToTV(d)
|
saveHomepageToTV(d)
|
||||||
|
|
||||||
listHomepageItems.clear()
|
|
||||||
homeLoading.isVisible = false
|
homeLoading.isVisible = false
|
||||||
homeLoadingError.isVisible = false
|
homeLoadingError.isVisible = false
|
||||||
homeMasterRecycler.isVisible = true
|
homeMasterRecycler.isVisible = true
|
||||||
homeLoadingShimmer.stopShimmer()
|
homeLoadingShimmer.stopShimmer()
|
||||||
//home_loaded?.isVisible = true
|
//home_loaded?.isVisible = true
|
||||||
if (toggleRandomButton) {
|
if (toggleRandomButton) {
|
||||||
//Flatten list
|
val distinct = d.values
|
||||||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
.flatMap { it.list.list }
|
||||||
d.values.forEach { dlist ->
|
.distinctBy { it.url }
|
||||||
mutableListOfResponse.addAll(dlist.list.list)
|
val hasItems = distinct.isNotEmpty()
|
||||||
|
val isPhone = isLayout(PHONE)
|
||||||
|
val randomClickListener = View.OnClickListener {
|
||||||
|
distinct.randomOrNull()?.let { activity.loadSearchResult(it) }
|
||||||
}
|
}
|
||||||
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
|
|
||||||
|
|
||||||
homeRandom.isVisible = listHomepageItems.isNotEmpty()
|
homeRandom.isVisible = isPhone && hasItems
|
||||||
|
homeRandom.setOnClickListener(randomClickListener)
|
||||||
|
homeRandomButtonTv.isVisible = !isPhone && hasItems
|
||||||
|
homeRandomButtonTv.setOnClickListener(randomClickListener)
|
||||||
} else {
|
} else {
|
||||||
homeRandom.isGone = true
|
homeRandom.isGone = true
|
||||||
|
homeRandomButtonTv.isGone = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -884,4 +892,44 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
}
|
}
|
||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) {
|
||||||
|
// Only apply custom behavior on TV interface
|
||||||
|
if (!isLayout(TV)) {
|
||||||
|
helper.runDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentFocus = activity?.currentFocus ?: run {
|
||||||
|
helper.runDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// isInsideRecycle is true when focus is inside home_master_recycler
|
||||||
|
var parent = currentFocus.parent
|
||||||
|
var isInsideRecycler = false
|
||||||
|
while (parent != null) {
|
||||||
|
if (parent is View && parent.id == R.id.home_master_recycler) {
|
||||||
|
isInsideRecycler = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent = parent.parent
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
// Case 1: Focus is within plugin content -> Move to plugin selector
|
||||||
|
isInsideRecycler -> {
|
||||||
|
binding?.homeMasterRecycler?.scrollToPosition(0)
|
||||||
|
// Defer focus request until after scroll ends
|
||||||
|
binding?.homeChangeApi?.post {
|
||||||
|
binding?.homeChangeApi?.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation
|
||||||
|
currentFocus.id == R.id.home_change_api ||
|
||||||
|
currentFocus.id == R.id.home_preview_reload_provider ||
|
||||||
|
currentFocus.id == R.id.home_preview_search_button -> {
|
||||||
|
activity?.findViewById<View>(R.id.navigation_home)?.requestFocus()
|
||||||
|
}
|
||||||
|
// Case 3: Any other location -> Use default back behavior
|
||||||
|
else -> helper.runDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ import android.os.Parcelable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.lagradost.cloudstream3.HomePageList
|
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
||||||
|
|
@ -17,9 +15,11 @@ import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.BaseAdapter
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.newSharedPool
|
||||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
|
@ -48,7 +48,7 @@ open class ParentItemAdapter(
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val sharedPool =
|
val sharedPool =
|
||||||
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 4) }
|
newSharedPool { setMaxRecycledViews(CONTENT, 4) }
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
|
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
||||||
import androidx.core.graphics.toColorInt
|
import androidx.core.graphics.toColorInt
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
|
|
||||||
class HomeParentItemAdapterPreview(
|
class HomeParentItemAdapterPreview(
|
||||||
val fragment: LifecycleOwner,
|
|
||||||
private val viewModel: HomeViewModel,
|
private val viewModel: HomeViewModel,
|
||||||
private val accountViewModel: AccountViewModel
|
private val accountViewModel: AccountViewModel
|
||||||
) : ParentItemAdapter(
|
) : ParentItemAdapter(
|
||||||
|
|
@ -104,7 +104,7 @@ class HomeParentItemAdapterPreview(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return HeaderViewHolder(binding, viewModel, accountViewModel, fragment)
|
return HeaderViewHolder(binding, viewModel, accountViewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
|
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
|
||||||
|
|
@ -131,7 +131,6 @@ class HomeParentItemAdapterPreview(
|
||||||
val binding: ViewBinding,
|
val binding: ViewBinding,
|
||||||
val viewModel: HomeViewModel,
|
val viewModel: HomeViewModel,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
fragment: LifecycleOwner,
|
|
||||||
) :
|
) :
|
||||||
ViewHolderState<Bundle>(binding) {
|
ViewHolderState<Bundle>(binding) {
|
||||||
|
|
||||||
|
|
@ -543,7 +542,7 @@ class HomeParentItemAdapterPreview(
|
||||||
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
||||||
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
||||||
|
|
||||||
fragment.observe(viewModel.currentAccount) { currentAccount ->
|
(headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
|
||||||
headProfilePic?.loadImage(currentAccount?.image)
|
headProfilePic?.loadImage(currentAccount?.image)
|
||||||
alternateHeadProfilePic?.loadImage(currentAccount?.image)
|
alternateHeadProfilePic?.loadImage(currentAccount?.image)
|
||||||
}
|
}
|
||||||
|
|
@ -774,7 +773,7 @@ class HomeParentItemAdapterPreview(
|
||||||
fun onViewAttachedToWindow() {
|
fun onViewAttachedToWindow() {
|
||||||
previewViewpager.registerOnPageChangeCallback(previewCallback)
|
previewViewpager.registerOnPageChangeCallback(previewCallback)
|
||||||
|
|
||||||
binding.root.findViewTreeLifecycleOwner()?.apply {
|
previewViewpager.apply {
|
||||||
observe(viewModel.preview) {
|
observe(viewModel.preview) {
|
||||||
updatePreview(it)
|
updatePreview(it)
|
||||||
}
|
}
|
||||||
|
|
@ -799,7 +798,7 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
toggleListHolder?.isGone = visible.isEmpty()
|
toggleListHolder?.isGone = visible.isEmpty()
|
||||||
}
|
}
|
||||||
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.HomePageList
|
import com.lagradost.cloudstream3.HomePageList
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.amap
|
import com.lagradost.cloudstream3.amap
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
|
|
@ -40,6 +40,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
||||||
|
|
@ -49,13 +50,12 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import kotlin.collections.set
|
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel : ViewModel() {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -67,11 +67,26 @@ class HomeViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
||||||
resumeWatching?.mapNotNull { resume ->
|
resumeWatching?.mapNotNull { resume ->
|
||||||
|
val headerCache = getKey<DownloadObjects.DownloadHeaderCached>(
|
||||||
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
resume.parentId.toString()
|
resume.parentId.toString()
|
||||||
) ?: return@mapNotNull null
|
)
|
||||||
|
|
||||||
|
val data = if (headerCache == null) {
|
||||||
|
// We store resume watching data in download header cache
|
||||||
|
// Because downloads automatically pruned outdated download headers we
|
||||||
|
// removed resume watching data. We should restore the data for affected users.
|
||||||
|
val oldData = getKey<DownloadObjects.DownloadHeaderCached>(
|
||||||
|
DOWNLOAD_HEADER_CACHE_BACKUP,
|
||||||
|
resume.parentId.toString()
|
||||||
|
) ?: return@mapNotNull null
|
||||||
|
|
||||||
|
// Restore data
|
||||||
|
setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData)
|
||||||
|
oldData
|
||||||
|
} else {
|
||||||
|
headerCache
|
||||||
|
}
|
||||||
|
|
||||||
val watchPos = getViewPos(resume.episodeId)
|
val watchPos = getViewPos(resume.episodeId)
|
||||||
|
|
||||||
|
|
@ -118,7 +133,7 @@ class HomeViewModel : ViewModel() {
|
||||||
private var currentShuffledList: List<SearchResponse> = listOf()
|
private var currentShuffledList: List<SearchResponse> = listOf()
|
||||||
|
|
||||||
private fun autoloadRepo(): APIRepository {
|
private fun autoloadRepo(): APIRepository {
|
||||||
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
|
return APIRepository(apis.withLock { apis.first { it.hasMainPage } })
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _availableWatchStatusTypes =
|
private val _availableWatchStatusTypes =
|
||||||
|
|
@ -501,9 +516,6 @@ class HomeViewModel : ViewModel() {
|
||||||
return@ioSafe
|
return@ioSafe
|
||||||
}
|
}
|
||||||
|
|
||||||
HomeChildItemAdapter.sharedPool.clear()
|
|
||||||
ParentItemAdapter.sharedPool.clear()
|
|
||||||
|
|
||||||
val api = getApiFromNameNull(preferredApiName)
|
val api = getApiFromNameNull(preferredApiName)
|
||||||
if (preferredApiName == noneApi.name) {
|
if (preferredApiName == noneApi.name) {
|
||||||
// just set to random
|
// just set to random
|
||||||
|
|
@ -523,7 +535,7 @@ class HomeViewModel : ViewModel() {
|
||||||
} else if (api == null) {
|
} else if (api == null) {
|
||||||
// API is not found aka not loaded or removed, post the loading
|
// API is not found aka not loaded or removed, post the loading
|
||||||
// progress if waiting for plugins, otherwise nothing
|
// progress if waiting for plugins, otherwise nothing
|
||||||
if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
|
if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) {
|
||||||
loadAndCancel(noneApi)
|
loadAndCancel(noneApi)
|
||||||
} else {
|
} else {
|
||||||
_page.postValue(Resource.Loading())
|
_page.postValue(Resource.Loading())
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,6 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
|
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
|
|
||||||
fun newInstance() = LibraryFragment()
|
fun newInstance() = LibraryFragment()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -104,14 +102,19 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateRandom(binding: FragmentLibraryBinding) {
|
private fun updateRandomVisibility(binding: FragmentLibraryBinding) {
|
||||||
|
if (!toggleRandomButton) {
|
||||||
|
binding.libraryRandom.isGone = true
|
||||||
|
binding.libraryRandomButtonTv.isGone = true
|
||||||
|
return
|
||||||
|
}
|
||||||
val position = libraryViewModel.currentPage.value ?: 0
|
val position = libraryViewModel.currentPage.value ?: 0
|
||||||
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
|
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
|
||||||
if (toggleRandomButton) {
|
val hasItems = pages[position].items.isNotEmpty()
|
||||||
listLibraryItems.clear()
|
val isPhone = isLayout(PHONE)
|
||||||
listLibraryItems.addAll(pages[position].items)
|
|
||||||
binding.libraryRandom.isVisible = listLibraryItems.isNotEmpty()
|
binding.libraryRandom.isVisible = isPhone && hasItems
|
||||||
} else binding.libraryRandom.isGone = true
|
binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fixLayout(view: View) {
|
override fun fixLayout(view: View) {
|
||||||
|
|
@ -194,17 +197,9 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
settingsManager.getBoolean(
|
settingsManager.getBoolean(
|
||||||
getString(R.string.random_button_key),
|
getString(R.string.random_button_key),
|
||||||
false
|
false
|
||||||
) && isLayout(PHONE)
|
)
|
||||||
binding.libraryRandom.visibility = View.GONE
|
binding.libraryRandom.visibility = View.GONE
|
||||||
}
|
binding.libraryRandomButtonTv.visibility = View.GONE
|
||||||
|
|
||||||
binding.libraryRandom.setOnClickListener {
|
|
||||||
if (listLibraryItems.isNotEmpty()) {
|
|
||||||
val listLibraryItem = listLibraryItems.random()
|
|
||||||
libraryViewModel.currentSyncApi?.syncIdName?.let {
|
|
||||||
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,14 +210,13 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
syncId: SyncIdName,
|
syncId: SyncIdName,
|
||||||
apiName: String? = null,
|
apiName: String? = null,
|
||||||
) {
|
) {
|
||||||
val availableProviders = synchronized(allProviders) {
|
val availableProviders = allProviders.filter {
|
||||||
allProviders.filter {
|
it.supportedSyncNames.contains(syncId)
|
||||||
it.supportedSyncNames.contains(syncId)
|
}.map { it.name } +
|
||||||
}.map { it.name } +
|
// Add the api if it exists
|
||||||
// Add the api if it exists
|
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
||||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
?: emptyList())
|
||||||
?: emptyList())
|
|
||||||
}
|
|
||||||
val baseOptions = listOf(
|
val baseOptions = listOf(
|
||||||
LibraryOpenerType.Default,
|
LibraryOpenerType.Default,
|
||||||
LibraryOpenerType.None,
|
LibraryOpenerType.None,
|
||||||
|
|
@ -387,7 +381,19 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
binding.searchBar.setExpanded(true)
|
binding.searchBar.setExpanded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRandom(binding)
|
// Set up random button click listener
|
||||||
|
if (toggleRandomButton) {
|
||||||
|
val randomClickListener = View.OnClickListener {
|
||||||
|
val position = libraryViewModel.currentPage.value ?: 0
|
||||||
|
val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener
|
||||||
|
pages[position].items.randomOrNull()?.let { item ->
|
||||||
|
loadLibraryItem(syncIdName, item.syncId, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
libraryRandom.setOnClickListener(randomClickListener)
|
||||||
|
libraryRandomButtonTv.setOnClickListener(randomClickListener)
|
||||||
|
}
|
||||||
|
updateRandomVisibility(binding)
|
||||||
|
|
||||||
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
||||||
// Without this there would be a flashing effect:
|
// Without this there would be a flashing effect:
|
||||||
|
|
@ -466,7 +472,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(libraryViewModel.currentPage) { position ->
|
observe(libraryViewModel.currentPage) { position ->
|
||||||
updateRandom(binding)
|
updateRandomVisibility(binding)
|
||||||
val all = binding.viewpager.allViews.toList()
|
val all = binding.viewpager.allViews.toList()
|
||||||
.filterIsInstance<AutofitRecyclerView>()
|
.filterIsInstance<AutofitRecyclerView>()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.ui.player
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.graphics.drawable.AnimatedImageDrawable
|
|
||||||
import android.graphics.drawable.AnimatedVectorDrawable
|
|
||||||
import android.media.metrics.PlaybackErrorEvent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.WindowManager
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.ProgressBar
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.annotation.LayoutRes
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.media3.common.PlaybackException
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
|
||||||
import androidx.media3.ui.DefaultTimeBar
|
|
||||||
import androidx.media3.ui.PlayerView
|
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.media3.ui.TimeBar
|
import androidx.viewbinding.ViewBinding
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
|
||||||
import com.github.rubensousa.previewseekbar.PreviewBar
|
|
||||||
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.playerEventListener
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
|
||||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
|
||||||
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
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
|
|
||||||
enum class PlayerResize(@StringRes val nameRes: Int) {
|
enum class PlayerResize(@StringRes val nameRes: Int) {
|
||||||
Fit(R.string.resize_fit),
|
Fit(R.string.resize_fit),
|
||||||
|
|
@ -79,677 +31,131 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90
|
||||||
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
|
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
abstract class AbstractPlayerFragment(
|
abstract class AbstractPlayerFragment<T : ViewBinding>(
|
||||||
var player: IPlayer = CS3IPlayer()
|
bindingCreator: BindingCreator<T>
|
||||||
) : Fragment() {
|
) : BaseFragment<T>(bindingCreator), PlayerView.Callbacks {
|
||||||
var resizeMode: Int = 0
|
|
||||||
var subView: SubtitleView? = null
|
|
||||||
protected open var hasPipModeSupport = true
|
|
||||||
|
|
||||||
var playerPausePlayHolderHolder: FrameLayout? = null
|
// Stored pre-initialization so subclasses can set them before onBindingCreated.
|
||||||
var playerPausePlay: ImageView? = null
|
private var _player: IPlayer = CS3IPlayer()
|
||||||
var playerBuffering: ProgressBar? = null
|
|
||||||
var playerView: PlayerView? = null
|
|
||||||
var piphide: FrameLayout? = null
|
|
||||||
var subtitleHolder: FrameLayout? = null
|
|
||||||
var currentPlayerStatus = CSPlayerLoading.IsBuffering
|
|
||||||
|
|
||||||
@LayoutRes
|
/** The shared [PlayerView] host that owns all player state and view references. */
|
||||||
protected open var layout: Int = R.layout.fragment_player
|
protected var playerHostView: PlayerView? = null
|
||||||
|
|
||||||
open fun nextEpisode() {
|
var player: IPlayer
|
||||||
throw NotImplementedError()
|
get() = playerHostView?.player ?: _player
|
||||||
}
|
set(value) {
|
||||||
|
_player = value
|
||||||
open fun prevEpisode() {
|
playerHostView?.player = value
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun playerPositionChanged(position: Long, duration: Long) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun playerStatusChanged() {}
|
|
||||||
|
|
||||||
open fun playerDimensionsLoaded(width: Int, height: Int) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun subtitlesChanged() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onTracksInfoChanged() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun exitedPipMode() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun keepScreenOn(on: Boolean) {
|
|
||||||
if (on) {
|
|
||||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
} else {
|
|
||||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateIsPlaying(
|
|
||||||
wasPlaying: CSPlayerLoading,
|
|
||||||
isPlaying: CSPlayerLoading
|
|
||||||
) {
|
|
||||||
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
|
||||||
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
|
|
||||||
currentPlayerStatus = isPlaying
|
|
||||||
|
|
||||||
keepScreenOn(!isPausedRightNow)
|
|
||||||
|
|
||||||
val isBuffering = CSPlayerLoading.IsBuffering == isPlaying
|
|
||||||
if (isBuffering) {
|
|
||||||
playerPausePlayHolderHolder?.isVisible = false
|
|
||||||
playerBuffering?.isVisible = true
|
|
||||||
} else {
|
|
||||||
playerPausePlayHolderHolder?.isVisible = true
|
|
||||||
playerBuffering?.isVisible = false
|
|
||||||
|
|
||||||
if(isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)){
|
|
||||||
playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24)
|
|
||||||
} else if (wasPlaying != isPlaying) {
|
|
||||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
|
|
||||||
val drawable = playerPausePlay?.drawable
|
|
||||||
|
|
||||||
var startedAnimation = false
|
|
||||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
|
||||||
if (drawable is AnimatedImageDrawable) {
|
|
||||||
drawable.start()
|
|
||||||
startedAnimation = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drawable is AnimatedVectorDrawable) {
|
|
||||||
drawable.start()
|
|
||||||
startedAnimation = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drawable is AnimatedVectorDrawableCompat) {
|
|
||||||
drawable.start()
|
|
||||||
startedAnimation = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// somehow the phone is wacked
|
|
||||||
if (!startedAnimation) {
|
|
||||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerPipHelper.updatePIPModeActions(
|
val subView: SubtitleView? get() = playerHostView?.subView
|
||||||
activity,
|
val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
|
||||||
isPlaying,
|
|
||||||
hasPipModeSupport,
|
/** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
|
||||||
player.getAspectRatio()
|
val playerView: androidx.media3.ui.PlayerView?
|
||||||
)
|
get() = playerHostView?.exoPlayerView
|
||||||
|
|
||||||
|
var currentPlayerStatus: CSPlayerLoading
|
||||||
|
get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering
|
||||||
|
set(value) { playerHostView?.currentPlayerStatus = value }
|
||||||
|
|
||||||
|
protected var mMediaSession: MediaSession?
|
||||||
|
get() = playerHostView?.mMediaSession
|
||||||
|
set(value) { playerHostView?.mMediaSession = value }
|
||||||
|
|
||||||
|
// No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as
|
||||||
|
// open so subclasses can override only what they need. The ones below throw
|
||||||
|
// to make it obvious when an implementation is missing.
|
||||||
|
|
||||||
|
override fun nextEpisode() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun prevEpisode() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playerPositionChanged(position: Long, duration: Long) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subtitlesChanged() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksInfoChanged() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exitedPipMode() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasNextMirror(): Boolean {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextMirror() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delegates to [PlayerView.playerError] by default; override to customize. */
|
||||||
|
override fun playerError(exception: Throwable) {
|
||||||
|
playerHostView?.playerError(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player fragments don't need system-bar padding adjustment by default. */
|
||||||
|
override fun fixLayout(view: View) = Unit
|
||||||
|
|
||||||
|
override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
|
||||||
|
val ctx = context ?: return
|
||||||
|
playerHostView = PlayerView(ctx)
|
||||||
|
playerHostView?.player = _player
|
||||||
|
playerHostView?.callbacks = this
|
||||||
|
playerHostView?.bindViews(binding.root)
|
||||||
|
playerHostView?.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pipReceiver: BroadcastReceiver? = null
|
|
||||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||||
try {
|
playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity)
|
||||||
isInPIPMode = isInPictureInPictureMode
|
|
||||||
if (isInPictureInPictureMode) {
|
|
||||||
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
|
|
||||||
piphide?.isVisible = false
|
|
||||||
pipReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(
|
|
||||||
context: Context,
|
|
||||||
intent: Intent,
|
|
||||||
) {
|
|
||||||
if (ACTION_MEDIA_CONTROL != intent.action) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
player.handleEvent(
|
|
||||||
CSPlayerEvent.entries[intent.getIntExtra(
|
|
||||||
EXTRA_CONTROL_TYPE,
|
|
||||||
0
|
|
||||||
)], source = PlayerEventSource.UI
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filter = IntentFilter()
|
|
||||||
filter.addAction(ACTION_MEDIA_CONTROL)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
|
||||||
activity?.registerReceiver(pipReceiver, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isPlaying = player.getIsPlaying()
|
|
||||||
val isPlayingValue =
|
|
||||||
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
|
||||||
updateIsPlaying(isPlayingValue, isPlayingValue)
|
|
||||||
} else {
|
|
||||||
// Restore the full-screen UI.
|
|
||||||
piphide?.isVisible = true
|
|
||||||
exitedPipMode()
|
|
||||||
pipReceiver?.let {
|
|
||||||
// Prevents java.lang.IllegalArgumentException: Receiver not registered
|
|
||||||
safe {
|
|
||||||
activity?.unregisterReceiver(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activity?.hideSystemUI()
|
|
||||||
this.view?.let { UIHelper.hideKeyboard(it) }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun hasNextMirror(): Boolean {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun nextMirror() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun playerError(exception: Throwable) {
|
|
||||||
fun showToast(message: String, gotoNext: Boolean = false) {
|
|
||||||
if (gotoNext && hasNextMirror()) {
|
|
||||||
showToast(
|
|
||||||
message,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
nextMirror()
|
|
||||||
} else {
|
|
||||||
showToast(
|
|
||||||
context?.getString(R.string.no_links_found_toast) + "\n" + message,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
)
|
|
||||||
activity?.popCurrentPage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ctx = context ?: return
|
|
||||||
when (exception) {
|
|
||||||
is PlaybackException -> {
|
|
||||||
val msg = exception.message ?: ""
|
|
||||||
val errorName = exception.errorCodeName
|
|
||||||
when (val code = exception.errorCode) {
|
|
||||||
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
|
||||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
|
||||||
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
|
||||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackException.ERROR_CODE_REMOTE_ERROR,
|
|
||||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
|
||||||
PlaybackException.ERROR_CODE_TIMEOUT,
|
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
|
||||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
|
|
||||||
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
|
|
||||||
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
|
||||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg",
|
|
||||||
gotoNext = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is InvalidFileException -> {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.source_error)}\n${exception.message}",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SocketTimeoutException -> {
|
|
||||||
/**
|
|
||||||
* Ensures this is run on the UI thread to prevent issues
|
|
||||||
* caused by SocketTimeoutException in torrents. Running
|
|
||||||
* on another thread can break player interactions or
|
|
||||||
* prevent switching to the next source.
|
|
||||||
*/
|
|
||||||
activity?.runOnUiThread {
|
|
||||||
showToast(
|
|
||||||
"${ctx.getString(R.string.remote_error)}\n${exception.message}",
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ErrorLoadingException -> {
|
|
||||||
exception.message?.let {
|
|
||||||
showToast(
|
|
||||||
it,
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
} ?: showToast(
|
|
||||||
exception.toString(),
|
|
||||||
gotoNext = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
exception.message?.let {
|
|
||||||
showToast(
|
|
||||||
it,
|
|
||||||
gotoNext = false
|
|
||||||
)
|
|
||||||
} ?: showToast(
|
|
||||||
exception.toString(),
|
|
||||||
gotoNext = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSubStyleChanged(style: SaveCaptionStyle) {
|
|
||||||
player.updateSubtitleStyle(style)
|
|
||||||
// Forcefully update the subtitle encoding in case the edge size is changed
|
|
||||||
player.seekTime(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
|
||||||
open fun playerUpdated(player: Any?) {
|
|
||||||
if (player is ExoPlayer) {
|
|
||||||
context?.let { ctx ->
|
|
||||||
mMediaSession?.release()
|
|
||||||
mMediaSession = MediaSession.Builder(ctx, player)
|
|
||||||
// Ensure unique ID for concurrent players
|
|
||||||
.setId(System.currentTimeMillis().toString())
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Necessary for multiple combined videos
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
playerView?.setShowMultiWindowTimeBar(true)
|
|
||||||
playerView?.player = player
|
|
||||||
playerView?.performClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected var mMediaSession: MediaSession? = null
|
|
||||||
|
|
||||||
// this can be used in the future for players other than exoplayer
|
|
||||||
//private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
|
|
||||||
// override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
|
||||||
// val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent?
|
|
||||||
// if (keyEvent != null) {
|
|
||||||
// if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP
|
|
||||||
// val consumed = when (keyEvent.keyCode) {
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause()
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay()
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop()
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext()
|
|
||||||
// else -> false
|
|
||||||
// }
|
|
||||||
// if (consumed) return true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return super.onMediaButtonEvent(mediaButtonEvent)
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
open fun onDownload(event: DownloadEvent) = Unit
|
|
||||||
|
|
||||||
/** This receives the events from the player, if you want to append functionality you do it here,
|
|
||||||
* do note that this only receives events for UI changes,
|
|
||||||
* and returning early WONT stop it from changing in eg the player time or pause status */
|
|
||||||
open fun mainCallback(event: PlayerEvent) {
|
|
||||||
// we don't want to spam DownloadEvent
|
|
||||||
if (event !is DownloadEvent) {
|
|
||||||
Log.i(TAG, "Handle event: $event")
|
|
||||||
}
|
|
||||||
when (event) {
|
|
||||||
is DownloadEvent -> {
|
|
||||||
onDownload(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ResizedEvent -> {
|
|
||||||
playerDimensionsLoaded(event.width, event.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerAttachedEvent -> {
|
|
||||||
playerUpdated(event.player)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SubtitlesUpdatedEvent -> {
|
|
||||||
subtitlesChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
is TimestampSkippedEvent -> {
|
|
||||||
onTimestampSkipped(event.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
is TimestampInvokedEvent -> {
|
|
||||||
onTimestamp(event.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
is TracksChangedEvent -> {
|
|
||||||
onTracksInfoChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
is EmbeddedSubtitlesFetchedEvent -> {
|
|
||||||
embeddedSubtitlesFetched(event.tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ErrorEvent -> {
|
|
||||||
playerError(event.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
is RequestAudioFocusEvent -> {
|
|
||||||
requestAudioFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
is EpisodeSeekEvent -> {
|
|
||||||
when (event.offset) {
|
|
||||||
-1 -> prevEpisode()
|
|
||||||
1 -> nextEpisode()
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is StatusEvent -> {
|
|
||||||
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
|
|
||||||
playerStatusChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
is PositionEvent -> {
|
|
||||||
playerPositionChanged(position = event.toMs, duration = event.durationMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
is VideoEndedEvent -> {
|
|
||||||
context?.let { ctx ->
|
|
||||||
// Only play next episode if autoplay is on (default)
|
|
||||||
if (PreferenceManager.getDefaultSharedPreferences(ctx)
|
|
||||||
?.getBoolean(
|
|
||||||
ctx.getString(R.string.autoplay_next_key),
|
|
||||||
true
|
|
||||||
) == true
|
|
||||||
) {
|
|
||||||
player.handleEvent(
|
|
||||||
CSPlayerEvent.NextEpisode,
|
|
||||||
source = PlayerEventSource.Player
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is PauseEvent -> Unit
|
|
||||||
is PlayEvent -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n", "UnsafeOptInUsageError")
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
resizeMode = DataStoreHelper.resizeMode
|
|
||||||
resize(resizeMode, false)
|
|
||||||
|
|
||||||
player.releaseCallbacks()
|
|
||||||
player.initCallbacks(
|
|
||||||
eventHandler = ::mainCallback,
|
|
||||||
requestedListeningPercentages = listOf(
|
|
||||||
SKIP_OP_VIDEO_PERCENTAGE,
|
|
||||||
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
|
||||||
NEXT_WATCH_EPISODE_PERCENTAGE,
|
|
||||||
UPDATE_SYNC_PROGRESS_PERCENTAGE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val player = player
|
|
||||||
if (player is CS3IPlayer) {
|
|
||||||
// preview bar
|
|
||||||
val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress)
|
|
||||||
val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView)
|
|
||||||
val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout)
|
|
||||||
if (progressBar != null && previewImageView != null && previewFrameLayout != null) {
|
|
||||||
var resume = false
|
|
||||||
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
|
|
||||||
override fun onScrubStart(previewBar: PreviewBar?) {
|
|
||||||
val hasPreview = player.hasPreview()
|
|
||||||
progressBar.isPreviewEnabled = hasPreview
|
|
||||||
resume = player.getIsPlaying()
|
|
||||||
if (resume) player.handleEvent(
|
|
||||||
CSPlayerEvent.Pause,
|
|
||||||
PlayerEventSource.Player
|
|
||||||
)
|
|
||||||
|
|
||||||
// No clashing UI
|
|
||||||
if (hasPreview) {
|
|
||||||
subView?.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScrubMove(
|
|
||||||
previewBar: PreviewBar?,
|
|
||||||
progress: Int,
|
|
||||||
fromUser: Boolean
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScrubStop(previewBar: PreviewBar?) {
|
|
||||||
if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
|
|
||||||
// Delay to prevent the small flicker of subtitle before seeking
|
|
||||||
subView?.postDelayed({
|
|
||||||
// If we are not scrubbing then show subtitles again
|
|
||||||
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
|
|
||||||
subView?.isVisible = true
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
progressBar.attachPreviewView(previewFrameLayout)
|
|
||||||
progressBar.setPreviewLoader { currentPosition, max ->
|
|
||||||
val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat()))
|
|
||||||
previewImageView.isGone = bitmap == null
|
|
||||||
previewImageView.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
|
|
||||||
player.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
|
|
||||||
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth)
|
|
||||||
|
|
||||||
/*previewImageView?.doOnLayout {
|
|
||||||
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams(
|
|
||||||
it.measuredWidth,
|
|
||||||
it.measuredHeight
|
|
||||||
)
|
|
||||||
}*/
|
|
||||||
/** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
|
|
||||||
* and once by the UI even if it should only be registered once by the UI */
|
|
||||||
playerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
|
|
||||||
?.addListener(object : TimeBar.OnScrubListener {
|
|
||||||
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
|
|
||||||
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
|
|
||||||
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
|
||||||
if (canceled) return
|
|
||||||
val playerDuration = player.getDuration() ?: return
|
|
||||||
val playerPosition = player.getPosition() ?: return
|
|
||||||
mainCallback(
|
|
||||||
PositionEvent(
|
|
||||||
source = PlayerEventSource.UI,
|
|
||||||
durationMs = playerDuration,
|
|
||||||
fromMs = playerPosition,
|
|
||||||
toMs = position
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged
|
|
||||||
|
|
||||||
try {
|
|
||||||
context?.let { ctx ->
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(
|
|
||||||
ctx
|
|
||||||
)
|
|
||||||
|
|
||||||
val currentPrefCacheSize =
|
|
||||||
settingsManager.getInt(getString(R.string.video_buffer_size_key), 0)
|
|
||||||
val currentPrefDiskSize =
|
|
||||||
settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0)
|
|
||||||
val currentPrefBufferSec =
|
|
||||||
settingsManager.getInt(getString(R.string.video_buffer_length_key), 0)
|
|
||||||
|
|
||||||
player.cacheSize = currentPrefCacheSize * 1024L * 1024L
|
|
||||||
player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L
|
|
||||||
player.videoBufferMs = currentPrefBufferSec * 1000L
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*context?.let { ctx ->
|
|
||||||
player.loadPlayer(
|
|
||||||
ctx,
|
|
||||||
false,
|
|
||||||
ExtractorLink(
|
|
||||||
"idk",
|
|
||||||
"bunny",
|
|
||||||
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
||||||
"",
|
|
||||||
Qualities.P720.value,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
player.release()
|
playerHostView?.release()
|
||||||
player.releaseCallbacks()
|
|
||||||
player = CS3IPlayer()
|
|
||||||
|
|
||||||
playerEventListener = null
|
|
||||||
keyEventListener = null
|
|
||||||
|
|
||||||
PlayerPipHelper.updatePIPModeActions(activity, CSPlayerLoading.IsPaused, false, null)
|
|
||||||
|
|
||||||
mMediaSession?.release()
|
|
||||||
mMediaSession = null
|
|
||||||
playerView?.player = null
|
|
||||||
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
|
|
||||||
|
|
||||||
keepScreenOn(false)
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nextResize() {
|
override fun onPause() {
|
||||||
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
|
playerHostView?.releaseKeyEventListener()
|
||||||
resize(resizeMode, true)
|
super.onPause()
|
||||||
}
|
|
||||||
|
|
||||||
fun resize(resize: Int, showToast: Boolean) {
|
|
||||||
resize(PlayerResize.entries[resize], showToast)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
|
||||||
open fun resize(resize: PlayerResize, showToast: Boolean) {
|
|
||||||
DataStoreHelper.resizeMode = resize.ordinal
|
|
||||||
val type = when (resize) {
|
|
||||||
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
|
||||||
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
|
||||||
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
|
||||||
}
|
|
||||||
playerView?.resizeMode = type
|
|
||||||
|
|
||||||
if (showToast)
|
|
||||||
showToast(resize.nameRes, Toast.LENGTH_SHORT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
player.onStop()
|
playerHostView?.onStop()
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
context?.let { ctx ->
|
context?.let { ctx ->
|
||||||
player.onResume(ctx)
|
playerHostView?.onResume(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onResume()
|
super.onResume()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
fun nextResize() {
|
||||||
inflater: LayoutInflater,
|
playerHostView?.nextResize()
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val root = inflater.inflate(layout, container, false)
|
|
||||||
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
|
|
||||||
playerPausePlay = root.findViewById(R.id.player_pause_play)
|
|
||||||
playerBuffering = root.findViewById(R.id.player_buffering)
|
|
||||||
playerView = root.findViewById(R.id.player_view)
|
|
||||||
piphide = root.findViewById(R.id.piphide)
|
|
||||||
subtitleHolder = root.findViewById(R.id.subtitle_holder)
|
|
||||||
return root
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
open fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||||
|
playerHostView?.resize(resize, showToast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.C.TIME_UNSET
|
import androidx.media3.common.C.TIME_UNSET
|
||||||
import androidx.media3.common.C.TRACK_TYPE_AUDIO
|
import androidx.media3.common.C.TRACK_TYPE_AUDIO
|
||||||
import androidx.media3.common.C.TRACK_TYPE_TEXT
|
import androidx.media3.common.C.TRACK_TYPE_TEXT
|
||||||
|
|
@ -28,6 +30,7 @@ import androidx.media3.common.TrackGroup
|
||||||
import androidx.media3.common.TrackSelectionOverride
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.VideoSize
|
import androidx.media3.common.VideoSize
|
||||||
|
// import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.database.StandaloneDatabaseProvider
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
|
|
@ -39,23 +42,28 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.cronet.CronetDataSource
|
import androidx.media3.datasource.cronet.CronetDataSource
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.DecoderCounters
|
||||||
|
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||||
|
import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.Renderer.STATE_ENABLED
|
import androidx.media3.exoplayer.Renderer.STATE_ENABLED
|
||||||
import androidx.media3.exoplayer.Renderer.STATE_STARTED
|
import androidx.media3.exoplayer.Renderer.STATE_STARTED
|
||||||
import androidx.media3.exoplayer.SeekParameters
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||||
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
||||||
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
||||||
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
|
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
|
||||||
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
||||||
|
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker
|
||||||
import androidx.media3.exoplayer.source.ClippingMediaSource
|
import androidx.media3.exoplayer.source.ClippingMediaSource
|
||||||
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
|
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
|
||||||
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2
|
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||||
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||||||
import androidx.media3.exoplayer.text.TextOutput
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
import androidx.media3.exoplayer.text.TextRenderer
|
import androidx.media3.exoplayer.text.TextRenderer
|
||||||
|
|
@ -65,6 +73,7 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
|
import com.lagradost.cloudstream3.AudioFile
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
|
|
@ -73,43 +82,43 @@ import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.AudioFile
|
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
|
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.player.live.LiveHelper
|
||||||
|
import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle
|
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
|
import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
import com.lagradost.cloudstream3.utils.DrmExtractorLink
|
import com.lagradost.cloudstream3.utils.DrmExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.CLEARKEY_UUID
|
|
||||||
import com.lagradost.cloudstream3.utils.WIDEVINE_UUID
|
|
||||||
import com.lagradost.cloudstream3.utils.PLAYREADY_UUID
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName
|
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName
|
||||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID
|
||||||
|
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import okhttp3.Interceptor
|
||||||
import org.chromium.net.CronetEngine
|
import org.chromium.net.CronetEngine
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.SecureRandom
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.SSLSession
|
import javax.net.ssl.SSLSession
|
||||||
import kotlin.collections.HashSet
|
import kotlin.uuid.toJavaUuid
|
||||||
import kotlin.text.StringBuilder
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
|
|
||||||
const val TAG = "CS3ExoPlayer"
|
const val TAG = "CS3ExoPlayer"
|
||||||
const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language"
|
const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language"
|
||||||
|
|
@ -199,16 +208,14 @@ class CS3IPlayer : IPlayer {
|
||||||
private var requestedListeningPercentages: List<Int>? = null
|
private var requestedListeningPercentages: List<Int>? = null
|
||||||
|
|
||||||
private var eventHandler: ((PlayerEvent) -> Unit)? = null
|
private var eventHandler: ((PlayerEvent) -> Unit)? = null
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
fun event(event: PlayerEvent) {
|
fun event(event: PlayerEvent) {
|
||||||
// Ensure that all work is done on the main looper, aka main thread
|
// Ensure that all work is done on the main thread.
|
||||||
if (Looper.myLooper() == mainHandler.looper) {
|
if (Looper.getMainLooper().isCurrentThread) {
|
||||||
|
eventHandler?.invoke(event)
|
||||||
|
} else runOnMainThread {
|
||||||
eventHandler?.invoke(event)
|
eventHandler?.invoke(event)
|
||||||
} else {
|
|
||||||
mainHandler.post {
|
|
||||||
eventHandler?.invoke(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,8 +235,9 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
override fun initCallbacks(
|
override fun initCallbacks(
|
||||||
eventHandler: ((PlayerEvent) -> Unit),
|
@MainThread eventHandler: ((PlayerEvent) -> Unit),
|
||||||
requestedListeningPercentages: List<Int>?,
|
requestedListeningPercentages: List<Int>?,
|
||||||
) {
|
) {
|
||||||
this.requestedListeningPercentages = requestedListeningPercentages
|
this.requestedListeningPercentages = requestedListeningPercentages
|
||||||
|
|
@ -240,23 +248,6 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// I know, this is not a perfect solution, however it works for fixing subs
|
|
||||||
private fun reloadSubs() {
|
|
||||||
exoPlayer?.applicationLooper?.let {
|
|
||||||
try {
|
|
||||||
Handler(it).post {
|
|
||||||
try {
|
|
||||||
seekTime(1L, source = PlayerEventSource.Player)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.stripTrackId(): String {
|
fun String.stripTrackId(): String {
|
||||||
return this.replace(Regex("""^\d+:"""), "")
|
return this.replace(Regex("""^\d+:"""), "")
|
||||||
}
|
}
|
||||||
|
|
@ -270,6 +261,10 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasPreview(): Boolean {
|
override fun hasPreview(): Boolean {
|
||||||
|
// No previews on livestreams because the previews get outdated
|
||||||
|
if (exoPlayer?.isCurrentMediaItemDynamic == true) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return imageGenerator.hasPreview()
|
return imageGenerator.hasPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,44 +378,47 @@ class CS3IPlayer : IPlayer {
|
||||||
?: return
|
?: return
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) {
|
override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) {
|
||||||
preferredAudioTrackLanguage = trackLanguage
|
preferredAudioTrackLanguage = trackLanguage
|
||||||
|
id?.let { trackId ->
|
||||||
if (id != null) {
|
val trackFormatIndex = formatIndex ?: 0
|
||||||
val audioTrack =
|
exoPlayer?.currentTracks?.groups
|
||||||
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO }
|
?.filter { it.type == TRACK_TYPE_AUDIO }
|
||||||
?.getTrack(id)
|
?.find { group ->
|
||||||
|
group.getFormats().any { (format, _) ->
|
||||||
if (audioTrack != null) {
|
format.id == trackId
|
||||||
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
}
|
||||||
?.buildUpon()
|
}
|
||||||
?.setOverrideForType(
|
?.let { group ->
|
||||||
TrackSelectionOverride(
|
exoPlayer?.trackSelectionParameters
|
||||||
audioTrack.first,
|
?.buildUpon()
|
||||||
audioTrack.second
|
?.setOverrideForType(
|
||||||
|
TrackSelectionOverride(
|
||||||
|
group.mediaTrackGroup,
|
||||||
|
trackFormatIndex
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
?.build()
|
||||||
?.build()
|
}
|
||||||
?: return
|
?.let { newParams ->
|
||||||
return
|
exoPlayer?.trackSelectionParameters = newParams
|
||||||
}
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback to language-based selection
|
||||||
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
|
||||||
?.buildUpon()
|
?.buildUpon()
|
||||||
?.setPreferredAudioLanguage(trackLanguage)
|
?.setPreferredAudioLanguage(trackLanguage)
|
||||||
?.build()
|
?.build() ?: return
|
||||||
?: return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all supported formats in a list
|
* Gets all supported formats in a list
|
||||||
* */
|
* */
|
||||||
private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> {
|
private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> {
|
||||||
return this.map {
|
return this.flatMap {
|
||||||
it.getFormats()
|
it.getFormats()
|
||||||
}.flatten()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
|
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
|
||||||
|
|
@ -431,11 +429,14 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Format.toAudioTrack(): AudioTrack {
|
private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack {
|
||||||
return AudioTrack(
|
return AudioTrack(
|
||||||
this.id?.stripTrackId(),
|
this.id,
|
||||||
this.label,
|
this.label,
|
||||||
this.language
|
this.language,
|
||||||
|
this.sampleMimeType,
|
||||||
|
this.channelCount,
|
||||||
|
formatIndex ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +445,7 @@ class CS3IPlayer : IPlayer {
|
||||||
this.id?.stripTrackId(),
|
this.id?.stripTrackId(),
|
||||||
this.label,
|
this.label,
|
||||||
this.language,
|
this.language,
|
||||||
this.sampleMimeType
|
this.sampleMimeType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -455,27 +456,35 @@ class CS3IPlayer : IPlayer {
|
||||||
this.language,
|
this.language,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
|
this.sampleMimeType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getVideoTracks(): CurrentTracks {
|
override fun getVideoTracks(): CurrentTracks {
|
||||||
val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList()
|
val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList()
|
||||||
val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO }
|
val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO }
|
||||||
.getFormats()
|
.getFormats()
|
||||||
.map { it.first.toVideoTrack() }
|
.map { it.first.toVideoTrack() }
|
||||||
val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats()
|
var currentAudioTrack: AudioTrack? = null
|
||||||
.map { it.first.toAudioTrack() }
|
val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO }
|
||||||
|
.flatMap { group ->
|
||||||
val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats()
|
group.getFormats().map { (format, formatIndex) ->
|
||||||
|
val audioTrack = format.toAudioTrack(formatIndex)
|
||||||
|
if (group.isTrackSelected(formatIndex)) {
|
||||||
|
currentAudioTrack = audioTrack
|
||||||
|
}
|
||||||
|
audioTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT }
|
||||||
|
.getFormats()
|
||||||
.map { it.first.toSubtitleTrack() }
|
.map { it.first.toSubtitleTrack() }
|
||||||
|
|
||||||
val currentTextTracks = textTracks.filter { track ->
|
val currentTextTracks = textTracks.filter { track ->
|
||||||
playerSelectedSubtitleTracks.any { it.second && it.first == track.id }
|
playerSelectedSubtitleTracks.any { it.second && it.first == track.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
return CurrentTracks(
|
return CurrentTracks(
|
||||||
exoPlayer?.videoFormat?.toVideoTrack(),
|
exoPlayer?.videoFormat?.toVideoTrack(),
|
||||||
exoPlayer?.audioFormat?.toAudioTrack(),
|
currentAudioTrack,
|
||||||
currentTextTracks,
|
currentTextTracks,
|
||||||
videoTracks,
|
videoTracks,
|
||||||
audioTracks,
|
audioTracks,
|
||||||
|
|
@ -489,60 +498,43 @@ class CS3IPlayer : IPlayer {
|
||||||
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
|
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
|
||||||
Log.i(TAG, "setPreferredSubtitles init $subtitle")
|
Log.i(TAG, "setPreferredSubtitles init $subtitle")
|
||||||
currentSubtitles = subtitle
|
currentSubtitles = subtitle
|
||||||
|
val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false
|
||||||
|
// Disable subtitles if null
|
||||||
|
if (subtitle == null) {
|
||||||
|
trackSelector.setParameters(
|
||||||
|
trackSelector.buildUponParameters()
|
||||||
|
.setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
|
||||||
|
.clearOverridesOfType(TRACK_TYPE_TEXT)
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Handle subtitle based on status
|
||||||
|
when (subtitleHelper.subtitleStatus(subtitle)) {
|
||||||
|
SubtitleStatus.REQUIRES_RELOAD -> {
|
||||||
|
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun getTextTrack(id: String) =
|
SubtitleStatus.NOT_FOUND -> {
|
||||||
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT }
|
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
|
||||||
?.getTrack(id)
|
return true
|
||||||
|
}
|
||||||
return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector ->
|
|
||||||
if (subtitle == null) {
|
|
||||||
trackSelector.setParameters(
|
|
||||||
trackSelector.buildUponParameters()
|
|
||||||
.setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
|
|
||||||
.clearOverridesOfType(TRACK_TYPE_TEXT)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
when (subtitleHelper.subtitleStatus(subtitle)) {
|
|
||||||
SubtitleStatus.REQUIRES_RELOAD -> {
|
|
||||||
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
|
|
||||||
return@let true
|
|
||||||
}
|
|
||||||
|
|
||||||
SubtitleStatus.IS_ACTIVE -> {
|
|
||||||
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
|
|
||||||
|
|
||||||
|
SubtitleStatus.IS_ACTIVE -> {
|
||||||
|
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
|
||||||
|
exoPlayer?.currentTracks?.groups
|
||||||
|
?.filter { it.type == TRACK_TYPE_TEXT }
|
||||||
|
?.getTrack(subtitle.getId())
|
||||||
|
?.let { (trackGroup, trackIndex) ->
|
||||||
trackSelector.setParameters(
|
trackSelector.setParameters(
|
||||||
trackSelector.buildUponParameters()
|
trackSelector.buildUponParameters()
|
||||||
.apply {
|
.setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
|
||||||
val track = getTextTrack(subtitle.getId())
|
.setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex))
|
||||||
if (track != null) {
|
|
||||||
setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
|
|
||||||
setOverrideForType(
|
|
||||||
TrackSelectionOverride(
|
|
||||||
track.first,
|
|
||||||
track.second
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ugliest code I have written, it seeks 1ms to *update* the subtitles
|
|
||||||
//exoPlayer?.applicationLooper?.let {
|
|
||||||
// Handler(it).postDelayed({
|
|
||||||
// seekTime(1L)
|
|
||||||
// }, 1)
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
SubtitleStatus.NOT_FOUND -> {
|
|
||||||
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
|
|
||||||
return@let true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
}
|
||||||
} ?: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentSubtitleOffset: Long = 0
|
private var currentSubtitleOffset: Long = 0
|
||||||
|
|
@ -551,10 +543,10 @@ class CS3IPlayer : IPlayer {
|
||||||
currentSubtitleOffset = offset
|
currentSubtitleOffset = offset
|
||||||
CustomDecoder.subtitleOffset = offset
|
CustomDecoder.subtitleOffset = offset
|
||||||
if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) {
|
if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) {
|
||||||
exoPlayer?.currentPosition?.let { pos ->
|
exoPlayer?.currentPosition?.also { pos ->
|
||||||
// This seems to properly refresh all subtitles
|
// This seems to properly refresh all subtitles
|
||||||
// It needs to be done as all subtitle cues with timings are pre-processed
|
// It needs to be done as all subtitle cues with timings are pre-processed
|
||||||
currentTextRenderer?.resetPosition(pos)
|
currentTextRenderer?.resetPosition(pos, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -742,13 +734,23 @@ class CS3IPlayer : IPlayer {
|
||||||
private var simpleCache: SimpleCache? = null
|
private var simpleCache: SimpleCache? = null
|
||||||
|
|
||||||
/// Create a small factory for small things, no cache, no cronet
|
/// Create a small factory for small things, no cache, no cronet
|
||||||
private fun createOnlineSource(headers: Map<String, String>?): HttpDataSource.Factory {
|
private fun createOnlineSource(
|
||||||
val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT)
|
headers: Map<String, String>?,
|
||||||
return source.apply {
|
interceptor: Interceptor?
|
||||||
if (!headers.isNullOrEmpty()) {
|
): HttpDataSource.Factory {
|
||||||
setDefaultRequestProperties(headers)
|
val client = if (interceptor == null) {
|
||||||
}
|
app.baseClient
|
||||||
|
} else {
|
||||||
|
app.baseClient.newBuilder()
|
||||||
|
.addInterceptor(interceptor)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT)
|
||||||
|
|
||||||
|
if (!headers.isNullOrEmpty()) {
|
||||||
|
source.setDefaultRequestProperties(headers)
|
||||||
|
}
|
||||||
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? {
|
fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? {
|
||||||
|
|
@ -787,10 +789,9 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
private fun createVideoSource(
|
private fun createVideoSource(
|
||||||
link: ExtractorLink,
|
link: ExtractorLink,
|
||||||
engine: CronetEngine?
|
engine: CronetEngine?,
|
||||||
|
interceptor: Interceptor?,
|
||||||
): HttpDataSource.Factory {
|
): HttpDataSource.Factory {
|
||||||
val provider = getApiFromNameNull(link.source)
|
|
||||||
val interceptor: Interceptor? = provider?.getVideoInterceptor(link)
|
|
||||||
val userAgent = link.headers.entries.find {
|
val userAgent = link.headers.entries.find {
|
||||||
it.key.equals("User-Agent", ignoreCase = true)
|
it.key.equals("User-Agent", ignoreCase = true)
|
||||||
}?.value ?: USER_AGENT
|
}?.value ?: USER_AGENT
|
||||||
|
|
@ -822,14 +823,7 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
// These are extra headers the browser like to insert, not sure if we want to include them
|
// These are extra headers the browser like to insert, not sure if we want to include them
|
||||||
// for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue.
|
// for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue.
|
||||||
val headers = mapOf(
|
val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization
|
||||||
"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"
|
|
||||||
) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization
|
|
||||||
|
|
||||||
return source.apply {
|
return source.apply {
|
||||||
setDefaultRequestProperties(headers)
|
setDefaultRequestProperties(headers)
|
||||||
|
|
@ -889,10 +883,10 @@ class CS3IPlayer : IPlayer {
|
||||||
private var currentTextRenderer: TextRenderer? = null
|
private var currentTextRenderer: TextRenderer? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? {
|
private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? {
|
||||||
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
|
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
|
||||||
for (lastTimeStamp in lastTimeStamps) {
|
for (lastTimeStamp in lastTimeStamps) {
|
||||||
if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) {
|
if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) {
|
||||||
return lastTimeStamp
|
return lastTimeStamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -951,6 +945,22 @@ class CS3IPlayer : IPlayer {
|
||||||
when (event) {
|
when (event) {
|
||||||
CSPlayerEvent.Play -> {
|
CSPlayerEvent.Play -> {
|
||||||
event(PlayEvent(source))
|
event(PlayEvent(source))
|
||||||
|
// If the player was stopped (e.g. notification dismissed) it lands in
|
||||||
|
// STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and
|
||||||
|
// then resume to the current position once we are in STATE_READY again.
|
||||||
|
if (playbackState == Player.STATE_IDLE) {
|
||||||
|
val seekPosition = currentPosition
|
||||||
|
exoPlayer?.addListener(object : Player.Listener {
|
||||||
|
private var seekApplied = false
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
if (seekApplied || playbackState != Player.STATE_READY) return
|
||||||
|
seekApplied = true
|
||||||
|
exoPlayer?.seekTo(currentWindow, seekPosition)
|
||||||
|
exoPlayer?.removeListener(this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
prepare()
|
||||||
|
}
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1004,7 +1014,7 @@ class CS3IPlayer : IPlayer {
|
||||||
if (lastTimeStamp.skipToNextEpisode) {
|
if (lastTimeStamp.skipToNextEpisode) {
|
||||||
handleEvent(CSPlayerEvent.NextEpisode, source)
|
handleEvent(CSPlayerEvent.NextEpisode, source)
|
||||||
} else {
|
} else {
|
||||||
seekTo(lastTimeStamp.endMs + 1L)
|
seekTo(lastTimeStamp.timestamp.endMs + 1L)
|
||||||
}
|
}
|
||||||
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
|
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
|
||||||
}
|
}
|
||||||
|
|
@ -1073,28 +1083,44 @@ class CS3IPlayer : IPlayer {
|
||||||
): ExoPlayer {
|
): ExoPlayer {
|
||||||
val exoPlayerBuilder =
|
val exoPlayerBuilder =
|
||||||
ExoPlayer.Builder(context)
|
ExoPlayer.Builder(context)
|
||||||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(context).setLiveTargetOffsetMs(
|
||||||
|
PREFERRED_LIVE_OFFSET
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setLivePlaybackSpeedControl(
|
||||||
|
DefaultLivePlaybackSpeedControl.Builder()
|
||||||
|
.setFallbackMaxPlaybackSpeed(1.03f)
|
||||||
|
.setFallbackMinPlaybackSpeed(0.97f)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput ->
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val current = settingsManager.getInt(
|
val current = settingsManager.getInt(
|
||||||
context.getString(R.string.software_decoding_key),
|
context.getString(R.string.software_decoding_key),
|
||||||
-1
|
-1
|
||||||
)
|
)
|
||||||
val softwareDecoding = when (current) {
|
val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) {
|
||||||
0 -> true // yes
|
0 -> true to false // HW+SW, aka on but prefer hw
|
||||||
1 -> false // no
|
2 -> true to true // SW+HW, aka on but prefer sw
|
||||||
|
1 -> false to false // HW, aka off
|
||||||
// -1 = automatic
|
// -1 = automatic
|
||||||
else -> {
|
// We do not want tv to have software decoding, because of crashes
|
||||||
// we do not want tv to have software decoding, because of crashes
|
else -> isLayout(PHONE or EMULATOR) to false
|
||||||
!isLayout(TV)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val factory = if (softwareDecoding) {
|
val factory = if (isSoftwareDecodingEnabled) {
|
||||||
NextRenderersFactory(context).apply {
|
FixedNextRenderersFactory(context).apply {
|
||||||
setEnableDecoderFallback(true)
|
setEnableDecoderFallback(true)
|
||||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
setExtensionRendererMode(
|
||||||
|
if (isSoftwareDecodingPreferred)
|
||||||
|
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
else
|
||||||
|
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// no nextlib = EXTENSION_RENDERER_MODE_OFF
|
||||||
DefaultRenderersFactory(context)
|
DefaultRenderersFactory(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1102,7 +1128,7 @@ class CS3IPlayer : IPlayer {
|
||||||
// Custom TextOutput to apply cue styling and rules to all subtitles
|
// Custom TextOutput to apply cue styling and rules to all subtitles
|
||||||
val customTextOutput = TextOutput { cue ->
|
val customTextOutput = TextOutput { cue ->
|
||||||
// Do not remove filterNotNull as Java typesystem is fucked
|
// Do not remove filterNotNull as Java typesystem is fucked
|
||||||
val (bitmapCues, textCues) = cue.cues.filterNotNull()
|
val (bitmapCues, textCues) = cue.cues.toList()
|
||||||
.partition { it.bitmap != null }
|
.partition { it.bitmap != null }
|
||||||
|
|
||||||
val styledBitmapCues = bitmapCues.map { bitmapCue ->
|
val styledBitmapCues = bitmapCues.map { bitmapCue ->
|
||||||
|
|
@ -1170,6 +1196,7 @@ class CS3IPlayer : IPlayer {
|
||||||
CustomDecoder.subtitleOffset = subtitleOffset
|
CustomDecoder.subtitleOffset = subtitleOffset
|
||||||
val decoder = CustomSubtitleDecoderFactory()
|
val decoder = CustomSubtitleDecoderFactory()
|
||||||
|
|
||||||
|
// @OptIn(ExperimentalApi::class)
|
||||||
val currentTextRenderer = TextRenderer(
|
val currentTextRenderer = TextRenderer(
|
||||||
customTextOutput,
|
customTextOutput,
|
||||||
eventHandler.looper,
|
eventHandler.looper,
|
||||||
|
|
@ -1252,7 +1279,7 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
item.drm?.let { drm ->
|
item.drm?.let { drm ->
|
||||||
when (drm.uuid) {
|
when (drm.uuid) {
|
||||||
CLEARKEY_UUID -> {
|
CLEARKEY_DRM_UUID.toJavaUuid() -> {
|
||||||
// Use headers from DrmMetadata for media requests
|
// Use headers from DrmMetadata for media requests
|
||||||
val client = dataSourceFactory
|
val client = dataSourceFactory
|
||||||
?: throw IllegalArgumentException("Must supply onlineSource")
|
?: throw IllegalArgumentException("Must supply onlineSource")
|
||||||
|
|
@ -1273,8 +1300,8 @@ class CS3IPlayer : IPlayer {
|
||||||
.createMediaSource(item.mediaItem)
|
.createMediaSource(item.mediaItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
WIDEVINE_UUID,
|
WIDEVINE_DRM_UUID.toJavaUuid(),
|
||||||
PLAYREADY_UUID -> {
|
PLAYREADY_DRM_UUID.toJavaUuid() -> {
|
||||||
// Use headers from DrmMetadata for media requests
|
// Use headers from DrmMetadata for media requests
|
||||||
val client = dataSourceFactory
|
val client = dataSourceFactory
|
||||||
?: throw IllegalArgumentException("Must supply onlineSource")
|
?: throw IllegalArgumentException("Must supply onlineSource")
|
||||||
|
|
@ -1308,7 +1335,7 @@ class CS3IPlayer : IPlayer {
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
val source = ConcatenatingMediaSource2.Builder()
|
val source = ConcatenatingMediaSource2.Builder()
|
||||||
mediaItemSlices.map { item ->
|
mediaItemSlices.forEach { item ->
|
||||||
source.add(
|
source.add(
|
||||||
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
||||||
ClippingMediaSource(
|
ClippingMediaSource(
|
||||||
|
|
@ -1322,7 +1349,7 @@ class CS3IPlayer : IPlayer {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val source =
|
val source =
|
||||||
ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only
|
ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only
|
||||||
mediaItemSlices.map { item ->
|
mediaItemSlices.forEach { item ->
|
||||||
source.addMediaSource(
|
source.addMediaSource(
|
||||||
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
||||||
ClippingMediaSource(
|
ClippingMediaSource(
|
||||||
|
|
@ -1345,6 +1372,7 @@ class CS3IPlayer : IPlayer {
|
||||||
)
|
)
|
||||||
setHandleAudioBecomingNoisy(true)
|
setHandleAudioBecomingNoisy(true)
|
||||||
setPlaybackSpeed(playBackSpeed)
|
setPlaybackSpeed(playBackSpeed)
|
||||||
|
this.addAnalyticsListener(tracksAnalyticsListener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1386,6 +1414,23 @@ class CS3IPlayer : IPlayer {
|
||||||
event(PlayerAttachedEvent(exoPlayer))
|
event(PlayerAttachedEvent(exoPlayer))
|
||||||
exoPlayer?.prepare()
|
exoPlayer?.prepare()
|
||||||
|
|
||||||
|
// For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map
|
||||||
|
// incrementally as data is buffered. The initial seek resolves to the nearest merged
|
||||||
|
// entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position.
|
||||||
|
// This may only be reproducible on large and fairly long fragmented MP4 files with
|
||||||
|
// multiple sidx boxes.
|
||||||
|
if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) {
|
||||||
|
exoPlayer?.addListener(object : Player.Listener {
|
||||||
|
private var seekApplied = false
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
if (seekApplied || playbackState != Player.STATE_READY) return
|
||||||
|
seekApplied = true
|
||||||
|
exoPlayer?.seekTo(currentWindow, playbackPosition)
|
||||||
|
exoPlayer?.removeListener(this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
exoPlayer?.let { exo ->
|
exoPlayer?.let { exo ->
|
||||||
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
|
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
|
||||||
isPlaying = exo.isPlaying
|
isPlaying = exo.isPlaying
|
||||||
|
|
@ -1398,6 +1443,8 @@ class CS3IPlayer : IPlayer {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiveHelper.registerPlayer(exoPlayer)
|
||||||
|
|
||||||
exoPlayer?.addListener(object : Player.Listener {
|
exoPlayer?.addListener(object : Player.Listener {
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
safe {
|
safe {
|
||||||
|
|
@ -1506,6 +1553,23 @@ class CS3IPlayer : IPlayer {
|
||||||
exoPlayer?.prepare()
|
exoPlayer?.prepare()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlaylistStuckException usually happens when the player position is ahead of the live window.
|
||||||
|
// Seek to the default location in that case
|
||||||
|
error.cause is HlsPlaylistTracker.PlaylistStuckException -> {
|
||||||
|
val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0
|
||||||
|
|
||||||
|
// Seek to live head
|
||||||
|
val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0
|
||||||
|
|
||||||
|
if (aheadOfLive > 100) {
|
||||||
|
exoPlayer?.seekTo(position - aheadOfLive)
|
||||||
|
} else {
|
||||||
|
exoPlayer?.seekToDefaultPosition()
|
||||||
|
}
|
||||||
|
exoPlayer?.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
event(ErrorEvent(error))
|
event(ErrorEvent(error))
|
||||||
}
|
}
|
||||||
|
|
@ -1577,9 +1641,9 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
|
private var lastTimeStamps: List<VideoSkipStamp> = emptyList()
|
||||||
|
|
||||||
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
|
override fun addTimeStamps(timeStamps: List<VideoSkipStamp>) {
|
||||||
lastTimeStamps = timeStamps
|
lastTimeStamps = timeStamps
|
||||||
timeStamps.forEach { timestamp ->
|
timeStamps.forEach { timestamp ->
|
||||||
exoPlayer?.createMessage { _, _ ->
|
exoPlayer?.createMessage { _, _ ->
|
||||||
|
|
@ -1588,7 +1652,7 @@ class CS3IPlayer : IPlayer {
|
||||||
// onTimestampInvoked?.invoke(payload)
|
// onTimestampInvoked?.invoke(payload)
|
||||||
}
|
}
|
||||||
?.setLooper(Looper.getMainLooper())
|
?.setLooper(Looper.getMainLooper())
|
||||||
?.setPosition(timestamp.startMs)
|
?.setPosition(timestamp.timestamp.startMs)
|
||||||
//?.setPayload(timestamp)
|
//?.setPayload(timestamp)
|
||||||
?.setDeleteAfterDelivery(false)
|
?.setDeleteAfterDelivery(false)
|
||||||
?.send()
|
?.send()
|
||||||
|
|
@ -1635,7 +1699,8 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
val (subSources, activeSubtitles) = getSubSources(
|
val (subSources, activeSubtitles) = getSubSources(
|
||||||
offlineSourceFactory = offlineSourceFactory,
|
offlineSourceFactory = offlineSourceFactory,
|
||||||
subtitleHelper,
|
subHelper = subtitleHelper,
|
||||||
|
interceptor = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
||||||
|
|
@ -1649,6 +1714,7 @@ class CS3IPlayer : IPlayer {
|
||||||
private fun getSubSources(
|
private fun getSubSources(
|
||||||
offlineSourceFactory: DataSource.Factory?,
|
offlineSourceFactory: DataSource.Factory?,
|
||||||
subHelper: PlayerSubtitleHelper,
|
subHelper: PlayerSubtitleHelper,
|
||||||
|
interceptor: Interceptor?,
|
||||||
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
|
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
|
||||||
val activeSubtitles = ArrayList<SubtitleData>()
|
val activeSubtitles = ArrayList<SubtitleData>()
|
||||||
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
|
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
|
||||||
|
|
@ -1670,8 +1736,9 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitleOrigin.URL -> {
|
SubtitleOrigin.URL -> {
|
||||||
|
val dataSourceFactory = createOnlineSource(sub.headers, interceptor)
|
||||||
activeSubtitles.add(sub)
|
activeSubtitles.add(sub)
|
||||||
SingleSampleMediaSource.Factory(createOnlineSource(sub.headers))
|
SingleSampleMediaSource.Factory(dataSourceFactory)
|
||||||
.createMediaSource(subConfig, TIME_UNSET)
|
.createMediaSource(subConfig, TIME_UNSET)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1686,14 +1753,13 @@ class CS3IPlayer : IPlayer {
|
||||||
*/
|
*/
|
||||||
private fun getAudioSources(
|
private fun getAudioSources(
|
||||||
audioTracks: List<AudioFile>,
|
audioTracks: List<AudioFile>,
|
||||||
|
interceptor: Interceptor?,
|
||||||
): List<MediaSource> {
|
): List<MediaSource> {
|
||||||
if (audioTracks.isEmpty()) return emptyList()
|
|
||||||
return audioTracks.mapNotNull { audio ->
|
return audioTracks.mapNotNull { audio ->
|
||||||
try {
|
try {
|
||||||
val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url)
|
val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url)
|
||||||
DefaultMediaSourceFactory(createOnlineSource(audio.headers)).createMediaSource(
|
val dataSourceFactory = createOnlineSource(audio.headers, interceptor)
|
||||||
mediaItem
|
DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem)
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}")
|
Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}")
|
||||||
null
|
null
|
||||||
|
|
@ -1705,7 +1771,6 @@ class CS3IPlayer : IPlayer {
|
||||||
return exoPlayer != null
|
return exoPlayer != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
private fun loadTorrent(context: Context, link: ExtractorLink) {
|
private fun loadTorrent(context: Context, link: ExtractorLink) {
|
||||||
ioSafe {
|
ioSafe {
|
||||||
|
|
@ -1755,7 +1820,7 @@ class CS3IPlayer : IPlayer {
|
||||||
defaultSet
|
defaultSet
|
||||||
)
|
)
|
||||||
?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null }
|
?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null }
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
} ?: default
|
} ?: default
|
||||||
|
|
||||||
|
|
@ -1828,7 +1893,7 @@ class CS3IPlayer : IPlayer {
|
||||||
if (ignoreSSL) {
|
if (ignoreSSL) {
|
||||||
// Disables ssl check
|
// Disables ssl check
|
||||||
val sslContext: SSLContext = SSLContext.getInstance("TLS")
|
val sslContext: SSLContext = SSLContext.getInstance("TLS")
|
||||||
sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom())
|
sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom())
|
||||||
sslContext.createSSLEngine()
|
sslContext.createSSLEngine()
|
||||||
HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession ->
|
HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession ->
|
||||||
true
|
true
|
||||||
|
|
@ -1850,7 +1915,7 @@ class CS3IPlayer : IPlayer {
|
||||||
drm = DrmMetadata(
|
drm = DrmMetadata(
|
||||||
kid = link.kid,
|
kid = link.kid,
|
||||||
key = link.key,
|
key = link.key,
|
||||||
uuid = link.uuid,
|
uuid = link.uuid.toJavaUuid(),
|
||||||
kty = link.kty,
|
kty = link.kty,
|
||||||
licenseUrl = link.licenseUrl,
|
licenseUrl = link.licenseUrl,
|
||||||
keyRequestParameters = link.keyRequestParameters,
|
keyRequestParameters = link.keyRequestParameters,
|
||||||
|
|
@ -1865,19 +1930,35 @@ class CS3IPlayer : IPlayer {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For DASH or HLS single streams (non-playlist), prefer the player's default
|
||||||
|
// live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick
|
||||||
|
// the live/default position when no explicit start position was provided.
|
||||||
|
if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) {
|
||||||
|
playbackPosition = TIME_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = getApiFromNameNull(link.source)
|
||||||
|
val interceptor: Interceptor? = provider?.getVideoInterceptor(link)
|
||||||
|
|
||||||
val onlineSourceFactory =
|
val onlineSourceFactory =
|
||||||
createVideoSource(link, tryCreateEngine(context, simpleCacheSize))
|
createVideoSource(
|
||||||
|
link = link,
|
||||||
|
engine = tryCreateEngine(context, simpleCacheSize),
|
||||||
|
interceptor = interceptor
|
||||||
|
)
|
||||||
|
|
||||||
val offlineSourceFactory = context.createOfflineSource()
|
val offlineSourceFactory = context.createOfflineSource()
|
||||||
|
|
||||||
val (subSources, activeSubtitles) = getSubSources(
|
val (subSources, activeSubtitles) = getSubSources(
|
||||||
offlineSourceFactory = offlineSourceFactory,
|
offlineSourceFactory = offlineSourceFactory,
|
||||||
subtitleHelper
|
subHelper = subtitleHelper,
|
||||||
|
interceptor = interceptor, // Backwards compatibility, needs a new api to work properly
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create audio sources from ExtractorLink's audioTracks
|
// Create audio sources from ExtractorLink's audioTracks
|
||||||
val audioSources = getAudioSources(
|
val audioSources = getAudioSources(
|
||||||
audioTracks = link.audioTracks,
|
audioTracks = link.audioTracks,
|
||||||
|
interceptor = interceptor, // Backwards compatibility, needs a new api to work properly
|
||||||
)
|
)
|
||||||
|
|
||||||
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
||||||
|
|
@ -1905,4 +1986,38 @@ class CS3IPlayer : IPlayer {
|
||||||
loadOfflinePlayer(context, it)
|
loadOfflinePlayer(context, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val tracksAnalyticsListener = object : AnalyticsListener {
|
||||||
|
|
||||||
|
override fun onVideoInputFormatChanged(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
format: Format,
|
||||||
|
decoderReuseEvaluation: DecoderReuseEvaluation?
|
||||||
|
) {
|
||||||
|
event(TracksChangedEvent())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioInputFormatChanged(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
format: Format,
|
||||||
|
decoderReuseEvaluation: DecoderReuseEvaluation?
|
||||||
|
) {
|
||||||
|
event(TracksChangedEvent())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVideoDisabled(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderCounters: DecoderCounters
|
||||||
|
) {
|
||||||
|
event(TracksChangedEvent())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDisabled(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderCounters: DecoderCounters
|
||||||
|
) {
|
||||||
|
event(TracksChangedEvent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2016 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes.
|
||||||
|
*/
|
||||||
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
|
import android.text.Html
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.TextUtils
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.Format.CueReplacementBehavior
|
||||||
|
import androidx.media3.common.text.Cue
|
||||||
|
import androidx.media3.common.text.Cue.AnchorType
|
||||||
|
import androidx.media3.common.util.Consumer
|
||||||
|
import androidx.media3.common.util.Log
|
||||||
|
import androidx.media3.common.util.ParsableByteArray
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.extractor.text.CuesWithTiming
|
||||||
|
import androidx.media3.extractor.text.SubtitleParser
|
||||||
|
import androidx.media3.extractor.text.SubtitleParser.OutputOptions
|
||||||
|
import com.google.common.base.Preconditions.checkNotNull
|
||||||
|
import com.google.common.collect.ImmutableList
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.regex.Matcher
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
/** A [SubtitleParser] for SubRip. */
|
||||||
|
@UnstableApi
|
||||||
|
class CustomSubripParser : SubtitleParser {
|
||||||
|
private val textBuilder: StringBuilder = StringBuilder()
|
||||||
|
private val tags: ArrayList<String> = ArrayList()
|
||||||
|
private val parsableByteArray: ParsableByteArray = ParsableByteArray()
|
||||||
|
|
||||||
|
override fun getCueReplacementBehavior(): @CueReplacementBehavior Int {
|
||||||
|
return CUE_REPLACEMENT_BEHAVIOR
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parse(
|
||||||
|
data: ByteArray,
|
||||||
|
offset: Int,
|
||||||
|
length: Int,
|
||||||
|
outputOptions: OutputOptions,
|
||||||
|
output: Consumer<CuesWithTiming>
|
||||||
|
) {
|
||||||
|
parsableByteArray.reset(data, /* limit= */offset + length)
|
||||||
|
parsableByteArray.setPosition(offset)
|
||||||
|
val charset = detectUtfCharset(parsableByteArray)
|
||||||
|
|
||||||
|
val cuesWithTimingBeforeRequestedStartTimeUs: MutableList<CuesWithTiming>? =
|
||||||
|
if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues)
|
||||||
|
ArrayList<CuesWithTiming>()
|
||||||
|
else
|
||||||
|
null
|
||||||
|
var currentLine: String?
|
||||||
|
while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) {
|
||||||
|
if (currentLine!!.isEmpty()) {
|
||||||
|
// Skip blank lines.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and check the index line.
|
||||||
|
try {
|
||||||
|
currentLine.toInt()
|
||||||
|
} catch (_: NumberFormatException) {
|
||||||
|
Log.w(TAG, "Skipping invalid index: $currentLine")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the timing line.
|
||||||
|
currentLine = parsableByteArray.readLine(charset)
|
||||||
|
if (currentLine == null) {
|
||||||
|
Log.w(TAG, "Unexpected end")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val startTimeUs: Long
|
||||||
|
val endTimeUs: Long
|
||||||
|
val matcher = SUBRIP_TIMING_LINE.matcher(currentLine)
|
||||||
|
if (matcher.matches()) {
|
||||||
|
startTimeUs = parseTimecode(matcher, /* groupOffset= */1)
|
||||||
|
endTimeUs = parseTimecode(matcher, /* groupOffset= */6)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Skipping invalid timing: $currentLine")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the text and tags.
|
||||||
|
textBuilder.setLength(0)
|
||||||
|
tags.clear()
|
||||||
|
currentLine = parsableByteArray.readLine(charset)
|
||||||
|
while (!TextUtils.isEmpty(currentLine)) {
|
||||||
|
if (textBuilder.isNotEmpty()) {
|
||||||
|
textBuilder.append("<br>")
|
||||||
|
}
|
||||||
|
textBuilder.append(processLine(currentLine!!, tags))
|
||||||
|
currentLine = parsableByteArray.readLine(charset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val text = Html.fromHtml(textBuilder.toString())
|
||||||
|
|
||||||
|
var alignmentTag: String? = null
|
||||||
|
for (i in tags.indices) {
|
||||||
|
val tag = tags[i]
|
||||||
|
if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) {
|
||||||
|
alignmentTag = tag
|
||||||
|
// Subsequent alignment tags should be ignored.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) {
|
||||||
|
output.accept(
|
||||||
|
CuesWithTiming(
|
||||||
|
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
|
||||||
|
startTimeUs, /* durationUs= */
|
||||||
|
endTimeUs - startTimeUs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else cuesWithTimingBeforeRequestedStartTimeUs?.add(
|
||||||
|
CuesWithTiming(
|
||||||
|
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
|
||||||
|
startTimeUs, /* durationUs= */
|
||||||
|
endTimeUs - startTimeUs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cuesWithTimingBeforeRequestedStartTimeUs != null) {
|
||||||
|
for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) {
|
||||||
|
output.accept(cuesWithTiming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if
|
||||||
|
* no BOM is found.
|
||||||
|
*/
|
||||||
|
private fun detectUtfCharset(data: ParsableByteArray): Charset {
|
||||||
|
val charset = data.readUtfCharsetFromBom()
|
||||||
|
return charset ?: StandardCharsets.UTF_8
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims and removes tags from the given line. The removed tags are added to `tags`.
|
||||||
|
*
|
||||||
|
* @param line The line to process.
|
||||||
|
* @param tags A list to which removed tags will be added.
|
||||||
|
* @return The processed line.
|
||||||
|
*/
|
||||||
|
private fun processLine(line: String, tags: ArrayList<String>): String {
|
||||||
|
var line = line
|
||||||
|
line = line.trim { it <= ' ' }
|
||||||
|
|
||||||
|
var removedCharacterCount = 0
|
||||||
|
val processedLine = StringBuilder(line)
|
||||||
|
val matcher = SUBRIP_TAG_PATTERN.matcher(line)
|
||||||
|
while (matcher.find()) {
|
||||||
|
val tag = matcher.group()
|
||||||
|
tags.add(tag)
|
||||||
|
val start = matcher.start() - removedCharacterCount
|
||||||
|
val tagLength = tag.length
|
||||||
|
processedLine.replace(start, /* end= */start + tagLength, /* str= */"")
|
||||||
|
removedCharacterCount += tagLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedLine.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a [Cue] based on the given text and alignment tag.
|
||||||
|
*
|
||||||
|
* @param text The text.
|
||||||
|
* @param alignmentTag The alignment tag, or `null` if no alignment tag is available.
|
||||||
|
* @return Built cue
|
||||||
|
*/
|
||||||
|
private fun buildCue(text: Spanned, alignmentTag: String?): Cue {
|
||||||
|
val cue = Cue.Builder().setText(text)
|
||||||
|
if (alignmentTag == null) {
|
||||||
|
return cue.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal alignment.
|
||||||
|
when (alignmentTag) {
|
||||||
|
ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START)
|
||||||
|
ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END)
|
||||||
|
ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
||||||
|
else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical alignment.
|
||||||
|
when (alignmentTag) {
|
||||||
|
ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END)
|
||||||
|
ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START)
|
||||||
|
ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
||||||
|
else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor()))
|
||||||
|
.setLine(
|
||||||
|
getFractionalPositionForAnchorType(cue.getLineAnchor()),
|
||||||
|
Cue.LINE_TYPE_FRACTION
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this
|
||||||
|
* implementation.
|
||||||
|
*/
|
||||||
|
const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int =
|
||||||
|
Format.CUE_REPLACEMENT_BEHAVIOR_MERGE
|
||||||
|
|
||||||
|
// Fractional positions for use when alignment tags are present.
|
||||||
|
private const val START_FRACTION = 0.08f
|
||||||
|
private const val END_FRACTION = 1 - START_FRACTION
|
||||||
|
private const val MID_FRACTION = 0.5f
|
||||||
|
|
||||||
|
private const val TAG = "SubripParser"
|
||||||
|
|
||||||
|
// The google devs are useless, this entire class is just to override this
|
||||||
|
private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?"
|
||||||
|
private val SUBRIP_TIMING_LINE: Pattern =
|
||||||
|
Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*")
|
||||||
|
|
||||||
|
// NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183].
|
||||||
|
private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}")
|
||||||
|
private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}"
|
||||||
|
|
||||||
|
// Alignment tags for SSA V4+.
|
||||||
|
private const val ALIGN_BOTTOM_LEFT = "{\\an1}"
|
||||||
|
private const val ALIGN_BOTTOM_MID = "{\\an2}"
|
||||||
|
private const val ALIGN_BOTTOM_RIGHT = "{\\an3}"
|
||||||
|
private const val ALIGN_MID_LEFT = "{\\an4}"
|
||||||
|
private const val ALIGN_MID_MID = "{\\an5}"
|
||||||
|
private const val ALIGN_MID_RIGHT = "{\\an6}"
|
||||||
|
private const val ALIGN_TOP_LEFT = "{\\an7}"
|
||||||
|
private const val ALIGN_TOP_MID = "{\\an8}"
|
||||||
|
private const val ALIGN_TOP_RIGHT = "{\\an9}"
|
||||||
|
|
||||||
|
private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long {
|
||||||
|
val hours = matcher.group(groupOffset + 1)
|
||||||
|
var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0
|
||||||
|
timestampMs += checkNotNull(matcher.group(groupOffset + 2))
|
||||||
|
.toLong() * 60 * 1000
|
||||||
|
timestampMs += checkNotNull(matcher.group(groupOffset + 3))
|
||||||
|
.toLong() * 1000
|
||||||
|
val millis = matcher.group(groupOffset + 4)
|
||||||
|
|
||||||
|
timestampMs += when (millis?.length) {
|
||||||
|
null -> 0L
|
||||||
|
1 -> millis.toLong() * 100L
|
||||||
|
2 -> millis.toLong() * 10L
|
||||||
|
3 -> millis.toLong() * 1L
|
||||||
|
else -> millis.substring(0, 3).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestampMs * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(b/289983417): Make package-private again, once it is no longer needed in
|
||||||
|
// DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed)
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE)
|
||||||
|
fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float {
|
||||||
|
return when (anchorType) {
|
||||||
|
Cue.ANCHOR_TYPE_START -> START_FRACTION
|
||||||
|
Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION
|
||||||
|
Cue.ANCHOR_TYPE_END -> END_FRACTION
|
||||||
|
Cue.TYPE_UNSET -> // Should never happen.
|
||||||
|
throw IllegalArgumentException()
|
||||||
|
|
||||||
|
else ->
|
||||||
|
throw IllegalArgumentException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,6 @@ import androidx.media3.extractor.text.SubtitleParser
|
||||||
import androidx.media3.extractor.text.dvb.DvbParser
|
import androidx.media3.extractor.text.dvb.DvbParser
|
||||||
import androidx.media3.extractor.text.pgs.PgsParser
|
import androidx.media3.extractor.text.pgs.PgsParser
|
||||||
import androidx.media3.extractor.text.ssa.SsaParser
|
import androidx.media3.extractor.text.ssa.SsaParser
|
||||||
import androidx.media3.extractor.text.subrip.SubripParser
|
|
||||||
import androidx.media3.extractor.text.ttml.TtmlParser
|
import androidx.media3.extractor.text.ttml.TtmlParser
|
||||||
import androidx.media3.extractor.text.tx3g.Tx3gParser
|
import androidx.media3.extractor.text.tx3g.Tx3gParser
|
||||||
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
|
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
|
||||||
|
|
@ -251,14 +250,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
ignoreCase = true
|
ignoreCase = true
|
||||||
)) -> SsaParser(fallbackFormat?.initializationData)
|
)) -> SsaParser(fallbackFormat?.initializationData)
|
||||||
|
|
||||||
trimmedText.startsWith("1", ignoreCase = true) -> SubripParser()
|
trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser()
|
||||||
fallbackFormat != null -> {
|
fallbackFormat != null -> {
|
||||||
when (val mimeType = fallbackFormat.sampleMimeType) {
|
when (fallbackFormat.sampleMimeType) {
|
||||||
MimeTypes.TEXT_VTT -> WebvttParser()
|
MimeTypes.TEXT_VTT -> WebvttParser()
|
||||||
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
|
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
|
||||||
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
|
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
|
||||||
MimeTypes.APPLICATION_TTML -> TtmlParser()
|
MimeTypes.APPLICATION_TTML -> TtmlParser()
|
||||||
MimeTypes.APPLICATION_SUBRIP -> SubripParser()
|
MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser()
|
||||||
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
|
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
|
||||||
// These decoders are not converted to parsers yet
|
// These decoders are not converted to parsers yet
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,17 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
|
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
|
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
|
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
|
||||||
|
|
||||||
class DownloadFileGenerator(
|
class DownloadFileGenerator(
|
||||||
episodes: List<ExtractorUri>,
|
episodes: List<ExtractorUri>
|
||||||
currentIndex: Int = 0
|
) : VideoGenerator<ExtractorUri>(episodes) {
|
||||||
) : VideoGenerator<ExtractorUri>(episodes, currentIndex) {
|
|
||||||
override val hasCache = false
|
override val hasCache = false
|
||||||
override val canSkipLoading = false
|
override val canSkipLoading = false
|
||||||
|
|
||||||
|
override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id
|
||||||
|
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
|
|
@ -28,14 +29,14 @@ class DownloadFileGenerator(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
isCasting: Boolean
|
isCasting: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val meta = getCurrent(offset) ?: return false
|
val meta = videos.getOrNull(offset) ?: return false
|
||||||
|
|
||||||
if (meta.uri == Uri.EMPTY) {
|
if (meta.uri == Uri.EMPTY) {
|
||||||
// We do this here so that we only load it when
|
// We do this here so that we only load it when
|
||||||
// we actually need it as it can be more expensive.
|
// we actually need it as it can be more expensive.
|
||||||
val info = meta.id?.let { id ->
|
val info = meta.id?.let { id ->
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
getDownloadFileInfoAndUpdateSettings(act, id)
|
getDownloadFileInfo(act, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPres
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
||||||
|
|
||||||
class DownloadedPlayerActivity : AppCompatActivity() {
|
class DownloadedPlayerActivity : AppCompatActivity() {
|
||||||
private val dTAG = "DownloadedPlayerAct"
|
companion object {
|
||||||
|
const val TAG = "DownloadedPlayerActivity"
|
||||||
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
||||||
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
|
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
|
||||||
|
|
@ -27,53 +29,83 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
||||||
CommonActivity.onUserLeaveHint(this)
|
CommonActivity.onUserLeaveHint(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Ignore same intent so the player doesnt totally
|
||||||
|
// reload if you are playing the same thing.
|
||||||
|
if (isSameIntent(intent)) return
|
||||||
|
setIntent(intent)
|
||||||
|
Log.i(TAG, "onNewIntent")
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSameIntent(newIntent: Intent): Boolean {
|
||||||
|
val old = intent ?: return false
|
||||||
|
// Compare URIs first
|
||||||
|
val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri
|
||||||
|
val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri
|
||||||
|
if (oldUri != null && oldUri == newUri) return true
|
||||||
|
// Fall back to comparing EXTRA_TEXT links
|
||||||
|
val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) }
|
||||||
|
val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) }
|
||||||
|
return oldText != null && oldText == newText
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
CommonActivity.loadThemes(this)
|
CommonActivity.loadThemes(this)
|
||||||
CommonActivity.init(this)
|
CommonActivity.init(this)
|
||||||
enableEdgeToEdgeCompat()
|
enableEdgeToEdgeCompat()
|
||||||
setContentView(R.layout.empty_layout)
|
setContentView(R.layout.empty_layout)
|
||||||
Log.i(dTAG, "onCreate")
|
Log.i(TAG, "onCreate")
|
||||||
|
handleIntent(intent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use moveTaskToBack instead of finish() so there is always exactly one task
|
||||||
|
* entry in recents, always reflecting the current file.
|
||||||
|
*
|
||||||
|
* finish() destroys the Activity but may leave the task in recents. Each new file
|
||||||
|
* open can create a new task entry, so recents accumulates stale entries for old
|
||||||
|
* files. The user then taps a stale entry and gets the wrong file.
|
||||||
|
*
|
||||||
|
* moveTaskToBack keeps the Activity alive in the background. There is only ever
|
||||||
|
* one task entry in recents. New files opened from the file manager arrive via
|
||||||
|
* onNewIntent on the live instance, updating the player immediately. The single
|
||||||
|
* recents entry always reflects the current state, ensuring we load the
|
||||||
|
* correct file.
|
||||||
|
*/
|
||||||
|
attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIntent(intent: Intent) {
|
||||||
val data = intent.data
|
val data = intent.data
|
||||||
|
|
||||||
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
|
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) {
|
if (
|
||||||
val extraText = safe { // I dont trust android
|
intent.action == Intent.ACTION_SEND ||
|
||||||
intent.getStringExtra(Intent.EXTRA_TEXT)
|
intent.action == Intent.ACTION_OPEN_DOCUMENT ||
|
||||||
}
|
intent.action == Intent.ACTION_VIEW
|
||||||
|
) {
|
||||||
|
val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
|
||||||
val cd = intent.clipData
|
val cd = intent.clipData
|
||||||
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
|
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
|
||||||
val url = item?.text?.toString()
|
val url = item?.text?.toString()
|
||||||
|
when {
|
||||||
// idk what I am doing, just hope any of these work
|
item?.uri != null -> playUri(this, item.uri)
|
||||||
if (item?.uri != null)
|
url != null -> playLink(this, url)
|
||||||
playUri(this, item.uri)
|
data != null -> playUri(this, data)
|
||||||
else if (url != null)
|
extraText != null -> playLink(this, extraText)
|
||||||
playLink(this, url)
|
else -> finishAndRemoveTask()
|
||||||
else if (data != null)
|
|
||||||
playUri(this, data)
|
|
||||||
else if (extraText != null)
|
|
||||||
playLink(this, extraText)
|
|
||||||
else {
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} else if (data?.scheme == "content") {
|
} else if (data?.scheme == "content") {
|
||||||
playUri(this, data)
|
playUri(this, data)
|
||||||
} else {
|
} else finishAndRemoveTask()
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
CommonActivity.setActivityInstance(this)
|
CommonActivity.setActivityInstance(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
class ExtractorLinkGenerator(
|
class ExtractorLinkGenerator(
|
||||||
private val links: List<ExtractorLink>,
|
private val links: List<ExtractorLink>,
|
||||||
private val subtitles: List<SubtitleData>,
|
private val subtitles: List<SubtitleData>,
|
||||||
) : NoVideoGenerator() {
|
) : NoVideoGenerator(null) {
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
|
import androidx.media3.exoplayer.text.TextRenderer
|
||||||
|
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) {
|
||||||
|
/** Somehow the nextlib authors decided that we need a text renderer that causes
|
||||||
|
* "ERROR_CODE_FAILED_RUNTIME_CHECK".
|
||||||
|
*
|
||||||
|
* Core issue: https://github.com/anilbeesetti/nextlib/pull/158
|
||||||
|
* Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718
|
||||||
|
* */
|
||||||
|
override fun buildTextRenderers(
|
||||||
|
context: Context,
|
||||||
|
output: TextOutput,
|
||||||
|
outputLooper: Looper,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
out: ArrayList<Renderer>
|
||||||
|
) {
|
||||||
|
out.add(TextRenderer(output, outputLooper))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,7 @@
|
||||||
package com.lagradost.cloudstream3.ui.player
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
val LOADTYPE_INAPP = setOf(
|
val LOADTYPE_INAPP = setOf(
|
||||||
ExtractorLinkType.VIDEO,
|
ExtractorLinkType.VIDEO,
|
||||||
|
|
@ -28,71 +25,27 @@ val LOADTYPE_CHROMECAST = setOf(
|
||||||
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
|
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
|
||||||
|
|
||||||
|
|
||||||
abstract class NoVideoGenerator : VideoGenerator<Nothing>(emptyList(), 0) {
|
abstract class NoVideoGenerator(val id : Int?) : VideoGenerator<Nothing>(emptyList()) {
|
||||||
override val hasCache = false
|
override val hasCache = false
|
||||||
override val canSkipLoading = false
|
override val canSkipLoading = false
|
||||||
|
override fun getId(index: Int): Int? = id
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class VideoGenerator<T : Any>(val videos: List<T>, var videoIndex: Int = 0) :
|
abstract class VideoGenerator<T : Any>(val videos: List<T>) {
|
||||||
IGenerator {
|
abstract val hasCache: Boolean
|
||||||
|
abstract val canSkipLoading: Boolean
|
||||||
|
abstract fun getId(index : Int) : Int?
|
||||||
|
|
||||||
override fun hasNext(): Boolean = videoIndex < videos.lastIndex
|
fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex
|
||||||
override fun hasPrev(): Boolean = videoIndex > 0
|
fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0
|
||||||
override fun getAll(): List<T>? = videos
|
|
||||||
override fun getCurrent(offset: Int): T? = videos.getOrNull(videoIndex + offset)
|
|
||||||
override fun next() {
|
|
||||||
if (hasNext()) {
|
|
||||||
videoIndex += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prev() {
|
@Throws
|
||||||
if (hasPrev()) {
|
abstract suspend fun generateLinks(
|
||||||
videoIndex -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun goto(index: Int) {
|
|
||||||
videoIndex = min(videos.lastIndex, max(0, index))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentId(): Int? {
|
|
||||||
return when (val current = getCurrent()) {
|
|
||||||
is ResultEpisode -> {
|
|
||||||
current.id
|
|
||||||
}
|
|
||||||
|
|
||||||
is ExtractorUri -> {
|
|
||||||
current.id
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO deprecate/remove IGenerator in favor of a more ergonomic and correct implementation
|
|
||||||
interface IGenerator {
|
|
||||||
val hasCache: Boolean
|
|
||||||
val canSkipLoading: Boolean
|
|
||||||
|
|
||||||
fun hasNext(): Boolean
|
|
||||||
fun hasPrev(): Boolean
|
|
||||||
fun next()
|
|
||||||
fun prev()
|
|
||||||
fun goto(index: Int)
|
|
||||||
|
|
||||||
fun getCurrentId(): Int? // this is used to save data or read data about this id
|
|
||||||
fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
|
|
||||||
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
|
|
||||||
|
|
||||||
/* not safe, must use try catch */
|
|
||||||
suspend fun generateLinks(
|
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||||
subtitleCallback: (SubtitleData) -> Unit,
|
subtitleCallback: (SubtitleData) -> Unit,
|
||||||
offset: Int = 0,
|
offset: Int,
|
||||||
isCasting: Boolean = false
|
isCasting: Boolean
|
||||||
): Boolean
|
): Boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,11 @@ package com.lagradost.cloudstream3.ui.player
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.annotation.MainThread
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
|
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||||
enum class PlayerEventType(val value: Int) {
|
|
||||||
Pause(0),
|
|
||||||
Play(1),
|
|
||||||
SeekForward(2),
|
|
||||||
SeekBack(3),
|
|
||||||
|
|
||||||
SkipCurrentChapter(4),
|
|
||||||
NextEpisode(5),
|
|
||||||
PrevEpisode(6),
|
|
||||||
PlayPauseToggle(7),
|
|
||||||
ToggleMute(8),
|
|
||||||
Lock(9),
|
|
||||||
ToggleHide(10),
|
|
||||||
ShowSpeed(11),
|
|
||||||
ShowMirrors(12),
|
|
||||||
Resize(13),
|
|
||||||
SearchSubtitlesOnline(14),
|
|
||||||
SkipOp(15),
|
|
||||||
Restart(16),
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class CSPlayerEvent(val value: Int) {
|
enum class CSPlayerEvent(val value: Int) {
|
||||||
Pause(0),
|
Pause(0),
|
||||||
|
|
@ -86,13 +67,13 @@ data class ErrorEvent(
|
||||||
|
|
||||||
/** Event when timestamps appear, null when it should disappear */
|
/** Event when timestamps appear, null when it should disappear */
|
||||||
data class TimestampInvokedEvent(
|
data class TimestampInvokedEvent(
|
||||||
val timestamp: EpisodeSkip.SkipStamp,
|
val timestamp: VideoSkipStamp,
|
||||||
override val source: PlayerEventSource = PlayerEventSource.Player,
|
override val source: PlayerEventSource = PlayerEventSource.Player,
|
||||||
) : PlayerEvent()
|
) : PlayerEvent()
|
||||||
|
|
||||||
/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */
|
/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */
|
||||||
data class TimestampSkippedEvent(
|
data class TimestampSkippedEvent(
|
||||||
val timestamp: EpisodeSkip.SkipStamp,
|
val timestamp: VideoSkipStamp,
|
||||||
override val source: PlayerEventSource = PlayerEventSource.Player,
|
override val source: PlayerEventSource = PlayerEventSource.Player,
|
||||||
) : PlayerEvent()
|
) : PlayerEvent()
|
||||||
|
|
||||||
|
|
@ -182,6 +163,7 @@ interface Track {
|
||||||
val id: String?
|
val id: String?
|
||||||
val label: String?
|
val label: String?
|
||||||
val language: String?
|
val language: String?
|
||||||
|
val sampleMimeType : String?
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VideoTrack(
|
data class VideoTrack(
|
||||||
|
|
@ -190,19 +172,23 @@ data class VideoTrack(
|
||||||
override val language: String?,
|
override val language: String?,
|
||||||
val width: Int?,
|
val width: Int?,
|
||||||
val height: Int?,
|
val height: Int?,
|
||||||
|
override val sampleMimeType: String?,
|
||||||
) : Track
|
) : Track
|
||||||
|
|
||||||
data class AudioTrack(
|
data class AudioTrack(
|
||||||
override val id: String?,
|
override val id: String?,
|
||||||
override val label: String?,
|
override val label: String?,
|
||||||
override val language: String?,
|
override val language: String?,
|
||||||
|
override val sampleMimeType: String?,
|
||||||
|
val channelCount: Int?,
|
||||||
|
val formatIndex: Int?,
|
||||||
) : Track
|
) : Track
|
||||||
|
|
||||||
data class TextTrack(
|
data class TextTrack(
|
||||||
override val id: String?,
|
override val id: String?,
|
||||||
override val label: String?,
|
override val label: String?,
|
||||||
override val language: String?,
|
override val language: String?,
|
||||||
val mimeType: String?,
|
override val sampleMimeType: String?,
|
||||||
) : Track
|
) : Track
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -215,8 +201,6 @@ data class CurrentTracks(
|
||||||
val allTextTracks: List<TextTrack>,
|
val allTextTracks: List<TextTrack>,
|
||||||
)
|
)
|
||||||
|
|
||||||
class InvalidFileException(msg: String) : Exception(msg)
|
|
||||||
|
|
||||||
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
|
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
|
||||||
const val ACTION_MEDIA_CONTROL = "media_control"
|
const val ACTION_MEDIA_CONTROL = "media_control"
|
||||||
const val EXTRA_CONTROL_TYPE = "control_type"
|
const val EXTRA_CONTROL_TYPE = "control_type"
|
||||||
|
|
@ -238,8 +222,9 @@ interface IPlayer {
|
||||||
fun getSubtitleOffset(): Long // in ms
|
fun getSubtitleOffset(): Long // in ms
|
||||||
fun setSubtitleOffset(offset: Long) // in ms
|
fun setSubtitleOffset(offset: Long) // in ms
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
fun initCallbacks(
|
fun initCallbacks(
|
||||||
eventHandler: ((PlayerEvent) -> Unit),
|
@MainThread eventHandler: ((PlayerEvent) -> Unit),
|
||||||
/** this is used to request when the player should report back view percentage */
|
/** this is used to request when the player should report back view percentage */
|
||||||
requestedListeningPercentages: List<Int>? = null,
|
requestedListeningPercentages: List<Int>? = null,
|
||||||
)
|
)
|
||||||
|
|
@ -249,7 +234,7 @@ interface IPlayer {
|
||||||
fun updateSubtitleStyle(style: SaveCaptionStyle)
|
fun updateSubtitleStyle(style: SaveCaptionStyle)
|
||||||
fun saveData()
|
fun saveData()
|
||||||
|
|
||||||
fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>)
|
fun addTimeStamps(timeStamps: List<VideoSkipStamp>)
|
||||||
|
|
||||||
fun loadPlayer(
|
fun loadPlayer(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -302,8 +287,8 @@ interface IPlayer {
|
||||||
fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null)
|
fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null)
|
||||||
|
|
||||||
/** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */
|
/** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */
|
||||||
fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null)
|
fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null)
|
||||||
|
|
||||||
/** Get the current subtitle cues, for use with syncing */
|
/** Get the current subtitle cues, for use with syncing */
|
||||||
fun getSubtitleCues(): List<SubtitleCue>
|
fun getSubtitleCues(): List<SubtitleCue>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ class LinkGenerator(
|
||||||
private val links: List<BasicLink>,
|
private val links: List<BasicLink>,
|
||||||
private val extract: Boolean = true,
|
private val extract: Boolean = true,
|
||||||
private val refererUrl: String? = null,
|
private val refererUrl: String? = null,
|
||||||
) : NoVideoGenerator() {
|
id: Int?
|
||||||
|
) : NoVideoGenerator(id) {
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
|
|
@ -78,10 +79,8 @@ class LinkGenerator(
|
||||||
class MinimalLinkGenerator(
|
class MinimalLinkGenerator(
|
||||||
private val links: List<CloudStreamPackage.MinimalVideoLink>,
|
private val links: List<CloudStreamPackage.MinimalVideoLink>,
|
||||||
private val subs: List<CloudStreamPackage.MinimalSubtitleLink>,
|
private val subs: List<CloudStreamPackage.MinimalSubtitleLink>,
|
||||||
private val id: Int? = null
|
id: Int?
|
||||||
) : NoVideoGenerator() {
|
) : NoVideoGenerator(id) {
|
||||||
override fun getCurrentId(): Int? = id
|
|
||||||
|
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package com.lagradost.cloudstream3.ui.player
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.ContextCompat.getString
|
import androidx.core.content.ContextCompat.getString
|
||||||
|
import androidx.navigation.NavOptions
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage
|
import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
|
|
@ -13,15 +13,25 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.safefile.SafeFile
|
import com.lagradost.safefile.SafeFile
|
||||||
|
|
||||||
object OfflinePlaybackHelper {
|
object OfflinePlaybackHelper {
|
||||||
|
/**
|
||||||
|
* Pop any existing player off the nav back stack before pushing the new one,
|
||||||
|
* keeping the stack flat (at most one player at a time). This prevents an
|
||||||
|
* OOM when many files are opened in sequence via DownloadedPlayerActivity.
|
||||||
|
*/
|
||||||
|
private val replacePlayerNavOptions = NavOptions.Builder()
|
||||||
|
.setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false)
|
||||||
|
.build()
|
||||||
|
|
||||||
fun playLink(activity: Activity, url: String) {
|
fun playLink(activity: Activity, url: String) {
|
||||||
activity.navigate(
|
activity.navigate(
|
||||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||||
LinkGenerator(
|
LinkGenerator(
|
||||||
listOf(
|
listOf(
|
||||||
BasicLink(url)
|
BasicLink(url)
|
||||||
)
|
), id = url.hashCode()
|
||||||
)
|
), 0
|
||||||
)
|
),
|
||||||
|
replacePlayerNavOptions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,8 +62,9 @@ object OfflinePlaybackHelper {
|
||||||
links,
|
links,
|
||||||
subs,
|
subs,
|
||||||
if (id != -1) id else null,
|
if (id != -1) id else null,
|
||||||
)
|
), 0
|
||||||
)
|
),
|
||||||
|
replacePlayerNavOptions
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -73,12 +84,12 @@ object OfflinePlaybackHelper {
|
||||||
name = name ?: getString(activity, R.string.downloaded_file),
|
name = name ?: getString(activity, R.string.downloaded_file),
|
||||||
// well not the same as a normal id, but we take it as users may want to
|
// well not the same as a normal id, but we take it as users may want to
|
||||||
// play downloaded files and save the location
|
// play downloaded files and save the location
|
||||||
id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()
|
id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode()
|
||||||
?.hashCode()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
), 0
|
||||||
)
|
),
|
||||||
|
replacePlayerNavOptions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,34 +9,188 @@ import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
|
||||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
|
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
import com.lagradost.cloudstream3.utils.videoskip.SkipAPI
|
||||||
|
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||||
|
import kotlinx.collections.immutable.PersistentList
|
||||||
|
import kotlinx.collections.immutable.PersistentSet
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.collections.immutable.toPersistentSet
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.annotations.Contract
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
typealias VideoLink = Pair<ExtractorLink?, ExtractorUri?>
|
||||||
|
|
||||||
|
data class GeneratorState(
|
||||||
|
val meta: Any?,
|
||||||
|
val nextMeta: Any?,
|
||||||
|
val allMeta: List<*>?,
|
||||||
|
val response: LoadResponse?,
|
||||||
|
val index: Int,
|
||||||
|
val id: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Immutable state of all current links relevant to displaying the video */
|
||||||
|
// @MustUseReturnValues
|
||||||
|
// @Immutable
|
||||||
|
data class VideoState(
|
||||||
|
val subtitles: PersistentSet<SubtitleData> = persistentSetOf(),
|
||||||
|
val links: PersistentSet<VideoLink> = persistentSetOf(),
|
||||||
|
val stamps: PersistentList<VideoSkipStamp> = persistentListOf(),
|
||||||
|
val loading: Resource<Unit> = Resource.Loading(),
|
||||||
|
val generatorState: GeneratorState? = null,
|
||||||
|
val instance: Int,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* This acts as a local cache for sorted links that are not copied over by the copy constructor.
|
||||||
|
*
|
||||||
|
* sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation
|
||||||
|
* */
|
||||||
|
private val sortedLinks: ConcurrentHashMap<Int, List<VideoLink>> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
fun clearSortedLinksCache() = sortedLinks.clear()
|
||||||
|
|
||||||
|
// Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result
|
||||||
|
// It is by all standards, idempotent and by extension also pure as it has no "visible" side effect
|
||||||
|
/** Returns .links in the sorted order according to the qualityProfile.
|
||||||
|
* Use .links if order is not needed */
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun sortLinks(qualityProfile: Int): List<VideoLink> {
|
||||||
|
return sortedLinks[qualityProfile] ?: links.sortedBy { link ->
|
||||||
|
// negative because we want to sort highest quality first
|
||||||
|
-getLinkPriority(qualityProfile, link.first)
|
||||||
|
}.also { value -> sortedLinks[qualityProfile] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item))
|
||||||
|
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun add(item: VideoLink): VideoState = copy(links = links.add(item))
|
||||||
|
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item))
|
||||||
|
|
||||||
|
@JvmName("addSubtitleData")
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun add(items: Collection<SubtitleData>): VideoState = copy(subtitles = subtitles.addAll(items))
|
||||||
|
|
||||||
|
@JvmName("addVideoLink")
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun add(items: Collection<VideoLink>): VideoState = copy(links = links.addAll(items))
|
||||||
|
|
||||||
|
@JvmName("addVideoSkipStamp")
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun add(items: Collection<VideoSkipStamp>): VideoState = copy(stamps = stamps.addAll(items))
|
||||||
|
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item))
|
||||||
|
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item))
|
||||||
|
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item))
|
||||||
|
|
||||||
|
@JvmName("setSubtitleData")
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun set(items: Collection<SubtitleData>): VideoState = copy(subtitles = items.toPersistentSet())
|
||||||
|
|
||||||
|
@JvmName("setVideoLink")
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun set(items: Collection<VideoLink>): VideoState = copy(links = items.toPersistentSet())
|
||||||
|
|
||||||
|
@JvmName("setVideoSkipStamp")
|
||||||
|
@Contract(pure = true)
|
||||||
|
fun set(items: Collection<VideoSkipStamp>): VideoState = copy(stamps = items.toPersistentList())
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VideoLive<T>(
|
||||||
|
val value: T,
|
||||||
|
val instance: Int,
|
||||||
|
)
|
||||||
|
|
||||||
class PlayerGeneratorViewModel : ViewModel() {
|
class PlayerGeneratorViewModel : ViewModel() {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "PlayViewGen"
|
const val TAG = "PlayViewGen"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var generator: IGenerator? = null
|
@Volatile
|
||||||
|
var generator: VideoGenerator<*>? = null
|
||||||
|
|
||||||
private val _currentLinks = MutableLiveData<Set<Pair<ExtractorLink?, ExtractorUri?>>>(setOf())
|
@Volatile
|
||||||
val currentLinks: LiveData<Set<Pair<ExtractorLink?, ExtractorUri?>>> = _currentLinks
|
var episodeIndex: Int = 0
|
||||||
|
|
||||||
private val _currentSubs = MutableLiveData<Set<SubtitleData>>(setOf())
|
/**
|
||||||
val currentSubs: LiveData<Set<SubtitleData>> = _currentSubs
|
* The state of the video player, only modify it by modifyState to make sure observe is called,
|
||||||
|
* and avoid concurrency issues.
|
||||||
|
*
|
||||||
|
* This value can be used without Synchronized or locking when reading, as all fields are immutable.
|
||||||
|
* */
|
||||||
|
@Volatile
|
||||||
|
var state = VideoState(instance = 0)
|
||||||
|
private set
|
||||||
|
|
||||||
private val _loadingLinks = MutableLiveData<Resource<Boolean?>>()
|
private val _currentLinks =
|
||||||
val loadingLinks: LiveData<Resource<Boolean?>> = _loadingLinks
|
MutableLiveData<VideoLive<Set<Pair<ExtractorLink?, ExtractorUri?>>>>(null)
|
||||||
|
val currentLinks: LiveData<VideoLive<Set<Pair<ExtractorLink?, ExtractorUri?>>>> = _currentLinks
|
||||||
|
|
||||||
private val _currentStamps = MutableLiveData<List<EpisodeSkip.SkipStamp>>(emptyList())
|
private val _currentSubtitles = MutableLiveData<VideoLive<Set<SubtitleData>>>(null)
|
||||||
val currentStamps: LiveData<List<EpisodeSkip.SkipStamp>> = _currentStamps
|
val currentSubtitles: LiveData<VideoLive<Set<SubtitleData>>> = _currentSubtitles
|
||||||
|
|
||||||
|
private val _loadingLinks = MutableLiveData<VideoLive<Resource<Unit>>>()
|
||||||
|
val loadingLinks: LiveData<VideoLive<Resource<Unit>>> = _loadingLinks
|
||||||
|
|
||||||
|
private val _currentStamps = MutableLiveData<VideoLive<List<VideoSkipStamp>>>(null)
|
||||||
|
val currentStamps: LiveData<VideoLive<List<VideoSkipStamp>>> = _currentStamps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifies the `state` variable safely, and with the correct observe behavior.
|
||||||
|
*
|
||||||
|
* Synchronized to avoid concurrency issues, and make this operation atomic.
|
||||||
|
* Otherwise, one update may be lost if they are done in parallel.
|
||||||
|
* */
|
||||||
|
@Synchronized
|
||||||
|
fun modifyState(op: VideoState.() -> VideoState) {
|
||||||
|
val oldState = state
|
||||||
|
state = op.invoke(oldState)
|
||||||
|
|
||||||
|
/** New instance, always push state */
|
||||||
|
if (state.instance != oldState.instance) {
|
||||||
|
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
|
||||||
|
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
|
||||||
|
_currentLinks.postValue(VideoLive(state.links, state.instance))
|
||||||
|
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only post the changed values, this makes sure we do not invoke the "observe"
|
||||||
|
*
|
||||||
|
* We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality
|
||||||
|
* to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged.
|
||||||
|
* */
|
||||||
|
if (state.links !== oldState.links)
|
||||||
|
_currentLinks.postValue(VideoLive(state.links, state.instance))
|
||||||
|
if (state.stamps !== oldState.stamps)
|
||||||
|
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
|
||||||
|
if (state.subtitles !== oldState.subtitles)
|
||||||
|
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
|
||||||
|
|
||||||
|
/** Normal equality here as it is not a collection */
|
||||||
|
if (state.loading != oldState.loading)
|
||||||
|
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
|
||||||
|
}
|
||||||
|
|
||||||
private val _currentSubtitleYear = MutableLiveData<Int?>(null)
|
private val _currentSubtitleYear = MutableLiveData<Int?>(null)
|
||||||
val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear
|
val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear
|
||||||
|
|
@ -52,41 +206,32 @@ class PlayerGeneratorViewModel : ViewModel() {
|
||||||
_currentSubtitleYear.postValue(year)
|
_currentSubtitleYear.postValue(year)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getId(): Int? {
|
|
||||||
return generator?.getCurrentId()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadLinks(episode: Int) {
|
|
||||||
generator?.goto(episode)
|
|
||||||
loadLinks()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadLinksPrev() {
|
fun loadLinksPrev() {
|
||||||
Log.i(TAG, "loadLinksPrev")
|
Log.i(TAG, "loadLinksPrev")
|
||||||
if (generator?.hasPrev() == true) {
|
if (generator?.hasPrev(episodeIndex) == true) {
|
||||||
generator?.prev()
|
episodeIndex += 1
|
||||||
loadLinks()
|
loadLinks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLinksNext() {
|
fun loadLinksNext() {
|
||||||
Log.i(TAG, "loadLinksNext")
|
Log.i(TAG, "loadLinksNext")
|
||||||
if (generator?.hasNext() == true) {
|
if (generator?.hasNext(episodeIndex) == true) {
|
||||||
generator?.next()
|
episodeIndex += 1
|
||||||
loadLinks()
|
loadLinks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasNextEpisode(): Boolean? {
|
fun hasNextEpisode(): Boolean? {
|
||||||
return generator?.hasNext()
|
return generator?.hasNext(episodeIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasPrevEpisode(): Boolean? {
|
fun hasPrevEpisode(): Boolean? {
|
||||||
return generator?.hasPrev()
|
return generator?.hasPrev(episodeIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun preLoadNextLinks() {
|
fun preLoadNextLinks() {
|
||||||
val id = getId()
|
val id = generator?.getId(episodeIndex)
|
||||||
// Do not preload if already loading
|
// Do not preload if already loading
|
||||||
if (id == currentLoadingEpisodeId) return
|
if (id == currentLoadingEpisodeId) return
|
||||||
|
|
||||||
|
|
@ -96,14 +241,15 @@ class PlayerGeneratorViewModel : ViewModel() {
|
||||||
|
|
||||||
currentJob = viewModelScope.launch {
|
currentJob = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (generator?.hasCache == true && generator?.hasNext() == true) {
|
if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) {
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
generator?.generateLinks(
|
generator?.generateLinks(
|
||||||
sourceTypes = LOADTYPE_INAPP,
|
sourceTypes = LOADTYPE_INAPP,
|
||||||
clearCache = false,
|
clearCache = false,
|
||||||
|
isCasting = false,
|
||||||
callback = {},
|
callback = {},
|
||||||
subtitleCallback = {},
|
subtitleCallback = {},
|
||||||
offset = 1
|
offset = episodeIndex + 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,129 +263,137 @@ class PlayerGeneratorViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLoadResponse(): LoadResponse? {
|
fun loadThisEpisode(index: Int) {
|
||||||
return safe { (generator as? RepoLinkGenerator?)?.page }
|
episodeIndex = index
|
||||||
}
|
|
||||||
|
|
||||||
fun getMeta(): Any? {
|
|
||||||
return safe { generator?.getCurrent() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllMeta(): List<Any>? {
|
|
||||||
return safe { generator?.getAll() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getNextMeta(): Any? {
|
|
||||||
return safe {
|
|
||||||
if (generator?.hasNext() == false) return@safe null
|
|
||||||
generator?.getCurrent(offset = 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadThisEpisode(index:Int) {
|
|
||||||
generator?.goto(index)
|
|
||||||
loadLinks()
|
loadLinks()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentIndex():Int?{
|
fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) {
|
||||||
val repoGen = generator as? RepoLinkGenerator ?: return null
|
Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index")
|
||||||
return repoGen.videoIndex
|
generator = newGenerator
|
||||||
|
episodeIndex = index
|
||||||
}
|
}
|
||||||
|
|
||||||
fun attachGenerator(newGenerator: IGenerator?) {
|
|
||||||
if (generator == null) {
|
|
||||||
generator = newGenerator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var extraSubtitles : MutableSet<SubtitleData> = mutableSetOf()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If duplicate nothing will happen
|
* If duplicate nothing will happen
|
||||||
* */
|
* */
|
||||||
fun addSubtitles(file: Set<SubtitleData>) = synchronized(extraSubtitles) {
|
fun addSubtitles(file: Set<SubtitleData>) {
|
||||||
extraSubtitles += file
|
val validFile = file.filter(::isValidSubtitle)
|
||||||
val current = _currentSubs.value ?: emptySet()
|
if (validFile.isNotEmpty())
|
||||||
val next = extraSubtitles + current
|
modifyState {
|
||||||
|
add(validFile)
|
||||||
// if it is of a different size then we have added distinct items
|
}
|
||||||
if (next.size != current.size) {
|
|
||||||
// Posting will refresh subtitles which will in turn
|
|
||||||
// make the subs to english if previously unselected
|
|
||||||
_currentSubs.postValue(next)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentJob: Job? = null
|
private var currentJob: Job? = null
|
||||||
private var currentStampJob: Job? = null
|
private var currentStampJob: Job? = null
|
||||||
|
|
||||||
fun loadStamps(duration: Long) {
|
fun loadStamps(duration: Long) {
|
||||||
//currentStampJob?.cancel()
|
|
||||||
currentStampJob = ioSafe {
|
currentStampJob = ioSafe {
|
||||||
val meta = generator?.getCurrent()
|
val genState = state.generatorState ?: return@ioSafe
|
||||||
val page = (generator as? RepoLinkGenerator?)?.page
|
val meta = genState.meta
|
||||||
if (page != null && meta is ResultEpisode) {
|
val page = genState.response
|
||||||
_currentStamps.postValue(listOf())
|
val id = genState.id
|
||||||
_currentStamps.postValue(
|
if (page == null || meta !is ResultEpisode) {
|
||||||
EpisodeSkip.getStamps(
|
return@ioSafe
|
||||||
page,
|
|
||||||
meta,
|
|
||||||
duration,
|
|
||||||
hasNextEpisode() ?: false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val stamps = SkipAPI.videoStamps(
|
||||||
|
page,
|
||||||
|
meta,
|
||||||
|
duration,
|
||||||
|
hasNextEpisode() ?: false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Avoid adding stamps to the wrong video */
|
||||||
|
modifyState {
|
||||||
|
if (id != this.generatorState?.id) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
set(stamps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var langFilterList = listOf<String>()
|
||||||
|
var filterSubByLang = false
|
||||||
|
|
||||||
|
fun isValidSubtitle(subtitle: SubtitleData): Boolean {
|
||||||
|
if (langFilterList.isEmpty() || !filterSubByLang) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only filter out subtitles fetched online */
|
||||||
|
if (subtitle.origin != SubtitleOrigin.URL) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return langFilterList.any { lang ->
|
||||||
|
subtitle.originalName.contains(lang, ignoreCase = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLinks(sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP) {
|
fun loadLinks(sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP) {
|
||||||
Log.i(TAG, "loadLinks")
|
Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex")
|
||||||
currentJob?.cancel()
|
currentJob?.cancel()
|
||||||
|
val index = episodeIndex
|
||||||
|
|
||||||
|
// Clear old data and reset the state
|
||||||
|
modifyState {
|
||||||
|
VideoState(
|
||||||
|
loading = Resource.Loading(),
|
||||||
|
generatorState = generator?.let { gen ->
|
||||||
|
GeneratorState(
|
||||||
|
meta = gen.videos.getOrNull(index),
|
||||||
|
nextMeta = gen.videos.getOrNull(index + 1),
|
||||||
|
id = gen.getId(index),
|
||||||
|
response = (gen as? RepoLinkGenerator)?.page,
|
||||||
|
index = index,
|
||||||
|
allMeta = gen.videos
|
||||||
|
)
|
||||||
|
},
|
||||||
|
instance = instance + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
currentJob = viewModelScope.launchSafe {
|
currentJob = viewModelScope.launchSafe {
|
||||||
// if we load links then we clear the prev loaded links
|
// Load more data
|
||||||
synchronized(extraSubtitles) {
|
|
||||||
extraSubtitles.clear()
|
|
||||||
}
|
|
||||||
val currentLinks = mutableSetOf<Pair<ExtractorLink?, ExtractorUri?>>()
|
|
||||||
val currentSubs = mutableSetOf<SubtitleData>()
|
|
||||||
|
|
||||||
// clear old data
|
|
||||||
_currentSubs.postValue(emptySet())
|
|
||||||
_currentLinks.postValue(emptySet())
|
|
||||||
|
|
||||||
// load more data
|
|
||||||
_loadingLinks.postValue(Resource.Loading())
|
|
||||||
val loadingState = safeApiCall {
|
val loadingState = safeApiCall {
|
||||||
generator?.generateLinks(
|
generator?.generateLinks(
|
||||||
sourceTypes = sourceTypes,
|
sourceTypes = sourceTypes,
|
||||||
clearCache = forceClearCache,
|
clearCache = forceClearCache,
|
||||||
callback = {
|
callback = { link ->
|
||||||
synchronized(currentLinks) {
|
if (isActive)
|
||||||
currentLinks.add(it)
|
modifyState {
|
||||||
// Clone to prevent ConcurrentModificationException
|
add(link)
|
||||||
safe {
|
|
||||||
// Extra safe since .toSet() iterates.
|
|
||||||
_currentLinks.postValue(currentLinks.toSet())
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
subtitleCallback = {
|
isCasting = false,
|
||||||
synchronized(extraSubtitles) {
|
offset = index,
|
||||||
currentSubs.add(it)
|
subtitleCallback = { link ->
|
||||||
safe {
|
if (isActive && isValidSubtitle(link))
|
||||||
_currentSubs.postValue(currentSubs + extraSubtitles)
|
modifyState {
|
||||||
|
add(link)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadingLinks.postValue(loadingState)
|
if (!isActive) {
|
||||||
_currentLinks.postValue(currentLinks)
|
return@launchSafe
|
||||||
synchronized(extraSubtitles) {
|
}
|
||||||
_currentSubs.postValue(currentSubs + extraSubtitles)
|
|
||||||
|
/** Only mark as success if we have not skipped loading */
|
||||||
|
modifyState {
|
||||||
|
if (!isActive) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
when (loading) {
|
||||||
|
is Resource.Loading -> copy(loading = loadingState)
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -128,7 +128,7 @@ object PlayerPipHelper {
|
||||||
getRemoteAction(
|
getRemoteAction(
|
||||||
activity,
|
activity,
|
||||||
R.drawable.baseline_headphones_24,
|
R.drawable.baseline_headphones_24,
|
||||||
R.string.audio_singluar,
|
R.string.audio_singular,
|
||||||
CSPlayerEvent.PlayAsAudio
|
CSPlayerEvent.PlayAsAudio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.media3.ui.SubtitleView
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
import com.lagradost.cloudstream3.SubtitleFile
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle
|
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle
|
||||||
|
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
|
|
||||||
enum class SubtitleStatus {
|
enum class SubtitleStatus {
|
||||||
|
|
@ -47,6 +48,16 @@ data class SubtitleData(
|
||||||
else "$url|$name"
|
else "$url|$name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true if langCode is the same as the IETF tag */
|
||||||
|
fun matchesLanguageCode(langCode: String): Boolean {
|
||||||
|
return getIETF_tag() == langCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */
|
||||||
|
fun getIETF_tag(): String? {
|
||||||
|
return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true)
|
||||||
|
}
|
||||||
|
|
||||||
val name = "$originalName $nameSuffix"
|
val name = "$originalName $nameSuffix"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,842 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
|
import android.graphics.drawable.AnimatedVectorDrawable
|
||||||
|
import android.media.metrics.PlaybackErrorEvent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.RelativeLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.DefaultTimeBar
|
||||||
|
import androidx.media3.ui.SubtitleView
|
||||||
|
import androidx.media3.ui.TimeBar
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||||
|
import com.github.rubensousa.previewseekbar.PreviewBar
|
||||||
|
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
|
import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||||
|
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
|
||||||
|
import com.lagradost.cloudstream3.utils.UserPreferenceDelegate
|
||||||
|
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event
|
||||||
|
* dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper]
|
||||||
|
* ([PlayerGestureHelper]), which is exposed via delegate properties for easier access.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class PlayerView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : FrameLayout(context, attrs) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PlayerView"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All gesture, volume, brightness and key-event logic lives here. */
|
||||||
|
val gestureHelper = PlayerGestureHelper(this)
|
||||||
|
|
||||||
|
/** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */
|
||||||
|
var isFullScreen: Boolean
|
||||||
|
get() = gestureHelper.isFullScreen
|
||||||
|
set(value) { gestureHelper.isFullScreen = value }
|
||||||
|
|
||||||
|
var isLocked: Boolean
|
||||||
|
get() = gestureHelper.isLocked
|
||||||
|
set(value) { gestureHelper.isLocked = value }
|
||||||
|
|
||||||
|
var videoOutline: View?
|
||||||
|
get() = gestureHelper.videoOutline
|
||||||
|
set(value) { gestureHelper.videoOutline = value }
|
||||||
|
|
||||||
|
/** Delegate methods */
|
||||||
|
fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode)
|
||||||
|
fun verifyVolume() = gestureHelper.verifyVolume()
|
||||||
|
fun setupKeyEventListener() = gestureHelper.setupKeyEventListener()
|
||||||
|
fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener()
|
||||||
|
fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout()
|
||||||
|
fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener()
|
||||||
|
|
||||||
|
/** Callbacks */
|
||||||
|
|
||||||
|
/** Host-fragment-level callbacks invoked by [mainCallback]. */
|
||||||
|
interface Callbacks {
|
||||||
|
fun nextEpisode() {}
|
||||||
|
fun prevEpisode() {}
|
||||||
|
fun playerPositionChanged(position: Long, duration: Long) {}
|
||||||
|
fun playerStatusChanged() {}
|
||||||
|
fun playerDimensionsLoaded(width: Int, height: Int) {}
|
||||||
|
fun subtitlesChanged() {}
|
||||||
|
fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
|
||||||
|
fun onTracksInfoChanged() {}
|
||||||
|
fun onTimestamp(timestamp: VideoSkipStamp?) {}
|
||||||
|
fun onTimestampSkipped(timestamp: VideoSkipStamp) {}
|
||||||
|
fun exitedPipMode() {}
|
||||||
|
fun hasNextMirror(): Boolean = false
|
||||||
|
fun nextMirror() {}
|
||||||
|
fun onDownload(event: DownloadEvent) {}
|
||||||
|
fun playerError(exception: Throwable) {}
|
||||||
|
/** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */
|
||||||
|
fun playerUpdated(player: Any?) {}
|
||||||
|
/** Called on a short single-tap on empty player area (no swipe, no double-tap). */
|
||||||
|
fun onSingleTap() {}
|
||||||
|
/** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */
|
||||||
|
fun onHoldSpeedUp(show: Boolean) {}
|
||||||
|
/** Called during brightness swipe with the current extra-brightness alpha (0–1). */
|
||||||
|
fun onBrightnessExtra(alpha: Float) {}
|
||||||
|
|
||||||
|
/** Touch event callbacks */
|
||||||
|
|
||||||
|
/** Returns whether the player UI (controls overlay) is currently visible. */
|
||||||
|
fun isUIShowing(): Boolean = false
|
||||||
|
/** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */
|
||||||
|
fun onTouchDown() {}
|
||||||
|
/** Called with seek-preview text during a horizontal-swipe, or null to clear it. */
|
||||||
|
fun onSeekPreviewText(text: String?) {}
|
||||||
|
/** Called when a swipe gesture begins; hide the player UI if desired. */
|
||||||
|
fun onHidePlayerUI() {}
|
||||||
|
/**
|
||||||
|
* Called at the end of each touch sequence.
|
||||||
|
* @param hadSwipe true if a swipe (brightness/volume/time) was in progress.
|
||||||
|
* @param wasUiShowing true if the UI was visible when the swipe began.
|
||||||
|
*/
|
||||||
|
fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {}
|
||||||
|
/**
|
||||||
|
* Called when the auto-hide timer fires: UI is showing, no touch is active.
|
||||||
|
* Implement to hide the player controls.
|
||||||
|
*/
|
||||||
|
fun onAutoHideUI() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var callbacks: Callbacks? = null
|
||||||
|
|
||||||
|
/** Player state */
|
||||||
|
|
||||||
|
var player: IPlayer = CS3IPlayer()
|
||||||
|
var resizeMode: Int = 0
|
||||||
|
var hasPipModeSupport: Boolean = true
|
||||||
|
var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering
|
||||||
|
var mMediaSession: MediaSession? = null
|
||||||
|
private var pipReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
/** Auto-hide */
|
||||||
|
private var autoHideToken = 0
|
||||||
|
private val autoHideHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
/** View references (populated by bindViews) */
|
||||||
|
|
||||||
|
var subView: SubtitleView? = null
|
||||||
|
var playerPausePlayHolderHolder: FrameLayout? = null
|
||||||
|
var playerPausePlay: ImageView? = null
|
||||||
|
var playerBuffering: ProgressBar? = null
|
||||||
|
/** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */
|
||||||
|
var exoPlayerView: androidx.media3.ui.PlayerView? = null
|
||||||
|
var piphide: FrameLayout? = null
|
||||||
|
var subtitleHolder: FrameLayout? = null
|
||||||
|
internal var playerRew: View? = null
|
||||||
|
internal var playerFfwd: View? = null
|
||||||
|
internal var exoRewText: TextView? = null
|
||||||
|
internal var exoFfwdText: TextView? = null
|
||||||
|
internal var playerCenterMenu: View? = null
|
||||||
|
internal var playerRewHolder: View? = null
|
||||||
|
internal var playerFfwdHolder: View? = null
|
||||||
|
internal var playerVideoHolder: View? = null
|
||||||
|
var playerProgressbarLeftHolder: RelativeLayout? = null
|
||||||
|
var playerProgressbarLeftIcon: ImageView? = null
|
||||||
|
var playerProgressbarLeftLevel1: ProgressBar? = null
|
||||||
|
var playerProgressbarLeftLevel2: ProgressBar? = null
|
||||||
|
var playerProgressbarRightHolder: RelativeLayout? = null
|
||||||
|
var playerProgressbarRightIcon: ImageView? = null
|
||||||
|
var playerProgressbarRightLevel1: ProgressBar? = null
|
||||||
|
var playerProgressbarRightLevel2: ProgressBar? = null
|
||||||
|
/** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */
|
||||||
|
internal var playerSpeedupButton: View? = null
|
||||||
|
var playerHolder: FrameLayout? = null
|
||||||
|
private var exoDuration: TextView? = null
|
||||||
|
private var timeLeft: TextView? = null
|
||||||
|
private var exoPosition: TextView? = null
|
||||||
|
private var timeLive: View? = null
|
||||||
|
private var exoProgress: LivePreviewTimeBar? = null
|
||||||
|
|
||||||
|
/** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */
|
||||||
|
var seekTime: Long = 10_000L
|
||||||
|
|
||||||
|
/** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */
|
||||||
|
var isVerticalOrientation: Boolean = false
|
||||||
|
|
||||||
|
/** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */
|
||||||
|
var autoPlayerRotateEnabled: Boolean = false
|
||||||
|
|
||||||
|
var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false)
|
||||||
|
|
||||||
|
// Kept so SubtitlesFragment can unsubscribe the exact same reference.
|
||||||
|
private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged
|
||||||
|
|
||||||
|
/** View discovery */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply
|
||||||
|
* remain null, all usage is null-safe.
|
||||||
|
*/
|
||||||
|
fun bindViews(root: View) {
|
||||||
|
exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration)
|
||||||
|
exoFfwdText = root.findViewById(R.id.exo_ffwd_text)
|
||||||
|
exoPlayerView = root.findViewById(R.id.player_view)
|
||||||
|
exoPosition = root.findViewById(R.id.exo_position)
|
||||||
|
exoRewText = root.findViewById(R.id.exo_rew_text)
|
||||||
|
piphide = root.findViewById(R.id.piphide)
|
||||||
|
playerBuffering = root.findViewById(R.id.player_buffering)
|
||||||
|
playerCenterMenu = root.findViewById(R.id.player_center_menu)
|
||||||
|
playerFfwd = root.findViewById(R.id.player_ffwd)
|
||||||
|
playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder)
|
||||||
|
playerHolder = root.findViewById(R.id.player_holder)
|
||||||
|
playerPausePlay = root.findViewById(R.id.player_pause_play)
|
||||||
|
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
|
||||||
|
playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder)
|
||||||
|
playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon)
|
||||||
|
playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1)
|
||||||
|
playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2)
|
||||||
|
playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder)
|
||||||
|
playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon)
|
||||||
|
playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1)
|
||||||
|
playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2)
|
||||||
|
playerRew = root.findViewById(R.id.player_rew)
|
||||||
|
playerRewHolder = root.findViewById(R.id.player_rew_holder)
|
||||||
|
playerSpeedupButton = root.findViewById(R.id.player_speedup_button)
|
||||||
|
playerVideoHolder = root.findViewById(R.id.player_video_holder)
|
||||||
|
subtitleHolder = root.findViewById(R.id.subtitle_holder)
|
||||||
|
timeLeft = root.findViewById(R.id.time_left)
|
||||||
|
timeLive = root.findViewById(R.id.time_live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener,
|
||||||
|
* player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper].
|
||||||
|
*/
|
||||||
|
fun initialize() {
|
||||||
|
resizeMode = DataStoreHelper.resizeMode
|
||||||
|
resize(resizeMode, false)
|
||||||
|
|
||||||
|
player.releaseCallbacks()
|
||||||
|
player.initCallbacks(
|
||||||
|
eventHandler = ::mainCallback,
|
||||||
|
requestedListeningPercentages = listOf(
|
||||||
|
SKIP_OP_VIDEO_PERCENTAGE,
|
||||||
|
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
||||||
|
NEXT_WATCH_EPISODE_PERCENTAGE,
|
||||||
|
UPDATE_SYNC_PROGRESS_PERCENTAGE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (player is CS3IPlayer) {
|
||||||
|
// Preview bar
|
||||||
|
val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress)
|
||||||
|
exoProgress = progressBar as? LivePreviewTimeBar
|
||||||
|
val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView)
|
||||||
|
val previewFrameLayout: FrameLayout? =
|
||||||
|
exoPlayerView?.findViewById(R.id.previewFrameLayout)
|
||||||
|
|
||||||
|
/** Hide the previewFrameLayout on TV to make the skip op button not float,
|
||||||
|
* as previewFrameLayout is normally invisible */
|
||||||
|
if(isLayout(TV)) {
|
||||||
|
previewFrameLayout?.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) {
|
||||||
|
var resume = false
|
||||||
|
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
|
||||||
|
override fun onScrubStart(previewBar: PreviewBar?) {
|
||||||
|
val cs3 = player as? CS3IPlayer ?: return
|
||||||
|
val hasPreview = cs3.hasPreview()
|
||||||
|
progressBar.isPreviewEnabled = hasPreview
|
||||||
|
resume = cs3.getIsPlaying()
|
||||||
|
if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
|
||||||
|
// No clashing UI
|
||||||
|
if (hasPreview) subView?.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {}
|
||||||
|
|
||||||
|
override fun onScrubStop(previewBar: PreviewBar?) {
|
||||||
|
val cs3 = player as? CS3IPlayer ?: return
|
||||||
|
if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
|
||||||
|
// Delay to prevent the small flicker of subtitle before seeking.
|
||||||
|
subView?.postDelayed({
|
||||||
|
// If we are not scrubbing then show subtitles again.
|
||||||
|
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
|
||||||
|
subView?.isVisible = true
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
progressBar.attachPreviewView(previewFrameLayout)
|
||||||
|
progressBar.setPreviewLoader { currentPosition, max ->
|
||||||
|
val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader
|
||||||
|
val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat()))
|
||||||
|
previewImageView.isGone = bitmap == null
|
||||||
|
previewImageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
|
||||||
|
(player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
|
||||||
|
(player as? CS3IPlayer)?.let {
|
||||||
|
(it.imageGenerator as? PreviewGenerator)?.params =
|
||||||
|
ImageParams.new16by9(screenWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
|
||||||
|
* and once by the UI even if it should only be registered once by the UI.
|
||||||
|
*/
|
||||||
|
exoPlayerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
|
||||||
|
?.addListener(object : TimeBar.OnScrubListener {
|
||||||
|
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
|
||||||
|
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
|
||||||
|
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
||||||
|
if (canceled) return
|
||||||
|
val playerDuration = player.getDuration() ?: return
|
||||||
|
val playerPosition = player.getPosition() ?: return
|
||||||
|
mainCallback(
|
||||||
|
PositionEvent(
|
||||||
|
source = PlayerEventSource.UI,
|
||||||
|
durationMs = playerDuration,
|
||||||
|
fromMs = playerPosition,
|
||||||
|
toMs = position
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Read seek time and rotation settings.
|
||||||
|
try {
|
||||||
|
val sm = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10)
|
||||||
|
.toLong() * 1000L
|
||||||
|
autoPlayerRotateEnabled = sm.getBoolean(
|
||||||
|
context.getString(R.string.auto_rotate_video_key), true
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
val seekSecs = (seekTime / 1000).toInt()
|
||||||
|
exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs)
|
||||||
|
exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs)
|
||||||
|
|
||||||
|
playerPausePlay?.setOnClickListener {
|
||||||
|
scheduleAutoHide()
|
||||||
|
if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
|
||||||
|
player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI)
|
||||||
|
} else {
|
||||||
|
player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playerRew?.setOnClickListener {
|
||||||
|
scheduleAutoHide()
|
||||||
|
gestureHelper.rewind()
|
||||||
|
}
|
||||||
|
playerFfwd?.setOnClickListener {
|
||||||
|
scheduleAutoHide()
|
||||||
|
gestureHelper.fastForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitlesFragment.applyStyleEvent += subStyleListener
|
||||||
|
|
||||||
|
try {
|
||||||
|
val ctx = context
|
||||||
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
val cs3 = player as? CS3IPlayer ?: return
|
||||||
|
cs3.cacheSize =
|
||||||
|
settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L
|
||||||
|
cs3.simpleCacheSize =
|
||||||
|
settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L
|
||||||
|
cs3.videoBufferMs =
|
||||||
|
settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration toggle click listeners
|
||||||
|
exoDuration?.setOnClickListener { setRemainingTimeCounter(true) }
|
||||||
|
timeLeft?.setOnClickListener { setRemainingTimeCounter(false) }
|
||||||
|
// Keep remaining-time text in sync with playback position
|
||||||
|
exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() }
|
||||||
|
|
||||||
|
// Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener)
|
||||||
|
gestureHelper.initialize()
|
||||||
|
setupKeyEventListener()
|
||||||
|
|
||||||
|
// Apply duration-mode display (remaining time vs elapsed); TV always shows remaining
|
||||||
|
setRemainingTimeCounter(durationMode || isLayout(TV))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle delegation */
|
||||||
|
|
||||||
|
var fullscreenNotch: Boolean = true // TODO SETTING
|
||||||
|
|
||||||
|
fun enterFullscreen(updateOrientation: () -> Unit = {}) {
|
||||||
|
val activity = context as? Activity
|
||||||
|
if (isFullScreen) {
|
||||||
|
activity?.hideSystemUI()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) {
|
||||||
|
val params = activity?.window?.attributes
|
||||||
|
params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
activity?.window?.attributes = params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exitFullscreen() {
|
||||||
|
val activity = context as? Activity
|
||||||
|
gestureHelper.resetZoomToDefault()
|
||||||
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
|
||||||
|
// Simply resets brightness and notch settings that might have been overridden.
|
||||||
|
val lp = activity?.window?.attributes
|
||||||
|
lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||||
|
}
|
||||||
|
activity?.window?.attributes = lp
|
||||||
|
activity?.showSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStop() {
|
||||||
|
player.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume(ctx: Context) {
|
||||||
|
player.onResume(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases all player resources. */
|
||||||
|
fun release() {
|
||||||
|
player.release()
|
||||||
|
player.releaseCallbacks()
|
||||||
|
player = CS3IPlayer()
|
||||||
|
|
||||||
|
// keyEventListener is deregistered in onPause so that the incoming player's
|
||||||
|
// onResume can register its own listener without racing against release().
|
||||||
|
|
||||||
|
PlayerPipHelper.updatePIPModeActions(
|
||||||
|
context as? Activity,
|
||||||
|
CSPlayerLoading.IsPaused,
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
mMediaSession?.release()
|
||||||
|
mMediaSession = null
|
||||||
|
exoPlayerView?.player = null
|
||||||
|
|
||||||
|
SubtitlesFragment.applyStyleEvent -= subStyleListener
|
||||||
|
|
||||||
|
gestureHelper.release()
|
||||||
|
autoHideHandler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
|
keepScreenOn(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPictureInPictureModeChanged(
|
||||||
|
isInPictureInPictureMode: Boolean,
|
||||||
|
activity: Activity?
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
isInPIPMode = isInPictureInPictureMode
|
||||||
|
if (isInPictureInPictureMode) {
|
||||||
|
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
|
||||||
|
piphide?.isVisible = false
|
||||||
|
pipReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (ACTION_MEDIA_CONTROL != intent.action) return
|
||||||
|
player.handleEvent(
|
||||||
|
CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)],
|
||||||
|
source = PlayerEventSource.UI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
|
activity?.registerReceiver(pipReceiver, filter)
|
||||||
|
}
|
||||||
|
val isPlaying = player.getIsPlaying()
|
||||||
|
val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
||||||
|
updateIsPlaying(status, status)
|
||||||
|
} else {
|
||||||
|
// Restore the full-screen UI.
|
||||||
|
piphide?.isVisible = true
|
||||||
|
callbacks?.exitedPipMode()
|
||||||
|
pipReceiver?.let {
|
||||||
|
// Prevents java.lang.IllegalArgumentException: Receiver not registered
|
||||||
|
safe { activity?.unregisterReceiver(it) }
|
||||||
|
}
|
||||||
|
activity?.hideSystemUI()
|
||||||
|
hideKeyboard(this)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player UI helpers */
|
||||||
|
|
||||||
|
private fun keepScreenOn(on: Boolean) {
|
||||||
|
val window = (context as? Activity)?.window ?: return
|
||||||
|
if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) {
|
||||||
|
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
||||||
|
val isBuffering = CSPlayerLoading.IsBuffering == isPlaying
|
||||||
|
currentPlayerStatus = isPlaying
|
||||||
|
|
||||||
|
keepScreenOn(isPlayingRightNow || isBuffering)
|
||||||
|
|
||||||
|
if (isBuffering) {
|
||||||
|
playerPausePlayHolderHolder?.isVisible = false
|
||||||
|
playerBuffering?.isVisible = true
|
||||||
|
} else {
|
||||||
|
playerPausePlayHolderHolder?.isVisible = true
|
||||||
|
playerBuffering?.isVisible = false
|
||||||
|
|
||||||
|
if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
|
||||||
|
playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24)
|
||||||
|
} else if (wasPlaying != isPlaying) {
|
||||||
|
playerPausePlay?.setImageResource(
|
||||||
|
if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play
|
||||||
|
)
|
||||||
|
val drawable = playerPausePlay?.drawable
|
||||||
|
var startedAnimation = false
|
||||||
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||||
|
if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true }
|
||||||
|
}
|
||||||
|
if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true }
|
||||||
|
if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true }
|
||||||
|
// Somehow the phone is wacked
|
||||||
|
if (!startedAnimation) {
|
||||||
|
playerPausePlay?.setImageResource(
|
||||||
|
if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerPausePlay?.setImageResource(
|
||||||
|
if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerPipHelper.updatePIPModeActions(
|
||||||
|
context as? Activity,
|
||||||
|
isPlaying,
|
||||||
|
hasPipModeSupport,
|
||||||
|
player.getAspectRatio()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
(context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playerUpdated(player: Any?) {
|
||||||
|
if (player is ExoPlayer) {
|
||||||
|
mMediaSession?.release()
|
||||||
|
mMediaSession = MediaSession.Builder(context, player)
|
||||||
|
// Ensure unique ID for concurrent players.
|
||||||
|
.setId(System.currentTimeMillis().toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Necessary for multiple combined videos.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
exoPlayerView?.setShowMultiWindowTimeBar(true)
|
||||||
|
exoPlayerView?.player = player
|
||||||
|
exoPlayerView?.performClick()
|
||||||
|
}
|
||||||
|
callbacks?.playerUpdated(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSubStyleChanged(style: SaveCaptionStyle) {
|
||||||
|
player.updateSubtitleStyle(style)
|
||||||
|
// Forcefully update the subtitle encoding in case the edge size is changed.
|
||||||
|
player.seekTime(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error handling */
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
fun playerError(exception: Throwable) {
|
||||||
|
fun showErrorToast(message: String) {
|
||||||
|
if (callbacks?.hasNextMirror() == true) {
|
||||||
|
showToast(message, Toast.LENGTH_SHORT)
|
||||||
|
callbacks?.nextMirror()
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
context.getString(R.string.no_links_found_toast) + "\n" + message,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
)
|
||||||
|
(context as? FragmentActivity)?.popCurrentPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (exception) {
|
||||||
|
is PlaybackException -> {
|
||||||
|
val msg = exception.message ?: ""
|
||||||
|
val errorName = exception.errorCodeName
|
||||||
|
when (val code = exception.errorCode) {
|
||||||
|
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||||
|
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||||
|
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||||
|
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED ->
|
||||||
|
showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg")
|
||||||
|
|
||||||
|
PlaybackException.ERROR_CODE_REMOTE_ERROR,
|
||||||
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||||
|
PlaybackException.ERROR_CODE_TIMEOUT,
|
||||||
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||||
|
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
|
||||||
|
showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg")
|
||||||
|
|
||||||
|
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
|
||||||
|
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
|
||||||
|
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED ->
|
||||||
|
showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg")
|
||||||
|
|
||||||
|
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||||
|
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES ->
|
||||||
|
showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg")
|
||||||
|
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED ->
|
||||||
|
showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg")
|
||||||
|
|
||||||
|
else ->
|
||||||
|
showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SocketTimeoutException ->
|
||||||
|
showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}")
|
||||||
|
|
||||||
|
is ErrorLoadingException ->
|
||||||
|
exception.message?.let { showErrorToast(it) }
|
||||||
|
?: showErrorToast(exception.toString())
|
||||||
|
|
||||||
|
else ->
|
||||||
|
exception.message?.let { showErrorToast(it) }
|
||||||
|
?: showErrorToast(exception.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize */
|
||||||
|
|
||||||
|
fun nextResize() {
|
||||||
|
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
|
||||||
|
resize(resizeMode, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resize(resize: Int, showToast: Boolean) {
|
||||||
|
// Clear all zoom state before applying the new resize mode
|
||||||
|
gestureHelper.clearZoomState()
|
||||||
|
resize(PlayerResize.entries[resize], showToast)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||||
|
DataStoreHelper.resizeMode = resize.ordinal
|
||||||
|
val type = when (resize) {
|
||||||
|
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||||
|
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||||
|
}
|
||||||
|
exoPlayerView?.resizeMode = type
|
||||||
|
if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orientation */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation]
|
||||||
|
* and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape.
|
||||||
|
* Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation.
|
||||||
|
*/
|
||||||
|
fun dynamicOrientation(): Int {
|
||||||
|
if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
return if (autoPlayerRotateEnabled && isVerticalOrientation)
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
|
||||||
|
else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event dispatch */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This receives the events from the player, if you want to append functionality
|
||||||
|
* you do it here, do note that this only receives events for UI changes,
|
||||||
|
* and returning early WON'T stop it from changing in e.g. the player time
|
||||||
|
* or pause status.
|
||||||
|
*/
|
||||||
|
@MainThread
|
||||||
|
fun mainCallback(event: PlayerEvent) {
|
||||||
|
// We don't want to spam DownloadEvent.
|
||||||
|
if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event")
|
||||||
|
when (event) {
|
||||||
|
is DownloadEvent -> callbacks?.onDownload(event)
|
||||||
|
is ResizedEvent -> {
|
||||||
|
// Skip 0x0 dimensions that the player emits when going to STATE_IDLE
|
||||||
|
// to avoid incorrectly resetting the auto-detected orientation.
|
||||||
|
if (event.width > 0 && event.height > 0) {
|
||||||
|
// TV never rotates; otherwise track whether the video is portrait.
|
||||||
|
isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width
|
||||||
|
}
|
||||||
|
callbacks?.playerDimensionsLoaded(event.width, event.height)
|
||||||
|
}
|
||||||
|
is PlayerAttachedEvent -> playerUpdated(event.player)
|
||||||
|
is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged()
|
||||||
|
is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp)
|
||||||
|
is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp)
|
||||||
|
is TracksChangedEvent -> callbacks?.onTracksInfoChanged()
|
||||||
|
is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks)
|
||||||
|
is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error)
|
||||||
|
is RequestAudioFocusEvent -> requestAudioFocus()
|
||||||
|
is EpisodeSeekEvent -> when (event.offset) {
|
||||||
|
-1 -> callbacks?.prevEpisode()
|
||||||
|
1 -> callbacks?.nextEpisode()
|
||||||
|
}
|
||||||
|
is StatusEvent -> {
|
||||||
|
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
|
||||||
|
scheduleAutoHide()
|
||||||
|
callbacks?.playerStatusChanged()
|
||||||
|
}
|
||||||
|
is PositionEvent -> callbacks?.playerPositionChanged(
|
||||||
|
position = event.toMs,
|
||||||
|
duration = event.durationMs
|
||||||
|
)
|
||||||
|
is VideoEndedEvent -> {
|
||||||
|
// Only play next episode if autoplay is on (default).
|
||||||
|
val ctx = context
|
||||||
|
if (PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true
|
||||||
|
) {
|
||||||
|
player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PauseEvent -> Unit
|
||||||
|
is PlayEvent -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duration display */
|
||||||
|
|
||||||
|
fun setRemainingTimeCounter(showRemaining: Boolean) {
|
||||||
|
durationMode = showRemaining
|
||||||
|
exoDuration?.isInvisible = showRemaining
|
||||||
|
timeLeft?.isVisible = showRemaining
|
||||||
|
if (showRemaining) updateRemainingTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRemainingTime() {
|
||||||
|
val duration = player.getDuration()
|
||||||
|
val position = player.getPosition()
|
||||||
|
|
||||||
|
if (exoProgress?.isAtLiveEdge() == true) {
|
||||||
|
timeLeft?.alpha = 0f
|
||||||
|
exoDuration?.alpha = 0f
|
||||||
|
timeLive?.isVisible = true
|
||||||
|
} else {
|
||||||
|
timeLeft?.alpha = 1f
|
||||||
|
exoDuration?.alpha = 1f
|
||||||
|
timeLive?.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration != null && duration > 1 && position != null) {
|
||||||
|
val remainingTimeSeconds = (duration - position + 500) / 1000
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-hide */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a delayed auto-hide of the player UI after [delayMs] ms.
|
||||||
|
* Any previously pending hide is canceled first.
|
||||||
|
* The hide fires only when no touch is active and [Callbacks.isUIShowing] is true;
|
||||||
|
* the actual hide action is delegated to [Callbacks.onAutoHideUI].
|
||||||
|
*/
|
||||||
|
fun scheduleAutoHide(delayMs: Long = 3000L) {
|
||||||
|
val token = ++autoHideToken
|
||||||
|
autoHideHandler.removeCallbacksAndMessages(null)
|
||||||
|
autoHideHandler.postDelayed({
|
||||||
|
if (token != autoHideToken) return@postDelayed
|
||||||
|
if (gestureHelper.isCurrentTouchValid) return@postDelayed
|
||||||
|
if (callbacks?.isUIShowing() != true) return@postDelayed
|
||||||
|
callbacks?.onAutoHideUI()
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */
|
||||||
|
fun cancelAutoHide() {
|
||||||
|
autoHideToken++
|
||||||
|
autoHideHandler.removeCallbacksAndMessages(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import kotlin.math.max
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.math.min
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
data class Cache(
|
data class Cache(
|
||||||
val linkCache: MutableSet<ExtractorLink>,
|
val linkCache: MutableSet<ExtractorLink>,
|
||||||
|
|
@ -23,9 +23,8 @@ data class Cache(
|
||||||
|
|
||||||
class RepoLinkGenerator(
|
class RepoLinkGenerator(
|
||||||
episodes: List<ResultEpisode>,
|
episodes: List<ResultEpisode>,
|
||||||
currentIndex: Int = 0,
|
|
||||||
val page: LoadResponse? = null,
|
val page: LoadResponse? = null,
|
||||||
) : VideoGenerator<ResultEpisode>(episodes, currentIndex) {
|
) : VideoGenerator<ResultEpisode>(episodes) {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "RepoLink"
|
const val TAG = "RepoLink"
|
||||||
val cache: HashMap<Pair<String, Int>, Cache> =
|
val cache: HashMap<Pair<String, Int>, Cache> =
|
||||||
|
|
@ -34,6 +33,7 @@ class RepoLinkGenerator(
|
||||||
|
|
||||||
override val hasCache = true
|
override val hasCache = true
|
||||||
override val canSkipLoading = true
|
override val canSkipLoading = true
|
||||||
|
override fun getId(index: Int): Int? = videos.getOrNull(index)?.id
|
||||||
|
|
||||||
// this is a simple array that is used to instantly load links if they are already loaded
|
// this is a simple array that is used to instantly load links if they are already loaded
|
||||||
//var linkCache = Array<Set<ExtractorLink>>(size = episodes.size, init = { setOf() })
|
//var linkCache = Array<Set<ExtractorLink>>(size = episodes.size, init = { setOf() })
|
||||||
|
|
@ -48,7 +48,7 @@ class RepoLinkGenerator(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
isCasting: Boolean,
|
isCasting: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val current = getCurrent(offset) ?: return false
|
val current = videos.getOrNull(offset) ?: return false
|
||||||
|
|
||||||
val currentCache = synchronized(cache) {
|
val currentCache = synchronized(cache) {
|
||||||
cache[current.apiName to current.id] ?: Cache(
|
cache[current.apiName to current.id] ?: Cache(
|
||||||
|
|
@ -61,10 +61,12 @@ class RepoLinkGenerator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// these act as a general filter to prevent duplication of links or names
|
// These act as a general filter to prevent duplication of links or names
|
||||||
val currentLinksUrls = mutableSetOf<String>() // makes all urls unique
|
// Avoid any possible ConcurrentModificationException
|
||||||
val currentSubsUrls = mutableSetOf<String>() // makes all subs urls unique
|
val currentLinksUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
val lastCountedSuffix = mutableMapOf<String, UInt>()
|
val currentSubsUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
// Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen!
|
||||||
|
val lastCountedSuffix = ConcurrentHashMap<String, AtomicInteger>()
|
||||||
|
|
||||||
synchronized(currentCache) {
|
synchronized(currentCache) {
|
||||||
val outdatedCache =
|
val outdatedCache =
|
||||||
|
|
@ -75,7 +77,10 @@ class RepoLinkGenerator(
|
||||||
currentCache.subtitleCache.clear()
|
currentCache.subtitleCache.clear()
|
||||||
currentCache.saturated = false
|
currentCache.saturated = false
|
||||||
} else if (currentCache.linkCache.isNotEmpty()) {
|
} else if (currentCache.linkCache.isNotEmpty()) {
|
||||||
Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// call all callbacks
|
// call all callbacks
|
||||||
|
|
@ -88,8 +93,7 @@ class RepoLinkGenerator(
|
||||||
|
|
||||||
currentCache.subtitleCache.forEach { sub ->
|
currentCache.subtitleCache.forEach { sub ->
|
||||||
currentSubsUrls.add(sub.url)
|
currentSubsUrls.add(sub.url)
|
||||||
val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u
|
lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet()
|
||||||
lastCountedSuffix[sub.originalName] = suffixCount
|
|
||||||
subtitleCallback(sub)
|
subtitleCallback(sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,17 +112,15 @@ class RepoLinkGenerator(
|
||||||
subtitleCallback = { file ->
|
subtitleCallback = { file ->
|
||||||
Log.d(TAG, "Loaded SubtitleFile: $file")
|
Log.d(TAG, "Loaded SubtitleFile: $file")
|
||||||
val correctFile = PlayerSubtitleHelper.getSubtitleData(file)
|
val correctFile = PlayerSubtitleHelper.getSubtitleData(file)
|
||||||
if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) {
|
if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) {
|
||||||
return@loadLinks
|
return@loadLinks
|
||||||
}
|
}
|
||||||
currentSubsUrls.add(correctFile.url)
|
|
||||||
|
|
||||||
// this part makes sure that all names are unique for UX
|
// this part makes sure that all names are unique for UX
|
||||||
|
val nameDecoded = correctFile.originalName.html().toString()
|
||||||
val nameDecoded = correctFile.originalName.html().toString().trim() // `%3Ch1%3Esub%20name…` → `<h1>sub name…` → `sub name…`
|
.trim() // `%3Ch1%3Esub%20name…` → `<h1>sub name…` → `sub name…`
|
||||||
|
val suffixCount =
|
||||||
val suffixCount = lastCountedSuffix.getOrDefault(nameDecoded, 0u) +1u
|
lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet()
|
||||||
lastCountedSuffix[nameDecoded] = suffixCount
|
|
||||||
|
|
||||||
val updatedFile =
|
val updatedFile =
|
||||||
correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount")
|
correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount")
|
||||||
|
|
@ -132,10 +134,9 @@ class RepoLinkGenerator(
|
||||||
},
|
},
|
||||||
callback = { link ->
|
callback = { link ->
|
||||||
Log.d(TAG, "Loaded ExtractorLink: $link")
|
Log.d(TAG, "Loaded ExtractorLink: $link")
|
||||||
if (link.url.isBlank() || currentLinksUrls.contains(link.url)) {
|
if (link.url.isBlank() || !currentLinksUrls.add(link.url)) {
|
||||||
return@loadLinks
|
return@loadLinks
|
||||||
}
|
}
|
||||||
currentLinksUrls.add(link.url)
|
|
||||||
|
|
||||||
synchronized(currentCache) {
|
synchronized(currentCache) {
|
||||||
if (currentCache.linkCache.add(link)) {
|
if (currentCache.linkCache.add(link)) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.GuardedBy
|
import androidx.annotation.GuardedBy
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.FileTypes
|
import androidx.media3.common.FileTypes
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.util.TimestampAdjuster
|
import androidx.media3.common.util.TimestampAdjuster
|
||||||
|
|
@ -48,7 +49,6 @@ import java.lang.reflect.Constructor
|
||||||
import java.lang.reflect.InvocationTargetException
|
import java.lang.reflect.InvocationTargetException
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [ExtractorsFactory] that provides an array of extractors for the following formats:
|
* An [ExtractorsFactory] that provides an array of extractors for the following formats:
|
||||||
*
|
*
|
||||||
|
|
@ -103,13 +103,16 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
|
||||||
private var tsTimestampSearchBytes: Int
|
private var tsTimestampSearchBytes: Int
|
||||||
private var textTrackTranscodingEnabled: Boolean
|
private var textTrackTranscodingEnabled: Boolean
|
||||||
private var subtitleParserFactory: SubtitleParser.Factory
|
private var subtitleParserFactory: SubtitleParser.Factory
|
||||||
|
private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int
|
||||||
private var jpegFlags: @JpegExtractor.Flags Int = 0
|
private var jpegFlags: @JpegExtractor.Flags Int = 0
|
||||||
|
private var heifFlags: @HeifExtractor.Flags Int = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
tsMode = TsExtractor.MODE_SINGLE_PMT
|
tsMode = TsExtractor.MODE_SINGLE_PMT
|
||||||
tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
|
tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
|
||||||
subtitleParserFactory = DefaultSubtitleParserFactory()
|
subtitleParserFactory = DefaultSubtitleParserFactory()
|
||||||
textTrackTranscodingEnabled = true
|
textTrackTranscodingEnabled = true
|
||||||
|
codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -346,6 +349,14 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun experimentalSetCodecsToParseWithinGopSampleDependencies(
|
||||||
|
codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int
|
||||||
|
): UpdatedDefaultExtractorsFactory {
|
||||||
|
this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets flags for [JpegExtractor] instances created by the factory.
|
* Sets flags for [JpegExtractor] instances created by the factory.
|
||||||
*
|
*
|
||||||
|
|
@ -361,6 +372,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets flags for [HeifExtractor] instances created by the factory.
|
||||||
|
*
|
||||||
|
* @see HeifExtractor.HeifExtractor
|
||||||
|
* @param flags The flags to use.
|
||||||
|
* @return The factory, for convenience.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun setHeifExtractorFlags(
|
||||||
|
flags: @HeifExtractor.Flags Int
|
||||||
|
): UpdatedDefaultExtractorsFactory {
|
||||||
|
this.heifFlags = flags
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun createExtractors(): Array<Extractor> {
|
override fun createExtractors(): Array<Extractor> {
|
||||||
return createExtractors(Uri.EMPTY, HashMap())
|
return createExtractors(Uri.EMPTY, HashMap())
|
||||||
|
|
@ -468,21 +494,26 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
|
||||||
extractors.add(
|
extractors.add(
|
||||||
FragmentedMp4Extractor(
|
FragmentedMp4Extractor(
|
||||||
subtitleParserFactory,
|
subtitleParserFactory,
|
||||||
fragmentedMp4Flags
|
fragmentedMp4Flags or
|
||||||
or (if (textTrackTranscodingEnabled)
|
FragmentedMp4Extractor
|
||||||
0
|
.codecsToParseWithinGopSampleDependenciesAsFlags(
|
||||||
else
|
codecsToParseWithinGopSampleDependencies
|
||||||
FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA)
|
) or
|
||||||
|
if (textTrackTranscodingEnabled) 0
|
||||||
|
else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
extractors.add(
|
extractors.add(
|
||||||
Mp4Extractor(
|
Mp4Extractor(
|
||||||
subtitleParserFactory,
|
subtitleParserFactory,
|
||||||
mp4Flags
|
mp4Flags or
|
||||||
or (if (textTrackTranscodingEnabled)
|
Mp4Extractor
|
||||||
0
|
.codecsToParseWithinGopSampleDependenciesAsFlags(
|
||||||
else
|
codecsToParseWithinGopSampleDependencies
|
||||||
Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA)
|
) or
|
||||||
|
if (textTrackTranscodingEnabled) 0
|
||||||
|
else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -524,12 +555,7 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
|
||||||
FileTypes.PNG -> extractors.add(PngExtractor())
|
FileTypes.PNG -> extractors.add(PngExtractor())
|
||||||
FileTypes.WEBP -> extractors.add(WebpExtractor())
|
FileTypes.WEBP -> extractors.add(WebpExtractor())
|
||||||
FileTypes.BMP -> extractors.add(BmpExtractor())
|
FileTypes.BMP -> extractors.add(BmpExtractor())
|
||||||
FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0
|
FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags))
|
||||||
&& (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0
|
|
||||||
) {
|
|
||||||
extractors.add(HeifExtractor())
|
|
||||||
}
|
|
||||||
|
|
||||||
FileTypes.AVIF -> extractors.add(AvifExtractor())
|
FileTypes.AVIF -> extractors.add(AvifExtractor())
|
||||||
FileTypes.WEBVTT, FileTypes.UNKNOWN -> {}
|
FileTypes.WEBVTT, FileTypes.UNKNOWN -> {}
|
||||||
else -> {}
|
else -> {}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.player.live
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugWarning
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
object LiveHelper {
|
||||||
|
private val liveManagers = WeakHashMap<Player, Pair<LiveManager, Player.Listener>>()
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun registerPlayer(player: Player?) {
|
||||||
|
if (player == null) {
|
||||||
|
debugWarning { "LiveHelper registerPlayer called with null player!" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicates
|
||||||
|
if (liveManagers.contains(player)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val liveManager = LiveManager(player)
|
||||||
|
val listener = object : Player.Listener {
|
||||||
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
|
val window = Timeline.Window()
|
||||||
|
timeline.getWindow(player.currentMediaItemIndex, window)
|
||||||
|
if (window.isDynamic) {
|
||||||
|
liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs))
|
||||||
|
}
|
||||||
|
super.onTimelineChanged(timeline, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPositionDiscontinuity(
|
||||||
|
oldPosition: Player.PositionInfo,
|
||||||
|
newPosition: Player.PositionInfo,
|
||||||
|
reason: Int
|
||||||
|
) {
|
||||||
|
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||||
|
val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs)
|
||||||
|
|
||||||
|
// Seek back to the optimal live spot
|
||||||
|
if (timeAheadOfLive > 100) {
|
||||||
|
player.seekTo(newPosition.positionMs - timeAheadOfLive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(liveManagers) {
|
||||||
|
player.addListener(listener)
|
||||||
|
liveManagers[player] = liveManager to listener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterPlayer(player: Player?) {
|
||||||
|
if (player == null) {
|
||||||
|
debugWarning { "LiveHelper unregisterPlayer called with null player!" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicates
|
||||||
|
if (!liveManagers.contains(player)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(liveManagers) {
|
||||||
|
liveManagers[player]?.let { (_, listener) ->
|
||||||
|
player.removeListener(listener)
|
||||||
|
}
|
||||||
|
liveManagers.remove(player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLiveManager(player: Player?) = liveManagers[player]?.first
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.player.live
|
||||||
|
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
// How much margin from the live point is still considered "live"
|
||||||
|
const val LIVE_MARGIN = 6_000L
|
||||||
|
|
||||||
|
// How many ms should we be behind the real live point?
|
||||||
|
// Too low, and we cannot pre-buffer
|
||||||
|
// Too high, and we are no longer live
|
||||||
|
const val PREFERRED_LIVE_OFFSET = 5_000L
|
||||||
|
|
||||||
|
// An extra offset from the optimal calculated timestamp
|
||||||
|
// This is to account for chunk updates not always being the same size
|
||||||
|
const val CHUNK_VARIANCE = 3000L
|
||||||
|
|
||||||
|
// A livestream chunk from the player, the time we get it and the duration can be used to calculate
|
||||||
|
// the expected live timestamp.
|
||||||
|
class LivestreamChunk(
|
||||||
|
durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis()
|
||||||
|
) {
|
||||||
|
// We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point.
|
||||||
|
// If we are ahead of the middle point we will reach the end before the new chunk is expected to be released.
|
||||||
|
val targetPosition = maxOf(0,minOf(
|
||||||
|
durationMs - PREFERRED_LIVE_OFFSET,
|
||||||
|
durationMs / 2 - CHUNK_VARIANCE
|
||||||
|
))
|
||||||
|
|
||||||
|
fun isPositionLive(position: Long): Boolean {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val livePosition = targetPosition + (currentTime - receiveTimeMs)
|
||||||
|
val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET
|
||||||
|
// println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive")
|
||||||
|
return withinLive
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTimeAheadOfLive(position: Long): Long {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val livePosition = targetPosition + (currentTime - receiveTimeMs)
|
||||||
|
// println("Ahead of live: ${position-livePosition}")
|
||||||
|
return position - livePosition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are two types of livestreams we need to manage
|
||||||
|
// 1. A livestream with no history, a continually sliding window.
|
||||||
|
// This livestream has no currentLiveOffset, which means we need to calculate
|
||||||
|
// the real live point based on when we receive the latest update and the size of that update.
|
||||||
|
// 2. A livestream with history.
|
||||||
|
// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point.
|
||||||
|
// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations.
|
||||||
|
class LiveManager {
|
||||||
|
private var _currentPlayer: WeakReference<Player>? = null
|
||||||
|
val currentPlayer: Player? get() = _currentPlayer?.get()
|
||||||
|
|
||||||
|
constructor(player: Player?) {
|
||||||
|
_currentPlayer = WeakReference(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastLivestreamChunk: LivestreamChunk? = null
|
||||||
|
|
||||||
|
fun submitLivestreamChunk(chunk: LivestreamChunk) {
|
||||||
|
lastLivestreamChunk = chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */
|
||||||
|
fun getTimeAheadOfLive(position: Long): Long {
|
||||||
|
val player = currentPlayer ?: return 0
|
||||||
|
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0
|
||||||
|
|
||||||
|
// If the currentLiveOffset is wrong we fall back to manual calculations
|
||||||
|
val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
|
||||||
|
val relativeOffset = player.currentLiveOffset - player.currentPosition + position
|
||||||
|
PREFERRED_LIVE_OFFSET - relativeOffset
|
||||||
|
} else {
|
||||||
|
lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure min of 0
|
||||||
|
return maxOf(0, ahead)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the stream is currently at the expected live edge, with margins */
|
||||||
|
fun isAtLiveEdge(): Boolean {
|
||||||
|
val player = currentPlayer ?: return false
|
||||||
|
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false
|
||||||
|
|
||||||
|
// If the currentLiveOffset is wrong we fall back to manual calculations
|
||||||
|
return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
|
||||||
|
player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET
|
||||||
|
} else {
|
||||||
|
lastLivestreamChunk?.isPositionLive(player.currentPosition) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.player.live
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.PlayerControlView
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.media3.ui.R
|
||||||
|
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) {
|
||||||
|
|
||||||
|
private var _currentPlayerView: WeakReference<PlayerView>? = null
|
||||||
|
val currentPlayer: Player? get() = _currentPlayerView?.get()?.player
|
||||||
|
|
||||||
|
fun registerPlayerView(player: PlayerView?) {
|
||||||
|
_currentPlayerView = WeakReference(player)
|
||||||
|
val controller =
|
||||||
|
_currentPlayerView?.get()?.findViewById<PlayerControlView>(R.id.exo_controller)
|
||||||
|
|
||||||
|
controller?.setProgressUpdateListener { position, bufferedPosition ->
|
||||||
|
currentPlayer?.let { player ->
|
||||||
|
if (isAtLiveEdge()) {
|
||||||
|
setPosition(player.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAtLiveEdge(): Boolean {
|
||||||
|
return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ import com.lagradost.cloudstream3.utils.drawableToBitmap
|
||||||
import com.lagradost.cloudstream3.utils.setText
|
import com.lagradost.cloudstream3.utils.setText
|
||||||
|
|
||||||
class ProfilesAdapter(
|
class ProfilesAdapter(
|
||||||
val usedProfile: Int,
|
val usedProfile: Int?,
|
||||||
val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit,
|
val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit,
|
||||||
) :
|
) :
|
||||||
NoStateAdapter<QualityDataHelper.QualityProfile>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
|
NoStateAdapter<QualityDataHelper.QualityProfile>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
|
||||||
|
|
@ -68,25 +68,27 @@ class ProfilesAdapter(
|
||||||
val profileBg: ImageView = binding.profileImageBackground
|
val profileBg: ImageView = binding.profileImageBackground
|
||||||
val wifiText: TextView = binding.textIsWifi
|
val wifiText: TextView = binding.textIsWifi
|
||||||
val dataText: TextView = binding.textIsMobileData
|
val dataText: TextView = binding.textIsMobileData
|
||||||
|
val downloadText: TextView = binding.textIsDownloadData
|
||||||
val outline: View = binding.outline
|
val outline: View = binding.outline
|
||||||
val cardView: View = binding.cardView
|
val cardView: View = binding.cardView
|
||||||
val itemView = holder.itemView
|
val itemView = holder.itemView
|
||||||
|
|
||||||
priorityText.setText(item.name)
|
priorityText.setText(item.name)
|
||||||
dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data
|
dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data)
|
||||||
wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi
|
wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi)
|
||||||
|
downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download)
|
||||||
|
|
||||||
fun setCurrentItem() {
|
fun setCurrentItem() {
|
||||||
val prevIndex = currentItem?.first
|
val prevIndex = currentItem
|
||||||
// Prevent UI bug when re-selecting the item quickly
|
// Prevent UI bug when re-selecting the item quickly
|
||||||
if (prevIndex == position) {
|
if (prevIndex == position) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentItem = position to item
|
currentItem = position
|
||||||
clickCallback.invoke(prevIndex, position)
|
clickCallback.invoke(prevIndex, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
outline.isVisible = currentItem?.second?.id == item.id
|
outline.isVisible = currentItem == position
|
||||||
val drawableResId = art[position % art.size]
|
val drawableResId = art[position % art.size]
|
||||||
profileBg.loadImage(drawableResId)
|
profileBg.loadImage(drawableResId)
|
||||||
|
|
||||||
|
|
@ -107,6 +109,7 @@ class ProfilesAdapter(
|
||||||
if (color != null) {
|
if (color != null) {
|
||||||
wifiText.backgroundTintList = ColorStateList.valueOf(color)
|
wifiText.backgroundTintList = ColorStateList.valueOf(color)
|
||||||
dataText.backgroundTintList = ColorStateList.valueOf(color)
|
dataText.backgroundTintList = ColorStateList.valueOf(color)
|
||||||
|
downloadText.backgroundTintList = ColorStateList.valueOf(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,9 +129,9 @@ class ProfilesAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentItem: Pair<Int, QualityDataHelper.QualityProfile>? = null
|
private var currentItem: Int? = null
|
||||||
|
|
||||||
fun getCurrentProfile(): QualityDataHelper.QualityProfile? {
|
fun getCurrentProfile(): QualityDataHelper.QualityProfile? {
|
||||||
return currentItem?.second
|
return currentItem?.let { index -> immutableCurrentList.getOrNull(index) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player.source_priority
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -9,14 +10,23 @@ import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
import com.lagradost.cloudstream3.utils.Qualities
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
object QualityDataHelper {
|
object QualityDataHelper {
|
||||||
private const val VIDEO_SOURCE_PRIORITY = "video_source_priority"
|
private const val VIDEO_SOURCE_PRIORITY = "video_source_priority"
|
||||||
private const val VIDEO_PROFILE_NAME = "video_profile_name"
|
private const val VIDEO_PROFILE_NAME = "video_profile_name"
|
||||||
private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority"
|
private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority"
|
||||||
|
|
||||||
|
// Old key only supporting one type per profile
|
||||||
|
@Deprecated("Changed to support multiple types per profile")
|
||||||
private const val VIDEO_PROFILE_TYPE = "video_profile_type"
|
private const val VIDEO_PROFILE_TYPE = "video_profile_type"
|
||||||
|
// New key supporting more than one type per profile
|
||||||
|
|
||||||
|
private const val VIDEO_PROFILE_TYPES = "video_profile_types_2"
|
||||||
private const val DEFAULT_SOURCE_PRIORITY = 1
|
private const val DEFAULT_SOURCE_PRIORITY = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatically skip loading links once this priority is reached
|
* Automatically skip loading links once this priority is reached
|
||||||
**/
|
**/
|
||||||
|
|
@ -33,13 +43,14 @@ object QualityDataHelper {
|
||||||
enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) {
|
enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) {
|
||||||
None(R.string.none, false),
|
None(R.string.none, false),
|
||||||
WiFi(R.string.wifi, true),
|
WiFi(R.string.wifi, true),
|
||||||
Data(R.string.mobile_data, true)
|
Data(R.string.mobile_data, true),
|
||||||
|
Download(R.string.download, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class QualityProfile(
|
data class QualityProfile(
|
||||||
val name: UiText,
|
val name: UiText,
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val type: QualityProfileType
|
val types: Set<QualityProfileType>
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getSourcePriority(profile: Int, name: String?): Int {
|
fun getSourcePriority(profile: Int, name: String?): Int {
|
||||||
|
|
@ -51,8 +62,21 @@ object QualityDataHelper {
|
||||||
) ?: DEFAULT_SOURCE_PRIORITY
|
) ?: DEFAULT_SOURCE_PRIORITY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getAllSourcePriorityNames(profile: Int): List<String> {
|
||||||
|
val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile"
|
||||||
|
return getKeys(folder)?.map { key ->
|
||||||
|
key.substringAfter("$folder/")
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
fun setSourcePriority(profile: Int, name: String, priority: Int) {
|
fun setSourcePriority(profile: Int, name: String, priority: Int) {
|
||||||
setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority)
|
val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile"
|
||||||
|
// Prevent unnecessary keys
|
||||||
|
if (priority == DEFAULT_SOURCE_PRIORITY) {
|
||||||
|
removeKey(folder, name)
|
||||||
|
} else {
|
||||||
|
setKey(folder, name, priority)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setProfileName(profile: Int, name: String?) {
|
fun setProfileName(profile: Int, name: String?) {
|
||||||
|
|
@ -85,16 +109,40 @@ object QualityDataHelper {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getQualityProfileType(profile: Int): QualityProfileType {
|
|
||||||
return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None
|
@Suppress("DEPRECATION")
|
||||||
|
fun getQualityProfileTypes(profile: Int): Set<QualityProfileType> {
|
||||||
|
val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile"
|
||||||
|
// Use arrays for to make with work with setKey properly (weird crashes otherwise)
|
||||||
|
val newProfiles = getKey<Array<QualityProfileType>>(newKey)?.toSet()
|
||||||
|
|
||||||
|
// Migrate to new profile key
|
||||||
|
if (newProfiles == null) {
|
||||||
|
val oldProfile =
|
||||||
|
getKey<QualityProfileType>("$currentAccount/$VIDEO_PROFILE_TYPE/$profile")
|
||||||
|
val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf()
|
||||||
|
setKey(newKey, newSet)
|
||||||
|
return newSet.toSet()
|
||||||
|
} else {
|
||||||
|
return newProfiles
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQualityProfileType(profile: Int, type: QualityProfileType?) {
|
fun addQualityProfileType(profile: Int, type: QualityProfileType) {
|
||||||
val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile"
|
val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile"
|
||||||
if (type == QualityProfileType.None) {
|
val currentTypes = getQualityProfileTypes(profile)
|
||||||
removeKey(path)
|
|
||||||
} else {
|
if (type != QualityProfileType.None) {
|
||||||
setKey(path, type)
|
setKey(path, (currentTypes + type).toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeQualityProfileType(profile: Int, type: QualityProfileType) {
|
||||||
|
val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile"
|
||||||
|
val currentTypes = getQualityProfileTypes(profile)
|
||||||
|
|
||||||
|
if (type != QualityProfileType.None) {
|
||||||
|
setKey(path, (currentTypes - type).toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,37 +154,39 @@ object QualityDataHelper {
|
||||||
val availableTypes = QualityProfileType.entries.toMutableList()
|
val availableTypes = QualityProfileType.entries.toMutableList()
|
||||||
val profiles = (1..PROFILE_COUNT).map { profileNumber ->
|
val profiles = (1..PROFILE_COUNT).map { profileNumber ->
|
||||||
// Get the real type
|
// Get the real type
|
||||||
val type = getQualityProfileType(profileNumber)
|
val types = getQualityProfileTypes(profileNumber)
|
||||||
|
|
||||||
// This makes it impossible to get more than one of each type
|
val uniqueTypes = types.mapNotNull { type ->
|
||||||
// Duplicates will be turned to None
|
// This makes it impossible to get more than one of each type
|
||||||
val uniqueType = if (type.unique && !availableTypes.remove(type)) {
|
if (type.unique && !availableTypes.remove(type)) {
|
||||||
QualityProfileType.None
|
null
|
||||||
} else {
|
} else {
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
QualityProfile(
|
QualityProfile(
|
||||||
getProfileName(profileNumber),
|
getProfileName(profileNumber),
|
||||||
profileNumber,
|
profileNumber,
|
||||||
uniqueType
|
uniqueTypes
|
||||||
)
|
)
|
||||||
}.toMutableList()
|
}.toMutableList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If no profile of this type exists: insert it on the earliest profile with None type
|
* If no profile of this type exists: insert it on the earliest profile
|
||||||
**/
|
**/
|
||||||
fun insertType(
|
fun insertType(
|
||||||
list: MutableList<QualityProfile>,
|
list: MutableList<QualityProfile>,
|
||||||
type: QualityProfileType
|
type: QualityProfileType
|
||||||
) {
|
) {
|
||||||
if (list.any { it.type == type }) return
|
if (list.any { it.types.contains(type) }) return
|
||||||
val index =
|
|
||||||
list.indexOfFirst { it.type == QualityProfileType.None }
|
synchronized(list) {
|
||||||
list.getOrNull(index)?.copy(type = type)
|
val firstItem = list.firstOrNull() ?: return
|
||||||
?.let { fixed ->
|
val fixedTypes = firstItem.types + type
|
||||||
list.set(index, fixed)
|
val fixedItem = firstItem.copy(types = fixedTypes)
|
||||||
}
|
list.set(0, fixedItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QualityProfileType.entries.forEach {
|
QualityProfileType.entries.forEach {
|
||||||
|
|
@ -145,7 +195,7 @@ object QualityDataHelper {
|
||||||
|
|
||||||
debugAssert({
|
debugAssert({
|
||||||
!QualityProfileType.entries.all { type ->
|
!QualityProfileType.entries.all { type ->
|
||||||
!type.unique || profiles.any { it.type == type }
|
!type.unique || profiles.any { it.types.contains(type) }
|
||||||
}
|
}
|
||||||
}, { "All unique quality types do not exist" })
|
}, { "All unique quality types do not exist" })
|
||||||
|
|
||||||
|
|
@ -155,4 +205,22 @@ object QualityDataHelper {
|
||||||
|
|
||||||
return profiles
|
return profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLinkPriority(
|
||||||
|
qualityProfile: Int,
|
||||||
|
linkData: ExtractorLink?
|
||||||
|
): Int {
|
||||||
|
val qualityPriority = getQualityPriority(
|
||||||
|
qualityProfile,
|
||||||
|
closestQuality(linkData?.quality)
|
||||||
|
)
|
||||||
|
val sourcePriority = getSourcePriority(qualityProfile, linkData?.source)
|
||||||
|
|
||||||
|
return qualityPriority + sourcePriority
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closestQuality(target: Int?): Qualities {
|
||||||
|
if (target == null) return Qualities.Unknown
|
||||||
|
return Qualities.entries.minBy { abs(it.value - target) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,45 +2,74 @@ package com.lagradost.cloudstream3.ui.player.source_priority
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import androidx.annotation.StyleRes
|
import androidx.annotation.StyleRes
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding
|
import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding
|
||||||
|
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames
|
||||||
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName
|
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName
|
||||||
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles
|
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||||
import com.lagradost.cloudstream3.utils.setText
|
import com.lagradost.cloudstream3.utils.setText
|
||||||
|
|
||||||
class QualityProfileDialog(
|
/** Simplified ExtractorLink for the quality profile dialog */
|
||||||
|
data class LinkSource(
|
||||||
|
val source: String
|
||||||
|
) {
|
||||||
|
constructor(extractorLink: ExtractorLink) : this(extractorLink.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QualityProfileDialog private constructor(
|
||||||
val activity: FragmentActivity,
|
val activity: FragmentActivity,
|
||||||
@StyleRes val themeRes: Int,
|
@StyleRes val themeRes: Int,
|
||||||
private val links: List<ExtractorLink>,
|
private val links: List<LinkSource>,
|
||||||
private val usedProfile: Int,
|
private val usedProfile: Int?,
|
||||||
private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit
|
private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?,
|
||||||
|
private val useProfileSelection: Boolean
|
||||||
) : Dialog(activity, themeRes) {
|
) : Dialog(activity, themeRes) {
|
||||||
override fun show() {
|
constructor(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
@StyleRes themeRes: Int,
|
||||||
|
links: List<LinkSource>,
|
||||||
|
usedProfile: Int,
|
||||||
|
profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit),
|
||||||
|
) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
@StyleRes themeRes: Int,
|
||||||
|
links: List<LinkSource>
|
||||||
|
) : this(activity, themeRes, links, null, null, false)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Run on IO as this may be a heavy operation
|
||||||
|
suspend fun getAllDefaultSources(): List<LinkSource> = ioWork {
|
||||||
|
getProfiles().flatMap {
|
||||||
|
getAllSourcePriorityNames(it.id)
|
||||||
|
}.distinct().map { LinkSource(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun show() {
|
||||||
val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false)
|
val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false)
|
||||||
|
|
||||||
setContentView(binding.root)//R.layout.player_quality_profile_dialog)
|
setContentView(binding.root)
|
||||||
fixSystemBarsPadding(binding.root)
|
fixSystemBarsPadding(binding.root)
|
||||||
/*val profilesRecyclerView: RecyclerView = profiles_recyclerview
|
|
||||||
val useBtt: View = use_btt
|
|
||||||
val editBtt: View = edit_btt
|
|
||||||
val cancelBtt: View = cancel_btt
|
|
||||||
val defaultBtt: View = set_default_btt
|
|
||||||
val currentProfileText: TextView = currently_selected_profile_text
|
|
||||||
val selectedItemActionsHolder: View = selected_item_holder*/
|
|
||||||
binding.apply {
|
binding.apply {
|
||||||
fun getCurrentProfile(): QualityDataHelper.QualityProfile? {
|
fun getCurrentProfile(): QualityDataHelper.QualityProfile? {
|
||||||
return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile()
|
return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshProfiles() {
|
fun refreshProfiles() {
|
||||||
currentlySelectedProfileText.setText(getProfileName(usedProfile))
|
if (usedProfile != null) {
|
||||||
|
currentlySelectedProfileText.setText(getProfileName(usedProfile))
|
||||||
|
}
|
||||||
(profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles())
|
(profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,37 +96,52 @@ class QualityProfileDialog(
|
||||||
|
|
||||||
setDefaultBtt.setOnClickListener {
|
setDefaultBtt.setOnClickListener {
|
||||||
val currentProfile = getCurrentProfile() ?: return@setOnClickListener
|
val currentProfile = getCurrentProfile() ?: return@setOnClickListener
|
||||||
val choices = QualityDataHelper.QualityProfileType.entries
|
val choices =
|
||||||
.filter { it != QualityDataHelper.QualityProfileType.None }
|
QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None }
|
||||||
val choiceNames = choices.map { txt(it.stringRes).asString(context) }
|
val choiceNames = choices.map { txt(it.stringRes).asString(context) }
|
||||||
|
val selectedIndices = choices.mapIndexed { index, type -> index to type }
|
||||||
|
.filter { currentProfile.types.contains(it.second) }.map { it.first }
|
||||||
|
|
||||||
activity.showBottomDialog(
|
activity.showMultiDialog(
|
||||||
choiceNames,
|
choiceNames,
|
||||||
choices.indexOf(currentProfile.type),
|
selectedIndices,
|
||||||
txt(R.string.set_default).asString(context),
|
txt(R.string.set_default).asString(context),
|
||||||
false,
|
|
||||||
{},
|
{},
|
||||||
{ index ->
|
{ index ->
|
||||||
val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog
|
val pickedChoices = index.mapNotNull { choices.getOrNull(it) }
|
||||||
// Remove previous picks
|
|
||||||
if (pickedChoice.unique) {
|
pickedChoices.forEach { pickedChoice ->
|
||||||
getProfiles().filter { it.type == pickedChoice }.forEach {
|
// Remove previous picks
|
||||||
QualityDataHelper.setQualityProfileType(it.id, null)
|
if (pickedChoice.unique) {
|
||||||
|
getProfiles().filter { it.types.contains(pickedChoice) }.forEach {
|
||||||
|
QualityDataHelper.removeQualityProfileType(it.id, pickedChoice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice)
|
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelBtt.setOnClickListener {
|
cancelBtt.isVisible = useProfileSelection
|
||||||
this@QualityProfileDialog.dismissSafe()
|
useBtt.isVisible = useProfileSelection
|
||||||
}
|
applyBtt.isVisible = !useProfileSelection
|
||||||
|
|
||||||
useBtt.setOnClickListener {
|
if (useProfileSelection) {
|
||||||
getCurrentProfile()?.let {
|
cancelBtt.setOnClickListener {
|
||||||
profileSelectionCallback.invoke(it)
|
this@QualityProfileDialog.dismissSafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
useBtt.setOnClickListener {
|
||||||
|
getCurrentProfile()?.let {
|
||||||
|
profileSelectionCallback?.invoke(it)
|
||||||
|
this@QualityProfileDialog.dismissSafe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applyBtt.setOnClickListener {
|
||||||
this@QualityProfileDialog.dismissSafe()
|
this@QualityProfileDialog.dismissSafe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import androidx.appcompat.app.AlertDialog
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding
|
import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
import com.lagradost.cloudstream3.utils.Qualities
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||||
|
|
@ -16,7 +15,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||||
class SourcePriorityDialog(
|
class SourcePriorityDialog(
|
||||||
val ctx: Context,
|
val ctx: Context,
|
||||||
@StyleRes themeRes: Int,
|
@StyleRes themeRes: Int,
|
||||||
val links: List<ExtractorLink>,
|
val links: List<LinkSource>,
|
||||||
private val profile: QualityDataHelper.QualityProfile,
|
private val profile: QualityDataHelper.QualityProfile,
|
||||||
/**
|
/**
|
||||||
* Notify that the profile overview should be updated, for example if the name has been updated
|
* Notify that the profile overview should be updated, for example if the name has been updated
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchViewModel
|
import com.lagradost.cloudstream3.ui.search.SearchViewModel
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.ActorData
|
import com.lagradost.cloudstream3.ActorData
|
||||||
import com.lagradost.cloudstream3.ActorRole
|
import com.lagradost.cloudstream3.ActorRole
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -14,6 +13,7 @@ import com.lagradost.cloudstream3.databinding.CastItemBinding
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.newSharedPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
|
|
@ -26,7 +26,7 @@ class ActorAdaptor(
|
||||||
})) {
|
})) {
|
||||||
companion object {
|
companion object {
|
||||||
val sharedPool =
|
val sharedPool =
|
||||||
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) }
|
newSharedPool { setMaxRecycledViews(CONTENT, 10) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Easier to store it here than to store it in the ActorData
|
// Easier to store it here than to store it in the ActorData
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.lagradost.cloudstream3.ui.result
|
package com.lagradost.cloudstream3.ui.result
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -8,8 +7,6 @@ import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.setPadding
|
import androidx.core.view.setPadding
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import coil3.dispose
|
import coil3.dispose
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||||
import com.lagradost.cloudstream3.CommonActivity
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
|
|
@ -24,6 +21,7 @@ import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
|
import com.lagradost.cloudstream3.ui.newSharedPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
|
@ -32,7 +30,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
import com.lagradost.cloudstream3.utils.setText
|
import com.lagradost.cloudstream3.utils.setText
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
|
|
@ -92,11 +91,10 @@ class EpisodeAdapter(
|
||||||
}
|
}
|
||||||
|
|
||||||
val sharedPool =
|
val sharedPool =
|
||||||
RecyclerView.RecycledViewPool()
|
newSharedPool {
|
||||||
.apply {
|
setMaxRecycledViews(HAS_POSTER or CONTENT, 10)
|
||||||
this.setMaxRecycledViews(HAS_POSTER or CONTENT, 10)
|
setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10)
|
||||||
this.setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClearView(holder: ViewHolderState<Any>) {
|
override fun onClearView(holder: ViewHolderState<Any>) {
|
||||||
|
|
@ -160,7 +158,7 @@ class EpisodeAdapter(
|
||||||
|
|
||||||
downloadButton.isVisible = hasDownloadSupport
|
downloadButton.isVisible = hasDownloadSupport
|
||||||
downloadButton.setDefaultClickListener(
|
downloadButton.setDefaultClickListener(
|
||||||
VideoDownloadHelper.DownloadEpisodeCached(
|
DownloadObjects.DownloadEpisodeCached(
|
||||||
name = item.name,
|
name = item.name,
|
||||||
poster = item.poster,
|
poster = item.poster,
|
||||||
episode = item.episode,
|
episode = item.episode,
|
||||||
|
|
@ -199,6 +197,11 @@ class EpisodeAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val status = VideoDownloadManager.downloadStatus[item.id]
|
||||||
|
downloadButton.resetView()
|
||||||
|
downloadButton.setPersistentId(item.id)
|
||||||
|
downloadButton.setStatus(status)
|
||||||
|
|
||||||
val name =
|
val name =
|
||||||
if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}"
|
if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}"
|
||||||
episodeFiller.isVisible = item.isFiller == true
|
episodeFiller.isVisible = item.isFiller == true
|
||||||
|
|
@ -376,7 +379,7 @@ class EpisodeAdapter(
|
||||||
binding.apply {
|
binding.apply {
|
||||||
downloadButton.isVisible = hasDownloadSupport
|
downloadButton.isVisible = hasDownloadSupport
|
||||||
downloadButton.setDefaultClickListener(
|
downloadButton.setDefaultClickListener(
|
||||||
VideoDownloadHelper.DownloadEpisodeCached(
|
DownloadObjects.DownloadEpisodeCached(
|
||||||
name = item.name,
|
name = item.name,
|
||||||
poster = item.poster,
|
poster = item.poster,
|
||||||
episode = item.episode,
|
episode = item.episode,
|
||||||
|
|
@ -415,6 +418,11 @@ class EpisodeAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val status = VideoDownloadManager.downloadStatus[item.id]
|
||||||
|
downloadButton.resetView()
|
||||||
|
downloadButton.setPersistentId(item.id)
|
||||||
|
downloadButton.setStatus(status)
|
||||||
|
|
||||||
val name =
|
val name =
|
||||||
if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}"
|
if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}"
|
||||||
episodeFiller.isVisible = item.isFiller == true
|
episodeFiller.isVisible = item.isFiller == true
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ package com.lagradost.cloudstream3.ui.result
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding
|
import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.newSharedPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
|
|
@ -27,7 +27,7 @@ class ImageAdapter(
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val sharedPool =
|
val sharedPool =
|
||||||
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) }
|
newSharedPool { setMaxRecycledViews(CONTENT, 10) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ import android.annotation.SuppressLint
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.animation.AlphaAnimation
|
import android.view.animation.AlphaAnimation
|
||||||
|
|
@ -18,18 +17,19 @@ import android.view.animation.DecelerateInterpolator
|
||||||
import android.widget.AbsListView
|
import android.widget.AbsListView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.widget.NestedScrollView
|
import androidx.core.widget.NestedScrollView
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.discord.panels.OverlappingPanelsLayout
|
import com.discord.panels.OverlappingPanelsLayout
|
||||||
import com.discord.panels.PanelState
|
import com.discord.panels.PanelState
|
||||||
import com.discord.panels.PanelsChildGestureRegionObserver
|
import com.discord.panels.PanelsChildGestureRegionObserver
|
||||||
import com.google.android.gms.cast.framework.CastButtonFactory
|
import com.google.android.gms.cast.framework.CastButtonFactory
|
||||||
import com.google.android.gms.cast.framework.CastContext
|
import com.google.android.gms.cast.framework.CastContext
|
||||||
import com.google.android.gms.cast.framework.CastState
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
|
|
@ -45,25 +45,34 @@ import com.lagradost.cloudstream3.databinding.FragmentResultBinding
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
|
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
|
||||||
import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding
|
import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding
|
||||||
import com.lagradost.cloudstream3.databinding.ResultSyncBinding
|
import com.lagradost.cloudstream3.databinding.ResultSyncBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
|
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
|
||||||
|
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup
|
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup
|
||||||
|
import com.lagradost.cloudstream3.ui.player.CS3IPlayer
|
||||||
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
||||||
import com.lagradost.cloudstream3.ui.player.FullScreenPlayer
|
import com.lagradost.cloudstream3.ui.player.IPlayer
|
||||||
|
import com.lagradost.cloudstream3.ui.player.PlayerView
|
||||||
|
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
|
||||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData
|
import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
|
import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral.Companion.pickDownloadPath
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
|
||||||
|
|
@ -72,6 +81,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings
|
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
|
|
@ -87,14 +97,20 @@ import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems
|
import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||||
import com.lagradost.cloudstream3.utils.getImageFromDrawable
|
import com.lagradost.cloudstream3.utils.getImageFromDrawable
|
||||||
import com.lagradost.cloudstream3.utils.setText
|
import com.lagradost.cloudstream3.utils.setText
|
||||||
import com.lagradost.cloudstream3.utils.setTextHtml
|
import com.lagradost.cloudstream3.utils.setTextHtml
|
||||||
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.util.concurrent.ConcurrentLinkedDeque
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
open class ResultFragmentPhone : FullScreenPlayer() {
|
open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
||||||
|
BindingCreator.Inflate(FragmentResultSwipeBinding::inflate)
|
||||||
|
), PlayerView.Callbacks {
|
||||||
private val gestureRegionsListener =
|
private val gestureRegionsListener =
|
||||||
object : PanelsChildGestureRegionObserver.GestureRegionsListener {
|
object : PanelsChildGestureRegionObserver.GestureRegionsListener {
|
||||||
override fun onGestureRegionsUpdate(gestureRegions: List<Rect>) {
|
override fun onGestureRegionsUpdate(gestureRegions: List<Rect>) {
|
||||||
|
|
@ -102,34 +118,105 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Queue of pending actions that is deferred to after a custom path is set */
|
||||||
|
private val pendingPathActions = ConcurrentLinkedDeque<Pair<Int, ResultEpisode>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends all actions to a queue, and asks for a user to enter the download folder if not already set up.
|
||||||
|
*
|
||||||
|
* Then processes the queue in the given order, only after the user has selected a folder.
|
||||||
|
* This is to defer the download to after a file path is set, due to perms.
|
||||||
|
* */
|
||||||
|
private fun requirePathForActions(list: Collection<Pair<Int, ResultEpisode>>) {
|
||||||
|
pendingPathActions.addAll(list)
|
||||||
|
val (_, path) = context?.getBasePath() ?: return
|
||||||
|
if (path == null) {
|
||||||
|
/** If we have not set any download path, then ask the user for it before we download it */
|
||||||
|
try {
|
||||||
|
/** Give the user some info of what we are doing and why, even if it may be missed */
|
||||||
|
showToast(R.string.download_path_pref)
|
||||||
|
pathPicker.launch(Uri.EMPTY)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t)
|
||||||
|
/** Something went wrong, TV Device?
|
||||||
|
* Use the fallback behavior of just downloading it even if no path is selected,
|
||||||
|
* and hope it works */
|
||||||
|
processPendingActions()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* Otherwise dispatch everything, as we already have a valid download path
|
||||||
|
* Even if this is "wrong", we do not care as the user has entered something
|
||||||
|
* */
|
||||||
|
processPendingActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all the items in the queue and dispatch them to the viewmodel in order */
|
||||||
|
private fun processPendingActions() = viewModel.viewModelScope.launchSafe {
|
||||||
|
while (!pendingPathActions.isEmpty()) {
|
||||||
|
try {
|
||||||
|
val (action, data) = pendingPathActions.pop()
|
||||||
|
viewModel.handleAction(
|
||||||
|
EpisodeClickEvent(
|
||||||
|
action,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (_: NoSuchElementException) {
|
||||||
|
/** In case of a race */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val pathPicker = getChooseFolderLauncher { uri, path ->
|
||||||
|
if (uri == null) {
|
||||||
|
/** No path selected, clear the list without acting on it, canceling */
|
||||||
|
if (!pendingPathActions.isEmpty()) {
|
||||||
|
/** Only show on non-empty, just in case */
|
||||||
|
showToast(R.string.download_canceled)
|
||||||
|
pendingPathActions.clear()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/** Select the folder, and dispatch everything */
|
||||||
|
pickDownloadPath(uri, path)
|
||||||
|
processPendingActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected lateinit var viewModel: ResultViewModel2
|
protected lateinit var viewModel: ResultViewModel2
|
||||||
protected lateinit var syncModel: SyncViewModel
|
protected lateinit var syncModel: SyncViewModel
|
||||||
|
|
||||||
protected var binding: FragmentResultSwipeBinding? = null
|
|
||||||
protected var resultBinding: FragmentResultBinding? = null
|
protected var resultBinding: FragmentResultBinding? = null
|
||||||
protected var recommendationBinding: ResultRecommendationsBinding? = null
|
protected var recommendationBinding: ResultRecommendationsBinding? = null
|
||||||
protected var syncBinding: ResultSyncBinding? = null
|
protected var syncBinding: ResultSyncBinding? = null
|
||||||
|
|
||||||
override var layout = R.layout.fragment_result_swipe
|
var player: IPlayer = CS3IPlayer()
|
||||||
|
protected open var hasPipModeSupport: Boolean = false
|
||||||
|
protected open var isFullScreenPlayer: Boolean = true
|
||||||
|
protected open var lockRotation: Boolean = true
|
||||||
|
protected var playerBinding: TrailerCustomLayoutBinding? = null
|
||||||
|
protected var isShowing: Boolean = false
|
||||||
|
|
||||||
override fun onCreateView(
|
protected var playerHostView: PlayerView? = null
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
|
|
||||||
syncModel = ViewModelProvider(this)[SyncViewModel::class.java]
|
|
||||||
updateUIEvent += ::updateUI
|
|
||||||
|
|
||||||
val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null
|
open fun updateUIVisibility() {}
|
||||||
FragmentResultSwipeBinding.bind(root).let { bind ->
|
|
||||||
resultBinding = bind.fragmentResult
|
|
||||||
recommendationBinding = bind.resultRecommendations
|
|
||||||
syncBinding = bind.resultSync
|
|
||||||
binding = bind
|
|
||||||
}
|
|
||||||
|
|
||||||
return root
|
protected fun uiReset() {
|
||||||
|
isShowing = false
|
||||||
|
updateUIVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun showMirrorsDialogue() {}
|
||||||
|
open fun showTracksDialogue() {}
|
||||||
|
open fun openOnlineSubPicker(
|
||||||
|
context: android.content.Context,
|
||||||
|
loadResponse: LoadResponse?,
|
||||||
|
dismissCallback: () -> Unit
|
||||||
|
) {}
|
||||||
|
|
||||||
|
override fun fixLayout(view: View) {
|
||||||
|
fixSystemBarsPadding(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
@ -153,7 +240,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
|
|
||||||
override fun playerError(exception: Throwable) {
|
override fun playerError(exception: Throwable) {
|
||||||
if (player.getIsPlaying()) { // because we don't want random toasts in player
|
if (player.getIsPlaying()) { // because we don't want random toasts in player
|
||||||
super.playerError(exception)
|
playerHostView?.playerError(exception)
|
||||||
} else {
|
} else {
|
||||||
nextMirror()
|
nextMirror()
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +340,8 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUIEvent -= ::updateUI
|
updateUIEvent -= ::updateUI
|
||||||
binding = null
|
playerHostView?.release()
|
||||||
|
playerBinding = null
|
||||||
resultBinding?.resultScroll?.setOnClickListener(null)
|
resultBinding?.resultScroll?.setOnClickListener(null)
|
||||||
resultBinding = null
|
resultBinding = null
|
||||||
syncBinding = null
|
syncBinding = null
|
||||||
|
|
@ -277,7 +365,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
|
|
||||||
var selectSeason: String? = null
|
var selectSeason: String? = null
|
||||||
var selectEpisodeRange: String? = null
|
var selectEpisodeRange: String? = null
|
||||||
var selectSort: EpisodeSortType? = null
|
|
||||||
|
|
||||||
private fun setUrl(url: String?) {
|
private fun setUrl(url: String?) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
|
|
@ -320,6 +407,10 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
afterPluginsLoadedEvent += ::reloadViewModel
|
afterPluginsLoadedEvent += ::reloadViewModel
|
||||||
activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
||||||
|
context?.let { ctx ->
|
||||||
|
playerHostView?.onResume(ctx)
|
||||||
|
playerHostView?.setupKeyEventListener()
|
||||||
|
}
|
||||||
super.onResume()
|
super.onResume()
|
||||||
PanelsChildGestureRegionObserver.Provider.get()
|
PanelsChildGestureRegionObserver.Provider.get()
|
||||||
.addGestureRegionsUpdateListener(gestureRegionsListener)
|
.addGestureRegionsUpdateListener(gestureRegionsListener)
|
||||||
|
|
@ -327,30 +418,44 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
afterPluginsLoadedEvent -= ::reloadViewModel
|
afterPluginsLoadedEvent -= ::reloadViewModel
|
||||||
|
playerHostView?.onStop()
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
private fun updateUI(id: Int?) {
|
private fun updateUI(id: Int?) {
|
||||||
syncModel.updateUserData()
|
syncModel.updateUserData()
|
||||||
viewModel.reloadEpisodes()
|
viewModel.reloadEpisodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) {
|
||||||
super.onConfigurationChanged(newConfig)
|
// Set up sub-binding references
|
||||||
view?.let { fixSystemBarsPadding(it) }
|
viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
|
||||||
}
|
syncModel = ViewModelProvider(this)[SyncViewModel::class.java]
|
||||||
|
updateUIEvent += ::updateUI
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
resultBinding = binding.fragmentResult
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
recommendationBinding = binding.resultRecommendations
|
||||||
super.onViewCreated(view, savedInstanceState)
|
syncBinding = binding.resultSync
|
||||||
|
|
||||||
|
// Set up trailer player
|
||||||
|
val ctx = context ?: return
|
||||||
|
playerHostView = PlayerView(ctx)
|
||||||
|
playerHostView?.player = player
|
||||||
|
playerHostView?.hasPipModeSupport = hasPipModeSupport
|
||||||
|
playerHostView?.callbacks = this
|
||||||
|
playerHostView?.bindViews(binding.root)
|
||||||
|
playerBinding = binding.root.findViewById<View?>(R.id.player_holder)?.let {
|
||||||
|
TrailerCustomLayoutBinding.bind(it)
|
||||||
|
}
|
||||||
|
playerHostView?.initialize()
|
||||||
|
|
||||||
// ===== setup =====
|
// ===== setup =====
|
||||||
fixSystemBarsPadding(view)
|
|
||||||
val storedData = getStoredData() ?: return
|
val storedData = getStoredData() ?: return
|
||||||
activity?.window?.decorView?.clearFocus()
|
activity?.window?.decorView?.clearFocus()
|
||||||
activity?.loadCache()
|
activity?.loadCache()
|
||||||
context?.updateHasTrailers()
|
context?.updateHasTrailers()
|
||||||
hideKeyboard()
|
hideKeyboard(binding.root)
|
||||||
if (storedData.restart || !viewModel.hasLoaded())
|
if (storedData.restart || !viewModel.hasLoaded())
|
||||||
viewModel.load(
|
viewModel.load(
|
||||||
activity,
|
activity,
|
||||||
|
|
@ -368,7 +473,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
// This may not be 100% reliable, and may delay for small period
|
// This may not be 100% reliable, and may delay for small period
|
||||||
// before resultCastItems will be scrollable again, but this does work
|
// before resultCastItems will be scrollable again, but this does work
|
||||||
// most of the time.
|
// most of the time.
|
||||||
binding?.resultOverlappingPanels?.registerEndPanelStateListeners(
|
binding.resultOverlappingPanels.registerEndPanelStateListeners(
|
||||||
object : OverlappingPanelsLayout.PanelStateListener {
|
object : OverlappingPanelsLayout.PanelStateListener {
|
||||||
override fun onPanelStateChange(panelState: PanelState) {
|
override fun onPanelStateChange(panelState: PanelState) {
|
||||||
PanelsChildGestureRegionObserver.Provider.get().apply {
|
PanelsChildGestureRegionObserver.Provider.get().apply {
|
||||||
|
|
@ -380,8 +485,8 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
|
|
||||||
// ===== ===== =====
|
// ===== ===== =====
|
||||||
|
|
||||||
binding?.resultSearch?.isGone = storedData.name.isBlank()
|
binding.resultSearch.isGone = storedData.name.isBlank()
|
||||||
binding?.resultSearch?.setOnClickListener {
|
binding.resultSearch.setOnClickListener {
|
||||||
QuickSearchFragment.pushSearch(activity, storedData.name)
|
QuickSearchFragment.pushSearch(activity, storedData.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,7 +515,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
focused: View?
|
focused: View?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Make the cast always focus the first visible item when focused
|
// Make the cast always focus the first visible item when focused
|
||||||
// from somewhere else. Otherwise it jumps to the last item.
|
// from somewhere else. Otherwise, it jumps to the last item.
|
||||||
return if (parent.focusedChild == null) {
|
return if (parent.focusedChild == null) {
|
||||||
scrollToPosition(this.findFirstCompletelyVisibleItemPosition())
|
scrollToPosition(this.findFirstCompletelyVisibleItemPosition())
|
||||||
true
|
true
|
||||||
|
|
@ -428,7 +533,13 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
EpisodeAdapter(
|
EpisodeAdapter(
|
||||||
api?.hasDownloadSupport == true,
|
api?.hasDownloadSupport == true,
|
||||||
{ episodeClick ->
|
{ episodeClick ->
|
||||||
viewModel.handleAction(episodeClick)
|
when (episodeClick.action) {
|
||||||
|
ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> {
|
||||||
|
requirePathForActions(listOf(episodeClick.action to episodeClick.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> viewModel.handleAction(episodeClick)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ downloadClickEvent ->
|
{ downloadClickEvent ->
|
||||||
DownloadButtonSetup.handleDownloadClick(downloadClickEvent)
|
DownloadButtonSetup.handleDownloadClick(downloadClickEvent)
|
||||||
|
|
@ -463,9 +574,9 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||||
val dy = scrollY - oldScrollY
|
val dy = scrollY - oldScrollY
|
||||||
if (dy > 0) { //check for scroll down
|
if (dy > 0) { //check for scroll down
|
||||||
binding?.resultBookmarkFab?.shrink()
|
binding.resultBookmarkFab.shrink()
|
||||||
} else if (dy < -5) {
|
} else if (dy < -5) {
|
||||||
binding?.resultBookmarkFab?.extend()
|
binding.resultBookmarkFab.extend()
|
||||||
}
|
}
|
||||||
if (!isFullScreenPlayer && player.getIsPlaying()) {
|
if (!isFullScreenPlayer && player.getIsPlaying()) {
|
||||||
if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height
|
if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height
|
||||||
|
|
@ -477,7 +588,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
binding?.apply {
|
binding.apply {
|
||||||
resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
|
resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
|
||||||
resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
|
resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
|
||||||
resultBack.setOnClickListener {
|
resultBack.setOnClickListener {
|
||||||
|
|
@ -490,6 +601,13 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
} else resultOverlappingPanels.closePanels()
|
} else resultOverlappingPanels.closePanels()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultMiniSync.setOnClickListener {
|
||||||
|
if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) {
|
||||||
|
resultOverlappingPanels.openStartPanel()
|
||||||
|
} else resultOverlappingPanels.closePanels()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool)
|
resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool)
|
||||||
resultMiniSync.adapter = ImageAdapter(
|
resultMiniSync.adapter = ImageAdapter(
|
||||||
nextFocusDown = R.id.result_sync_set_score,
|
nextFocusDown = R.id.result_sync_set_score,
|
||||||
|
|
@ -500,6 +618,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
} else resultOverlappingPanels.closePanels()
|
} else resultOverlappingPanels.closePanels()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
*/
|
||||||
resultSubscribe.setOnClickListener {
|
resultSubscribe.setOnClickListener {
|
||||||
viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
|
viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
|
||||||
if (newStatus == null) return@toggleSubscriptionStatus
|
if (newStatus == null) return@toggleSubscriptionStatus
|
||||||
|
|
@ -662,7 +781,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
observeNullable(viewModel.subscribeStatus) { isSubscribed ->
|
observeNullable(viewModel.subscribeStatus) { isSubscribed ->
|
||||||
binding?.resultSubscribe?.isVisible = isSubscribed != null
|
binding.resultSubscribe.isVisible = isSubscribed != null
|
||||||
if (isSubscribed == null) return@observeNullable
|
if (isSubscribed == null) return@observeNullable
|
||||||
|
|
||||||
val drawable = if (isSubscribed) {
|
val drawable = if (isSubscribed) {
|
||||||
|
|
@ -671,11 +790,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
R.drawable.baseline_notifications_none_24
|
R.drawable.baseline_notifications_none_24
|
||||||
}
|
}
|
||||||
|
|
||||||
binding?.resultSubscribe?.setImageResource(drawable)
|
binding.resultSubscribe.setImageResource(drawable)
|
||||||
}
|
}
|
||||||
|
|
||||||
observeNullable(viewModel.favoriteStatus) { isFavorite ->
|
observeNullable(viewModel.favoriteStatus) { isFavorite ->
|
||||||
binding?.resultFavorite?.isVisible = isFavorite != null
|
binding.resultFavorite.isVisible = isFavorite != null
|
||||||
if (isFavorite == null) return@observeNullable
|
if (isFavorite == null) return@observeNullable
|
||||||
|
|
||||||
val drawable = if (isFavorite) {
|
val drawable = if (isFavorite) {
|
||||||
|
|
@ -684,7 +803,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
R.drawable.ic_baseline_favorite_border_24
|
R.drawable.ic_baseline_favorite_border_24
|
||||||
}
|
}
|
||||||
|
|
||||||
binding?.resultFavorite?.setImageResource(drawable)
|
binding.resultFavorite.setImageResource(drawable)
|
||||||
}
|
}
|
||||||
|
|
||||||
observeNullable(viewModel.episodes) { episodes ->
|
observeNullable(viewModel.episodes) { episodes ->
|
||||||
|
|
@ -692,8 +811,58 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
// no failure?
|
// no failure?
|
||||||
resultEpisodeLoading.isVisible = episodes is Resource.Loading
|
resultEpisodeLoading.isVisible = episodes is Resource.Loading
|
||||||
resultEpisodes.isVisible = episodes is Resource.Success
|
resultEpisodes.isVisible = episodes is Resource.Success
|
||||||
|
resultBatchDownloadButton.isVisible =
|
||||||
|
episodes is Resource.Success && episodes.value.isNotEmpty()
|
||||||
|
|
||||||
if (episodes is Resource.Success) {
|
if (episodes is Resource.Success) {
|
||||||
(resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value)
|
(resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value)
|
||||||
|
|
||||||
|
// Show quality dialog with all sources
|
||||||
|
resultBatchDownloadButton.setOnLongClickListener {
|
||||||
|
ioSafe {
|
||||||
|
val defaultSources = QualityProfileDialog.getAllDefaultSources()
|
||||||
|
val activity = activity ?: return@ioSafe
|
||||||
|
activity.runOnUiThread {
|
||||||
|
QualityProfileDialog(
|
||||||
|
activity,
|
||||||
|
R.style.DialogFullscreenPlayer,
|
||||||
|
defaultSources,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBatchDownloadButton.setOnClickListener { view ->
|
||||||
|
val episodeStart =
|
||||||
|
episodes.value.firstOrNull()?.episode ?: return@setOnClickListener
|
||||||
|
val episodeEnd =
|
||||||
|
episodes.value.lastOrNull()?.episode ?: return@setOnClickListener
|
||||||
|
|
||||||
|
val episodeRange = if (episodeStart == episodeEnd) {
|
||||||
|
episodeStart.toString()
|
||||||
|
} else {
|
||||||
|
txt(
|
||||||
|
R.string.episodes_range,
|
||||||
|
episodeStart,
|
||||||
|
episodeEnd
|
||||||
|
).asString(view.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rangeMessage = txt(
|
||||||
|
R.string.download_episode_range,
|
||||||
|
episodeRange
|
||||||
|
).asString(view.context)
|
||||||
|
|
||||||
|
AlertDialog.Builder(view.context, R.style.AlertDialogCustom)
|
||||||
|
.setTitle(R.string.download_all)
|
||||||
|
.setMessage(rangeMessage)
|
||||||
|
.setPositiveButton(R.string.yes) { _, _ ->
|
||||||
|
requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it })
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel) { _, _ -> }.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -723,8 +892,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
)
|
)
|
||||||
return@setOnLongClickListener true
|
return@setOnLongClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val status = VideoDownloadManager.downloadStatus[ep.id]
|
||||||
|
downloadButton.setStatus(status)
|
||||||
downloadButton.setDefaultClickListener(
|
downloadButton.setDefaultClickListener(
|
||||||
VideoDownloadHelper.DownloadEpisodeCached(
|
DownloadObjects.DownloadEpisodeCached(
|
||||||
name = ep.name,
|
name = ep.name,
|
||||||
poster = ep.poster,
|
poster = ep.poster,
|
||||||
episode = 0,
|
episode = 0,
|
||||||
|
|
@ -741,18 +913,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
|
|
||||||
when (click.action) {
|
when (click.action) {
|
||||||
DOWNLOAD_ACTION_DOWNLOAD -> {
|
DOWNLOAD_ACTION_DOWNLOAD -> {
|
||||||
viewModel.handleAction(
|
requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep))
|
||||||
EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||||
viewModel.handleAction(
|
requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep))
|
||||||
EpisodeClickEvent(
|
|
||||||
ACTION_DOWNLOAD_MIRROR,
|
|
||||||
ep
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> DownloadButtonSetup.handleDownloadClick(click)
|
else -> DownloadButtonSetup.handleDownloadClick(click)
|
||||||
|
|
@ -826,8 +991,15 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
resultComingSoon.isVisible = d.comingSoon
|
resultComingSoon.isVisible = d.comingSoon
|
||||||
resultDataHolder.isGone = d.comingSoon
|
resultDataHolder.isGone = d.comingSoon
|
||||||
|
|
||||||
resultCastItems.isGone = d.actors.isNullOrEmpty()
|
val prefs =
|
||||||
(resultCastItems.adapter as? ActorAdaptor)?.submitList(d.actors)
|
androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
|
||||||
|
val showCast = prefs.getBoolean(
|
||||||
|
root.context.getString(R.string.show_cast_in_details_key),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty()
|
||||||
|
(resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList())
|
||||||
|
|
||||||
if (d.contentRatingText == null) {
|
if (d.contentRatingText == null) {
|
||||||
// If there is no rating to display, we don't want an empty gap
|
// If there is no rating to display, we don't want an empty gap
|
||||||
|
|
@ -841,7 +1013,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
syncModel.addFromUrl(d.url)
|
syncModel.addFromUrl(d.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding?.apply {
|
binding.apply {
|
||||||
resultSearch.isGone = d.title.isBlank()
|
resultSearch.isGone = d.title.isBlank()
|
||||||
resultSearch.setOnClickListener {
|
resultSearch.setOnClickListener {
|
||||||
QuickSearchFragment.pushSearch(activity, d.title)
|
QuickSearchFragment.pushSearch(activity, d.title)
|
||||||
|
|
@ -876,10 +1048,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
(data as? Resource.Failure)?.let { data ->
|
(data as? Resource.Failure)?.let { data ->
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
resultErrorText.text = storedData.url.plus("\n") + data.errorString
|
resultErrorText.text = storedData.url.plus("\n") + data.errorString
|
||||||
}
|
}
|
||||||
|
|
||||||
binding?.resultBookmarkFab?.isVisible = data is Resource.Success
|
binding.resultBookmarkFab.isVisible = data is Resource.Success
|
||||||
resultFinishLoading.isVisible = data is Resource.Success
|
resultFinishLoading.isVisible = data is Resource.Success
|
||||||
|
|
||||||
resultLoading.isVisible = data is Resource.Loading
|
resultLoading.isVisible = data is Resource.Loading
|
||||||
|
|
@ -927,7 +1100,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(viewModel.trailers) { trailers ->
|
observe(viewModel.trailers) { trailers ->
|
||||||
setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet!
|
setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet!
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(syncModel.synced) { list ->
|
observe(syncModel.synced) { list ->
|
||||||
|
|
@ -936,8 +1109,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
|
|
||||||
val newList = list.filter { it.isSynced && it.hasAccount }
|
val newList = list.filter { it.isSynced && it.hasAccount }
|
||||||
|
|
||||||
binding?.resultMiniSync?.isVisible = newList.isNotEmpty()
|
binding.resultMiniSync.isVisible = newList.isNotEmpty()
|
||||||
(binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1032,7 +1204,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED)
|
binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED)
|
||||||
}
|
}
|
||||||
observe(viewModel.recommendations) { recommendations ->
|
observe(viewModel.recommendations) { recommendations ->
|
||||||
setRecommendations(recommendations, null)
|
setRecommendations(recommendations, null)
|
||||||
|
|
@ -1093,7 +1265,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(viewModel.watchStatus) { watchType ->
|
observe(viewModel.watchStatus) { watchType ->
|
||||||
binding?.resultBookmarkFab?.apply {
|
binding.resultBookmarkFab.apply {
|
||||||
setText(watchType.stringRes)
|
setText(watchType.stringRes)
|
||||||
if (watchType == WatchType.NONE) {
|
if (watchType == WatchType.NONE) {
|
||||||
context?.colorFromAttribute(R.attr.white)
|
context?.colorFromAttribute(R.attr.white)
|
||||||
|
|
@ -1148,6 +1320,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
viewModel.skipLoading()
|
viewModel.skipLoading()
|
||||||
}
|
}
|
||||||
isVisible = true
|
isVisible = true
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})"
|
text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1268,6 +1441,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
playerHostView?.releaseKeyEventListener()
|
||||||
super.onPause()
|
super.onPause()
|
||||||
PanelsChildGestureRegionObserver.Provider.get()
|
PanelsChildGestureRegionObserver.Provider.get()
|
||||||
.addGestureRegionsUpdateListener(gestureRegionsListener)
|
.addGestureRegionsUpdateListener(gestureRegionsListener)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
|
@ -61,7 +62,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
||||||
import com.lagradost.cloudstream3.utils.UiImage
|
|
||||||
import com.lagradost.cloudstream3.utils.getImageFromDrawable
|
import com.lagradost.cloudstream3.utils.getImageFromDrawable
|
||||||
import com.lagradost.cloudstream3.utils.setText
|
import com.lagradost.cloudstream3.utils.setText
|
||||||
import com.lagradost.cloudstream3.utils.setTextHtml
|
import com.lagradost.cloudstream3.utils.setTextHtml
|
||||||
|
|
@ -559,7 +559,7 @@ class ResultFragmentTv : BaseFragment<FragmentResultTvBinding>(
|
||||||
ExtractorLinkGenerator(
|
ExtractorLinkGenerator(
|
||||||
extractedTrailerLinks,
|
extractedTrailerLinks,
|
||||||
emptyList()
|
emptyList()
|
||||||
)
|
), 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -925,11 +925,16 @@ class ResultFragmentTv : BaseFragment<FragmentResultTvBinding>(
|
||||||
resultTvComingSoon.isVisible = d.comingSoon
|
resultTvComingSoon.isVisible = d.comingSoon
|
||||||
|
|
||||||
populateChips(resultTag, d.tags)
|
populateChips(resultTag, d.tags)
|
||||||
resultCastItems.isGone = d.actors.isNullOrEmpty()
|
val prefs =
|
||||||
(resultCastItems.adapter as? ActorAdaptor)?.submitList(
|
androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
|
||||||
d.actors
|
val showCast = prefs.getBoolean(
|
||||||
|
root.context.getString(R.string.show_cast_in_details_key),
|
||||||
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty()
|
||||||
|
(resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList())
|
||||||
|
|
||||||
if (d.contentRatingText == null) {
|
if (d.contentRatingText == null) {
|
||||||
// If there is no rating to display, we don't want an empty gap
|
// If there is no rating to display, we don't want an empty gap
|
||||||
resultMetaContentRating.width = 0
|
resultMetaContentRating.width = 0
|
||||||
|
|
|
||||||
|
|
@ -5,41 +5,74 @@ import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.screenHeight
|
import com.lagradost.cloudstream3.CommonActivity.screenHeight
|
||||||
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
|
||||||
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
||||||
|
import com.lagradost.cloudstream3.ui.player.CSPlayerLoading
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerEventSource
|
import com.lagradost.cloudstream3.ui.player.PlayerEventSource
|
||||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||||
|
|
||||||
open class ResultTrailerPlayer : ResultFragmentPhone() {
|
class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||||
|
|
||||||
override var lockRotation = false
|
override var lockRotation = false
|
||||||
override var isFullScreenPlayer = false
|
override var isFullScreenPlayer = false
|
||||||
override var hasPipModeSupport = false
|
override var hasPipModeSupport = false
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "RESULT_TRAILER"
|
const val TAG = "ResultTrailerPlayer"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playerWidthHeight: Pair<Int, Int>? = null
|
private var playerWidthHeight: Pair<Int, Int>? = null
|
||||||
|
private var introVisible = true
|
||||||
|
|
||||||
|
// Single-tap on empty player area: toggle controls.
|
||||||
|
override fun onSingleTap() {
|
||||||
|
if (introVisible) return
|
||||||
|
if (isShowing) uiReset() else showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showControls() {
|
||||||
|
if (introVisible) return
|
||||||
|
isShowing = true
|
||||||
|
updateUIVisibility()
|
||||||
|
playerHostView?.scheduleAutoHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isUIShowing(): Boolean = isShowing
|
||||||
|
|
||||||
|
override fun onAutoHideUI() {
|
||||||
|
if (player.getIsPlaying()) uiReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHidePlayerUI() = uiReset()
|
||||||
|
|
||||||
|
// When the hold-speedup gesture fires, hide controls so the video is unobstructed.
|
||||||
|
// The speedup button show/hide and speed change are handled by PlayerView.
|
||||||
|
override fun onHoldSpeedUp(show: Boolean) {
|
||||||
|
if (show && isShowing) uiReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {
|
||||||
|
if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) {
|
||||||
|
isShowing = true
|
||||||
|
showControls()
|
||||||
|
} else playerHostView?.scheduleAutoHide()
|
||||||
|
}
|
||||||
|
|
||||||
override fun nextEpisode() {}
|
override fun nextEpisode() {}
|
||||||
|
|
||||||
override fun prevEpisode() {}
|
override fun prevEpisode() {}
|
||||||
|
override fun playerPositionChanged(position: Long, duration: Long) {}
|
||||||
override fun playerPositionChanged(position: Long, duration : Long) {}
|
|
||||||
|
|
||||||
override fun nextMirror() {}
|
override fun nextMirror() {}
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
|
@ -49,33 +82,28 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fixPlayerSize() {
|
private fun fixPlayerSize() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
binding?.apply {
|
||||||
binding?.apply {
|
if (isFullScreenPlayer) {
|
||||||
if (isFullScreenPlayer) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// Remove listener
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(root, null)
|
ViewCompat.setOnApplyWindowInsetsListener(root, null)
|
||||||
root.overlay.clear() // Clear the cutout overlay
|
root.overlay.clear()
|
||||||
root.setPadding(0, 0, 0, 0) // Reset padding for full screen
|
}
|
||||||
} else {
|
root.setPadding(0, 0, 0, 0)
|
||||||
// Reapply padding when not in full screen
|
} else {
|
||||||
fixSystemBarsPadding(root)
|
fixSystemBarsPadding(root)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
ViewCompat.requestApplyInsets(root)
|
ViewCompat.requestApplyInsets(root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playerWidthHeight?.let { (w, h) ->
|
playerWidthHeight?.let { (w, h) ->
|
||||||
if(w <= 0 || h <= 0) return@let
|
if (w <= 0 || h <= 0) return@let
|
||||||
|
|
||||||
val orientation = context?.resources?.configuration?.orientation ?: return
|
val orientation = context?.resources?.configuration?.orientation ?: return
|
||||||
|
|
||||||
val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight
|
||||||
screenWidth
|
|
||||||
} else {
|
|
||||||
screenHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
//result_trailer_loading?.isVisible = false
|
|
||||||
resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer
|
resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer
|
||||||
binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer
|
binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer
|
||||||
|
|
||||||
|
|
@ -83,35 +111,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||||
|
|
||||||
resultBinding?.fragmentTrailer?.playerBackground?.apply {
|
resultBinding?.fragmentTrailer?.playerBackground?.apply {
|
||||||
isVisible = true
|
isVisible = true
|
||||||
layoutParams =
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams(
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to
|
||||||
if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playerBinding?.playerIntroPlay?.apply {
|
playerBinding?.playerIntroPlay?.apply {
|
||||||
layoutParams =
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams(
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
resultBinding?.resultTopHolder?.measuredHeight
|
)
|
||||||
?: FrameLayout.LayoutParams.MATCH_PARENT
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerBinding?.playerIntroPlay?.isGone == true) {
|
if (playerBinding?.playerIntroPlay?.isGone == true) {
|
||||||
resultBinding?.resultTopHolder?.apply {
|
resultBinding?.resultTopHolder?.apply {
|
||||||
|
|
||||||
val anim = ValueAnimator.ofInt(
|
val anim = ValueAnimator.ofInt(
|
||||||
measuredHeight,
|
measuredHeight,
|
||||||
if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to
|
if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to
|
||||||
)
|
)
|
||||||
anim.addUpdateListener { valueAnimator ->
|
anim.addUpdateListener { va ->
|
||||||
val `val` = valueAnimator.animatedValue as Int
|
val v = va.animatedValue as Int
|
||||||
val layoutParams: ViewGroup.LayoutParams =
|
val lp: ViewGroup.LayoutParams = layoutParams
|
||||||
layoutParams
|
lp.height = v
|
||||||
layoutParams.height = `val`
|
layoutParams = lp
|
||||||
setLayoutParams(layoutParams)
|
|
||||||
}
|
}
|
||||||
anim.duration = 200
|
anim.duration = 200
|
||||||
anim.start()
|
anim.start()
|
||||||
|
|
@ -120,9 +143,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun playerDimensionsLoaded(width: Int, height : Int) {
|
override fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||||
playerWidthHeight = width to height
|
playerWidthHeight = width to height
|
||||||
fixPlayerSize()
|
fixPlayerSize()
|
||||||
|
// Apply autorotation when fullscreen (lockRotation = true).
|
||||||
|
// PlayerView already set isVerticalOrientation before this callback fires.
|
||||||
|
if (lockRotation) {
|
||||||
|
activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun showMirrorsDialogue() {}
|
override fun showMirrorsDialogue() {}
|
||||||
|
|
@ -132,33 +160,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||||
context: Context,
|
context: Context,
|
||||||
loadResponse: LoadResponse?,
|
loadResponse: LoadResponse?,
|
||||||
dismissCallback: () -> Unit
|
dismissCallback: () -> Unit
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
override fun subtitlesChanged() {}
|
override fun subtitlesChanged() {}
|
||||||
|
|
||||||
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
|
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
|
||||||
override fun onTracksInfoChanged() {}
|
override fun onTracksInfoChanged() {}
|
||||||
|
|
||||||
override fun exitedPipMode() {}
|
override fun exitedPipMode() {}
|
||||||
|
|
||||||
|
override fun onSeekPreviewText(text: String?) {
|
||||||
|
playerBinding?.playerTimeText?.apply {
|
||||||
|
isVisible = text != null
|
||||||
|
if (text != null) this.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateFullscreen(fullscreen: Boolean) {
|
private fun updateFullscreen(fullscreen: Boolean) {
|
||||||
isFullScreenPlayer = fullscreen
|
isFullScreenPlayer = fullscreen
|
||||||
lockRotation = fullscreen
|
lockRotation = fullscreen
|
||||||
|
playerHostView?.isFullScreen = fullscreen
|
||||||
|
|
||||||
playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24)
|
playerBinding?.playerFullscreen?.setImageResource(
|
||||||
|
if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24
|
||||||
|
)
|
||||||
if (fullscreen) {
|
if (fullscreen) {
|
||||||
enterFullscreen()
|
playerHostView?.enterFullscreen()
|
||||||
binding?.apply {
|
binding?.apply {
|
||||||
resultTopBar.isVisible = false
|
resultTopBar.isVisible = false
|
||||||
resultFullscreenHolder.isVisible = true
|
resultFullscreenHolder.isVisible = true
|
||||||
resultMainHolder.isVisible = false
|
resultMainHolder.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
resultBinding?.fragmentTrailer?.playerBackground?.let { view ->
|
resultBinding?.fragmentTrailer?.playerBackground?.let { view ->
|
||||||
(view.parent as ViewGroup?)?.removeView(view)
|
(view.parent as ViewGroup?)?.removeView(view)
|
||||||
binding?.resultFullscreenHolder?.addView(view)
|
binding?.resultFullscreenHolder?.addView(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
binding?.apply {
|
binding?.apply {
|
||||||
resultTopBar.isVisible = true
|
resultTopBar.isVisible = true
|
||||||
|
|
@ -169,36 +203,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||||
resultBinding?.resultSmallscreenHolder?.addView(view)
|
resultBinding?.resultSmallscreenHolder?.addView(view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exitFullscreen()
|
playerHostView?.exitFullscreen()
|
||||||
}
|
}
|
||||||
fixPlayerSize()
|
fixPlayerSize()
|
||||||
uiReset()
|
uiReset()
|
||||||
|
|
||||||
if (isFullScreenPlayer) {
|
if (isFullScreenPlayer) {
|
||||||
activity?.attachBackPressedCallback("ResultTrailerPlayer") {
|
activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) }
|
||||||
updateFullscreen(false)
|
} else {
|
||||||
}
|
activity?.detachBackPressedCallback("ResultTrailerPlayer")
|
||||||
} else activity?.detachBackPressedCallback("ResultTrailerPlayer")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateUIVisibility() {
|
override fun updateUIVisibility() {
|
||||||
super.updateUIVisibility()
|
super.updateUIVisibility()
|
||||||
playerBinding?.playerGoBackHolder?.isVisible = false
|
playerBinding?.apply {
|
||||||
|
playerGoBackHolder.isVisible = false
|
||||||
|
val controlsVisible = isShowing && !introVisible
|
||||||
|
playerTopHolder.isVisible = controlsVisible
|
||||||
|
playerVideoHolder.isVisible = controlsVisible
|
||||||
|
shadowOverlay.isVisible = controlsVisible
|
||||||
|
playerPausePlayHolderHolder.isVisible =
|
||||||
|
controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering
|
||||||
|
}
|
||||||
|
// Fade center controls in/out; also resets stale fillAfter alpha from seek animations.
|
||||||
|
playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun playerStatusChanged() {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
if (introVisible) {
|
||||||
playerBinding?.playerFullscreen?.setOnClickListener {
|
playerBinding?.playerPausePlayHolderHolder?.isVisible = false
|
||||||
updateFullscreen(!isFullScreenPlayer)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
playerHostView?.videoOutline = playerBinding?.videoOutline
|
||||||
|
playerHostView?.requestUpdateBrightnessOverlayOnNextLayout()
|
||||||
|
|
||||||
|
playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) }
|
||||||
updateFullscreen(isFullScreenPlayer)
|
updateFullscreen(isFullScreenPlayer)
|
||||||
uiReset()
|
uiReset()
|
||||||
|
|
||||||
playerBinding?.playerIntroPlay?.setOnClickListener {
|
playerBinding?.playerIntroPlay?.setOnClickListener {
|
||||||
playerBinding?.playerIntroPlay?.isGone = true
|
playerBinding?.playerIntroPlay?.isGone = true
|
||||||
|
introVisible = false
|
||||||
player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
|
player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
|
||||||
updateUIVisibility()
|
|
||||||
fixPlayerSize()
|
fixPlayerSize()
|
||||||
|
showControls()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
package com.lagradost.cloudstream3.ui.result
|
package com.lagradost.cloudstream3.ui.result
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.*
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
|
|
@ -10,33 +11,67 @@ import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
import com.lagradost.cloudstream3.actions.AlwaysAskAction
|
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.apis
|
import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||||
|
import com.lagradost.cloudstream3.ActorData
|
||||||
|
import com.lagradost.cloudstream3.AnimeLoadResponse
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.CommonActivity.getCastSession
|
import com.lagradost.cloudstream3.CommonActivity.getCastSession
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
|
import com.lagradost.cloudstream3.DubStatus
|
||||||
|
import com.lagradost.cloudstream3.EpisodeResponse
|
||||||
|
import com.lagradost.cloudstream3.IDownloadableMinimum
|
||||||
|
import com.lagradost.cloudstream3.LiveStreamLoadResponse
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
|
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
|
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
|
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie
|
import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
|
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
|
||||||
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
|
import com.lagradost.cloudstream3.MovieLoadResponse
|
||||||
|
import com.lagradost.cloudstream3.ProviderType
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.Score
|
||||||
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
|
import com.lagradost.cloudstream3.SeasonData
|
||||||
|
import com.lagradost.cloudstream3.ShowStatus
|
||||||
|
import com.lagradost.cloudstream3.SimklSyncServices
|
||||||
|
import com.lagradost.cloudstream3.SubtitleFile
|
||||||
|
import com.lagradost.cloudstream3.TorrentLoadResponse
|
||||||
|
import com.lagradost.cloudstream3.TrackerType
|
||||||
|
import com.lagradost.cloudstream3.TrailerData
|
||||||
|
import com.lagradost.cloudstream3.TvSeriesLoadResponse
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.VPNStatus
|
||||||
|
import com.lagradost.cloudstream3.actions.AlwaysAskAction
|
||||||
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
|
import com.lagradost.cloudstream3.amap
|
||||||
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
|
import com.lagradost.cloudstream3.isLiveStream
|
||||||
import com.lagradost.cloudstream3.metaproviders.SyncRedirector
|
import com.lagradost.cloudstream3.metaproviders.SyncRedirector
|
||||||
import com.lagradost.cloudstream3.mvvm.*
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugException
|
||||||
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
|
import com.lagradost.cloudstream3.runAllAsync
|
||||||
|
import com.lagradost.cloudstream3.sortUrls
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository
|
import com.lagradost.cloudstream3.ui.APIRepository
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||||
import com.lagradost.cloudstream3.ui.player.IGenerator
|
|
||||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL
|
import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL
|
||||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST
|
import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST
|
||||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
|
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
|
||||||
|
|
@ -44,21 +79,22 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
|
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
|
||||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||||
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction
|
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
|
||||||
import com.lagradost.cloudstream3.utils.*
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
|
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.CastHelper.startCast
|
import com.lagradost.cloudstream3.utils.CastHelper.startCast
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.editor
|
import com.lagradost.cloudstream3.utils.DataStore.editor
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData
|
||||||
|
|
@ -85,10 +121,31 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName
|
import com.lagradost.cloudstream3.utils.Editor
|
||||||
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
|
import com.lagradost.cloudstream3.utils.FillerEpisodeCheck
|
||||||
|
import com.lagradost.cloudstream3.utils.INFER_TYPE
|
||||||
|
import com.lagradost.cloudstream3.utils.Qualities
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
|
import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||||
|
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle
|
||||||
|
import com.lagradost.cloudstream3.utils.loadExtractor
|
||||||
|
import com.lagradost.cloudstream3.utils.newExtractorLink
|
||||||
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelChildren
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
/** This starts at 1 */
|
/** This starts at 1 */
|
||||||
data class EpisodeRange(
|
data class EpisodeRange(
|
||||||
|
|
@ -262,11 +319,12 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData {
|
||||||
TvType.Live -> R.string.live_singular
|
TvType.Live -> R.string.live_singular
|
||||||
TvType.Others -> R.string.other_singular
|
TvType.Others -> R.string.other_singular
|
||||||
TvType.NSFW -> R.string.nsfw_singular
|
TvType.NSFW -> R.string.nsfw_singular
|
||||||
TvType.Music -> R.string.music_singlar
|
TvType.Music -> R.string.music_singular
|
||||||
TvType.AudioBook -> R.string.audio_book_singular
|
TvType.AudioBook -> R.string.audio_book_singular
|
||||||
TvType.CustomMedia -> R.string.custom_media_singluar
|
TvType.CustomMedia -> R.string.custom_media_singular
|
||||||
TvType.Audio -> R.string.audio_singluar
|
TvType.Audio -> R.string.audio_singular
|
||||||
TvType.Podcast -> R.string.podcast_singluar
|
TvType.Podcast -> R.string.podcast_singular
|
||||||
|
TvType.Video -> R.string.video_singular
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
yearText = txt(year?.toString()),
|
yearText = txt(year?.toString()),
|
||||||
|
|
@ -391,7 +449,7 @@ fun SelectPopup.getOptions(context: Context): List<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ExtractedTrailerData(
|
data class ExtractedTrailerData(
|
||||||
var mirros: List<Pair<ExtractorLink,String>>,//Pair of extracted trailer link and original trailer link
|
var mirros: List<Pair<ExtractorLink, String>>,//Pair of extracted trailer link and original trailer link
|
||||||
var subtitles: List<SubtitleFile> = emptyList(),
|
var subtitles: List<SubtitleFile> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -421,8 +479,8 @@ class ResultViewModel2 : ViewModel() {
|
||||||
private var currentShowFillers: Boolean = false
|
private var currentShowFillers: Boolean = false
|
||||||
var currentRepo: APIRepository? = null
|
var currentRepo: APIRepository? = null
|
||||||
private var currentId: Int? = null
|
private var currentId: Int? = null
|
||||||
private var fillers: Map<Int, Boolean> = emptyMap()
|
private var fillers: HashSet<Int> = hashSetOf()
|
||||||
private var generator: IGenerator? = null
|
private var generator: RepoLinkGenerator? = null
|
||||||
private var preferDubStatus: DubStatus? = null
|
private var preferDubStatus: DubStatus? = null
|
||||||
private var preferStartEpisode: Int? = null
|
private var preferStartEpisode: Int? = null
|
||||||
private var preferStartSeason: Int? = null
|
private var preferStartSeason: Int? = null
|
||||||
|
|
@ -667,228 +725,6 @@ class ResultViewModel2 : ViewModel() {
|
||||||
index to list
|
index to list
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadSubtitle(
|
|
||||||
context: Context?,
|
|
||||||
link: ExtractorSubtitleLink,
|
|
||||||
fileName: String,
|
|
||||||
folder: String
|
|
||||||
) {
|
|
||||||
ioSafe {
|
|
||||||
VideoDownloadManager.downloadThing(
|
|
||||||
context ?: return@ioSafe,
|
|
||||||
link,
|
|
||||||
"$fileName ${link.name}",
|
|
||||||
folder,
|
|
||||||
if (link.url.contains(".srt")) "srt" else "vtt",
|
|
||||||
false,
|
|
||||||
null, createNotificationCallback = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFolder(currentType: TvType, titleName: String): String {
|
|
||||||
return if (currentType.isEpisodeBased()) {
|
|
||||||
val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName)
|
|
||||||
"${currentType.getFolderPrefix()}/$sanitizedFileName"
|
|
||||||
} else currentType.getFolderPrefix()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downloadSubtitle(
|
|
||||||
context: Context?,
|
|
||||||
link: SubtitleData,
|
|
||||||
meta: VideoDownloadManager.DownloadEpisodeMetadata,
|
|
||||||
) {
|
|
||||||
context?.let { ctx ->
|
|
||||||
val fileName = VideoDownloadManager.getFileName(ctx, meta)
|
|
||||||
val folder = getFolder(meta.type ?: return, meta.mainName)
|
|
||||||
downloadSubtitle(
|
|
||||||
ctx,
|
|
||||||
ExtractorSubtitleLink(link.name, link.url, "", link.headers),
|
|
||||||
fileName,
|
|
||||||
folder
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startDownload(
|
|
||||||
context: Context?,
|
|
||||||
episode: ResultEpisode,
|
|
||||||
currentIsMovie: Boolean,
|
|
||||||
currentHeaderName: String,
|
|
||||||
currentType: TvType,
|
|
||||||
currentPoster: String?,
|
|
||||||
apiName: String,
|
|
||||||
parentId: Int,
|
|
||||||
url: String,
|
|
||||||
links: List<ExtractorLink>,
|
|
||||||
subs: List<SubtitleData>?
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
if (context == null) return
|
|
||||||
|
|
||||||
val meta =
|
|
||||||
getMeta(
|
|
||||||
episode,
|
|
||||||
currentHeaderName,
|
|
||||||
apiName,
|
|
||||||
currentPoster,
|
|
||||||
currentIsMovie,
|
|
||||||
currentType
|
|
||||||
)
|
|
||||||
|
|
||||||
val folder = getFolder(currentType, currentHeaderName)
|
|
||||||
|
|
||||||
val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let
|
|
||||||
|
|
||||||
// SET VISUAL KEYS
|
|
||||||
setKey(
|
|
||||||
DOWNLOAD_HEADER_CACHE,
|
|
||||||
parentId.toString(),
|
|
||||||
VideoDownloadHelper.DownloadHeaderCached(
|
|
||||||
apiName = apiName,
|
|
||||||
url = url,
|
|
||||||
type = currentType,
|
|
||||||
name = currentHeaderName,
|
|
||||||
poster = currentPoster,
|
|
||||||
id = parentId,
|
|
||||||
cacheTime = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
setKey(
|
|
||||||
DataStore.getFolderName(
|
|
||||||
DOWNLOAD_EPISODE_CACHE,
|
|
||||||
parentId.toString()
|
|
||||||
), // 3 deep folder for faster acess
|
|
||||||
episode.id.toString(),
|
|
||||||
VideoDownloadHelper.DownloadEpisodeCached(
|
|
||||||
name = episode.name,
|
|
||||||
poster = episode.poster,
|
|
||||||
episode = episode.episode,
|
|
||||||
season = episode.season,
|
|
||||||
id = episode.id,
|
|
||||||
parentId = parentId,
|
|
||||||
score = episode.score,
|
|
||||||
description = episode.description,
|
|
||||||
cacheTime = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// DOWNLOAD VIDEO
|
|
||||||
VideoDownloadManager.downloadEpisodeUsingWorker(
|
|
||||||
context,
|
|
||||||
src,//url ?: return,
|
|
||||||
folder,
|
|
||||||
meta,
|
|
||||||
links
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1. Checks if the lang should be downloaded
|
|
||||||
// 2. Makes it into the download format
|
|
||||||
// 3. Downloads it as a .vtt file
|
|
||||||
val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF()
|
|
||||||
|
|
||||||
subs?.filter { subtitle ->
|
|
||||||
downloadList.any { langTagIETF ->
|
|
||||||
subtitle.languageCode == langTagIETF ||
|
|
||||||
subtitle.originalName.contains(
|
|
||||||
fromTagToEnglishLanguageName(
|
|
||||||
langTagIETF
|
|
||||||
) ?: langTagIETF
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) }
|
|
||||||
?.take(3) // max subtitles download hardcoded (?_?)
|
|
||||||
?.forEach { link ->
|
|
||||||
val fileName = VideoDownloadManager.getFileName(context, meta)
|
|
||||||
downloadSubtitle(context, link, fileName, folder)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadEpisode(
|
|
||||||
activity: Activity?,
|
|
||||||
episode: ResultEpisode,
|
|
||||||
currentIsMovie: Boolean,
|
|
||||||
currentHeaderName: String,
|
|
||||||
currentType: TvType,
|
|
||||||
currentPoster: String?,
|
|
||||||
apiName: String,
|
|
||||||
parentId: Int,
|
|
||||||
url: String,
|
|
||||||
) {
|
|
||||||
ioSafe {
|
|
||||||
val generator = RepoLinkGenerator(listOf(episode))
|
|
||||||
val currentLinks = mutableSetOf<ExtractorLink>()
|
|
||||||
val currentSubs = mutableSetOf<SubtitleData>()
|
|
||||||
generator.generateLinks(
|
|
||||||
clearCache = false,
|
|
||||||
sourceTypes = LOADTYPE_INAPP_DOWNLOAD,
|
|
||||||
callback = {
|
|
||||||
it.first?.let { link ->
|
|
||||||
currentLinks.add(link)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subtitleCallback = { sub ->
|
|
||||||
currentSubs.add(sub)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (currentLinks.isEmpty()) {
|
|
||||||
main {
|
|
||||||
showToast(
|
|
||||||
R.string.no_links_found_toast,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@ioSafe
|
|
||||||
} else {
|
|
||||||
main {
|
|
||||||
showToast(
|
|
||||||
R.string.download_started,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startDownload(
|
|
||||||
activity,
|
|
||||||
episode,
|
|
||||||
currentIsMovie,
|
|
||||||
currentHeaderName,
|
|
||||||
currentType,
|
|
||||||
currentPoster,
|
|
||||||
apiName,
|
|
||||||
parentId,
|
|
||||||
url,
|
|
||||||
sortUrls(currentLinks),
|
|
||||||
sortSubs(currentSubs),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMeta(
|
|
||||||
episode: ResultEpisode,
|
|
||||||
titleName: String,
|
|
||||||
apiName: String,
|
|
||||||
currentPoster: String?,
|
|
||||||
currentIsMovie: Boolean,
|
|
||||||
tvType: TvType,
|
|
||||||
): VideoDownloadManager.DownloadEpisodeMetadata {
|
|
||||||
return VideoDownloadManager.DownloadEpisodeMetadata(
|
|
||||||
episode.id,
|
|
||||||
VideoDownloadManager.sanitizeFilename(titleName),
|
|
||||||
apiName,
|
|
||||||
episode.poster ?: currentPoster,
|
|
||||||
episode.name,
|
|
||||||
if (currentIsMovie) null else episode.season,
|
|
||||||
if (currentIsMovie) null else episode.episode,
|
|
||||||
tvType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _watchStatus: MutableLiveData<WatchType> = MutableLiveData(WatchType.NONE)
|
private val _watchStatus: MutableLiveData<WatchType> = MutableLiveData(WatchType.NONE)
|
||||||
|
|
@ -1067,6 +903,28 @@ class ResultViewModel2 : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getMeta(
|
||||||
|
episode: ResultEpisode,
|
||||||
|
titleName: String,
|
||||||
|
apiName: String,
|
||||||
|
currentPoster: String?,
|
||||||
|
currentIsMovie: Boolean,
|
||||||
|
tvType: TvType,
|
||||||
|
): DownloadObjects.DownloadEpisodeMetadata {
|
||||||
|
return DownloadObjects.DownloadEpisodeMetadata(
|
||||||
|
episode.id,
|
||||||
|
episode.parentId,
|
||||||
|
sanitizeFilename(titleName),
|
||||||
|
apiName,
|
||||||
|
episode.poster ?: currentPoster,
|
||||||
|
episode.name,
|
||||||
|
if (currentIsMovie) null else episode.season,
|
||||||
|
if (currentIsMovie) null else episode.episode,
|
||||||
|
tvType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the favorite status of an item.
|
* Toggles the favorite status of an item.
|
||||||
*
|
*
|
||||||
|
|
@ -1435,9 +1293,10 @@ class ResultViewModel2 : ViewModel() {
|
||||||
subs += sub
|
subs += sub
|
||||||
updatePage()
|
updatePage()
|
||||||
},
|
},
|
||||||
isCasting = isCasting
|
isCasting = isCasting,
|
||||||
|
offset = 0
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
|
@ -1466,7 +1325,7 @@ class ResultViewModel2 : ViewModel() {
|
||||||
episodeIds: Array<String>,
|
episodeIds: Array<String>,
|
||||||
watchState: VideoWatchState
|
watchState: VideoWatchState
|
||||||
) {
|
) {
|
||||||
val watchStateString = DataStore.mapper.writeValueAsString(watchState)
|
val watchStateString = watchState.toJson()
|
||||||
episodeIds.forEach {
|
episodeIds.forEach {
|
||||||
if (getVideoWatchState(it.toInt()) != watchState) {
|
if (getVideoWatchState(it.toInt()) != watchState) {
|
||||||
editor.setKeyRaw(
|
editor.setKeyRaw(
|
||||||
|
|
@ -1612,16 +1471,17 @@ class ResultViewModel2 : ViewModel() {
|
||||||
|
|
||||||
ACTION_DOWNLOAD_EPISODE -> {
|
ACTION_DOWNLOAD_EPISODE -> {
|
||||||
val response = currentResponse ?: return
|
val response = currentResponse ?: return
|
||||||
downloadEpisode(
|
DownloadQueueManager.addToQueue(
|
||||||
activity,
|
DownloadObjects.DownloadQueueItem(
|
||||||
click.data,
|
click.data,
|
||||||
response.isMovie(),
|
response.isMovie(),
|
||||||
response.name,
|
response.name,
|
||||||
response.type,
|
response.type,
|
||||||
response.posterUrl,
|
response.posterUrl,
|
||||||
response.apiName,
|
response.apiName,
|
||||||
response.getId(),
|
response.getId(),
|
||||||
response.url
|
response.url,
|
||||||
|
).toWrapper()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1632,9 +1492,8 @@ class ResultViewModel2 : ViewModel() {
|
||||||
LOADTYPE_INAPP_DOWNLOAD,
|
LOADTYPE_INAPP_DOWNLOAD,
|
||||||
txt(R.string.episode_action_download_mirror)
|
txt(R.string.episode_action_download_mirror)
|
||||||
) { (result, index) ->
|
) { (result, index) ->
|
||||||
ioSafe {
|
DownloadQueueManager.addToQueue(
|
||||||
startDownload(
|
DownloadObjects.DownloadQueueItem(
|
||||||
activity,
|
|
||||||
click.data,
|
click.data,
|
||||||
response.isMovie(),
|
response.isMovie(),
|
||||||
response.name,
|
response.name,
|
||||||
|
|
@ -1645,8 +1504,8 @@ class ResultViewModel2 : ViewModel() {
|
||||||
response.url,
|
response.url,
|
||||||
listOf(result.links[index]),
|
listOf(result.links[index]),
|
||||||
result.subs,
|
result.subs,
|
||||||
)
|
).toWrapper()
|
||||||
}
|
)
|
||||||
showToast(
|
showToast(
|
||||||
R.string.download_started,
|
R.string.download_started,
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
|
|
@ -1686,26 +1545,24 @@ class ResultViewModel2 : ViewModel() {
|
||||||
|
|
||||||
ACTION_PLAY_EPISODE_IN_PLAYER -> {
|
ACTION_PLAY_EPISODE_IN_PLAYER -> {
|
||||||
val list = HashMap<String, String>(currentResponse?.syncData ?: emptyMap())
|
val list = HashMap<String, String>(currentResponse?.syncData ?: emptyMap())
|
||||||
|
val generator = generator ?: return
|
||||||
|
|
||||||
|
// I know kinda shit to iterate all, but it is 100% sure to work
|
||||||
|
val index = generator.videos.indexOfFirst { value -> value.id == click.data.id }
|
||||||
|
|
||||||
generator?.also {
|
|
||||||
it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work
|
|
||||||
?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id }
|
|
||||||
?.let { index ->
|
|
||||||
if (index >= 0)
|
|
||||||
it.goto(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentResponse?.type == TvType.CustomMedia) {
|
if (currentResponse?.type == TvType.CustomMedia) {
|
||||||
generator?.generateLinks(
|
generator.generateLinks(
|
||||||
|
offset = index,
|
||||||
clearCache = true,
|
clearCache = true,
|
||||||
LOADTYPE_ALL,
|
isCasting = false,
|
||||||
|
sourceTypes = LOADTYPE_ALL,
|
||||||
callback = {},
|
callback = {},
|
||||||
subtitleCallback = {})
|
subtitleCallback = {})
|
||||||
} else {
|
} else {
|
||||||
activity?.navigate(
|
activity?.navigate(
|
||||||
R.id.global_to_navigation_player,
|
R.id.global_to_navigation_player,
|
||||||
GeneratorPlayer.newInstance(
|
GeneratorPlayer.newInstance(
|
||||||
generator ?: return, list
|
generator, index,list
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1829,14 +1686,13 @@ class ResultViewModel2 : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val realRecommendations = ArrayList<SearchResponse>()
|
val realRecommendations = ArrayList<SearchResponse>()
|
||||||
val apiNames = synchronized(apis) {
|
val apiNames = apis.filter {
|
||||||
apis.filter {
|
it.name.contains("gogoanime", true) ||
|
||||||
it.name.contains("gogoanime", true) ||
|
it.name.contains("9anime", true)
|
||||||
it.name.contains("9anime", true)
|
}.map {
|
||||||
}.map {
|
it.name
|
||||||
it.name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.recommendations?.forEach { rec ->
|
meta.recommendations?.forEach { rec ->
|
||||||
apiNames.forEach { name ->
|
apiNames.forEach { name ->
|
||||||
realRecommendations.add(rec.copy(apiName = name))
|
realRecommendations.add(rec.copy(apiName = name))
|
||||||
|
|
@ -1855,7 +1711,7 @@ class ResultViewModel2 : ViewModel() {
|
||||||
{
|
{
|
||||||
if (this !is AnimeLoadResponse) return@runAllAsync
|
if (this !is AnimeLoadResponse) return@runAllAsync
|
||||||
// already exist, no need to run getTracker
|
// already exist, no need to run getTracker
|
||||||
if (this.getAniListId() != null && this.getMalId() != null) return@runAllAsync
|
if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync
|
||||||
|
|
||||||
val res = APIHolder.getTracker(
|
val res = APIHolder.getTracker(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
|
|
@ -1873,9 +1729,12 @@ class ResultViewModel2 : ViewModel() {
|
||||||
this.year
|
this.year
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name)
|
||||||
|
|
||||||
val ids = arrayOf(
|
val ids = arrayOf(
|
||||||
AccountManager.malApi.idPrefix to res?.malId?.toString(),
|
AccountManager.malApi.idPrefix to res?.malId?.toString(),
|
||||||
AccountManager.aniListApi.idPrefix to res?.aniId
|
AccountManager.aniListApi.idPrefix to res?.aniId,
|
||||||
|
AccountManager.kitsuApi.idPrefix to kitsuId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ids.any { (id, new) ->
|
if (ids.any { (id, new) ->
|
||||||
|
|
@ -1972,11 +1831,10 @@ class ResultViewModel2 : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private suspend fun updateFillers(name: String) {
|
private suspend fun updateFillers(data: LoadResponse) {
|
||||||
fillers =
|
fillers = ioWorkSafe {
|
||||||
ioWorkSafe {
|
FillerEpisodeCheck.getFillerEpisodes(data)
|
||||||
FillerEpisodeCheck.getFillerEpisodes(name)
|
} ?: hashSetOf()
|
||||||
} ?: emptyMap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeDubStatus(status: DubStatus) {
|
fun changeDubStatus(status: DubStatus) {
|
||||||
|
|
@ -2313,8 +2171,8 @@ class ResultViewModel2 : ViewModel() {
|
||||||
) {
|
) {
|
||||||
_episodes.postValue(Resource.Loading())
|
_episodes.postValue(Resource.Loading())
|
||||||
|
|
||||||
if (updateFillers && loadResponse is AnimeLoadResponse) {
|
if (updateFillers) {
|
||||||
updateFillers(loadResponse.name)
|
updateFillers(loadResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
val allEpisodes = when (loadResponse) {
|
val allEpisodes = when (loadResponse) {
|
||||||
|
|
@ -2355,7 +2213,7 @@ class ResultViewModel2 : ViewModel() {
|
||||||
index,
|
index,
|
||||||
i.score,
|
i.score,
|
||||||
i.description,
|
i.description,
|
||||||
fillers.getOrDefault(episode, false),
|
fillers.contains(episode),
|
||||||
loadResponse.type,
|
loadResponse.type,
|
||||||
mainId,
|
mainId,
|
||||||
totalIndex,
|
totalIndex,
|
||||||
|
|
@ -2595,26 +2453,34 @@ class ResultViewModel2 : ViewModel() {
|
||||||
loadResponse.trailers.windowed(limit, limit, true).takeWhile { list ->
|
loadResponse.trailers.windowed(limit, limit, true).takeWhile { list ->
|
||||||
list.amap { trailerData ->
|
list.amap { trailerData ->
|
||||||
try {
|
try {
|
||||||
val links = arrayListOf<Pair<ExtractorLink,String>>()
|
val links = arrayListOf<Pair<ExtractorLink, String>>()
|
||||||
val subs = arrayListOf<SubtitleFile>()
|
val subs = arrayListOf<SubtitleFile>()
|
||||||
if (!loadExtractor(
|
if (!loadExtractor(
|
||||||
trailerData.extractorUrl,
|
trailerData.extractorUrl,
|
||||||
trailerData.referer,
|
trailerData.referer,
|
||||||
{ subs.add(it) },
|
{ subs.add(it) },
|
||||||
{ links.add(Pair(it,trailerData.extractorUrl))}) && trailerData.raw
|
{
|
||||||
|
links.add(
|
||||||
|
Pair(
|
||||||
|
it,
|
||||||
|
trailerData.extractorUrl
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) && trailerData.raw
|
||||||
) {
|
) {
|
||||||
arrayListOf(
|
arrayListOf(
|
||||||
Pair(
|
Pair(
|
||||||
newExtractorLink(
|
newExtractorLink(
|
||||||
"",
|
"",
|
||||||
"Trailer",
|
"Trailer",
|
||||||
trailerData.extractorUrl,
|
trailerData.extractorUrl,
|
||||||
type = INFER_TYPE
|
type = INFER_TYPE
|
||||||
) {
|
) {
|
||||||
this.referer = trailerData.referer ?: ""
|
this.referer = trailerData.referer ?: ""
|
||||||
this.quality = Qualities.Unknown.value
|
this.quality = Qualities.Unknown.value
|
||||||
this.headers = trailerData.headers
|
this.headers = trailerData.headers
|
||||||
},trailerData.extractorUrl)
|
}, trailerData.extractorUrl
|
||||||
|
)
|
||||||
) to arrayListOf()
|
) to arrayListOf()
|
||||||
} else {
|
} else {
|
||||||
links to subs
|
links to subs
|
||||||
|
|
@ -2812,7 +2678,7 @@ class ResultViewModel2 : ViewModel() {
|
||||||
setKey(
|
setKey(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
mainId.toString(),
|
mainId.toString(),
|
||||||
VideoDownloadHelper.DownloadHeaderCached(
|
DownloadObjects.DownloadHeaderCached(
|
||||||
apiName = apiName,
|
apiName = apiName,
|
||||||
url = validUrl,
|
url = validUrl,
|
||||||
type = loadResponse.type,
|
type = loadResponse.type,
|
||||||
|
|
@ -2840,4 +2706,4 @@ class ResultViewModel2 : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.throwAbleToResource
|
import com.lagradost.cloudstream3.mvvm.throwAbleToResource
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
|
|
@ -276,6 +277,7 @@ class SyncViewModel : ViewModel() {
|
||||||
// fix because of bad old data :pensive:
|
// fix because of bad old data :pensive:
|
||||||
val realName = when (syncName) {
|
val realName = when (syncName) {
|
||||||
"MAL" -> malApi.idPrefix
|
"MAL" -> malApi.idPrefix
|
||||||
|
"Kitsu" -> kitsuApi.idPrefix
|
||||||
"Simkl" -> simklApi.idPrefix
|
"Simkl" -> simklApi.idPrefix
|
||||||
"AniList" -> aniListApi.idPrefix
|
"AniList" -> aniListApi.idPrefix
|
||||||
else -> syncName
|
else -> syncName
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
|
import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
|
||||||
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
||||||
|
|
@ -12,6 +11,7 @@ import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.newSharedPool
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
|
import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ class SearchAdapter(
|
||||||
})) {
|
})) {
|
||||||
companion object {
|
companion object {
|
||||||
val sharedPool =
|
val sharedPool =
|
||||||
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) }
|
newSharedPool { setMaxRecycledViews(CONTENT, 10) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasNext: Boolean = false
|
var hasNext: Boolean = false
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.view.doOnLayout
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
|
@ -55,7 +57,9 @@ import com.lagradost.cloudstream3.ui.home.HomeViewModel
|
||||||
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
||||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
|
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
|
@ -71,6 +75,8 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||||
|
|
@ -137,6 +143,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
bottomSheetDialog?.ownHide()
|
bottomSheetDialog?.ownHide()
|
||||||
|
activity?.detachBackPressedCallback("SearchFragment")
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,17 +407,29 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
|
|
||||||
val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) }
|
val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) }
|
||||||
val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true
|
val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true
|
||||||
|
val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true
|
||||||
|
|
||||||
selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList()
|
selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList()
|
||||||
|
|
||||||
if (isLayout(TV)) {
|
if (!isLayout(PHONE)) {
|
||||||
binding.searchFilter.isFocusable = true
|
binding.searchFilter.isFocusable = true
|
||||||
binding.searchFilter.isFocusableInTouchMode = true
|
binding.searchFilter.isFocusableInTouchMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide suggestions when search view loses focus (phone only)
|
||||||
|
if (isLayout(PHONE)) {
|
||||||
|
binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus ->
|
||||||
|
if (!hasFocus) {
|
||||||
|
searchViewModel.clearSuggestions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
override fun onQueryTextSubmit(query: String): Boolean {
|
override fun onQueryTextSubmit(query: String): Boolean {
|
||||||
search(query)
|
search(query)
|
||||||
|
searchViewModel.clearSuggestions()
|
||||||
|
|
||||||
binding.mainSearch.let {
|
binding.mainSearch.let {
|
||||||
hideKeyboard(it)
|
hideKeyboard(it)
|
||||||
|
|
@ -425,51 +444,25 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
if (showHistory) {
|
if (showHistory) {
|
||||||
searchViewModel.clearSearch()
|
searchViewModel.clearSearch()
|
||||||
searchViewModel.updateHistory()
|
searchViewModel.updateHistory()
|
||||||
|
searchViewModel.clearSuggestions()
|
||||||
|
} else {
|
||||||
|
// Fetch suggestions when user is typing (if enabled)
|
||||||
|
if (isSearchSuggestionsEnabled) {
|
||||||
|
searchViewModel.fetchSuggestions(newText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding.apply {
|
binding.apply {
|
||||||
searchHistoryHolder.isVisible = showHistory
|
searchHistoryRecycler.isVisible = showHistory
|
||||||
searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch
|
searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch
|
||||||
searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch
|
searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch
|
||||||
|
// Hide suggestions when showing history or showing search results
|
||||||
|
searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
binding.searchClearCallHistory.setOnClickListener {
|
|
||||||
activity?.let { ctx ->
|
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(ctx)
|
|
||||||
val dialogClickListener =
|
|
||||||
DialogInterface.OnClickListener { _, which ->
|
|
||||||
when (which) {
|
|
||||||
DialogInterface.BUTTON_POSITIVE -> {
|
|
||||||
removeKeys("$currentAccount/$SEARCH_HISTORY_KEY")
|
|
||||||
searchViewModel.updateHistory()
|
|
||||||
}
|
|
||||||
|
|
||||||
DialogInterface.BUTTON_NEGATIVE -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
builder.setTitle(R.string.clear_history).setMessage(
|
|
||||||
ctx.getString(R.string.delete_message).format(
|
|
||||||
ctx.getString(R.string.history)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setPositiveButton(R.string.sort_clear, dialogClickListener)
|
|
||||||
.setNegativeButton(R.string.cancel, dialogClickListener)
|
|
||||||
.show().setDefaultFocus()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
// ye you somehow fucked up formatting did you?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
observe(searchViewModel.searchResponse) {
|
observe(searchViewModel.searchResponse) {
|
||||||
when (it) {
|
when (it) {
|
||||||
is Resource.Success -> {
|
is Resource.Success -> {
|
||||||
|
|
@ -503,24 +496,30 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
try {
|
try {
|
||||||
// https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist
|
// https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist
|
||||||
listLock.lock()
|
listLock.lock()
|
||||||
|
|
||||||
|
val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray()
|
||||||
|
|
||||||
|
val sortedList = list.toList().sortedWith(compareBy { (providerName, _) ->
|
||||||
|
val index = pinnedOrder.indexOf(providerName)
|
||||||
|
if (index == -1) Int.MAX_VALUE else index
|
||||||
|
})
|
||||||
|
|
||||||
(binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply {
|
(binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply {
|
||||||
val newItems = list.map { ongoing ->
|
val newItems = sortedList.map { (providerName, providerData) ->
|
||||||
val dataList = ongoing.value.list
|
val dataList = providerData.list
|
||||||
val dataListFiltered =
|
val dataListFiltered =
|
||||||
context?.filterSearchResultByFilmQuality(dataList) ?: dataList
|
context?.filterSearchResultByFilmQuality(dataList) ?: dataList
|
||||||
|
|
||||||
val homePageList = HomePageList(
|
val homePageList = HomePageList(
|
||||||
ongoing.key,
|
providerName,
|
||||||
dataListFiltered
|
dataListFiltered
|
||||||
)
|
)
|
||||||
|
|
||||||
val expandableList = HomeViewModel.ExpandableHomepageList(
|
HomeViewModel.ExpandableHomepageList(
|
||||||
homePageList,
|
homePageList,
|
||||||
ongoing.value.currentPage,
|
providerData.currentPage,
|
||||||
ongoing.value.hasNext
|
providerData.hasNext
|
||||||
)
|
)
|
||||||
|
|
||||||
expandableList
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submitList(newItems)
|
submitList(newItems)
|
||||||
|
|
@ -559,6 +558,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
val searchItem = click.item
|
val searchItem = click.item
|
||||||
when (click.clickAction) {
|
when (click.clickAction) {
|
||||||
SEARCH_HISTORY_OPEN -> {
|
SEARCH_HISTORY_OPEN -> {
|
||||||
|
if (searchItem == null) return@SearchHistoryAdaptor
|
||||||
searchViewModel.clearSearch()
|
searchViewModel.clearSearch()
|
||||||
if (searchItem.type.isNotEmpty())
|
if (searchItem.type.isNotEmpty())
|
||||||
updateChips(
|
updateChips(
|
||||||
|
|
@ -569,21 +569,76 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
SEARCH_HISTORY_REMOVE -> {
|
SEARCH_HISTORY_REMOVE -> {
|
||||||
|
if (searchItem == null) return@SearchHistoryAdaptor
|
||||||
removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key)
|
removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key)
|
||||||
searchViewModel.updateHistory()
|
searchViewModel.updateHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SEARCH_HISTORY_CLEAR -> {
|
||||||
|
// Show confirmation dialog (from footer button)
|
||||||
|
activity?.let { ctx ->
|
||||||
|
val builder: AlertDialog.Builder = AlertDialog.Builder(ctx)
|
||||||
|
val dialogClickListener =
|
||||||
|
DialogInterface.OnClickListener { _, which ->
|
||||||
|
when (which) {
|
||||||
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
|
removeKeys("$currentAccount/$SEARCH_HISTORY_KEY")
|
||||||
|
searchViewModel.updateHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
builder.setTitle(R.string.clear_history).setMessage(
|
||||||
|
ctx.getString(R.string.delete_message).format(
|
||||||
|
ctx.getString(R.string.history)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPositiveButton(R.string.sort_clear, dialogClickListener)
|
||||||
|
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||||
|
.show().setDefaultFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// wth are you doing???
|
// wth are you doing???
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val suggestionAdapter = SearchSuggestionAdapter { callback ->
|
||||||
|
when (callback.clickAction) {
|
||||||
|
SEARCH_SUGGESTION_CLICK -> {
|
||||||
|
// Search directly
|
||||||
|
binding.mainSearch.setQuery(callback.suggestion, true)
|
||||||
|
searchViewModel.clearSuggestions()
|
||||||
|
}
|
||||||
|
SEARCH_SUGGESTION_FILL -> {
|
||||||
|
// Fill the search box without searching
|
||||||
|
binding.mainSearch.setQuery(callback.suggestion, false)
|
||||||
|
}
|
||||||
|
SEARCH_SUGGESTION_CLEAR -> {
|
||||||
|
// Clear suggestions (from footer button)
|
||||||
|
searchViewModel.clearSuggestions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding.apply {
|
binding.apply {
|
||||||
searchHistoryRecycler.adapter = historyAdapter
|
searchHistoryRecycler.adapter = historyAdapter
|
||||||
searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
|
searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
|
||||||
//searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1)
|
//searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1)
|
||||||
|
|
||||||
|
// Setup suggestions RecyclerView
|
||||||
|
searchSuggestionsRecycler.adapter = suggestionAdapter
|
||||||
|
searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context)
|
||||||
|
|
||||||
searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
||||||
searchMasterRecycler.adapter = masterAdapter
|
searchMasterRecycler.adapter = masterAdapter
|
||||||
//searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
|
//searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
|
||||||
|
|
@ -599,7 +654,11 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
|
|
||||||
sq?.let { query ->
|
sq?.let { query ->
|
||||||
if (query.isBlank()) return@let
|
if (query.isBlank()) return@let
|
||||||
mainSearch.setQuery(query, true)
|
|
||||||
|
// Queries are dropped if you are submitted before layout finishes
|
||||||
|
mainSearch.doOnLayout {
|
||||||
|
mainSearch.setQuery(query, true)
|
||||||
|
}
|
||||||
// Clear the query as to not make it request the same query every time the page is opened
|
// Clear the query as to not make it request the same query every time the page is opened
|
||||||
arguments?.remove(SEARCH_QUERY)
|
arguments?.remove(SEARCH_QUERY)
|
||||||
savedInstanceState?.remove(SEARCH_QUERY)
|
savedInstanceState?.remove(SEARCH_QUERY)
|
||||||
|
|
@ -608,8 +667,34 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(searchViewModel.currentHistory) { list ->
|
observe(searchViewModel.currentHistory) { list ->
|
||||||
binding.searchClearCallHistory.isVisible = list.isNotEmpty()
|
|
||||||
(binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list)
|
(binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list)
|
||||||
|
// Scroll to top to show newest items (list is sorted by newest first)
|
||||||
|
if (list.isNotEmpty()) {
|
||||||
|
binding.searchHistoryRecycler.scrollToPosition(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe search suggestions
|
||||||
|
observe(searchViewModel.searchSuggestions) { suggestions ->
|
||||||
|
val hasSuggestions = suggestions.isNotEmpty()
|
||||||
|
binding.searchSuggestionsRecycler.isVisible = hasSuggestions
|
||||||
|
(binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions)
|
||||||
|
|
||||||
|
// On non-phone layouts, redirect focus and handle back button
|
||||||
|
if (!isLayout(PHONE)) {
|
||||||
|
if (hasSuggestions) {
|
||||||
|
binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler
|
||||||
|
// Attach back button callback to clear suggestions
|
||||||
|
activity?.attachBackPressedCallback("SearchFragment") {
|
||||||
|
searchViewModel.clearSuggestions()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset to default focus target (history)
|
||||||
|
binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler
|
||||||
|
// Detach back button callback when no suggestions
|
||||||
|
activity?.detachBackPressedCallback("SearchFragment")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchViewModel.updateHistory()
|
searchViewModel.updateHistory()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP
|
import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||||
|
|
||||||
object SearchHelper {
|
object SearchHelper {
|
||||||
fun handleSearchClickCallback(callback: SearchClickCallback) {
|
fun handleSearchClickCallback(callback: SearchClickCallback) {
|
||||||
|
|
@ -31,7 +31,7 @@ object SearchHelper {
|
||||||
handleDownloadClick(
|
handleDownloadClick(
|
||||||
DownloadClickEvent(
|
DownloadClickEvent(
|
||||||
DOWNLOAD_ACTION_PLAY_FILE,
|
DOWNLOAD_ACTION_PLAY_FILE,
|
||||||
VideoDownloadHelper.DownloadEpisodeCached(
|
DownloadObjects.DownloadEpisodeCached(
|
||||||
name = card.name,
|
name = card.name,
|
||||||
poster = card.posterUrl,
|
poster = card.posterUrl,
|
||||||
episode = card.episode ?: 0,
|
episode = card.episode ?: 0,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue