mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-08-24 08:43:32 +00:00
Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b75ca6ace |
663 changed files with 26114 additions and 56403 deletions
30
.github/locales.py
vendored
30
.github/locales.py
vendored
|
|
@ -1,13 +1,14 @@
|
|||
import re
|
||||
import glob
|
||||
import requests
|
||||
import os
|
||||
import lxml.etree as ET # builtin library doesn't preserve comments
|
||||
|
||||
|
||||
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
||||
START_MARKER = "/* begin language list */"
|
||||
END_MARKER = "/* end language list */"
|
||||
XML_NAME = "app/src/main/res/values-b+"
|
||||
XML_NAME = "app/src/main/res/values-"
|
||||
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
|
||||
INDENT = " "*4
|
||||
|
||||
|
|
@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER)
|
|||
|
||||
# Load already added langs
|
||||
languages = {}
|
||||
for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
|
||||
name, iso = lang.groups()
|
||||
languages[iso] = name
|
||||
for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
|
||||
flag, name, iso = lang.groups()
|
||||
languages[iso] = (flag, name)
|
||||
|
||||
# Add not yet added langs
|
||||
for folder in glob.glob(f"{XML_NAME}*"):
|
||||
iso = folder[len(XML_NAME):].replace("+", "-")
|
||||
iso = folder[len(XML_NAME):]
|
||||
if iso not in languages.keys():
|
||||
entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found
|
||||
languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple
|
||||
entry = iso_map.get(iso.lower(),{'nativeName':iso})
|
||||
languages[iso] = ("", entry['nativeName'].split(',')[0])
|
||||
|
||||
# Create pairs
|
||||
pairs = []
|
||||
for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name
|
||||
name = languages[iso]
|
||||
pairs.append(f'{INDENT}Pair("{name}", "{iso}"),')
|
||||
# Create triples
|
||||
triples = []
|
||||
for iso in sorted(languages.keys()):
|
||||
flag, name = languages[iso]
|
||||
triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
|
||||
|
||||
# Update settings file
|
||||
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
||||
before_src +
|
||||
START_MARKER +
|
||||
"\n" +
|
||||
"\n".join(pairs) +
|
||||
"\n".join(triples) +
|
||||
"\n" +
|
||||
END_MARKER +
|
||||
after_src
|
||||
|
|
@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
|
|||
with open(file, 'wb') as fp:
|
||||
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
|
||||
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
|
||||
# Remove trailing new line to be consistent with weblate
|
||||
fp.seek(-1, os.SEEK_END)
|
||||
fp.truncate()
|
||||
except ET.ParseError as ex:
|
||||
print(f"[{file}] {ex}")
|
||||
|
|
|
|||
35
.github/workflows/build_to_archive.yml
vendored
35
.github/workflows/build_to_archive.yml
vendored
|
|
@ -9,9 +9,6 @@ on:
|
|||
- '**/wcokey.txt'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: "Archive-build"
|
||||
cancel-in-progress: true
|
||||
|
|
@ -27,7 +24,6 @@ jobs:
|
|||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/secrets"
|
||||
|
||||
- name: Generate access token (archive)
|
||||
id: generate_archive_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
|
|
@ -35,18 +31,14 @@ jobs:
|
|||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/cloudstream-archive"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Fetch keystore
|
||||
id: fetch_keystore
|
||||
run: |
|
||||
|
|
@ -57,33 +49,24 @@ jobs:
|
|||
KEY_PWD="$(cat keystore_password.txt)"
|
||||
echo "::add-mask::${KEY_PWD}"
|
||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew assemblePrereleaseRelease
|
||||
run: |
|
||||
./gradlew assemblePrerelease
|
||||
env:
|
||||
SIGNING_KEY_ALIAS: "key0"
|
||||
SIGNING_KEY_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_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||
MAL_KEY: ${{ secrets.MAL_KEY }}
|
||||
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "recloudstream/cloudstream-archive"
|
||||
token: ${{ steps.generate_archive_token.outputs.token }}
|
||||
path: "archive"
|
||||
|
||||
- name: Move build
|
||||
run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
|
||||
run: |
|
||||
cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
|
||||
|
||||
- name: Push archive
|
||||
run: |
|
||||
|
|
|
|||
37
.github/workflows/generate_dokka.yml
vendored
37
.github/workflows/generate_dokka.yml
vendored
|
|
@ -1,18 +1,19 @@
|
|||
name: Dokka
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
|
||||
concurrency:
|
||||
group: "dokka"
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# choose your default branch
|
||||
- master
|
||||
- main
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -24,14 +25,13 @@ jobs:
|
|||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/dokka"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
path: "src"
|
||||
|
||||
- name: Checkout dokka
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
repository: "recloudstream/dokka"
|
||||
path: "dokka"
|
||||
|
|
@ -44,15 +44,13 @@ jobs:
|
|||
rm -rf "./library"
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Generate Dokka
|
||||
run: |
|
||||
|
|
@ -61,7 +59,8 @@ jobs:
|
|||
./gradlew docs:dokkaGeneratePublicationHtml
|
||||
|
||||
- name: Copy Dokka
|
||||
run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
|
||||
run: |
|
||||
cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
|
||||
|
||||
- name: Push builds
|
||||
run: |
|
||||
|
|
|
|||
105
.github/workflows/instrumented-tests.yml
vendored
105
.github/workflows/instrumented-tests.yml
vendored
|
|
@ -1,105 +0,0 @@
|
|||
name: Instrumented Tests
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
instrumented-tests:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.body == '/run-tests'
|
||||
steps:
|
||||
- name: Check permission
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const login = context.payload.comment.user.login;
|
||||
const association = context.payload.comment.author_association;
|
||||
const allowed = ['OWNER', 'MEMBER'];
|
||||
|
||||
const isAllowed =
|
||||
login === 'Luna712' ||
|
||||
allowed.includes(association);
|
||||
|
||||
if (!isAllowed) {
|
||||
core.setFailed(`User ${login} is not permitted to trigger this workflow.`);
|
||||
}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.issue.number }}/head
|
||||
|
||||
- name: Post started comment
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: 'Instrumented tests are running. [View live progress](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
|
||||
})
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache-read-only: false
|
||||
|
||||
- name: Get target SDK
|
||||
id: sdk
|
||||
run: |
|
||||
TARGET_SDK=$(grep 'targetSdk' gradle/libs.versions.toml | grep -o '[0-9]\+' | head -1)
|
||||
echo "version=$TARGET_SDK" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Run Instrumented Tests
|
||||
id: tests
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ steps.sdk.outputs.version }}
|
||||
arch: x86_64
|
||||
profile: Nexus 6
|
||||
script: ./gradlew connectedPrereleaseDebugAndroidTest
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: instrumented-test-results
|
||||
path: '**/build/reports/androidTests/'
|
||||
|
||||
- name: Post finished comment
|
||||
if: always()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const success = '${{ steps.tests.outcome }}' === 'success';
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: success
|
||||
? 'Instrumented tests passed. [View results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
|
||||
: 'Instrumented tests failed. [View results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
|
||||
})
|
||||
88
.github/workflows/issue_action.yml
vendored
Normal file
88
.github/workflows/issue_action.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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@v7
|
||||
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@v4
|
||||
- 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@v7
|
||||
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'
|
||||
|
||||
|
||||
31
.github/workflows/prerelease.yml
vendored
31
.github/workflows/prerelease.yml
vendored
|
|
@ -12,9 +12,6 @@ concurrency:
|
|||
group: "pre-release"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -26,18 +23,14 @@ jobs:
|
|||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/secrets"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Fetch keystore
|
||||
id: fetch_keystore
|
||||
run: |
|
||||
|
|
@ -48,27 +41,19 @@ jobs:
|
|||
KEY_PWD="$(cat keystore_password.txt)"
|
||||
echo "::add-mask::${KEY_PWD}"
|
||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
|
||||
run: |
|
||||
./gradlew assemblePrerelease build androidSourcesJar
|
||||
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
|
||||
env:
|
||||
SIGNING_KEY_ALIAS: "key0"
|
||||
SIGNING_KEY_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_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||
MAL_KEY: ${{ secrets.MAL_KEY }}
|
||||
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
|
||||
|
||||
- name: Create pre-release
|
||||
uses: marvinpinto/action-automatic-releases@latest
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
automatic_release_tag: "pre-release"
|
||||
|
|
|
|||
30
.github/workflows/pull_request.yml
vendored
30
.github/workflows/pull_request.yml
vendored
|
|
@ -2,40 +2,22 @@ name: Artifact Build
|
|||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
cache-read-only: false
|
||||
|
||||
- name: Ensure binary compatibility
|
||||
# This is to ensure that your code is backwards compatible.
|
||||
# If this fails you need to add a @Prerelease annotation to new code.
|
||||
run: ./gradlew library:checkKotlinAbi
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew assemblePrereleaseDebug lint check
|
||||
|
||||
run: ./gradlew assemblePrereleaseDebug
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pull-request-build
|
||||
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
||||
|
|
|
|||
20
.github/workflows/update_locales.yml
vendored
20
.github/workflows/update_locales.yml
vendored
|
|
@ -1,19 +1,17 @@
|
|||
name: Fix locale issues
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- '**.xml'
|
||||
workflow_dispatch:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: "locale"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
create:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -25,17 +23,15 @@ jobs:
|
|||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/cloudstream"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip3 install lxml requests
|
||||
|
||||
run: |
|
||||
pip3 install lxml
|
||||
- name: Edit files
|
||||
run: python3 .github/locales.py
|
||||
|
||||
run: |
|
||||
python3 .github/locales.py
|
||||
- name: Commit to the repo
|
||||
run: |
|
||||
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -96,6 +96,11 @@ replay_pid*
|
|||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
|
|
|||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"githubPullRequests.ignoredPullRequestBranches": [
|
||||
"master"
|
||||
],
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
11
AI-POLICY.md
11
AI-POLICY.md
|
|
@ -1,11 +0,0 @@
|
|||
# AI Policy
|
||||
|
||||
AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions.
|
||||
|
||||
1. Always state any AI usage in pull requests and issues.
|
||||
|
||||
2. Always test code before making a pull request. We do not want to test your AI generated code.
|
||||
|
||||
3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI.
|
||||
|
||||
4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions.
|
||||
21
COMPOSE.md
21
COMPOSE.md
|
|
@ -1,21 +0,0 @@
|
|||
# Migration guide to Compose
|
||||
|
||||
### 1. MVI instead of MVVM
|
||||
|
||||
The current design of CloudStream loosely uses the MVVM architecture.
|
||||
|
||||
This means that the UI invokes the viewmodel with function calls, and it responds with LiveData fields that are observed. While this has worked, it generates a lot of boilerplate and has created some friction.
|
||||
|
||||
To make it easier to work with Compose, the new architecture will be based on MVI. In short this means that the viewmodel exposes a singular immutable class that is observed, and receives all UI events with a singular event that is a sealed class. All the UI should be able to be recreated based on this singular state class, and all interactions should be able to be replayed using only the event callback.
|
||||
|
||||
For a more detailed overview, see: https://www.youtube.com/watch?v=b2z1jvD4VMQ
|
||||
|
||||
This is part of the effort to make CloudStream cross platform, as it allows us to decouple UI and logic.
|
||||
|
||||
### 2. KMP-compatible libraries
|
||||
|
||||
We plan to leverage Kotlin's KMP project to compile our code to different architectures. However, this requires us to only use KMP-compatible libraries, no Java. Therefore any pull requests must ensure that they use KMP-compatible libraries only.
|
||||
|
||||
### 3. UI Changes
|
||||
|
||||
While migrating to the new compose UI, you also have the opportunity to change the UI. However, this should only be to freshen up the UI, not completely redesign it. It is also important to stress that this process should not lose any features of the old UI, and be very conservative with adding new features.
|
||||
|
|
@ -1,96 +1,53 @@
|
|||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
|
||||
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.dokka)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("org.jetbrains.dokka")
|
||||
}
|
||||
|
||||
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
||||
|
||||
abstract class GenerateGitHashTask : DefaultTask() {
|
||||
fun getGitCommitHash(): String {
|
||||
return try {
|
||||
val headFile = file("${project.rootDir}/.git/HEAD")
|
||||
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val headFile: RegularFileProperty
|
||||
|
||||
@get:InputDirectory
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val headsDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
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 (headFile.exists()) {
|
||||
val headContent = headFile.readText().trim()
|
||||
if (headContent.startsWith("ref:")) {
|
||||
val refPath = headContent.substring(5) // e.g., refs/heads/main
|
||||
val commitFile = File(head.parentFile, refPath)
|
||||
val commitFile = file("${project.rootDir}/.git/$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
|
||||
} else {
|
||||
"" // If .git/HEAD doesn't exist
|
||||
}.take(7) // Return the short commit hash
|
||||
} 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)
|
||||
"" // Just return an empty string if any exception occurs
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
|
||||
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
|
||||
)
|
||||
}
|
||||
viewBinding {
|
||||
enable = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// 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) {
|
||||
if (prereleaseStoreFile != null) {
|
||||
create("prerelease") {
|
||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
||||
|
||||
storeFile = prereleaseStoreFile?.let { file(it) }
|
||||
storeFile = file(prereleaseStoreFile)
|
||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||
|
|
@ -104,10 +61,12 @@ android {
|
|||
applicationId = "com.lagradost.cloudstream3"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = libs.versions.versionCode.get().toInt()
|
||||
versionName = libs.versions.versionName.get()
|
||||
versionCode = 66
|
||||
versionName = "4.5.4"
|
||||
|
||||
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
|
||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||
resValue("string", "commit_hash", getGitCommitHash())
|
||||
resValue("bool", "is_prerelease", "false")
|
||||
|
||||
// Reads local.properties
|
||||
val localProperties = gradleLocalProperties(rootDir, project.providers)
|
||||
|
|
@ -127,16 +86,6 @@ android {
|
|||
"SIMKL_CLIENT_SECRET",
|
||||
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
|
||||
)
|
||||
buildConfigField(
|
||||
"String",
|
||||
"MAL_KEY",
|
||||
"\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\""
|
||||
)
|
||||
buildConfigField(
|
||||
"String",
|
||||
"ANILIST_KEY",
|
||||
"\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\""
|
||||
)
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
|
@ -164,9 +113,12 @@ android {
|
|||
productFlavors {
|
||||
create("stable") {
|
||||
dimension = "state"
|
||||
resValue("bool", "is_prerelease", "false")
|
||||
}
|
||||
create("prerelease") {
|
||||
dimension = "state"
|
||||
resValue("bool", "is_prerelease", "true")
|
||||
buildConfigField("boolean", "BETA", "true")
|
||||
applicationIdSuffix = ".prerelease"
|
||||
if (signingConfigs.names.contains("prerelease")) {
|
||||
signingConfig = signingConfigs.getByName("prerelease")
|
||||
|
|
@ -184,29 +136,13 @@ android {
|
|||
targetCompatibility = JavaVersion.toVersion(javaTarget.target)
|
||||
}
|
||||
|
||||
java {
|
||||
// Use Java 17 toolchain even if a higher JDK runs the build.
|
||||
// We still use Java 8 for now which higher JDKs have deprecated.
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
checkReleaseBuilds = false
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = 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"
|
||||
|
|
@ -217,46 +153,43 @@ dependencies {
|
|||
testImplementation(libs.junit)
|
||||
testImplementation(libs.json)
|
||||
androidTestImplementation(libs.core)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
implementation(libs.junit.ktx)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.instancio.core)
|
||||
androidTestImplementation(libs.junit.ktx)
|
||||
androidTestImplementation(libs.kotlin.test)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
|
||||
// Android Core & Lifecycle
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.activity.ktx)
|
||||
implementation(libs.annotation)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.fragment.ktx)
|
||||
implementation(libs.bundles.lifecycle)
|
||||
implementation(libs.bundles.navigation)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kotlinx.serialization.json) // JSON Parser
|
||||
implementation(libs.bundles.navigationKtx)
|
||||
implementation(libs.lifecycle.livedata.ktx)
|
||||
implementation(libs.lifecycle.viewmodel.ktx)
|
||||
|
||||
// Design & UI
|
||||
implementation(libs.preference.ktx)
|
||||
implementation(libs.material)
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.swiperefreshlayout)
|
||||
|
||||
// Coil Image Loading
|
||||
implementation(libs.bundles.coil)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// Media 3 (ExoPlayer)
|
||||
implementation(libs.bundles.media3)
|
||||
implementation(libs.video)
|
||||
|
||||
// FFmpeg Decoding
|
||||
implementation(libs.bundles.nextlib)
|
||||
|
||||
// Anime-db for filler
|
||||
implementation(libs.anime.db)
|
||||
|
||||
// PlayBack
|
||||
implementation(libs.colorpicker) // Subtitle Color Picker
|
||||
implementation(libs.newpipeextractor) // For Trailers
|
||||
implementation(libs.juniversalchardet) // Subtitle Decoding
|
||||
|
||||
// FFmpeg Decoding
|
||||
implementation(libs.bundles.nextlibMedia3)
|
||||
|
||||
// Crash Reports (AcraApplication.kt)
|
||||
implementation(libs.acra.core)
|
||||
implementation(libs.acra.toast)
|
||||
|
||||
// UI Stuff
|
||||
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
|
||||
implementation(libs.palette.ktx) // Palette for Images -> Colors
|
||||
|
|
@ -267,37 +200,50 @@ dependencies {
|
|||
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
|
||||
|
||||
// Extensions & Other Libs
|
||||
implementation(libs.jsoup) // HTML Parser
|
||||
implementation(libs.ksoup) // HTML Parser
|
||||
implementation(libs.rhino) // Run JavaScript
|
||||
implementation(libs.quickjs)
|
||||
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
|
||||
implementation(libs.safefile) // To Prevent the URI File Fu*kery
|
||||
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.jackson.module.kotlin) // JSON Parser
|
||||
implementation(libs.zipline)
|
||||
|
||||
// Temp/deprecated; will be removed once extensions have time to migrate from using it
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
// Deprecated; will be removed once extensions have time to migrate from using it
|
||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||
implementation(libs.conscrypt.android) {
|
||||
version {
|
||||
strictly("2.5.2")
|
||||
}
|
||||
because("2.5.3 crashes everything for everyone.")
|
||||
} // To Fix SSL Fu*kery on Android 9
|
||||
implementation(libs.jackson.module.kotlin) {
|
||||
version {
|
||||
strictly("2.13.1")
|
||||
}
|
||||
because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.")
|
||||
} // JSON Parser
|
||||
|
||||
// Torrent Support
|
||||
implementation(libs.torrentserver)
|
||||
|
||||
// Downloading & Networking
|
||||
implementation(libs.work.runtime)
|
||||
implementation(libs.work.runtime.ktx)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
tasks.register<Jar>("androidSourcesJar") {
|
||||
archiveClassifier.set("sources")
|
||||
from(android.sourceSets.getByName("main").java.directories) // Full Sources
|
||||
from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
|
||||
}
|
||||
|
||||
tasks.register<Copy>("copyJar") {
|
||||
dependsOn("build", ":library:jvmJar")
|
||||
from(
|
||||
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
|
||||
"../library/build/libs"
|
||||
|
|
@ -324,21 +270,15 @@ tasks.register<Jar>("makeJar") {
|
|||
tasks.withType<KotlinJvmCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(javaTarget)
|
||||
jvmDefault.set(JvmDefaultMode.ENABLE)
|
||||
optIn.addAll(
|
||||
"com.lagradost.cloudstream3.InternalAPI",
|
||||
"com.lagradost.cloudstream3.Prerelease",
|
||||
)
|
||||
freeCompilerArgs.add("-Xjvm-default=all-compatibility")
|
||||
}
|
||||
}
|
||||
|
||||
dokka {
|
||||
moduleName = "App"
|
||||
dokkaSourceSets {
|
||||
configureEach {
|
||||
suppress = name != "prereleaseDebug"
|
||||
main {
|
||||
analysisPlatform = KotlinPlatform.JVM
|
||||
displayName = "JVM"
|
||||
documentedVisibilities(
|
||||
VisibilityModifier.Public,
|
||||
VisibilityModifier.Protected
|
||||
|
|
|
|||
13
app/lint.xml
13
app/lint.xml
|
|
@ -1,13 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lint>
|
||||
<!-- ByteOrderMark has errors in values-b+ja/strings.xml, but it's handled by weblate so we don't really care. -->
|
||||
<issue id="ByteOrderMark" severity="ignore" />
|
||||
|
||||
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
|
||||
<issue id="MissingTranslation" severity="ignore" />
|
||||
|
||||
<!-- We only care about the source language here. -->
|
||||
<issue id="StringFormatInvalid">
|
||||
<ignore path="**/res/values-*/**" />
|
||||
</issue>
|
||||
</lint>
|
||||
|
|
@ -55,6 +55,12 @@ class ExampleInstrumentedTest {
|
|||
return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providersExist() {
|
||||
Assert.assertTrue(getAllProviders().isNotEmpty())
|
||||
println("Done providersExist")
|
||||
}
|
||||
|
||||
@Throws
|
||||
private inline fun <reified T : ViewBinding> testAllLayouts(
|
||||
activity: Activity,
|
||||
|
|
@ -130,14 +136,14 @@ class ExampleInstrumentedTest {
|
|||
@Test
|
||||
@Throws(AssertionError::class)
|
||||
fun providerCorrectData() {
|
||||
val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
|
||||
Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
|
||||
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
|
||||
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
|
||||
for (api in getAllProviders()) {
|
||||
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
|
||||
Assert.assertTrue("Api does not contain a name", api.name != "NONE")
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} does not contain a valid language code",
|
||||
langTagsIETF.contains(api.lang)
|
||||
isoNames.contains(api.lang)
|
||||
)
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} does not contain any supported types",
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.lagradost.cloudstream3.SkipSerializationTest
|
||||
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}")
|
||||
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
serializableClasses.forEach { kClass ->
|
||||
runCatching {
|
||||
val instance = Instancio.of(kClass.java).withMaxDepth(10).create()
|
||||
|
||||
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}")
|
||||
}.onFailure { e ->
|
||||
failures.add("FAILED ${kClass.qualifiedName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.isNotEmpty()) {
|
||||
throw AssertionError("${failures.size} class(es) failed:\n${failures.joinToString("\n")}")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
||||
@Test
|
||||
fun isIdenticalDeserialization() {
|
||||
val serializableClasses = findSerializableClasses("com.lagradost")
|
||||
println("Number of serializable classes: ${serializableClasses.size}")
|
||||
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
serializableClasses.forEach { kClass ->
|
||||
runCatching {
|
||||
val instance = Instancio.of(kClass.java).withMaxDepth(10).create()
|
||||
// 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}")
|
||||
}.onFailure { e ->
|
||||
failures.add("FAILED ${kClass.qualifiedName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.isNotEmpty()) {
|
||||
throw AssertionError("${failures.size} class(es) failed:\n${failures.joinToString("\n")}")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
&& kClass.java.annotations.none { it is SkipSerializationTest }
|
||||
&& !kClass.isAbstract
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@Serializable
|
||||
data class UriData(
|
||||
@Serializable(with = UriSerializer::class)
|
||||
@SerialName("uri") val uri: Uri = Uri.EMPTY,
|
||||
)
|
||||
|
||||
class UriSerializerTest {
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="go.torrServer.gojni" /> <!-- torrServer has a different api level -->
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads -->
|
||||
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
|
||||
|
|
@ -16,53 +18,12 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" /> <!-- We can use to read the tv channel list -->
|
||||
|
||||
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
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 -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
|
|
@ -74,8 +35,9 @@
|
|||
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
|
||||
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
||||
<application
|
||||
android:name=".CloudStreamApp"
|
||||
android:name=".AcraApplication"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:appCategory="video"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
|
|
@ -83,12 +45,11 @@
|
|||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:pageSizeCompat="enabled"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="${target_sdk_version}">
|
||||
tools:targetApi="35">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
|
|
@ -149,31 +110,14 @@
|
|||
android:launchMode="singleTask"
|
||||
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
||||
no idea about side effects
|
||||
|
||||
Not exported to prevent bypassing the AccountSelectActivity
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:exported="false"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="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>
|
||||
android:supportsPictureInPicture="true">
|
||||
|
||||
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||
<intent-filter>
|
||||
|
|
@ -200,14 +144,7 @@
|
|||
|
||||
<data android:scheme="cloudstreamrepo" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="csshare" />
|
||||
</intent-filter>
|
||||
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
|
@ -231,7 +168,7 @@
|
|||
<data android:scheme="cloudstreamcontinuewatching" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
|
@ -244,6 +181,21 @@
|
|||
</intent-filter>
|
||||
</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
|
||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||
android:enabled="false"
|
||||
|
|
@ -259,12 +211,6 @@
|
|||
android:foregroundServiceType="dataSync"
|
||||
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 -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
|
|
|||
28
app/src/main/cpp/native-lib.cpp
Normal file
28
app/src/main/cpp/native-lib.cpp
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#include <jni.h>
|
||||
#include <csignal>
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "CloudStream Crash Handler"
|
||||
volatile sig_atomic_t gSignalStatus = 0;
|
||||
void handleNativeCrash(int signal) {
|
||||
gSignalStatus = signal;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
|
||||
#define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
|
||||
REGISTER_SIGNAL(SIGSEGV)
|
||||
#undef REGISTER_SIGNAL
|
||||
}
|
||||
|
||||
//extern "C" JNIEXPORT void JNICALL
|
||||
//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
|
||||
// int *p = nullptr;
|
||||
// *p = 0;
|
||||
//}
|
||||
|
||||
extern "C" JNIEXPORT int JNICALL
|
||||
Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
|
||||
//__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
|
||||
return gSignalStatus;
|
||||
}
|
||||
|
|
@ -1,78 +1,233 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
/**
|
||||
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
|
||||
* Use CloudStreamApp instead.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
|
||||
level = DeprecationLevel.ERROR
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import com.lagradost.api.setContext
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.mvvm.safeAsync
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.CoreConfiguration
|
||||
import org.acra.data.CrashReportData
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.ReportSender
|
||||
import org.acra.sender.ReportSenderFactory
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.PrintStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class CustomReportSender : ReportSender {
|
||||
// Sends all your crashes to google forms
|
||||
override fun send(context: Context, errorContent: CrashReportData) {
|
||||
/*println("Sending report")
|
||||
val url =
|
||||
"https://docs.google.com/forms/d/e/$id/formResponse"
|
||||
val data = mapOf(
|
||||
"entry.$entry" to errorContent.toJSON()
|
||||
)
|
||||
class AcraApplication {
|
||||
|
||||
thread { // to not run it on main thread
|
||||
runBlocking {
|
||||
safeAsync {
|
||||
app.post(url, data = data)
|
||||
//println("Report response: $post")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runOnMainThread { // to run it on main looper
|
||||
safe {
|
||||
Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSenderFactory : ReportSenderFactory {
|
||||
override fun create(context: Context, config: CoreConfiguration): ReportSender {
|
||||
return CustomReportSender()
|
||||
}
|
||||
|
||||
override fun enabled(config: CoreConfiguration): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
|
||||
Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(thread: Thread, error: Throwable) {
|
||||
ACRA.errorReporter.handleException(error)
|
||||
try {
|
||||
PrintStream(errorFile).use { ps ->
|
||||
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
|
||||
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
|
||||
error.printStackTrace(ps)
|
||||
}
|
||||
} catch (ignored: FileNotFoundException) {
|
||||
}
|
||||
try {
|
||||
onError.invoke()
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AcraApplication : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// if we want to initialise coil at earliest
|
||||
// (maybe when loading an image or gif using in splash screen activity)
|
||||
//ImageLoader.buildImageLoader(applicationContext)
|
||||
|
||||
ExceptionHandler(filesDir.resolve("last_error")) {
|
||||
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
|
||||
startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||
}.also {
|
||||
exceptionHandler = it
|
||||
Thread.setDefaultUncaughtExceptionHandler(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
context = base
|
||||
|
||||
initAcra {
|
||||
//core configuration:
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
|
||||
reportContent = listOf(
|
||||
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
|
||||
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
|
||||
ReportField.STACK_TRACE,
|
||||
)
|
||||
|
||||
// removed this due to bug when starting the app, moved it to when it actually crashes
|
||||
//each plugin you chose above can be configured in a block like this:
|
||||
/*toast {
|
||||
text = getString(R.string.acra_report_toast)
|
||||
//opening this block automatically enables the plugin.
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): coil3.ImageLoader {
|
||||
// Coil Module will be initialized & setSafe globally when first loadImage() is invoked
|
||||
return ImageLoader.buildImageLoader(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var exceptionHandler: ExceptionHandler? = null
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
val context get() = CloudStreamApp.context
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
fun removeKeys(folder: String): Int? =
|
||||
CloudStreamApp.removeKeys(folder)
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
fun <T> setKey(path: String, value: T) =
|
||||
CloudStreamApp.setKey(path, value)
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
fun <T> setKey(folder: String, path: String, value: T) =
|
||||
CloudStreamApp.setKey(folder, path, value)
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
|
||||
CloudStreamApp.getKey(path, defVal)
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
inline fun <reified T : Any> getKey(path: String): T? =
|
||||
CloudStreamApp.getKey(path)
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
|
||||
CloudStreamApp.getKey(folder, path)
|
||||
|
||||
@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
|
||||
CloudStreamApp.getKey(folder, path, defVal)
|
||||
/** Use to get activity from Context */
|
||||
tailrec fun Context.getActivity(): Activity? {
|
||||
return when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.getActivity()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private var _context: WeakReference<Context>? = null
|
||||
var context
|
||||
get() = _context?.get()
|
||||
private set(value) {
|
||||
_context = WeakReference(value)
|
||||
setContext(WeakReference(value))
|
||||
}
|
||||
|
||||
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
|
||||
return context?.getKey(path, valueType)
|
||||
}
|
||||
|
||||
fun <T : Any> setKeyClass(path: String, value: T) {
|
||||
context?.setKey(path, value)
|
||||
}
|
||||
|
||||
fun removeKeys(folder: String): Int? {
|
||||
return context?.removeKeys(folder)
|
||||
}
|
||||
|
||||
fun <T> setKey(path: String, value: T) {
|
||||
context?.setKey(path, value)
|
||||
}
|
||||
|
||||
fun <T> setKey(folder: String, path: String, value: T) {
|
||||
context?.setKey(folder, path, value)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
|
||||
return context?.getKey(path, defVal)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(path: String): T? {
|
||||
return context?.getKey(path)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
|
||||
return context?.getKey(folder, path)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
|
||||
return context?.getKey(folder, path, defVal)
|
||||
}
|
||||
|
||||
fun getKeys(folder: String): List<String>? {
|
||||
return context?.getKeys(folder)
|
||||
}
|
||||
|
||||
fun removeKey(folder: String, path: String) {
|
||||
context?.removeKey(folder, path)
|
||||
}
|
||||
|
||||
fun removeKey(path: String) {
|
||||
context?.removeKey(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
|
||||
* */
|
||||
fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
|
||||
context?.openBrowser(url, fallbackWebview, fragment)
|
||||
}
|
||||
|
||||
/** Will fallback to webview if in TV layout */
|
||||
fun openBrowser(url: String, activity: FragmentActivity?) {
|
||||
openBrowser(
|
||||
url,
|
||||
isLayout(TV or EMULATOR),
|
||||
activity?.supportFragmentManager?.fragments?.lastOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import com.lagradost.api.setContext
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.mvvm.safeAsync
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
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.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.PrintStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class ExceptionHandler(
|
||||
val errorFile: File,
|
||||
val onError: (() -> Unit)
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
|
||||
override fun uncaughtException(thread: Thread, error: Throwable) {
|
||||
try {
|
||||
val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
thread.threadId()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
thread.id
|
||||
}
|
||||
|
||||
PrintStream(errorFile).use { ps ->
|
||||
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
|
||||
ps.println("Fatal exception on thread ${thread.name} ($threadId)")
|
||||
error.printStackTrace(ps)
|
||||
}
|
||||
} catch (_: FileNotFoundException) {
|
||||
}
|
||||
try {
|
||||
onError()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
class CloudStreamApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// If we want to initialize Coil as early as possible, maybe when
|
||||
// loading an image or GIF in a splash screen activity.
|
||||
// buildImageLoader(applicationContext)
|
||||
|
||||
ExceptionHandler(filesDir.resolve("last_error")) {
|
||||
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
|
||||
startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||
}.also {
|
||||
exceptionHandler = it
|
||||
Thread.setDefaultUncaughtExceptionHandler(it)
|
||||
}
|
||||
|
||||
AppDebug.isDebug = BuildConfig.DEBUG
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
context = base
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||
// Coil module will be initialized globally when first loadImage() is invoked.
|
||||
return buildImageLoader(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var exceptionHandler: ExceptionHandler? = null
|
||||
|
||||
/** Use to get Activity from Context. */
|
||||
tailrec fun Context.getActivity(): Activity? {
|
||||
return when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.getActivity()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private var _context: WeakReference<Context>? = null
|
||||
var context
|
||||
get() = _context?.get()
|
||||
private set(value) {
|
||||
_context = WeakReference(value)
|
||||
setContext(value)
|
||||
}
|
||||
|
||||
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
|
||||
return context?.getKey(path, valueType)
|
||||
}
|
||||
|
||||
fun <T : Any> setKeyClass(path: String, value: T) {
|
||||
context?.setKey(path, value)
|
||||
}
|
||||
|
||||
fun removeKeys(folder: String): Int? {
|
||||
return context?.removeKeys(folder)
|
||||
}
|
||||
|
||||
fun <T> setKey(path: String, value: T) {
|
||||
context?.setKey(path, value)
|
||||
}
|
||||
|
||||
fun <T> setKey(folder: String, path: String, value: T) {
|
||||
context?.setKey(folder, path, value)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
|
||||
return context?.getKey(path, defVal)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(path: String): T? {
|
||||
return context?.getKey(path)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
|
||||
return context?.getKey(folder, path)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
|
||||
return context?.getKey(folder, path, defVal)
|
||||
}
|
||||
|
||||
fun getKeys(folder: String): List<String>? {
|
||||
return context?.getKeys(folder)
|
||||
}
|
||||
|
||||
fun removeKey(folder: String, path: String) {
|
||||
context?.removeKey(folder, path)
|
||||
}
|
||||
|
||||
fun removeKey(path: String) {
|
||||
context?.removeKey(path)
|
||||
}
|
||||
|
||||
/** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */
|
||||
fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) {
|
||||
context?.openBrowser(url, fallbackWebView, fragment)
|
||||
}
|
||||
|
||||
/** Will fall back to WebView if in TV or emulator layout. */
|
||||
fun openBrowser(url: String, activity: FragmentActivity?) {
|
||||
openBrowser(
|
||||
url,
|
||||
isLayout(TV or EMULATOR),
|
||||
activity?.supportFragmentManager?.fragments?.lastOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
|
|
@ -27,41 +24,35 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isNotEmpty
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import com.google.android.material.navigationrail.NavigationRailView
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||
import com.lagradost.cloudstream3.databinding.ToastBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
|
||||
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
||||
import com.lagradost.cloudstream3.ui.player.Torrent
|
||||
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
|
||||
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter
|
||||
import com.lagradost.cloudstream3.ui.result.ImageAdapter
|
||||
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
|
||||
import com.lagradost.cloudstream3.utils.UIHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
|
||||
enum class FocusDirection {
|
||||
Start,
|
||||
|
|
@ -110,15 +101,15 @@ object CommonActivity {
|
|||
return displayMetrics.heightPixels
|
||||
}
|
||||
|
||||
var isPipDesired: Boolean = false
|
||||
var canEnterPipMode: Boolean = false
|
||||
var canShowPipMode: Boolean = false
|
||||
var isInPIPMode: Boolean = false
|
||||
|
||||
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
||||
val onDialogDismissedEvent = Event<Int>()
|
||||
|
||||
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
||||
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
||||
var appliedTheme: Int = 0
|
||||
var appliedColor: Int = 0
|
||||
|
||||
private var currentToast: Toast? = null
|
||||
|
||||
|
|
@ -191,35 +182,23 @@ object CommonActivity {
|
|||
currentToast = toast
|
||||
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) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set locale
|
||||
* @param languageTag shall a IETF BCP 47 conformant tag.
|
||||
* Check [com.lagradost.cloudstream3.utils.SubtitleHelper].
|
||||
*
|
||||
* See locales on:
|
||||
* https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json
|
||||
* https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
|
||||
* https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml
|
||||
* https://iso639-3.sil.org/code_tables/639/data/all
|
||||
*/
|
||||
fun setLocale(context: Context?, languageTag: String?) {
|
||||
if (context == null || languageTag == null) return
|
||||
val locale = Locale.forLanguageTag(languageTag)
|
||||
* Not all languages can be fetched from locale with a code.
|
||||
* This map allows sidestepping the default Locale(languageCode)
|
||||
* when setting the app language.
|
||||
**/
|
||||
val appLanguageExceptions = hashMapOf(
|
||||
"zh-rTW" to Locale.TRADITIONAL_CHINESE
|
||||
)
|
||||
|
||||
fun setLocale(context: Context?, languageCode: String?) {
|
||||
if (context == null || languageCode == null) return
|
||||
val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
|
||||
val resources: Resources = context.resources
|
||||
val config = resources.configuration
|
||||
Locale.setDefault(locale)
|
||||
|
|
@ -227,7 +206,6 @@ object CommonActivity {
|
|||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
context.createConfigurationContext(config)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(
|
||||
config,
|
||||
|
|
@ -244,8 +222,16 @@ object CommonActivity {
|
|||
fun init(act: Activity) {
|
||||
setActivityInstance(act)
|
||||
ioSafe { Torrent.deleteAllFiles() }
|
||||
|
||||
val componentActivity = activity as? ComponentActivity ?: return
|
||||
|
||||
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
|
||||
//https://developer.android.com/guide/topics/ui/picture-in-picture
|
||||
canShowPipMode =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
|
||||
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
|
||||
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
|
||||
|
||||
componentActivity.updateLocale()
|
||||
componentActivity.updateTv()
|
||||
AccountManager.initMainAPI()
|
||||
|
|
@ -261,7 +247,7 @@ object CommonActivity {
|
|||
?: return@registerForActivityResult
|
||||
action.onResultSafe(act, result.data)
|
||||
removeKey("last_click_action")
|
||||
removeKey("last_opened")
|
||||
removeKey("last_opened_id")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,15 +269,13 @@ object CommonActivity {
|
|||
}
|
||||
}
|
||||
|
||||
/** Enters pip mode if it is both possible and desired to do so*/
|
||||
private fun Activity.enterPIPMode() {
|
||||
if (!isPipDesired || !this.isPIPPossible()) return
|
||||
|
||||
if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// Use fallback just in case
|
||||
@Suppress("DEPRECATION")
|
||||
enterPictureInPictureMode()
|
||||
|
|
@ -307,10 +291,10 @@ object CommonActivity {
|
|||
}
|
||||
}
|
||||
|
||||
fun onUserLeaveHint(act: Activity) {
|
||||
// On Android 12 and later we use setAutoEnterEnabled() instead.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
|
||||
act.enterPIPMode()
|
||||
fun onUserLeaveHint(act: Activity?) {
|
||||
if (canEnterPipMode && canShowPipMode) {
|
||||
act?.enterPIPMode()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTheme(act: Activity) {
|
||||
|
|
@ -350,10 +334,6 @@ object CommonActivity {
|
|||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
R.style.MonetMode else R.style.AppTheme
|
||||
|
||||
"Dracula" -> R.style.DraculaMode
|
||||
"Lavender" -> R.style.LavenderMode
|
||||
"SilentBlue" -> R.style.SilentBlueMode
|
||||
|
||||
else -> R.style.AppTheme
|
||||
}
|
||||
|
||||
|
|
@ -389,8 +369,6 @@ object CommonActivity {
|
|||
|
||||
act.theme.applyStyle(currentTheme, true)
|
||||
act.theme.applyStyle(currentOverlayTheme, true)
|
||||
appliedTheme = currentTheme
|
||||
appliedColor = currentOverlayTheme
|
||||
act.updateTv()
|
||||
if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
|
||||
act.theme.applyStyle(
|
||||
|
|
@ -423,7 +401,8 @@ object CommonActivity {
|
|||
|
||||
private fun View.hasContent(): Boolean {
|
||||
return isShown && when (this) {
|
||||
is ViewGroup -> this.isNotEmpty()
|
||||
//is RecyclerView -> this.childCount > 0
|
||||
is ViewGroup -> this.childCount > 0
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
|
@ -453,7 +432,7 @@ object CommonActivity {
|
|||
// if cant focus but visible then break and let android decide
|
||||
// the exception if is the view is a parent and has children that wants focus
|
||||
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
|
||||
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty()
|
||||
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
|
||||
} ?: false
|
||||
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
|
||||
|
||||
|
|
@ -532,7 +511,87 @@ object CommonActivity {
|
|||
|
||||
|
||||
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
|
||||
|
||||
//when (keyCode) {
|
||||
// KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
// println("DPAD PRESSED")
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
/** overrides focus and custom key events */
|
||||
|
|
@ -569,7 +628,6 @@ object CommonActivity {
|
|||
|
||||
else -> null
|
||||
}
|
||||
|
||||
// println("NEXT FOCUS : $nextView")
|
||||
if (nextView != null) {
|
||||
nextView.requestFocus()
|
||||
|
|
@ -577,15 +635,10 @@ object CommonActivity {
|
|||
return true
|
||||
}
|
||||
|
||||
// TODO: Figure out why removing the check for SearchAutoComplete seems
|
||||
// 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")
|
||||
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
|
||||
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
||||
) {
|
||||
showInputMethod(act.currentFocus?.findFocus())
|
||||
UIHelper.showInputMethod(act.currentFocus?.findFocus())
|
||||
}
|
||||
|
||||
//println("Keycode: $keyCode")
|
||||
|
|
@ -594,6 +647,7 @@ object CommonActivity {
|
|||
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
||||
// Toast.LENGTH_LONG
|
||||
//)
|
||||
|
||||
}
|
||||
|
||||
// if someone else want to override the focus then don't handle the event as it is already
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.ui.HeaderViewDecoration
|
||||
|
||||
fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
|
||||
val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
|
||||
view.addItemDecoration(HeaderViewDecoration(headerView))
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import android.content.SharedPreferences
|
|||
import android.content.res.ColorStateList
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
|
|
@ -23,14 +24,14 @@ import android.widget.CheckBox
|
|||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.get
|
||||
import androidx.core.view.isGone
|
||||
|
|
@ -64,9 +65,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
|
|||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.APIHolder.initAll
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.loadThemes
|
||||
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
|
||||
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
|
||||
|
|
@ -97,7 +98,6 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STR
|
|||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.APIRepository
|
||||
|
|
@ -119,7 +119,6 @@ import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
|||
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.ui.settings.Globals.updateTv
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
|
||||
|
|
@ -157,31 +156,24 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
|
|||
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
|
||||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate
|
||||
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||
import com.lagradost.cloudstream3.utils.TvChannelUtils
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.showProgress
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||
import com.lagradost.cloudstream3.utils.setText
|
||||
import com.lagradost.cloudstream3.utils.setTextHtml
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import com.lagradost.safefile.SafeFile
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
|
@ -191,9 +183,9 @@ import java.net.URLDecoder
|
|||
import java.nio.charset.Charset
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.reflect.full.createInstance
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
|
||||
companion object {
|
||||
var activityResultLauncher: ActivityResultLauncher<Intent>? = null
|
||||
|
|
@ -202,21 +194,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
const val ANIMATED_OUTLINE: Boolean = false
|
||||
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"
|
||||
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
|
||||
|
||||
|
|
@ -278,6 +255,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
* @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.
|
||||
* */
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
fun handleAppIntentUrl(
|
||||
activity: FragmentActivity?,
|
||||
str: String?,
|
||||
|
|
@ -354,7 +332,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
|
||||
R.id.navigation_search
|
||||
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
|
||||
val uri = str.toUri()
|
||||
val uri = Uri.parse(str)
|
||||
val name = uri.getQueryParameter("name")
|
||||
val url = URLDecoder.decode(uri.authority, "UTF-8")
|
||||
|
||||
|
|
@ -364,8 +342,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
LinkGenerator(
|
||||
listOf(BasicLink(url, name)),
|
||||
extract = true,
|
||||
id = url.hashCode()
|
||||
), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
|
||||
|
|
@ -381,20 +358,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
START_ACTION_RESUME_LATEST
|
||||
)
|
||||
}
|
||||
} else if (str.startsWith(APP_STRING_SHARE)) {
|
||||
try {
|
||||
val data = str.substringAfter("$APP_STRING_SHARE:")
|
||||
val parts = data.split("?", limit = 2)
|
||||
loadResult(
|
||||
String(base64DecodeArray(parts[1]), Charsets.UTF_8),
|
||||
String(base64DecodeArray(parts[0]), Charsets.UTF_8),
|
||||
""
|
||||
)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
showToast("Invalid Uri", Toast.LENGTH_SHORT)
|
||||
return false
|
||||
}
|
||||
} else if (!isWebview) {
|
||||
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
|
||||
this.navigate(R.id.navigation_downloads)
|
||||
|
|
@ -410,39 +373,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
return true
|
||||
}
|
||||
|
||||
val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
|
||||
if (matchedApi != null) {
|
||||
loadResult(str, matchedApi.name, "")
|
||||
synchronized(apis) {
|
||||
for (api in apis) {
|
||||
if (str.startsWith(api.mainUrl)) {
|
||||
loadResult(str, api.name, "")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
fun centerView(view: View?) {
|
||||
if (view == null) return
|
||||
try {
|
||||
Log.v(TAG, "centerView: $view")
|
||||
val r = Rect(0, 0, 0, 0)
|
||||
view.getDrawingRect(r)
|
||||
val x = r.centerX()
|
||||
val y = r.centerY()
|
||||
val dx = r.width() / 2 //screenWidth / 2
|
||||
val dy = screenHeight / 2
|
||||
val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
|
||||
view.requestRectangleOnScreen(r2, false)
|
||||
// TvFocus.current =TvFocus.current.copy(y=y.toFloat())
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var lastPopup: SearchResponse? = null
|
||||
var lastPopupJob: Job? = null
|
||||
fun loadPopup(result: SearchResponse, load: Boolean = true) {
|
||||
lastPopup = result
|
||||
val syncName = syncViewModel.syncName(result.apiName)
|
||||
|
|
@ -458,8 +404,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
syncViewModel.clear()
|
||||
}
|
||||
|
||||
lastPopupJob?.cancel()
|
||||
lastPopupJob = if (load) {
|
||||
if (load) {
|
||||
viewModel.load(
|
||||
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
||||
.contains(DubStatus.Dubbed)
|
||||
|
|
@ -506,7 +451,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
R.id.navigation_downloads,
|
||||
R.id.navigation_settings,
|
||||
R.id.navigation_download_child,
|
||||
R.id.navigation_download_queue,
|
||||
R.id.navigation_subtitles,
|
||||
R.id.navigation_chrome_subtitles,
|
||||
R.id.navigation_settings_player,
|
||||
|
|
@ -521,7 +465,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
).contains(destination.id)
|
||||
|
||||
|
||||
/*val dontPush = listOf(
|
||||
val dontPush = listOf(
|
||||
R.id.navigation_home,
|
||||
R.id.navigation_search,
|
||||
R.id.navigation_results_phone,
|
||||
|
|
@ -552,19 +496,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
|
||||
layoutParams = params
|
||||
}*/
|
||||
}
|
||||
|
||||
val landscape = when (resources.configuration.orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
true
|
||||
}
|
||||
|
||||
Configuration.ORIENTATION_PORTRAIT -> {
|
||||
isLayout(TV or EMULATOR)
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
binding?.apply {
|
||||
navRailView.isVisible = isNavVisible && isLandscape()
|
||||
navView.isVisible = isNavVisible && !isLandscape()
|
||||
navHostFragment.apply {
|
||||
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
|
||||
layoutParams =
|
||||
(navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||
marginStart =
|
||||
if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
|
||||
}
|
||||
}
|
||||
navRailView.isVisible = isNavVisible && landscape
|
||||
navView.isVisible = isNavVisible && !landscape
|
||||
|
||||
/**
|
||||
* We need to make sure if we return to a sub-fragment,
|
||||
|
|
@ -572,11 +522,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
* highlight the wrong one in UI.
|
||||
*/
|
||||
when (destination.id) {
|
||||
in listOf(
|
||||
R.id.navigation_downloads,
|
||||
R.id.navigation_download_child,
|
||||
R.id.navigation_download_queue
|
||||
) -> {
|
||||
in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
|
||||
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||
}
|
||||
|
|
@ -698,9 +644,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
.setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
if (dontShowAgainCheck.isChecked) {
|
||||
settingsManager.edit(commit = true) {
|
||||
putInt(getString(R.string.confirm_exit_key), 1)
|
||||
}
|
||||
settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit()
|
||||
}
|
||||
// finish() causes a bug on some TVs where player
|
||||
// may keep playing after closing the app.
|
||||
|
|
@ -725,11 +669,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
|
||||
this.sendBroadcast(broadcastIntent)
|
||||
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
||||
detachBackPressedCallback("MainActivityDefault")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
handleAppIntent(intent)
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
|
|
@ -738,7 +681,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
if (intent == null) return
|
||||
val str = intent.dataString
|
||||
loadCache()
|
||||
|
||||
handleAppIntentUrl(this, str, false, intent.extras)
|
||||
}
|
||||
|
||||
|
|
@ -757,35 +699,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
// Check if we are already at the selected destination
|
||||
if (navController.currentDestination?.id == destinationId) return false
|
||||
|
||||
// Make all nav buttons focus on this specific view when nextFocusRightId
|
||||
val targetView = when (destinationId) {
|
||||
// Please note that if R.id.navigation_home is readded, then it will only take affect when
|
||||
// navigation to home for the second time as onNavDestinationSelected will not get called
|
||||
// when first loading up the app
|
||||
|
||||
// R.id.navigation_home -> R.id.home_preview_change_api
|
||||
R.id.navigation_search -> R.id.main_search
|
||||
R.id.navigation_library -> R.id.main_search
|
||||
R.id.navigation_downloads -> R.id.download_appbar
|
||||
else -> null
|
||||
}
|
||||
if (targetView != null && isLayout(TV or EMULATOR)) {
|
||||
val fromView = binding?.navRailView
|
||||
if (fromView != null) {
|
||||
fromView.nextFocusRightId = targetView
|
||||
|
||||
for (focusView in arrayOf(
|
||||
R.id.navigation_downloads,
|
||||
R.id.navigation_home,
|
||||
R.id.navigation_search,
|
||||
R.id.navigation_library,
|
||||
R.id.navigation_settings,
|
||||
)) {
|
||||
fromView.findViewById<View?>(focusView)?.nextFocusRightId = targetView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true)
|
||||
.setEnterAnim(R.anim.enter_anim)
|
||||
.setExitAnim(R.anim.exit_anim)
|
||||
|
|
@ -807,33 +720,32 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private val pluginsLock = Mutex()
|
||||
private fun onAllPluginsLoaded(success: Boolean = false) {
|
||||
ioSafe {
|
||||
pluginsLock.withLock {
|
||||
allProviders.withLock {
|
||||
synchronized(allProviders) {
|
||||
// Load cloned sites after plugins have been loaded since clones depend on plugins.
|
||||
try {
|
||||
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
|
||||
list.forEach { custom ->
|
||||
allProviders.firstOrNull {
|
||||
it::class.simpleName == custom.parentClassName
|
||||
}?.let {
|
||||
allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
|
||||
?.let {
|
||||
allProviders.add(
|
||||
it::class.createInstance().apply {
|
||||
it.javaClass.getDeclaredConstructor().newInstance()
|
||||
.apply {
|
||||
name = custom.name
|
||||
lang = custom.lang
|
||||
mainUrl = custom.url.trimEnd('/')
|
||||
canBeOverridden = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// it.hashCode() is not enough to make sure they are distinct
|
||||
apis = allProviders.distinctBy {
|
||||
it.lang + it.name + it.mainUrl + it::class.qualifiedName
|
||||
}
|
||||
apis =
|
||||
allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name }
|
||||
APIHolder.apiMap = null
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
|
|
@ -859,8 +771,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
private fun hidePreviewPopupDialog() {
|
||||
bottomPreviewPopup.dismissSafe(this)
|
||||
lastPopupJob?.cancel()
|
||||
lastPopupJob = null
|
||||
bottomPreviewPopup = null
|
||||
bottomPreviewBinding = null
|
||||
}
|
||||
|
|
@ -1180,14 +1090,35 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
app.initClient(this, ignoreSSL = false)
|
||||
@OptIn(UnsafeSSL::class)
|
||||
insecureApp.initClient(this, ignoreSSL = true)
|
||||
private fun centerView(view: View?) {
|
||||
if (view == null) return
|
||||
try {
|
||||
Log.v(TAG, "centerView: $view")
|
||||
val r = Rect(0, 0, 0, 0)
|
||||
view.getDrawingRect(r)
|
||||
val x = r.centerX()
|
||||
val y = r.centerY()
|
||||
val dx = r.width() / 2 //screenWidth / 2
|
||||
val dy = screenHeight / 2
|
||||
val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
|
||||
view.requestRectangleOnScreen(r2, false)
|
||||
// TvFocus.current =TvFocus.current.copy(y=y.toFloat())
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
app.initClient(this)
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
setLastError(this)
|
||||
val errorFile = filesDir.resolve("last_error")
|
||||
if (errorFile.exists() && errorFile.isFile) {
|
||||
lastError = errorFile.readText(Charset.defaultCharset())
|
||||
errorFile.delete()
|
||||
} else {
|
||||
lastError = null
|
||||
}
|
||||
|
||||
val settingsForProvider = SettingsJson()
|
||||
settingsForProvider.enableAdult =
|
||||
|
|
@ -1196,8 +1127,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
MainAPI.settingsForProvider = settingsForProvider
|
||||
|
||||
loadThemes(this)
|
||||
enableEdgeToEdgeCompat()
|
||||
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
|
||||
updateLocale()
|
||||
super.onCreate(savedInstanceState)
|
||||
try {
|
||||
|
|
@ -1215,11 +1144,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
// backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting?
|
||||
safe {
|
||||
val appVer = BuildConfig.VERSION_NAME
|
||||
val lastAppAutoBackup: String = getKey<String>("VERSION_NAME") ?: ""
|
||||
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
|
||||
if (appVer != lastAppAutoBackup) {
|
||||
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
|
||||
if (lastAppAutoBackup.isEmpty()) return@safe
|
||||
|
||||
safe {
|
||||
backup(this)
|
||||
}
|
||||
|
|
@ -1251,7 +1178,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
if (isLayout(TV)) {
|
||||
// Put here any button you don't want focusing it to center the view
|
||||
val exceptionButtons = listOf(
|
||||
//R.id.home_preview_play_btt,
|
||||
R.id.home_preview_play_btt,
|
||||
R.id.home_preview_info_btt,
|
||||
R.id.home_preview_hidden_next_focus,
|
||||
R.id.home_preview_hidden_prev_focus,
|
||||
|
|
@ -1283,22 +1210,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
null
|
||||
}
|
||||
|
||||
binding?.apply {
|
||||
fixSystemBarsPadding(
|
||||
navView,
|
||||
heightResId = R.dimen.nav_view_height,
|
||||
padTop = false,
|
||||
overlayCutout = false
|
||||
)
|
||||
|
||||
fixSystemBarsPadding(
|
||||
navRailView,
|
||||
widthResId = R.dimen.nav_rail_view_width,
|
||||
padRight = false,
|
||||
padTop = false
|
||||
)
|
||||
}
|
||||
|
||||
// overscan
|
||||
val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
|
||||
binding?.homeRoot?.setPadding(padding, padding, padding, padding)
|
||||
|
|
@ -1389,9 +1300,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
false
|
||||
)
|
||||
}
|
||||
|
||||
// Add your channel creation here
|
||||
|
||||
}
|
||||
} else {
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
||||
|
|
@ -1429,9 +1337,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
else -> {
|
||||
resultviewPreviewBookmark.isEnabled = false
|
||||
resultviewPreviewBookmark.showProgress()
|
||||
//resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
|
||||
//resultviewPreviewBookmark.setText(R.string.loading)
|
||||
resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
|
||||
resultviewPreviewBookmark.setText(R.string.loading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1657,7 +1564,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
ioSafe {
|
||||
initAll()
|
||||
// No duplicates (which can happen by registerMainAPI)
|
||||
apis = allProviders.distinctBy { it }
|
||||
apis = synchronized(allProviders) {
|
||||
allProviders.distinctBy { it }
|
||||
}
|
||||
}
|
||||
|
||||
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
|
||||
|
|
@ -1680,6 +1589,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
if (navDestination.matchDestination(R.id.navigation_home)) {
|
||||
attachBackPressedCallback("MainActivity") {
|
||||
showConfirmExitDialog(settingsManager)
|
||||
@Suppress("DEPRECATION")
|
||||
window?.navigationBarColor =
|
||||
colorFromAttribute(R.attr.primaryGrayBackground)
|
||||
updateLocale()
|
||||
}
|
||||
} else detachBackPressedCallback("MainActivity")
|
||||
}
|
||||
|
|
@ -1711,23 +1624,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
|
||||
binding?.navRailView?.apply {
|
||||
if (isLayout(PHONE)) {
|
||||
itemRippleColor = rippleColor
|
||||
itemActiveIndicatorColor = rippleColor
|
||||
} else {
|
||||
val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f))
|
||||
val rippleColorTransparent =
|
||||
ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f))
|
||||
itemSpacing = 12.toPx // expandedItemSpacing does not have an attr
|
||||
itemRippleColor = rippleColorTransparent
|
||||
itemActiveIndicatorColor = rippleColor
|
||||
}
|
||||
setupWithNavController(navController)
|
||||
/*if (isLayout(TV or EMULATOR)) {
|
||||
if (isLayout(TV or EMULATOR)) {
|
||||
background?.alpha = 200
|
||||
} else {
|
||||
background?.alpha = 255
|
||||
}*/
|
||||
}
|
||||
|
||||
setOnItemSelectedListener { item ->
|
||||
onNavDestinationSelected(
|
||||
|
|
@ -1775,58 +1679,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
}
|
||||
|
||||
val rail = binding?.navRailView
|
||||
if (rail != null) {
|
||||
binding?.navRailView?.labelVisibilityMode =
|
||||
NavigationRailView.LABEL_VISIBILITY_UNLABELED
|
||||
//val focus = mutableSetOf<Int>()
|
||||
|
||||
var prevId: Int? = null
|
||||
var prevView: View? = null
|
||||
|
||||
// The genius engineers at google did not actually
|
||||
// write a nextFocus for the navrail
|
||||
rail.findViewById<View?>(R.id.navigation_settings)?.nextFocusDownId =
|
||||
R.id.nav_footer_profile_card
|
||||
for (id in arrayOf(
|
||||
R.id.navigation_home,
|
||||
R.id.navigation_search,
|
||||
R.id.navigation_library,
|
||||
R.id.navigation_downloads,
|
||||
R.id.navigation_settings
|
||||
)) {
|
||||
val view = rail.findViewById<View?>(id) ?: continue
|
||||
prevId?.let { view.nextFocusUpId = it }
|
||||
prevView?.nextFocusDownId = id
|
||||
|
||||
prevView = view
|
||||
prevId = id
|
||||
// Uncomment for focus expand
|
||||
/*if (!isLayout(TV)) {
|
||||
view.onFocusChangeListener = null
|
||||
} else {
|
||||
view.onFocusChangeListener =
|
||||
View.OnFocusChangeListener { v, hasFocus ->
|
||||
if (hasFocus) {
|
||||
focus += id
|
||||
binding?.navRailView?.labelVisibilityMode =
|
||||
NavigationRailView.LABEL_VISIBILITY_LABELED
|
||||
binding?.navRailView?.expand()
|
||||
} else {
|
||||
focus -= id
|
||||
v.post {
|
||||
if (focus.isEmpty()) {
|
||||
binding?.navRailView?.labelVisibilityMode =
|
||||
NavigationRailView.LABEL_VISIBILITY_UNLABELED
|
||||
binding?.navRailView?.collapse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation button long click functionality to scroll to top
|
||||
for (view in listOf(binding?.navView, binding?.navRailView)) {
|
||||
view?.findViewById<View?>(R.id.navigation_home)?.setOnLongClickListener {
|
||||
|
|
@ -1939,7 +1791,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
fun buildMediaQueueItem(video: String): MediaQueueItem {
|
||||
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
|
||||
//movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream")
|
||||
val mediaInfo = MediaInfo.Builder(video.toUri().toString())
|
||||
val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString())
|
||||
.setStreamType(MediaInfo.STREAM_TYPE_NONE)
|
||||
.setContentType(MimeTypes.IMAGE_JPEG)
|
||||
// .setMetadata(movieMetadata).build()
|
||||
|
|
@ -1965,7 +1817,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
if (BuildConfig.DEBUG) {
|
||||
var providersAndroidManifestString = "Current androidmanifest should be:\n"
|
||||
allProviders.withLock {
|
||||
synchronized(allProviders) {
|
||||
for (api in allProviders) {
|
||||
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
|
||||
api.mainUrl.removePrefix(
|
||||
|
|
@ -2001,24 +1853,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
migrateResumeWatching()
|
||||
}
|
||||
|
||||
main {
|
||||
val channelId =
|
||||
TvChannelUtils.getChannelId(this@MainActivity, getString(R.string.app_name))
|
||||
if (channelId == null) {
|
||||
Log.d("TvChannel", "Channel not found, creating")
|
||||
TvChannelUtils.createTvChannel(this@MainActivity)
|
||||
} else {
|
||||
Log.d("TvChannel", "Channel ID: $channelId")
|
||||
}
|
||||
}
|
||||
|
||||
getKey<String>(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
|
||||
DataStoreHelper.currentHomePage = homepage
|
||||
removeKey(USER_SELECTED_HOMEPAGE_API)
|
||||
}
|
||||
|
||||
try {
|
||||
if (getKey<Boolean>(HAS_DONE_SETUP_KEY, false) != true) {
|
||||
if (getKey(HAS_DONE_SETUP_KEY, false) != true) {
|
||||
navController.navigate(R.id.navigation_setup_language)
|
||||
// If no plugins bring up extensions screen
|
||||
} else if (PluginManager.getPluginsOnline().isEmpty()
|
||||
|
|
@ -2043,14 +1884,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
// }
|
||||
// }
|
||||
|
||||
attachBackPressedCallback("MainActivityDefault") {
|
||||
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
@Suppress("DEPRECATION")
|
||||
window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
|
||||
updateLocale()
|
||||
runDefault()
|
||||
}
|
||||
|
||||
// Start the download queue
|
||||
DownloadQueueManager.init(this)
|
||||
// If we don't disable we end up in a loop with default behavior calling
|
||||
// this callback as well, so we disable it, run default behavior,
|
||||
// then re-enable this callback so it can be used for next back press.
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Biometric stuff **/
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
|
|
@ -21,8 +21,7 @@ import java.io.File
|
|||
|
||||
fun updateDurationAndPosition(position: Long, duration: Long) {
|
||||
if (position <= 0 || duration <= 0) return
|
||||
val episode = getKey<ResultEpisode>("last_opened") ?: return
|
||||
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
|
||||
DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
|
||||
ResultFragment.updateUI()
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +98,7 @@ abstract class OpenInAppAction(
|
|||
intent.component = ComponentName(packageName, intentClass)
|
||||
}
|
||||
putExtra(context, intent, video, result, index)
|
||||
setKey("last_opened", video)
|
||||
setKey("last_opened_id", video.id)
|
||||
launchResult(intent)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,16 +16,12 @@ import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
|
|||
import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
|
||||
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvExPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
||||
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.NextPlayerPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
|
||||
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
||||
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
|
||||
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
||||
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.VlcPackage
|
||||
|
|
@ -34,26 +30,25 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
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.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.FutureTask
|
||||
import kotlin.reflect.jvm.jvmName
|
||||
|
||||
object VideoClickActionHolder {
|
||||
val allVideoClickActions = atomicListOf(
|
||||
val allVideoClickActions = threadSafeListOf(
|
||||
// Default
|
||||
PlayInBrowserAction(),
|
||||
CopyClipboardAction(),
|
||||
ViewM3U8Action(),
|
||||
PlayMirrorAction(),
|
||||
// main support external apps
|
||||
VlcPackage(),
|
||||
MpvPackage(),
|
||||
MpvExPackage(),
|
||||
NextPlayerPackage(),
|
||||
JustPlayerPackage(),
|
||||
FcastAction(),
|
||||
|
|
@ -65,8 +60,6 @@ object VideoClickActionHolder {
|
|||
MpvYTDLPackage(),
|
||||
MpvKtPackage(),
|
||||
MpvKtPreviewPackage(),
|
||||
OnlyPlayer(),
|
||||
MpvRxPackage(),
|
||||
// Always Ask option
|
||||
AlwaysAskAction(),
|
||||
// added by plugins
|
||||
|
|
@ -160,7 +153,7 @@ abstract class VideoClickAction {
|
|||
}
|
||||
}
|
||||
|
||||
fun uniqueId() = "$sourcePlugin:${this::class.qualifiedName}"
|
||||
fun uniqueId() = "$sourcePlugin:${this::class.jvmName}"
|
||||
|
||||
@Throws
|
||||
abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.SkipSerializationTest
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.ui.player.ExtractorUri
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
|
||||
|
|
@ -19,14 +18,9 @@ import com.lagradost.cloudstream3.utils.DrmExtractorLink
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.newExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
|
||||
import com.lagradost.cloudstream3.utils.serializers.UriSerializer
|
||||
import com.lagradost.cloudstream3.utils.newExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* If you want to support CloudStream 3 as an external player, then this shows how to play any video link
|
||||
|
|
@ -53,17 +47,19 @@ class CloudStreamPackage : OpenInAppAction(
|
|||
const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SkipSerializationTest //.Uri has issues with Jackson
|
||||
data class MinimalVideoLink(
|
||||
@JsonProperty("uri") @SerialName("uri")
|
||||
@Serializable(with = UriSerializer::class)
|
||||
@JsonProperty("uri")
|
||||
val uri: Uri?,
|
||||
@JsonProperty("url") @SerialName("url") val url: String?,
|
||||
@JsonProperty("mimeType") @SerialName("mimeType") val mimeType: String = "video/mp4",
|
||||
@JsonProperty("name") @SerialName("name") val name: String?,
|
||||
@JsonProperty("headers") @SerialName("headers") var headers: Map<String, String> = mapOf(),
|
||||
@JsonProperty("quality") @SerialName("quality") val quality: Int?,
|
||||
@JsonProperty("url")
|
||||
val url: String?,
|
||||
@JsonProperty("mimeType")
|
||||
val mimeType: String = "video/mp4",
|
||||
@JsonProperty("name")
|
||||
val name: String?,
|
||||
@JsonProperty("headers")
|
||||
var headers: Map<String, String> = mapOf(),
|
||||
@JsonProperty("quality")
|
||||
val quality: Int?,
|
||||
) {
|
||||
companion object {
|
||||
fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink(
|
||||
|
|
@ -99,12 +95,16 @@ class CloudStreamPackage : OpenInAppAction(
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
data class MinimalSubtitleLink(
|
||||
@JsonProperty("url") @SerialName("url") val url: String,
|
||||
@JsonProperty("mimeType") @SerialName("mimeType") val mimeType: String = "text/vtt",
|
||||
@JsonProperty("name") @SerialName("name") val name: String?,
|
||||
@JsonProperty("headers") @SerialName("headers") var headers: Map<String, String> = mapOf(),
|
||||
@JsonProperty("url")
|
||||
val url: String,
|
||||
@JsonProperty("mimeType")
|
||||
val mimeType: String = "text/vtt",
|
||||
@JsonProperty("name")
|
||||
val name: String?,
|
||||
@JsonProperty("headers")
|
||||
var headers: Map<String, String> = mapOf(),
|
||||
) {
|
||||
companion object {
|
||||
fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink(
|
||||
|
|
@ -122,9 +122,7 @@ class CloudStreamPackage : OpenInAppAction(
|
|||
originalName = name ?: "Unknown",
|
||||
headers = headers,
|
||||
origin = SubtitleOrigin.URL,
|
||||
languageCode = fromCodeToLangTagIETF(name) ?:
|
||||
fromLanguageToTagIETF(name, true) ?:
|
||||
name,
|
||||
languageCode = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||
|
|
@ -44,7 +45,7 @@ open class MpvKtPackage(
|
|||
|
||||
intent.apply {
|
||||
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
|
||||
setDataAndType(link.url.toUri(), "video/*")
|
||||
setDataAndType(Uri.parse(link.url), "video/*")
|
||||
|
||||
// m3u8 plays, but changing sources feature is not available
|
||||
// makeTempM3U8Intent(activity, this, result)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.api.Log
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
|
|
@ -17,9 +18,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
|||
// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
|
||||
// https://mpv-android.github.io/mpv-android/intent.html
|
||||
|
||||
//https://github.com/marlboro-advance/mpvEx
|
||||
class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity")
|
||||
|
||||
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
|
||||
override val sourceTypes = setOf(
|
||||
ExtractorLinkType.VIDEO,
|
||||
|
|
@ -28,10 +26,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
|
|||
)
|
||||
}
|
||||
|
||||
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction(
|
||||
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction(
|
||||
txt(appName),
|
||||
packageName,
|
||||
intentClass
|
||||
"is.xyz.mpv.MPVActivity"
|
||||
) {
|
||||
override val oneSource = true // mpv has poor playlist support on TV
|
||||
override suspend fun putExtra(
|
||||
|
|
@ -46,7 +44,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
|
|||
putExtra("title", video.name)
|
||||
|
||||
if (index != null) {
|
||||
setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*")
|
||||
setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*")
|
||||
} else {
|
||||
makeTempM3U8Intent(context, this, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import android.net.Uri
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
|
|
@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() {
|
|||
) {
|
||||
val link = result.links.getOrNull(index ?: 0) ?: return
|
||||
val i = Intent(Intent.ACTION_VIEW)
|
||||
i.data = link.url.toUri()
|
||||
i.data = Uri.parse(link.url)
|
||||
launch(i)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package com.lagradost.cloudstream3.actions.temp
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||
import com.lagradost.cloudstream3.ui.player.ExtractorUri
|
||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.ui.player.VideoGenerator
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
|
||||
class PlayMirrorAction : VideoClickAction() {
|
||||
override val name = txt(R.string.episode_action_play_mirror)
|
||||
|
||||
override val oneSource = true
|
||||
|
||||
override val isPlayer = true
|
||||
|
||||
override val sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP
|
||||
|
||||
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
|
||||
|
||||
override suspend fun runAction(
|
||||
context: Context?,
|
||||
video: ResultEpisode,
|
||||
result: LinkLoadingResult,
|
||||
index: Int?
|
||||
) {
|
||||
//Implemented a generator to handle the single
|
||||
val activity = context as? Activity ?: return
|
||||
val link = index?.let { result.links[it] }
|
||||
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
|
||||
override val hasCache: Boolean = false
|
||||
override val canSkipLoading: Boolean = false
|
||||
override fun getId(index: Int): Int = video.id
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
sourceTypes: Set<ExtractorLinkType>,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset: Int,
|
||||
isCasting: Boolean
|
||||
): Boolean {
|
||||
index?.let { callback(link to null) }
|
||||
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
activity.navigate(
|
||||
R.id.global_to_navigation_player,
|
||||
GeneratorPlayer.newInstance(
|
||||
generatorMirror, 0, result.syncData
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import android.content.Intent
|
|||
import android.os.Build
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.api.Log
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||
|
|
@ -60,7 +60,7 @@ open class VlcPackage: OpenInAppAction(
|
|||
intent.putExtra("secure_uri", true)
|
||||
intent.putExtra("title", video.name)
|
||||
|
||||
val subsLang = getKey<String>(SUBTITLE_AUTO_SELECT_KEY) ?: "en"
|
||||
val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en"
|
||||
result.subs.firstOrNull {
|
||||
subsLang == it.languageCode
|
||||
}?.let {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
|
|
@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction(
|
|||
val link = result.links[index ?: 0]
|
||||
|
||||
intent.apply {
|
||||
setDataAndType(link.url.toUri(), "video/*")
|
||||
setDataAndType(Uri.parse(link.url), "video/*")
|
||||
|
||||
val title = video.name ?: video.headerName
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.lagradost.cloudstream3.actions.temp.fcast
|
||||
|
||||
import android.content.Context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import android.net.nsd.NsdServiceInfo
|
|||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import android.util.Log
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
|
||||
class FcastManager {
|
||||
|
|
@ -73,25 +72,18 @@ class FcastManager {
|
|||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
||||
// Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback
|
||||
safe {
|
||||
if (serviceInfo == null) return@safe
|
||||
if (serviceInfo == null) return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
|
||||
Build.VERSION_CODES.TIRAMISU
|
||||
) >= 7
|
||||
) {
|
||||
nsdManager?.registerServiceInfoCallback(
|
||||
serviceInfo,
|
||||
Build.VERSION_CODES.TIRAMISU) >= 7) {
|
||||
nsdManager?.registerServiceInfoCallback(serviceInfo,
|
||||
Runnable::run,
|
||||
object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.e(tag, "Service registration failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.d(
|
||||
tag,
|
||||
Log.d(tag,
|
||||
"Service updated: ${serviceInfo.serviceName}," +
|
||||
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
|
||||
)
|
||||
|
|
@ -100,24 +92,18 @@ class FcastManager {
|
|||
_currentDevices.add(PublicDeviceInfo(serviceInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.d(tag, "Service lost: ${serviceInfo.serviceName},")
|
||||
synchronized(_currentDevices) {
|
||||
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {}
|
||||
})
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
|
||||
override fun onResolveFailed(
|
||||
serviceInfo: NsdServiceInfo?,
|
||||
errorCode: Int
|
||||
) {
|
||||
}
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
||||
if (serviceInfo == null) return
|
||||
|
|
@ -134,7 +120,6 @@ class FcastManager {
|
|||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
|
||||
if (serviceInfo == null) return
|
||||
|
|
@ -183,8 +168,7 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
|
|||
val host: String? = if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||
SdkExtensions.getExtensionVersion(
|
||||
Build.VERSION_CODES.TIRAMISU
|
||||
) >= 7
|
||||
Build.VERSION_CODES.TIRAMISU) >= 7
|
||||
) {
|
||||
serviceInfo.hostAddresses.firstOrNull()?.hostAddress
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
package com.lagradost.cloudstream3.actions.temp.fcast
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1
|
||||
enum class Opcode(val value: Byte) {
|
||||
None(0),
|
||||
|
|
@ -22,18 +18,18 @@ enum class Opcode(val value: Byte) {
|
|||
Pong(13);
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
data class PlayMessage(
|
||||
@JsonProperty("container") @SerialName("container") val container: String,
|
||||
@JsonProperty("url") @SerialName("url") val url: String? = null,
|
||||
@JsonProperty("content") @SerialName("content") val content: String? = null,
|
||||
@JsonProperty("time") @SerialName("time") val time: Double? = null,
|
||||
@JsonProperty("speed") @SerialName("speed") val speed: Double? = null,
|
||||
@JsonProperty("headers") @SerialName("headers") val headers: Map<String, String>? = null,
|
||||
val container: String,
|
||||
val url: String? = null,
|
||||
val content: String? = null,
|
||||
val time: Double? = null,
|
||||
val speed: Double? = null,
|
||||
val headers: Map<String, String>? = null
|
||||
)
|
||||
|
||||
data class SeekMessage(
|
||||
val time: Double,
|
||||
val time: Double
|
||||
)
|
||||
|
||||
data class PlaybackUpdateMessage(
|
||||
|
|
@ -41,26 +37,26 @@ data class PlaybackUpdateMessage(
|
|||
val time: Double,
|
||||
val duration: Double,
|
||||
val state: Int,
|
||||
val speed: Double,
|
||||
val speed: Double
|
||||
)
|
||||
|
||||
data class VolumeUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val volume: Double,
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
data class PlaybackErrorMessage(
|
||||
val message: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class SetSpeedMessage(
|
||||
val speed: Double,
|
||||
val speed: Double
|
||||
)
|
||||
|
||||
data class SetVolumeMessage(
|
||||
val volume: Double,
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
data class VersionMessage(
|
||||
val version: Long,
|
||||
val version: Long
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,68 +1,16 @@
|
|||
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.LiveData
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||
observeNullable(liveData) { t -> t?.run(action) }
|
||||
}
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
||||
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
liveData.removeObservers(this)
|
||||
liveData.observe(this, action)
|
||||
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
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)
|
||||
}
|
||||
liveData.observe(this) { action(it) }
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.network
|
|||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.Prerelease
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
|
|
@ -16,24 +15,11 @@ import org.conscrypt.Conscrypt
|
|||
import java.io.File
|
||||
import java.security.Security
|
||||
|
||||
// Backwards compatible constructor, mark as deprecated later
|
||||
fun Requests.initClient(context: Context) {
|
||||
this.baseClient = buildDefaultClient(context)
|
||||
}
|
||||
|
||||
/** Only use ignoreSSL if you know what you are doing*/
|
||||
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 {
|
||||
return buildDefaultClient(context, false)
|
||||
}
|
||||
|
||||
/** Only use ignoreSSL if you know what you are doing*/
|
||||
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
|
||||
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
||||
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
|
@ -41,11 +27,7 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
|||
val baseClient = OkHttpClient.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.apply {
|
||||
if (ignoreSSL) {
|
||||
ignoreAllSSLErrors()
|
||||
}
|
||||
}
|
||||
.ignoreAllSSLErrors()
|
||||
.cache(
|
||||
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
||||
// The server response dictates if and when stuff should be cached.
|
||||
|
|
@ -70,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
|||
return baseClient
|
||||
}
|
||||
|
||||
//val Request.cookies: Map<String, String>
|
||||
// get() {
|
||||
// return this.headers.getCookies("Cookie")
|
||||
// }
|
||||
|
||||
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
|
|||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||
import kotlin.Throws
|
||||
|
||||
|
||||
abstract class Plugin : BasePlugin() {
|
||||
/**
|
||||
* Called when your Plugin is loaded
|
||||
|
|
@ -25,8 +26,10 @@ abstract class Plugin : BasePlugin() {
|
|||
fun registerVideoClickAction(element: VideoClickAction) {
|
||||
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
||||
element.sourcePlugin = this.filename
|
||||
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||
VideoClickActionHolder.allVideoClickActions.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will contain your resources if you specified requiresResources in gradle
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import android.os.Build
|
|||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
|
@ -21,17 +20,15 @@ import androidx.fragment.app.FragmentActivity
|
|||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AllLanguagesName
|
||||
import com.lagradost.cloudstream3.AutoDownloadMode
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.InternalAPI
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||
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_OK
|
||||
import com.lagradost.cloudstream3.R
|
||||
|
|
@ -46,7 +43,6 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
|
|||
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||
|
|
@ -55,14 +51,12 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
|||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
|
||||
import com.lagradost.cloudstream3.utils.extractorApis
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import dalvik.system.PathClassLoader
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
|
||||
|
|
@ -75,15 +69,13 @@ const val EXTENSIONS_CHANNEL_NAME = "Extensions"
|
|||
const val EXTENSIONS_CHANNEL_DESCRIPT = "Extension notification channel"
|
||||
|
||||
// Data class for internal storage
|
||||
@Serializable
|
||||
data class PluginData(
|
||||
@JsonProperty("internalName") @SerialName("internalName") val internalName: String,
|
||||
@JsonProperty("url") @SerialName("url") val url: String?,
|
||||
@JsonProperty("isOnline") @SerialName("isOnline") val isOnline: Boolean,
|
||||
@JsonProperty("filePath") @SerialName("filePath") val filePath: String,
|
||||
@JsonProperty("version") @SerialName("version") val version: Int,
|
||||
@JsonProperty("internalName") val internalName: String,
|
||||
@JsonProperty("url") val url: String?,
|
||||
@JsonProperty("isOnline") val isOnline: Boolean,
|
||||
@JsonProperty("filePath") val filePath: String,
|
||||
@JsonProperty("version") val version: Int,
|
||||
) {
|
||||
@WorkerThread
|
||||
fun toSitePlugin(): SitePlugin {
|
||||
return SitePlugin(
|
||||
this.filePath,
|
||||
|
|
@ -98,9 +90,7 @@ data class PluginData(
|
|||
null,
|
||||
null,
|
||||
null,
|
||||
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
|
||||
File(this.filePath).length()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -176,11 +166,11 @@ object PluginManager {
|
|||
|
||||
|
||||
fun getPluginsOnline(): Array<PluginData> {
|
||||
return getKey<Array<PluginData>>(PLUGINS_KEY) ?: emptyArray()
|
||||
return getKey(PLUGINS_KEY) ?: emptyArray()
|
||||
}
|
||||
|
||||
fun getPluginsLocal(): Array<PluginData> {
|
||||
return getKey<Array<PluginData>>(PLUGINS_KEY_LOCAL) ?: emptyArray()
|
||||
return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray()
|
||||
}
|
||||
|
||||
private val CLOUD_STREAM_FOLDER =
|
||||
|
|
@ -224,17 +214,17 @@ object PluginManager {
|
|||
// Helper class for updateAllOnlinePluginsAndLoadThem
|
||||
data class OnlinePluginData(
|
||||
val savedData: PluginData,
|
||||
val onlineData: PluginWrapper,
|
||||
val onlineData: Pair<String, SitePlugin>,
|
||||
) {
|
||||
val isOutdated =
|
||||
onlineData.plugin.version > savedData.version || onlineData.plugin.version == PLUGIN_VERSION_ALWAYS_UPDATE
|
||||
val isDisabled = onlineData.plugin.status == PROVIDER_STATUS_DOWN
|
||||
onlineData.second.version > savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE
|
||||
val isDisabled = onlineData.second.status == PROVIDER_STATUS_DOWN
|
||||
|
||||
fun validOnlineData(context: Context): Boolean {
|
||||
return getPluginPath(
|
||||
context,
|
||||
savedData.internalName,
|
||||
onlineData.repositoryData.url
|
||||
onlineData.first
|
||||
).absolutePath == savedData.filePath
|
||||
}
|
||||
}
|
||||
|
|
@ -268,8 +258,12 @@ object PluginManager {
|
|||
* 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!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
|
@ -282,19 +276,19 @@ object PluginManager {
|
|||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||
|
||||
val onlinePlugins = urls.toList().amap {
|
||||
getRepoPlugins(it) ?: emptyList()
|
||||
}.flatten().distinctBy { it.plugin.url }
|
||||
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||
}.flatten().distinctBy { it.second.url }
|
||||
|
||||
// Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated
|
||||
val outdatedPlugins = getPluginsOnline().map { savedData ->
|
||||
onlinePlugins
|
||||
.filter { onlineData -> savedData.internalName == onlineData.plugin.internalName }
|
||||
.filter { onlineData -> savedData.internalName == onlineData.second.internalName }
|
||||
.map { onlineData ->
|
||||
OnlinePluginData(savedData, onlineData)
|
||||
}.filter {
|
||||
it.validOnlineData(activity)
|
||||
}
|
||||
}.flatten().distinctBy { it.onlineData.plugin.url }
|
||||
}.flatten().distinctBy { it.onlineData.second.url }
|
||||
|
||||
debugPrint {
|
||||
"Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}"
|
||||
|
|
@ -309,14 +303,13 @@ object PluginManager {
|
|||
} else if (pluginData.isOutdated) {
|
||||
downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.plugin.url,
|
||||
pluginData.onlineData.plugin.fileHash,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.savedData.internalName,
|
||||
File(pluginData.savedData.filePath),
|
||||
true
|
||||
).let { success ->
|
||||
if (success)
|
||||
updatedPlugins.add(pluginData.onlineData.plugin.name)
|
||||
updatedPlugins.add(pluginData.onlineData.second.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -346,8 +339,12 @@ object PluginManager {
|
|||
* 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!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
||||
activity: Activity,
|
||||
|
|
@ -359,15 +356,15 @@ object PluginManager {
|
|||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||
val onlinePlugins = urls.toList().amap {
|
||||
getRepoPlugins(it)?.toList() ?: emptyList()
|
||||
}.flatten().distinctBy { it.plugin.url }
|
||||
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||
}.flatten().distinctBy { it.second.url }
|
||||
|
||||
val providerLang = activity.getApiProviderLangSettings()
|
||||
//Log.i(TAG, "providerLang => ${providerLang.toJson()}")
|
||||
|
||||
// Iterate online repos and returns not downloaded plugins
|
||||
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
|
||||
val sitePlugin = onlineData.plugin
|
||||
val sitePlugin = onlineData.second
|
||||
val tvtypes = sitePlugin.tvTypes ?: listOf()
|
||||
|
||||
//Don't include empty urls
|
||||
|
|
@ -379,7 +376,7 @@ object PluginManager {
|
|||
}
|
||||
|
||||
//Omit already existing plugins
|
||||
if (getPluginPath(activity, sitePlugin.internalName, onlineData.repositoryData.url).exists()) {
|
||||
if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) {
|
||||
Log.i(TAG, "Skip > ${sitePlugin.internalName}")
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
|
@ -421,14 +418,13 @@ object PluginManager {
|
|||
notDownloadedPlugins.amap { pluginData ->
|
||||
downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.plugin.url,
|
||||
pluginData.onlineData.plugin.fileHash,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.savedData.internalName,
|
||||
pluginData.onlineData.repositoryData.url,
|
||||
pluginData.onlineData.first,
|
||||
!pluginData.isDisabled
|
||||
).let { success ->
|
||||
if (success)
|
||||
newDownloadPlugins.add(pluginData.onlineData.plugin.name)
|
||||
newDownloadPlugins.add(pluginData.onlineData.second.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -457,8 +453,12 @@ object PluginManager {
|
|||
* 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!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
|
@ -479,9 +479,13 @@ object PluginManager {
|
|||
* 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!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@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?) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
||||
|
|
@ -500,8 +504,12 @@ object PluginManager {
|
|||
* 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!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
|
@ -512,9 +520,6 @@ object PluginManager {
|
|||
val res = dir.mkdirs()
|
||||
if (!res) {
|
||||
Log.w(TAG, "Failed to create local directories")
|
||||
// We have tried to load local plugins, but exit early.
|
||||
// This needs to be true to prevent the downloader waiting for plugins.
|
||||
loadedLocalPlugins = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -567,11 +572,6 @@ object PluginManager {
|
|||
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!
|
||||
* @return true if safe mode file is present
|
||||
|
|
@ -616,7 +616,7 @@ object PluginManager {
|
|||
return false
|
||||
}
|
||||
InputStreamReader(stream).use { reader ->
|
||||
manifest = parseJson<BasePlugin.Manifest>(reader.readText())
|
||||
manifest = parseJson(reader, BasePlugin.Manifest::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -657,15 +657,9 @@ object PluginManager {
|
|||
context.resources.configuration
|
||||
)
|
||||
}
|
||||
synchronized(plugins) {
|
||||
plugins[filePath] = pluginInstance
|
||||
}
|
||||
synchronized(classLoaders) {
|
||||
classLoaders[loader] = pluginInstance
|
||||
}
|
||||
synchronized(urlPlugins) {
|
||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||
}
|
||||
if (pluginInstance is Plugin) {
|
||||
pluginInstance.load(context)
|
||||
} else {
|
||||
|
|
@ -701,34 +695,26 @@ object PluginManager {
|
|||
}
|
||||
|
||||
// remove all registered apis
|
||||
synchronized(APIHolder.apis) {
|
||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
||||
removePluginMapping(it)
|
||||
}
|
||||
|
||||
APIHolder.allProviders.withLock {
|
||||
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
||||
}
|
||||
synchronized(APIHolder.allProviders) {
|
||||
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
|
||||
}
|
||||
|
||||
extractorApis.withLock {
|
||||
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
||||
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
|
||||
|
||||
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
|
||||
}
|
||||
|
||||
VideoClickActionHolder.allVideoClickActions.withLock {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spits out a unique and safe filename based on name.
|
||||
|
|
@ -757,27 +743,25 @@ object PluginManager {
|
|||
suspend fun downloadPlugin(
|
||||
activity: Activity,
|
||||
pluginUrl: String,
|
||||
pluginHash: String?,
|
||||
internalName: String,
|
||||
repositoryUrl: String,
|
||||
loadPlugin: Boolean
|
||||
): Boolean {
|
||||
val file = getPluginPath(activity, internalName, repositoryUrl)
|
||||
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
|
||||
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
|
||||
}
|
||||
|
||||
suspend fun downloadPlugin(
|
||||
activity: Activity,
|
||||
pluginUrl: String,
|
||||
pluginHash: String?,
|
||||
internalName: String,
|
||||
file: File,
|
||||
loadPlugin: Boolean,
|
||||
loadPlugin: Boolean
|
||||
): Boolean {
|
||||
try {
|
||||
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
||||
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
||||
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
|
||||
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
|
||||
|
||||
val data = PluginData(
|
||||
internalName,
|
||||
|
|
@ -824,9 +808,13 @@ object PluginManager {
|
|||
* 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!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@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) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
||||
|
|
@ -838,16 +826,16 @@ object PluginManager {
|
|||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||
val onlinePlugins = urls.toList().amap {
|
||||
getRepoPlugins(it) ?: emptyList()
|
||||
}.flatten().distinctBy { it.plugin.url }
|
||||
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||
}.flatten().distinctBy { it.second.url }
|
||||
|
||||
val allPlugins = getPluginsOnline().flatMap { savedData ->
|
||||
onlinePlugins
|
||||
.filter { it.plugin.internalName == savedData.internalName }
|
||||
.filter { it.second.internalName == savedData.internalName }
|
||||
.mapNotNull { onlineData ->
|
||||
OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) }
|
||||
}
|
||||
}.distinctBy { it.onlineData.plugin.url }
|
||||
}.distinctBy { it.onlineData.second.url }
|
||||
|
||||
val updatedPlugins = mutableListOf<String>()
|
||||
|
||||
|
|
@ -855,7 +843,7 @@ object PluginManager {
|
|||
if (pluginData.isDisabled) {
|
||||
Log.e(
|
||||
"PluginManager",
|
||||
"Unloading disabled plugin: ${pluginData.onlineData.plugin.name}"
|
||||
"Unloading disabled plugin: ${pluginData.onlineData.second.name}"
|
||||
)
|
||||
unloadPlugin(pluginData.savedData.filePath)
|
||||
} else {
|
||||
|
|
@ -864,14 +852,13 @@ object PluginManager {
|
|||
|
||||
if (downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.plugin.url,
|
||||
pluginData.onlineData.plugin.fileHash,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.savedData.internalName,
|
||||
existingFile,
|
||||
true
|
||||
)
|
||||
) {
|
||||
updatedPlugins.add(pluginData.onlineData.plugin.name)
|
||||
updatedPlugins.add(pluginData.onlineData.second.name)
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package com.lagradost.cloudstream3.plugins
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.amap
|
||||
import com.lagradost.cloudstream3.app
|
||||
|
|
@ -16,27 +15,23 @@ import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileNa
|
|||
import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Comes with the app, always available in the app, non removable.
|
||||
*/
|
||||
@Serializable
|
||||
* */
|
||||
|
||||
data class Repository(
|
||||
@JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("description") @SerialName("description") val description: String?,
|
||||
@JsonProperty("manifestVersion") @SerialName("manifestVersion") val manifestVersion: Int,
|
||||
@JsonProperty("pluginLists") @SerialName("pluginLists") val pluginLists: List<String>,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("description") val description: String?,
|
||||
@JsonProperty("manifestVersion") val manifestVersion: Int,
|
||||
@JsonProperty("pluginLists") val pluginLists: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -45,81 +40,40 @@ data class Repository(
|
|||
* 1: Ok
|
||||
* 2: Slow
|
||||
* 3: Beta only
|
||||
*/
|
||||
@Serializable
|
||||
* */
|
||||
data class SitePlugin(
|
||||
// Url to the .cs3 file
|
||||
@JsonProperty("url") @SerialName("url") val url: String,
|
||||
@JsonProperty("url") val url: String,
|
||||
// Status to remotely disable the provider
|
||||
@JsonProperty("status") @SerialName("status") val status: Int,
|
||||
@JsonProperty("status") val status: Int,
|
||||
// Integer over 0, any change of this will trigger an auto update
|
||||
@JsonProperty("version") @SerialName("version") val version: Int,
|
||||
@JsonProperty("version") val version: Int,
|
||||
// Unused currently, used to make the api backwards compatible?
|
||||
// Set to 1
|
||||
@JsonProperty("apiVersion") @SerialName("apiVersion") val apiVersion: Int,
|
||||
@JsonProperty("apiVersion") val apiVersion: Int,
|
||||
// Name to be shown in app
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("name") val name: String,
|
||||
// Name to be referenced internally. Separate to make name and url changes possible
|
||||
@JsonProperty("internalName") @SerialName("internalName") val internalName: String,
|
||||
@JsonProperty("authors") @SerialName("authors") val authors: List<String>,
|
||||
@JsonProperty("description") @SerialName("description") val description: String?,
|
||||
@JsonProperty("internalName") val internalName: String,
|
||||
@JsonProperty("authors") val authors: List<String>,
|
||||
@JsonProperty("description") val description: String?,
|
||||
// Might be used to go directly to the plugin repo in the future
|
||||
@JsonProperty("repositoryUrl") @SerialName("repositoryUrl") val repositoryUrl: String?,
|
||||
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
||||
// These types are yet to be mapped and used, ignore for now
|
||||
@JsonProperty("tvTypes") @SerialName("tvTypes") val tvTypes: List<String>?,
|
||||
// Most often a language tag like "en" or "zh-TW"
|
||||
@JsonProperty("language") @SerialName("language") val language: String?,
|
||||
@JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?,
|
||||
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
||||
@JsonProperty("language") val language: String?,
|
||||
@JsonProperty("iconUrl") val iconUrl: String?,
|
||||
// Automatically generated by the gradle plugin
|
||||
@JsonProperty("fileSize") @SerialName("fileSize") val fileSize: Long?,
|
||||
@JsonProperty("fileHash") @SerialName("fileHash") val fileHash: String?,
|
||||
@JsonProperty("fileSize") val fileSize: Long?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginWrapper(
|
||||
@JsonProperty("repository") @SerialName("repository") val repository: Repository,
|
||||
@JsonProperty("repositoryData") @SerialName("repositoryData") val repositoryData: RepositoryData,
|
||||
@JsonProperty("plugin") @SerialName("plugin") val plugin: SitePlugin
|
||||
) {
|
||||
companion object {
|
||||
private val localRepository = Repository("", "", "", 1, emptyList())
|
||||
private val localRepositoryData = RepositoryData("", "", "")
|
||||
fun getLocalPluginWrapper(plugin: SitePlugin): PluginWrapper {
|
||||
return PluginWrapper(
|
||||
localRepository,
|
||||
localRepositoryData,
|
||||
plugin
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object RepositoryManager {
|
||||
const val ONLINE_PLUGINS_FOLDER = "Extensions"
|
||||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||
getKey<Array<RepositoryData>>("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||
}
|
||||
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) }
|
||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||
}
|
||||
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||
|
||||
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
||||
fun convertRawGitUrl(url: String): String {
|
||||
|
|
@ -141,36 +95,31 @@ object RepositoryManager {
|
|||
}
|
||||
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
|
||||
safeAsync {
|
||||
if (fixedUrl.startsWith("!")) {
|
||||
val response = app.get("https://py.md/${fixedUrl.removePrefix("!")}", allowRedirects = false)
|
||||
val url = response.headers["Location"] ?: return@safeAsync null
|
||||
if (url.startsWith("https://py.md/404")) return@safeAsync null
|
||||
if (url.removeSuffix("/") == "https://py.md") return@safeAsync null
|
||||
return@safeAsync url
|
||||
} else {
|
||||
val response = app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false)
|
||||
val url = response.headers["Location"] ?: return@safeAsync null
|
||||
app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
|
||||
it2.headers["Location"]?.let { url ->
|
||||
if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
|
||||
if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
|
||||
return@safeAsync url
|
||||
}
|
||||
}
|
||||
}
|
||||
} else null
|
||||
}
|
||||
|
||||
suspend fun parseRepository(url: String): Repository? {
|
||||
return safeAsync {
|
||||
// Take manifestVersion and such into account later
|
||||
app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES)
|
||||
.parsedSafe<Repository>()
|
||||
app.get(convertRawGitUrl(url)).parsedSafe()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
|
||||
// Take manifestVersion and such into account later
|
||||
return try {
|
||||
app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES)
|
||||
.parsed<Array<SitePlugin>>().toList()
|
||||
val response = app.get(convertRawGitUrl(pluginUrls))
|
||||
// Normal parsed function not working?
|
||||
// return response.parsedSafe()
|
||||
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
emptyList()
|
||||
|
|
@ -179,68 +128,37 @@ object RepositoryManager {
|
|||
|
||||
/**
|
||||
* Gets all plugins from repositories and pairs them with the repository url
|
||||
*/
|
||||
suspend fun getRepoPlugins(repositoryData: RepositoryData): List<PluginWrapper>? {
|
||||
val repo = parseRepository(repositoryData.url) ?: return null
|
||||
val list = repo.pluginLists.amap { url ->
|
||||
* */
|
||||
suspend fun getRepoPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>>? {
|
||||
val repo = parseRepository(repositoryUrl) ?: return null
|
||||
return repo.pluginLists.amap { url ->
|
||||
parsePlugins(url).map {
|
||||
PluginWrapper(repo, repositoryData, it)
|
||||
repositoryUrl to it
|
||||
}
|
||||
}.flatten()
|
||||
return list
|
||||
}
|
||||
|
||||
suspend fun downloadPluginToFile(
|
||||
context: Context,
|
||||
pluginUrl: String,
|
||||
file: File,
|
||||
expectedFileHash: String?
|
||||
file: File
|
||||
): File? {
|
||||
return safeAsync {
|
||||
val parentDir = file.parentFile ?: return@safeAsync null
|
||||
parentDir.mkdirs()
|
||||
file.mkdirs()
|
||||
|
||||
// Prevent corrupting the plugin file if the operation fails
|
||||
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
|
||||
// Overwrite if exists
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
file.createNewFile()
|
||||
|
||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
write(body.byteStream(), file.outputStream())
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
fun getRepositories(): Array<RepositoryData> {
|
||||
return getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
|
||||
return getKey(REPOSITORIES_KEY) ?: emptyArray()
|
||||
}
|
||||
|
||||
// Don't want to read before we write in another thread
|
||||
|
|
@ -255,7 +173,7 @@ object RepositoryManager {
|
|||
|
||||
/**
|
||||
* Also deletes downloaded repository plugins
|
||||
*/
|
||||
* */
|
||||
suspend fun removeRepository(context: Context, repository: RepositoryData) {
|
||||
val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
|
||||
|
||||
|
|
@ -282,4 +200,13 @@ object RepositoryManager {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,49 +2,62 @@ package com.lagradost.cloudstream3.plugins
|
|||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
import java.security.MessageDigest
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object VotingApi {
|
||||
object VotingApi { // please do not cheat the votes lol
|
||||
private const val LOGKEY = "VotingApi"
|
||||
private const val API_DOMAIN = "https://api.countify.xyz"
|
||||
|
||||
private fun transformUrl(url: String): String =
|
||||
private const val API_DOMAIN = "https://counterapi.com/api"
|
||||
|
||||
private fun transformUrl(url: String): String = // dont touch or all votes get reset
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest("${url}#funny-salt".toByteArray())
|
||||
.fold("") { str, it -> str + "%02x".format(it) }
|
||||
|
||||
suspend fun SitePlugin.getVotes(): Int = getVotes(url)
|
||||
fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
|
||||
suspend fun SitePlugin.vote(): Int = vote(url)
|
||||
fun SitePlugin.canVote(): Boolean = canVote(this.url)
|
||||
suspend fun SitePlugin.getVotes(): Int {
|
||||
return getVotes(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 fun getRepository(pluginUrl: String) = pluginUrl
|
||||
.split("/")
|
||||
.drop(2)
|
||||
.take(3)
|
||||
.joinToString("-")
|
||||
|
||||
private suspend fun readVote(pluginUrl: String): Int {
|
||||
val id = transformUrl(pluginUrl)
|
||||
val url = "$API_DOMAIN/get-total/$id"
|
||||
Log.d(LOGKEY, "Requesting GET: $url")
|
||||
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
|
||||
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
|
||||
Log.d(LOGKEY, "Requesting: $url")
|
||||
return app.get(url).parsedSafe<Result>()?.value ?: 0
|
||||
}
|
||||
|
||||
private suspend fun writeVote(pluginUrl: String): Boolean {
|
||||
val id = transformUrl(pluginUrl)
|
||||
val url = "$API_DOMAIN/increment/$id"
|
||||
Log.d(LOGKEY, "Requesting POST: $url")
|
||||
return app.post(url, emptyMap<String, String>())
|
||||
.parsedSafe<CountifyResult>()?.count != null
|
||||
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
|
||||
Log.d(LOGKEY, "Requesting: $url")
|
||||
return app.get(url).parsedSafe<Result>()?.value != null
|
||||
}
|
||||
|
||||
suspend fun getVotes(pluginUrl: String): Int =
|
||||
|
|
@ -52,38 +65,34 @@ object VotingApi {
|
|||
votesCache[pluginUrl] = it
|
||||
}
|
||||
|
||||
fun hasVoted(pluginUrl: String): Boolean =
|
||||
getKey<Boolean>("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||
fun hasVoted(pluginUrl: String) =
|
||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||
|
||||
fun canVote(pluginUrl: String): Boolean =
|
||||
PluginManager.urlPlugins.contains(pluginUrl)
|
||||
fun canVote(pluginUrl: String): Boolean {
|
||||
return PluginManager.urlPlugins.contains(pluginUrl)
|
||||
}
|
||||
|
||||
private val voteLock = Mutex()
|
||||
|
||||
suspend fun vote(pluginUrl: String): Int {
|
||||
// Prevent multiple requests at the same time.
|
||||
voteLock.withLock {
|
||||
if (!canVote(pluginUrl)) {
|
||||
main {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.extension_install_first,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return getVotes(pluginUrl)
|
||||
}
|
||||
|
||||
if (hasVoted(pluginUrl)) {
|
||||
main {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.already_voted,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return getVotes(pluginUrl)
|
||||
}
|
||||
|
||||
|
||||
if (writeVote(pluginUrl)) {
|
||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
||||
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
||||
|
|
@ -93,9 +102,7 @@ object VotingApi {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CountifyResult(
|
||||
@JsonProperty("id") @SerialName("id") val id: String? = null,
|
||||
@JsonProperty("count") @SerialName("count") val count: Int? = null,
|
||||
private data class Result(
|
||||
val value: Int?
|
||||
)
|
||||
}
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
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.getDub
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
|||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
// println("Update subscriptions!")
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services
|
|||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Handle notification actions such as pause/resume downloads */
|
||||
class VideoDownloadService : Service() {
|
||||
|
||||
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
||||
|
|
@ -43,3 +42,19 @@ class VideoDownloadService : Service() {
|
|||
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))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
||||
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.MALApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class AccountManager {
|
||||
companion object {
|
||||
const val NONE_ID: Int = -1
|
||||
val malApi = MALApi()
|
||||
val kitsuApi = KitsuApi()
|
||||
val aniListApi = AniListApi()
|
||||
val simklApi = SimklApi()
|
||||
val localListApi = LocalList()
|
||||
|
||||
val openSubtitlesApi = OpenSubtitlesApi()
|
||||
val addic7ed = Addic7ed()
|
||||
val subDlApi = SubDlApi()
|
||||
val subSourceApi = SubSourceApi()
|
||||
val animeSkipApi = AnimeSkipAuth()
|
||||
|
||||
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
||||
var cachedAccountIds: MutableMap<String, Int>
|
||||
|
||||
const val ACCOUNT_TOKEN = "auth_tokens"
|
||||
const val ACCOUNT_IDS = "auth_ids"
|
||||
|
||||
fun accounts(prefix: String): Array<AuthData> {
|
||||
require(prefix != "NONE")
|
||||
return getKey<Array<AuthData>>(
|
||||
ACCOUNT_TOKEN,
|
||||
"${prefix}/${DataStoreHelper.currentAccount}"
|
||||
) ?: arrayOf()
|
||||
}
|
||||
|
||||
fun updateAccounts(prefix: String, array: Array<AuthData>) {
|
||||
require(prefix != "NONE")
|
||||
setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array)
|
||||
synchronized(cachedAccounts) {
|
||||
cachedAccounts[prefix] = array
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAccountsId(prefix: String, id: Int) {
|
||||
require(prefix != "NONE")
|
||||
setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id)
|
||||
synchronized(cachedAccountIds) {
|
||||
cachedAccountIds[prefix] = id
|
||||
}
|
||||
}
|
||||
|
||||
val allApis = arrayOf(
|
||||
SyncRepo(malApi),
|
||||
SyncRepo(kitsuApi),
|
||||
SyncRepo(aniListApi),
|
||||
SyncRepo(simklApi),
|
||||
SyncRepo(localListApi),
|
||||
SubtitleRepo(openSubtitlesApi),
|
||||
SubtitleRepo(addic7ed),
|
||||
SubtitleRepo(subDlApi),
|
||||
PlainAuthRepo(animeSkipApi),
|
||||
SubtitleRepo(subSourceApi)
|
||||
)
|
||||
|
||||
fun updateAccountIds() {
|
||||
val ids = mutableMapOf<String, Int>()
|
||||
for (api in allApis) {
|
||||
ids.put(
|
||||
api.idPrefix,
|
||||
getKey<Int>(
|
||||
ACCOUNT_IDS,
|
||||
"${api.idPrefix}/${DataStoreHelper.currentAccount}",
|
||||
NONE_ID
|
||||
) ?: NONE_ID
|
||||
)
|
||||
}
|
||||
synchronized(cachedAccountIds) {
|
||||
cachedAccountIds = ids
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val data = mutableMapOf<String, Array<AuthData>>()
|
||||
val ids = mutableMapOf<String, Int>()
|
||||
for (api in allApis) {
|
||||
data.put(api.idPrefix, accounts(api.idPrefix))
|
||||
ids.put(
|
||||
api.idPrefix,
|
||||
getKey<Int>(
|
||||
ACCOUNT_IDS,
|
||||
"${api.idPrefix}/${DataStoreHelper.currentAccount}",
|
||||
NONE_ID
|
||||
) ?: NONE_ID
|
||||
)
|
||||
}
|
||||
cachedAccounts = data
|
||||
cachedAccountIds = ids
|
||||
}
|
||||
|
||||
// I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions
|
||||
// accessing other classes
|
||||
fun initMainAPI() {
|
||||
LoadResponse.malIdPrefix = malApi.idPrefix
|
||||
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
|
||||
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
||||
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
||||
}
|
||||
|
||||
val subtitleProviders = arrayOf(
|
||||
SubtitleRepo(openSubtitlesApi),
|
||||
SubtitleRepo(addic7ed),
|
||||
SubtitleRepo(subDlApi),
|
||||
SubtitleRepo(subSourceApi)
|
||||
)
|
||||
val syncApis = arrayOf(
|
||||
SyncRepo(malApi),
|
||||
SyncRepo(kitsuApi),
|
||||
SyncRepo(aniListApi),
|
||||
SyncRepo(simklApi),
|
||||
SyncRepo(localListApi)
|
||||
)
|
||||
|
||||
const val APP_STRING = "cloudstreamapp"
|
||||
const val APP_STRING_REPO = "cloudstreamrepo"
|
||||
const val APP_STRING_PLAYER = "cloudstreamplayer"
|
||||
|
||||
// Instantly start the search given a query
|
||||
const val APP_STRING_SEARCH = "cloudstreamsearch"
|
||||
|
||||
// Instantly resume watching a show
|
||||
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
||||
|
||||
const val APP_STRING_SHARE = "csshare"
|
||||
|
||||
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||
var secondsLong = seconds.toLong()
|
||||
val days = TimeUnit.SECONDS
|
||||
.toDays(secondsLong)
|
||||
secondsLong -= TimeUnit.DAYS.toSeconds(days)
|
||||
|
||||
val hours = TimeUnit.SECONDS
|
||||
.toHours(secondsLong)
|
||||
secondsLong -= TimeUnit.HOURS.toSeconds(hours)
|
||||
|
||||
val minutes = TimeUnit.SECONDS
|
||||
.toMinutes(secondsLong)
|
||||
secondsLong -= TimeUnit.MINUTES.toSeconds(minutes)
|
||||
if (minutes < 0) {
|
||||
return completedValue
|
||||
}
|
||||
//println("$days $hours $minutes")
|
||||
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,53 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias
|
||||
import android.util.Base64
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.base64Encode
|
||||
import com.lagradost.cloudstream3.splitUrlParameters
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.ActorData
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.NextAiring
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.Score
|
||||
import com.lagradost.cloudstream3.SearchQuality
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.ShowStatus
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.TraktApi
|
||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
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.UiText
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import java.net.URL
|
||||
import java.security.SecureRandom
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class AuthLoginPage(
|
||||
/** The website to open to authenticate */
|
||||
|
|
@ -20,74 +55,62 @@ data class AuthLoginPage(
|
|||
/**
|
||||
* State/control code to verify against the redirectUrl to make sure the request is valid.
|
||||
* This parameter will be saved, and then used in AuthAPI::login.
|
||||
*/
|
||||
* */
|
||||
val payload: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthToken(
|
||||
/**
|
||||
* This is the general access tokens/api token representing a logged in user.
|
||||
* Access tokens are the thing that applications use to make API requests on behalf of a user.
|
||||
*/
|
||||
@JsonProperty("accessToken") @SerialName("accessToken")
|
||||
*
|
||||
* `Access tokens are the thing that applications use to make API requests on behalf of a user.`
|
||||
* */
|
||||
@JsonProperty("accessToken")
|
||||
val accessToken: String? = null,
|
||||
/** For OAuth a special refresh token is issues to refresh the access token. */
|
||||
@JsonProperty("refreshToken") @SerialName("refreshToken")
|
||||
/**
|
||||
* For OAuth a special refresh token is issues to refresh the access token.
|
||||
* */
|
||||
@JsonProperty("refreshToken")
|
||||
val refreshToken: String? = null,
|
||||
/** In UnixTime (sec) when it expires */
|
||||
@JsonProperty("accessTokenLifetime") @SerialName("accessTokenLifetime")
|
||||
@JsonProperty("accessTokenLifetime")
|
||||
val accessTokenLifetime: Long? = null,
|
||||
/** In UnixTime (sec) when it expires */
|
||||
@JsonProperty("refreshTokenLifetime") @SerialName("refreshTokenLifetime")
|
||||
@JsonProperty("refreshTokenLifetime")
|
||||
val refreshTokenLifetime: Long? = null,
|
||||
/**
|
||||
* Sometimes AuthToken needs to be customized to store e.g. username/password,
|
||||
* this acts as a catch all to store text or JSON data.
|
||||
*/
|
||||
@JsonProperty("payload") @SerialName("payload")
|
||||
/** Sometimes AuthToken needs to be customized to store e.g. username/password,
|
||||
* this acts as a catch all to store text or JSON data. */
|
||||
@JsonProperty("payload")
|
||||
val payload: String? = null,
|
||||
) {
|
||||
fun isAccessTokenExpired(marginSec: Long = 10L) =
|
||||
accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime
|
||||
accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime
|
||||
|
||||
fun isRefreshTokenExpired(marginSec: Long = 10L) =
|
||||
refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime
|
||||
refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class) // JsonNames is an experimental annotation for now
|
||||
@Serializable
|
||||
data class AuthUser(
|
||||
/** Account display-name, can also be email if name does not exist */
|
||||
@JsonProperty("name") @SerialName("name")
|
||||
@JsonProperty("name")
|
||||
val name: String?,
|
||||
/**
|
||||
* Unique account identifier. If a subsequent login is done then it
|
||||
* will be refused if another account with the same id exists.
|
||||
*/
|
||||
@JsonProperty("id") @SerialName("id")
|
||||
/** Unique account identifier,
|
||||
* if a subsequent login is done then it will be refused if another account with the same id exists*/
|
||||
@JsonProperty("id")
|
||||
val id: Int,
|
||||
/** Profile picture URL */
|
||||
@JsonProperty("profilePicture") @SerialName("profilePicture")
|
||||
@JsonProperty("profilePicture")
|
||||
val profilePicture: String? = null,
|
||||
/** Profile picture Headers of the URL */
|
||||
@JsonProperty("profilePictureHeaders") @JsonAlias("profilePictureHeader")
|
||||
@SerialName("profilePictureHeaders") @JsonNames("profilePictureHeader")
|
||||
val profilePictureHeaders: Map<String, String>? = null,
|
||||
@JsonProperty("profilePictureHeader")
|
||||
val profilePictureHeaders: Map<String, String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Stores all information that should be used to authorize access.
|
||||
* Be aware that token and user may change independently when a refresh is needed,
|
||||
* and as such there should be no strong pairing between the two.
|
||||
*
|
||||
* Any local set/get key should use user.id.toString(),
|
||||
* as token.accessToken (even hashed) is unsecure, and will rotate.
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthData(
|
||||
@JsonProperty("user") @SerialName("user") val user: AuthUser,
|
||||
@JsonProperty("token") @SerialName("token") val token: AuthToken,
|
||||
@JsonProperty("user")
|
||||
val user: AuthUser,
|
||||
@JsonProperty("token")
|
||||
val token: AuthToken,
|
||||
)
|
||||
|
||||
data class AuthPinData(
|
||||
|
|
@ -110,12 +133,15 @@ data class AuthLoginRequirement(
|
|||
)
|
||||
|
||||
/** What the user responds to the AuthLoginRequirement */
|
||||
@Serializable
|
||||
data class AuthLoginResponse(
|
||||
@JsonProperty("password") @SerialName("password") val password: String?,
|
||||
@JsonProperty("username") @SerialName("username") val username: String?,
|
||||
@JsonProperty("email") @SerialName("email") val email: String?,
|
||||
@JsonProperty("server") @SerialName("server") val server: String?,
|
||||
@JsonProperty("password")
|
||||
val password: String?,
|
||||
@JsonProperty("username")
|
||||
val username: String?,
|
||||
@JsonProperty("email")
|
||||
val email: String?,
|
||||
@JsonProperty("server")
|
||||
val server: String?,
|
||||
)
|
||||
|
||||
/** Stateless Authentication class used for all personalized content */
|
||||
|
|
@ -148,32 +174,17 @@ abstract class AuthAPI {
|
|||
open val inAppLoginRequirement: AuthLoginRequirement? = null
|
||||
|
||||
companion object {
|
||||
@Deprecated(
|
||||
message = "Use APIHolder.unixTime instead",
|
||||
replaceWith = ReplaceWith(
|
||||
expression = "APIHolder.unixTime",
|
||||
imports = ["com.lagradost.cloudstream3.APIHolder"]
|
||||
),
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
val unixTime: Long
|
||||
get() = APIHolder.unixTime
|
||||
|
||||
@Deprecated(
|
||||
message = "Use APIHolder.unixTimeMS instead",
|
||||
replaceWith = ReplaceWith(
|
||||
expression = "unixTimeMS",
|
||||
imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"]
|
||||
),
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
get() = System.currentTimeMillis() / 1000L
|
||||
val unixTimeMs: Long
|
||||
get() = unixTimeMS
|
||||
get() = System.currentTimeMillis()
|
||||
|
||||
fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
|
||||
return splitUrlParameters(
|
||||
return splitQuery(
|
||||
URL(
|
||||
redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun generateCodeVerifier(): String {
|
||||
|
|
@ -182,8 +193,9 @@ abstract class AuthAPI {
|
|||
val secureRandom = SecureRandom()
|
||||
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
|
||||
secureRandom.nextBytes(codeVerifierBytes)
|
||||
return base64Encode(codeVerifierBytes).trimEnd('=')
|
||||
.replace("+", "-").replace("/", "_").replace("\n", "")
|
||||
return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=')
|
||||
.replace("+", "-")
|
||||
.replace("/", "_").replace("\n", "")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,25 +233,15 @@ abstract class AuthAPI {
|
|||
@Throws
|
||||
open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError()
|
||||
|
||||
/**
|
||||
* An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid.
|
||||
*
|
||||
* Note that this will currently only be called *once* on logout,
|
||||
* and as such any network issues it will fail silently, and the token will not be revoked.
|
||||
*/
|
||||
@Throws
|
||||
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
|
||||
|
||||
@Throws
|
||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
||||
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
|
||||
fun toRepo(): AuthRepo = when (this) {
|
||||
is SubtitleAPI -> SubtitleRepo(this)
|
||||
is SyncAPI -> SyncRepo(this)
|
||||
else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
||||
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
|
||||
fun loginInfo(): LoginInfo? {
|
||||
return this.toRepo().authUser()?.let { user ->
|
||||
LoginInfo(
|
||||
|
|
@ -250,16 +252,625 @@ abstract class AuthAPI {
|
|||
}
|
||||
}
|
||||
|
||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
||||
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
|
||||
suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow()
|
||||
}
|
||||
|
||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
||||
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
|
||||
class LoginInfo(
|
||||
val profilePicture: String? = null,
|
||||
val name: String?,
|
||||
val accountIndex: Int,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless synchronization class, used for syncing status about a specific movie/show.
|
||||
*
|
||||
* All non-null `AuthToken` will be non-expired when each function is called.
|
||||
*/
|
||||
abstract class SyncAPI : AuthAPI() {
|
||||
/**
|
||||
* Set this to true if the user updates something on the list like watch status or score
|
||||
**/
|
||||
open var requireLibraryRefresh: Boolean = true
|
||||
open val mainUrl: String = "NONE"
|
||||
|
||||
/** Currently unused, but will be used to correctly render the UI.
|
||||
* This should specify what sync watch types can be used with this service. */
|
||||
open val supportedWatchTypes: Set<SyncWatchType> = SyncWatchType.entries.toSet()
|
||||
/**
|
||||
* Allows certain providers to open pages from
|
||||
* library links.
|
||||
**/
|
||||
open val syncIdName: SyncIdName? = null
|
||||
|
||||
/** Modify the current status of an item */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun updateStatus(
|
||||
token: AuthToken?,
|
||||
id: String,
|
||||
newStatus: AbstractSyncStatus
|
||||
): Boolean = throw NotImplementedError()
|
||||
|
||||
/** Get the current status of an item */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun status(token: AuthToken?, id: String): AbstractSyncStatus? =
|
||||
throw NotImplementedError()
|
||||
|
||||
/** Get metadata about an item */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun load(token: AuthToken?, id: String): SyncResult? = throw NotImplementedError()
|
||||
|
||||
/** Search this service for any results for a given query */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun search(token: AuthToken?, query: String): List<SyncSearchResult>? =
|
||||
throw NotImplementedError()
|
||||
|
||||
/** Get the current library/bookmarks of this service */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun library(token: AuthToken?): LibraryMetadata? = throw NotImplementedError()
|
||||
|
||||
/** Helper function, may be used in the future */
|
||||
@Throws
|
||||
open fun urlToId(url: String): String? = null
|
||||
|
||||
data class SyncSearchResult(
|
||||
override val name: String,
|
||||
override val apiName: String,
|
||||
var syncId: String,
|
||||
override val url: String,
|
||||
override var posterUrl: String?,
|
||||
override var type: TvType? = null,
|
||||
override var quality: SearchQuality? = null,
|
||||
override var posterHeaders: Map<String, String>? = null,
|
||||
override var id: Int? = null,
|
||||
override var score: Score? = null,
|
||||
) : SearchResponse
|
||||
|
||||
abstract class AbstractSyncStatus {
|
||||
abstract var status: SyncWatchType
|
||||
abstract var score: Score?
|
||||
abstract var watchedEpisodes: Int?
|
||||
abstract var isFavorite: Boolean?
|
||||
abstract var maxEpisodes: Int?
|
||||
}
|
||||
|
||||
data class SyncStatus(
|
||||
override var status: SyncWatchType,
|
||||
override var score: Score?,
|
||||
override var watchedEpisodes: Int?,
|
||||
override var isFavorite: Boolean? = null,
|
||||
override var maxEpisodes: Int? = null,
|
||||
) : AbstractSyncStatus()
|
||||
|
||||
data class SyncResult(
|
||||
/**Used to verify*/
|
||||
var id: String,
|
||||
|
||||
var totalEpisodes: Int? = null,
|
||||
|
||||
var title: String? = null,
|
||||
var publicScore: Score? = null,
|
||||
/**In minutes*/
|
||||
var duration: Int? = null,
|
||||
var synopsis: String? = null,
|
||||
var airStatus: ShowStatus? = null,
|
||||
var nextAiring: NextAiring? = null,
|
||||
var studio: List<String>? = null,
|
||||
var genres: List<String>? = null,
|
||||
var synonyms: List<String>? = null,
|
||||
var trailers: List<String>? = null,
|
||||
var isAdult: Boolean? = null,
|
||||
var posterUrl: String? = null,
|
||||
var backgroundPosterUrl: String? = null,
|
||||
|
||||
/** In unixtime */
|
||||
var startDate: Long? = null,
|
||||
/** In unixtime */
|
||||
var endDate: Long? = null,
|
||||
var recommendations: List<SyncSearchResult>? = null,
|
||||
var nextSeason: SyncSearchResult? = null,
|
||||
var prevSeason: SyncSearchResult? = null,
|
||||
var actors: List<ActorData>? = null,
|
||||
)
|
||||
|
||||
data class Page(
|
||||
val title: UiText, var items: List<LibraryItem>
|
||||
) {
|
||||
fun sort(method: ListSorting?, query: String? = null) {
|
||||
items = when (method) {
|
||||
ListSorting.Query ->
|
||||
if (query != null) {
|
||||
items.sortedBy {
|
||||
-FuzzySearch.partialRatio(
|
||||
query.lowercase(), it.name.lowercase()
|
||||
)
|
||||
}
|
||||
} else items
|
||||
|
||||
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) }
|
||||
ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) }
|
||||
ListSorting.AlphabeticalA -> items.sortedBy { it.name }
|
||||
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
||||
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
||||
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
|
||||
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
|
||||
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
|
||||
else -> items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LibraryMetadata(
|
||||
val allLibraryLists: List<LibraryList>,
|
||||
val supportedListSorting: Set<ListSorting>
|
||||
)
|
||||
|
||||
data class LibraryList(
|
||||
val name: UiText,
|
||||
val items: List<LibraryItem>
|
||||
)
|
||||
|
||||
data class LibraryItem(
|
||||
override val name: String,
|
||||
override val url: String,
|
||||
/**
|
||||
* Unique unchanging string used for data storage.
|
||||
* This should be the actual id when you change scores and status
|
||||
* since score changes from library might get added in the future.
|
||||
**/
|
||||
val syncId: String,
|
||||
val episodesCompleted: Int?,
|
||||
val episodesTotal: Int?,
|
||||
val personalRating: Score?,
|
||||
val lastUpdatedUnixTime: Long?,
|
||||
override val apiName: String,
|
||||
override var type: TvType?,
|
||||
override var posterUrl: String?,
|
||||
override var posterHeaders: Map<String, String>?,
|
||||
override var quality: SearchQuality?,
|
||||
val releaseDate: Date?,
|
||||
override var id: Int? = null,
|
||||
val plot: String? = null,
|
||||
override var score: Score? = null,
|
||||
val tags: List<String>? = null
|
||||
) : SearchResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless subtitle class for external subtitles.
|
||||
*
|
||||
* All non-null `AuthToken` will be non-expired when each function is called.
|
||||
*/
|
||||
abstract class SubtitleAPI : AuthAPI() {
|
||||
@WorkerThread
|
||||
@Throws
|
||||
open suspend fun search(token: AuthToken?, query: SubtitleSearch): List<SubtitleEntity>? =
|
||||
throw NotImplementedError()
|
||||
|
||||
@WorkerThread
|
||||
@Throws
|
||||
open suspend fun load(token: AuthToken?, data: SubtitleEntity): String? =
|
||||
throw NotImplementedError()
|
||||
|
||||
@WorkerThread
|
||||
@Throws
|
||||
open suspend fun SubtitleResource.getResources(token: AuthToken?, data: SubtitleEntity) {
|
||||
this.addUrl(load(token, data))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@Throws
|
||||
suspend fun getResource(token: AuthToken?, data: SubtitleEntity): SubtitleResource {
|
||||
return SubtitleResource().apply {
|
||||
this.getResources(token, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
|
||||
abstract class AuthRepo(open val api: AuthAPI) {
|
||||
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
||||
val idPrefix get() = api.idPrefix
|
||||
val name get() = api.name
|
||||
val icon get() = api.icon
|
||||
val requiresLogin get() = api.requiresLogin
|
||||
val createAccountUrl get() = api.createAccountUrl
|
||||
val hasOAuth2 get() = api.hasOAuth2
|
||||
val hasPin get() = api.hasPin
|
||||
val hasInApp get() = api.hasInApp
|
||||
val inAppLoginRequirement get() = api.inAppLoginRequirement
|
||||
val isAvailable get() = !api.requiresLogin || authUser() != null
|
||||
|
||||
companion object {
|
||||
private val oauthPayload: MutableMap<String, String?> = mutableMapOf()
|
||||
}
|
||||
|
||||
@Throws
|
||||
protected suspend fun freshToken(): AuthToken? {
|
||||
val data = authData() ?: return null
|
||||
if (data.token.isAccessTokenExpired()) {
|
||||
val newToken = api.refreshToken(data.token) ?: return null
|
||||
refreshUser(AuthData(user = data.user, token = newToken))
|
||||
return newToken
|
||||
}
|
||||
return data.token
|
||||
}
|
||||
|
||||
@Throws
|
||||
fun openOAuth2Page(): Boolean {
|
||||
val page = api.loginRequest() ?: return false
|
||||
synchronized(oauthPayload) {
|
||||
oauthPayload.put(idPrefix, page.payload)
|
||||
}
|
||||
openBrowser(page.url)
|
||||
return true
|
||||
}
|
||||
|
||||
fun openOAuth2PageWithToast() {
|
||||
try {
|
||||
if (!openOAuth2Page()) {
|
||||
showToast(txt(R.string.authenticated_user_fail, api.name))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
if (t is ErrorLoadingException && t.message != null) {
|
||||
showToast(t.message)
|
||||
return
|
||||
}
|
||||
showToast(txt(R.string.authenticated_user_fail, api.name))
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(from: AuthUser) {
|
||||
val currentAccounts = AccountManager.accounts(idPrefix)
|
||||
val newAccounts = currentAccounts.filter { it.user.id != from.id }.toTypedArray()
|
||||
if (newAccounts.size < currentAccounts.size) {
|
||||
AccountManager.updateAccounts(idPrefix, newAccounts)
|
||||
AccountManager.updateAccountsId(idPrefix, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshUser(newAuth: AuthData) {
|
||||
val currentAccounts = AccountManager.accounts(idPrefix)
|
||||
val newAccounts = currentAccounts.map {
|
||||
if (it.user.id == newAuth.user.id) {
|
||||
newAuth
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.toTypedArray()
|
||||
AccountManager.updateAccounts(idPrefix, newAccounts)
|
||||
}
|
||||
|
||||
fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) {
|
||||
AccountManager.cachedAccountIds[idPrefix]?.let { id ->
|
||||
AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
fun authToken(): AuthToken? = authData()?.token
|
||||
|
||||
fun authUser(): AuthUser? = authData()?.user
|
||||
|
||||
val accounts
|
||||
get() = synchronized(AccountManager.cachedAccounts) {
|
||||
AccountManager.cachedAccounts[idPrefix] ?: emptyArray()
|
||||
}
|
||||
var accountId
|
||||
get() = synchronized(AccountManager.cachedAccountIds) {
|
||||
AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID
|
||||
}
|
||||
set(value) {
|
||||
AccountManager.updateAccountsId(idPrefix, value)
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun pinRequest() =
|
||||
api.pinRequest()
|
||||
|
||||
@Throws
|
||||
private suspend fun setupLogin(token: AuthToken): Boolean {
|
||||
val user = api.user(token) ?: return false
|
||||
|
||||
val newAccount = AuthData(
|
||||
token = token,
|
||||
user = user,
|
||||
)
|
||||
|
||||
val currentAccounts = AccountManager.accounts(idPrefix)
|
||||
if (currentAccounts.any { it.user.id == newAccount.user.id }) {
|
||||
throw ErrorLoadingException("Already logged into this account")
|
||||
}
|
||||
|
||||
val newAccounts = currentAccounts + newAccount
|
||||
AccountManager.updateAccounts(idPrefix, newAccounts)
|
||||
AccountManager.updateAccountsId(idPrefix, user.id)
|
||||
if (this is SyncRepo) {
|
||||
requireLibraryRefresh = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun login(form: AuthLoginResponse): Boolean {
|
||||
return setupLogin(api.login(form) ?: return false)
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun login(payload: AuthPinData): Boolean {
|
||||
return setupLogin(api.login(payload) ?: return false)
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun login(redirectUrl: String): Boolean {
|
||||
return setupLogin(
|
||||
api.login(
|
||||
redirectUrl,
|
||||
synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stateless safe abstraction of SyncAPI */
|
||||
class SyncRepo(override val api: SyncAPI) : AuthRepo(api) {
|
||||
val syncIdName = api.syncIdName
|
||||
var requireLibraryRefresh: Boolean
|
||||
get() = api.requireLibraryRefresh
|
||||
set(value) {
|
||||
api.requireLibraryRefresh = value
|
||||
}
|
||||
|
||||
suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result<Boolean> =
|
||||
runCatching {
|
||||
val status = api.updateStatus(freshToken() ?: return@runCatching false, id, newStatus)
|
||||
requireLibraryRefresh = true
|
||||
status
|
||||
}
|
||||
|
||||
suspend fun status(id: String): Result<SyncAPI.AbstractSyncStatus?> = runCatching {
|
||||
api.status(freshToken(), id)
|
||||
}
|
||||
|
||||
suspend fun load(id: String): Result<SyncAPI.SyncResult?> = runCatching {
|
||||
api.load(freshToken(), id)
|
||||
}
|
||||
|
||||
suspend fun library(): Result<SyncAPI.LibraryMetadata?> = runCatching {
|
||||
api.library(freshToken())
|
||||
}
|
||||
}
|
||||
|
||||
/** Stateless safe abstraction of SubtitleAPI */
|
||||
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||
companion object {
|
||||
data class SavedSearchResponse(
|
||||
val unixTime: Long,
|
||||
val response: List<SubtitleEntity>,
|
||||
val query: SubtitleSearch
|
||||
)
|
||||
|
||||
data class SavedResourceResponse(
|
||||
val unixTime: Long,
|
||||
val response: SubtitleResource,
|
||||
val query: SubtitleEntity
|
||||
)
|
||||
|
||||
// maybe make this a generic struct? right now there is a lot of boilerplate
|
||||
private val searchCache = threadSafeListOf<SavedSearchResponse>()
|
||||
private var searchCacheIndex: Int = 0
|
||||
private val resourceCache = threadSafeListOf<SavedResourceResponse>()
|
||||
private var resourceCacheIndex: Int = 0
|
||||
const val CACHE_SIZE = 20
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun getResource(data: SubtitleEntity): Resource<SubtitleResource> = safeApiCall {
|
||||
synchronized(resourceCache) {
|
||||
for (item in resourceCache) {
|
||||
// 20 min save
|
||||
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
||||
return@safeApiCall item.response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val returnValue = api.getResource(freshToken(), data)
|
||||
synchronized(resourceCache) {
|
||||
val add = SavedResourceResponse(unixTime, returnValue, data)
|
||||
if (resourceCache.size > CACHE_SIZE) {
|
||||
resourceCache[resourceCacheIndex] = add // rolling cache
|
||||
resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE
|
||||
} else {
|
||||
resourceCache.add(add)
|
||||
}
|
||||
}
|
||||
returnValue
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun search(query: SubtitleSearch): Resource<List<SubtitleEntity>> {
|
||||
return safeApiCall {
|
||||
synchronized(searchCache) {
|
||||
for (item in searchCache) {
|
||||
// 120 min save
|
||||
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
||||
return@safeApiCall item.response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val returnValue =
|
||||
api.search(freshToken(), query) ?: throw ErrorLoadingException("Null subtitles")
|
||||
|
||||
// only cache valid return values
|
||||
if (returnValue.isNotEmpty()) {
|
||||
val add = SavedSearchResponse(unixTime, returnValue, query)
|
||||
synchronized(searchCache) {
|
||||
if (searchCache.size > CACHE_SIZE) {
|
||||
searchCache[searchCacheIndex] = add // rolling cache
|
||||
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
||||
} else {
|
||||
searchCache.add(add)
|
||||
}
|
||||
}
|
||||
}
|
||||
returnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AccountManager {
|
||||
companion object {
|
||||
const val NONE_ID: Int = -1
|
||||
val malApi = MALApi()
|
||||
val aniListApi = AniListApi()
|
||||
val simklApi = SimklApi()
|
||||
val traktApi = TraktApi()
|
||||
val localListApi = LocalList()
|
||||
|
||||
val openSubtitlesApi = OpenSubtitlesApi()
|
||||
val addic7ed = Addic7ed()
|
||||
val subDlApi = SubDlApi()
|
||||
val subSourceApi = SubSourceApi()
|
||||
|
||||
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
||||
var cachedAccountIds: MutableMap<String, Int>
|
||||
|
||||
const val ACCOUNT_TOKEN = "auth_tokens"
|
||||
const val ACCOUNT_IDS = "auth_ids"
|
||||
|
||||
fun accounts(prefix: String): Array<AuthData> {
|
||||
require(prefix != "NONE")
|
||||
return getKey<Array<AuthData>>(
|
||||
ACCOUNT_TOKEN,
|
||||
"${prefix}/${DataStoreHelper.currentAccount}"
|
||||
) ?: arrayOf()
|
||||
}
|
||||
|
||||
fun updateAccounts(prefix: String, array: Array<AuthData>) {
|
||||
require(prefix != "NONE")
|
||||
setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array)
|
||||
synchronized(cachedAccounts) {
|
||||
cachedAccounts[prefix] = array
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAccountsId(prefix: String, id: Int) {
|
||||
require(prefix != "NONE")
|
||||
setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id)
|
||||
synchronized(cachedAccountIds) {
|
||||
cachedAccountIds[prefix] = id
|
||||
}
|
||||
}
|
||||
|
||||
val allApis = arrayOf(
|
||||
SyncRepo(malApi),
|
||||
SyncRepo(aniListApi),
|
||||
SyncRepo(simklApi),
|
||||
SyncRepo(traktApi),
|
||||
SyncRepo(localListApi),
|
||||
|
||||
SubtitleRepo(openSubtitlesApi),
|
||||
SubtitleRepo(addic7ed),
|
||||
SubtitleRepo(subDlApi),
|
||||
SubtitleRepo(subSourceApi)
|
||||
)
|
||||
|
||||
fun updateAccountIds() {
|
||||
val ids = mutableMapOf<String, Int>()
|
||||
for (api in allApis) {
|
||||
ids.put(
|
||||
api.idPrefix,
|
||||
getKey<Int>(
|
||||
ACCOUNT_IDS,
|
||||
"${api.idPrefix}/${DataStoreHelper.currentAccount}",
|
||||
NONE_ID
|
||||
) ?: NONE_ID
|
||||
)
|
||||
}
|
||||
synchronized(cachedAccountIds) {
|
||||
cachedAccountIds = ids
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val data = mutableMapOf<String, Array<AuthData>>()
|
||||
val ids = mutableMapOf<String, Int>()
|
||||
for (api in allApis) {
|
||||
data.put(api.idPrefix, accounts(api.idPrefix))
|
||||
ids.put(
|
||||
api.idPrefix,
|
||||
getKey<Int>(
|
||||
ACCOUNT_IDS,
|
||||
"${api.idPrefix}/${DataStoreHelper.currentAccount}",
|
||||
NONE_ID
|
||||
) ?: NONE_ID
|
||||
)
|
||||
}
|
||||
cachedAccounts = data
|
||||
cachedAccountIds = ids
|
||||
}
|
||||
|
||||
// I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions
|
||||
// accessing other classes
|
||||
fun initMainAPI() {
|
||||
LoadResponse.malIdPrefix = malApi.idPrefix
|
||||
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
||||
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
||||
LoadResponse.traktIdPrefix = traktApi.idPrefix
|
||||
}
|
||||
|
||||
val subtitleProviders = arrayOf(
|
||||
SubtitleRepo(openSubtitlesApi),
|
||||
SubtitleRepo(addic7ed),
|
||||
SubtitleRepo(subDlApi),
|
||||
SubtitleRepo(subSourceApi)
|
||||
)
|
||||
val syncApis = arrayOf(
|
||||
SyncRepo(malApi),
|
||||
SyncRepo(aniListApi),
|
||||
SyncRepo(simklApi),
|
||||
SyncRepo(traktApi),
|
||||
SyncRepo(localListApi)
|
||||
)
|
||||
|
||||
const val APP_STRING = "cloudstreamapp"
|
||||
const val APP_STRING_REPO = "cloudstreamrepo"
|
||||
const val APP_STRING_PLAYER = "cloudstreamplayer"
|
||||
|
||||
// Instantly start the search given a query
|
||||
const val APP_STRING_SEARCH = "cloudstreamsearch"
|
||||
|
||||
// Instantly resume watching a show
|
||||
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
||||
|
||||
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||
var secondsLong = seconds.toLong()
|
||||
val days = TimeUnit.SECONDS
|
||||
.toDays(secondsLong)
|
||||
secondsLong -= TimeUnit.DAYS.toSeconds(days)
|
||||
|
||||
val hours = TimeUnit.SECONDS
|
||||
.toHours(secondsLong)
|
||||
secondsLong -= TimeUnit.HOURS.toSeconds(hours)
|
||||
|
||||
val minutes = TimeUnit.SECONDS
|
||||
.toMinutes(secondsLong)
|
||||
secondsLong -= TimeUnit.MINUTES.toSeconds(minutes)
|
||||
if (minutes < 0) {
|
||||
return completedValue
|
||||
}
|
||||
//println("$days $hours $minutes")
|
||||
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
|
||||
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.syncproviders.AccountManager.Companion.NONE_ID
|
||||
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. */
|
||||
abstract class AuthRepo(open val api: AuthAPI) {
|
||||
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
||||
val idPrefix get() = api.idPrefix
|
||||
val name get() = api.name
|
||||
val icon get() = api.icon
|
||||
val requiresLogin get() = api.requiresLogin
|
||||
val createAccountUrl get() = api.createAccountUrl
|
||||
val hasOAuth2 get() = api.hasOAuth2
|
||||
val hasPin get() = api.hasPin
|
||||
val hasInApp get() = api.hasInApp
|
||||
val inAppLoginRequirement get() = api.inAppLoginRequirement
|
||||
val isAvailable get() = !api.requiresLogin || authUser() != null
|
||||
|
||||
companion object {
|
||||
private val oauthPayload: MutableMap<String, String?> = mutableMapOf()
|
||||
}
|
||||
|
||||
@Throws
|
||||
protected suspend fun freshAuth(): AuthData? {
|
||||
val data = authData() ?: return null
|
||||
if (data.token.isAccessTokenExpired()) {
|
||||
val newToken = api.refreshToken(data.token) ?: return null
|
||||
val newAuth = AuthData(user = data.user, token = newToken)
|
||||
refreshUser(newAuth)
|
||||
return newAuth
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@Throws
|
||||
fun openOAuth2Page(): Boolean {
|
||||
val page = api.loginRequest() ?: return false
|
||||
synchronized(oauthPayload) {
|
||||
oauthPayload.put(idPrefix, page.payload)
|
||||
}
|
||||
openBrowser(page.url)
|
||||
return true
|
||||
}
|
||||
|
||||
fun openOAuth2PageWithToast() {
|
||||
try {
|
||||
if (!openOAuth2Page()) {
|
||||
showToast(txt(R.string.authenticated_user_fail, api.name))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
if (t is ErrorLoadingException && t.message != null) {
|
||||
showToast(t.message)
|
||||
return
|
||||
}
|
||||
showToast(txt(R.string.authenticated_user_fail, api.name))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(from: AuthUser) {
|
||||
val currentAccounts = AccountManager.accounts(idPrefix)
|
||||
val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id }
|
||||
if (newAccounts.size < currentAccounts.size) {
|
||||
AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray())
|
||||
AccountManager.updateAccountsId(idPrefix, 0)
|
||||
}
|
||||
|
||||
for (oldAccount in oldAccounts) {
|
||||
try {
|
||||
api.invalidateToken(oldAccount.token)
|
||||
} catch (_: NotImplementedError) {
|
||||
// no-op
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshUser(newAuth: AuthData) {
|
||||
val currentAccounts = AccountManager.accounts(idPrefix)
|
||||
val newAccounts = currentAccounts.map {
|
||||
if (it.user.id == newAuth.user.id) {
|
||||
newAuth
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.toTypedArray()
|
||||
AccountManager.updateAccounts(idPrefix, newAccounts)
|
||||
}
|
||||
|
||||
fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) {
|
||||
AccountManager.cachedAccountIds[idPrefix]?.let { id ->
|
||||
AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
fun authToken(): AuthToken? = authData()?.token
|
||||
|
||||
fun authUser(): AuthUser? = authData()?.user
|
||||
|
||||
val accounts
|
||||
get() = synchronized(AccountManager.cachedAccounts) {
|
||||
AccountManager.cachedAccounts[idPrefix] ?: emptyArray()
|
||||
}
|
||||
var accountId
|
||||
get() = synchronized(AccountManager.cachedAccountIds) {
|
||||
AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID
|
||||
}
|
||||
set(value) {
|
||||
AccountManager.updateAccountsId(idPrefix, value)
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun pinRequest() =
|
||||
api.pinRequest()
|
||||
|
||||
@Throws
|
||||
private suspend fun setupLogin(token: AuthToken): Boolean {
|
||||
val user = api.user(token) ?: return false
|
||||
|
||||
val newAccount = AuthData(
|
||||
token = token,
|
||||
user = user,
|
||||
)
|
||||
|
||||
val currentAccounts = AccountManager.accounts(idPrefix)
|
||||
if (currentAccounts.any { it.user.id == newAccount.user.id }) {
|
||||
throw ErrorLoadingException("Already logged into this account")
|
||||
}
|
||||
|
||||
val newAccounts = currentAccounts + newAccount
|
||||
AccountManager.updateAccounts(idPrefix, newAccounts)
|
||||
AccountManager.updateAccountsId(idPrefix, user.id)
|
||||
if (this is SyncRepo) {
|
||||
requireLibraryRefresh = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun login(form: AuthLoginResponse): Boolean {
|
||||
return setupLogin(api.login(form) ?: return false)
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun login(payload: AuthPinData): Boolean {
|
||||
return setupLogin(api.login(payload) ?: return false)
|
||||
}
|
||||
|
||||
@Throws
|
||||
suspend fun login(redirectUrl: String): Boolean {
|
||||
return setupLogin(
|
||||
api.login(
|
||||
redirectUrl,
|
||||
synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
/** Work in progress */
|
||||
abstract class BackupAPI : AuthAPI() {
|
||||
open val filename : String = "cloudstream-backup.json"
|
||||
|
||||
/** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */
|
||||
@Throws
|
||||
open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError()
|
||||
|
||||
/** Get the backup file as a JSON string from the remote storage. */
|
||||
@Throws
|
||||
open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError()
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||
|
||||
/**
|
||||
* Stateless subtitle class for external subtitles.
|
||||
*
|
||||
* All non-null `AuthToken` will be non-expired when each function is called.
|
||||
*/
|
||||
abstract class SubtitleAPI : AuthAPI() {
|
||||
@WorkerThread
|
||||
@Throws
|
||||
open suspend fun search(auth: AuthData?, query: SubtitleSearch): List<SubtitleEntity>? =
|
||||
throw NotImplementedError()
|
||||
|
||||
@WorkerThread
|
||||
@Throws
|
||||
open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? =
|
||||
throw NotImplementedError()
|
||||
|
||||
@WorkerThread
|
||||
@Throws
|
||||
open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) {
|
||||
this.addUrl(load(auth, subtitle))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@Throws
|
||||
suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource {
|
||||
return SubtitleResource().apply {
|
||||
this.getResources(auth, subtitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||
|
||||
/** Stateless safe abstraction of SubtitleAPI */
|
||||
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||
companion object {
|
||||
data class SavedSearchResponse(
|
||||
val unixTime: Long,
|
||||
val response: List<SubtitleEntity>,
|
||||
val query: SubtitleSearch,
|
||||
val idPrefix: String,
|
||||
)
|
||||
|
||||
data class SavedResourceResponse(
|
||||
val unixTime: Long,
|
||||
val response: SubtitleResource,
|
||||
val query: SubtitleEntity
|
||||
)
|
||||
|
||||
// maybe make this a generic struct? right now there is a lot of boilerplate
|
||||
private val searchCache = atomicListOf<SavedSearchResponse>()
|
||||
private var searchCacheIndex: Int = 0
|
||||
private val resourceCache = atomicListOf<SavedResourceResponse>()
|
||||
private var resourceCacheIndex: Int = 0
|
||||
const val CACHE_SIZE = 20
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
|
||||
val cached = resourceCache.withLock {
|
||||
var found: SubtitleResource? = null
|
||||
for (item in resourceCache) {
|
||||
// 20 min save
|
||||
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
||||
found = item.response
|
||||
break
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
if (cached != null) return@runCatching cached
|
||||
|
||||
val returnValue = api.resource(freshAuth(), data)
|
||||
resourceCache.withLock {
|
||||
val add = SavedResourceResponse(unixTime, returnValue, data)
|
||||
if (resourceCache.size > CACHE_SIZE) {
|
||||
resourceCache[resourceCacheIndex] = add // rolling cache
|
||||
resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE
|
||||
} else {
|
||||
resourceCache.add(add)
|
||||
}
|
||||
}
|
||||
returnValue
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
|
||||
return runCatching {
|
||||
val cached = searchCache.withLock {
|
||||
var found: List<SubtitleEntity>? = null
|
||||
for (item in searchCache) {
|
||||
// 120 min save
|
||||
if (item.idPrefix == idPrefix && item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
||||
found = item.response
|
||||
break
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (cached != null) return@runCatching cached
|
||||
val returnValue = api.search(freshAuth(), query) ?: emptyList()
|
||||
|
||||
// only cache valid return values
|
||||
if (returnValue.isNotEmpty()) {
|
||||
val add = SavedSearchResponse(unixTime, returnValue, query, idPrefix)
|
||||
searchCache.withLock {
|
||||
if (searchCache.size > CACHE_SIZE) {
|
||||
searchCache[searchCacheIndex] = add // rolling cache
|
||||
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
||||
} else {
|
||||
searchCache.add(add)
|
||||
}
|
||||
}
|
||||
}
|
||||
returnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.lagradost.cloudstream3.ActorData
|
||||
import com.lagradost.cloudstream3.NextAiring
|
||||
import com.lagradost.cloudstream3.Score
|
||||
import com.lagradost.cloudstream3.SearchQuality
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.ShowStatus
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.Levenshtein
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Stateless synchronization class, used for syncing status about a specific movie/show.
|
||||
*
|
||||
* All non-null `AuthToken` will be non-expired when each function is called.
|
||||
*/
|
||||
abstract class SyncAPI : AuthAPI() {
|
||||
/**
|
||||
* Set this to true if the user updates something on the list like watch status or score
|
||||
**/
|
||||
open var requireLibraryRefresh: Boolean = true
|
||||
open val mainUrl: String = "NONE"
|
||||
|
||||
/** Currently unused, but will be used to correctly render the UI.
|
||||
* This should specify what sync watch types can be used with this service. */
|
||||
open val supportedWatchTypes: Set<SyncWatchType> = SyncWatchType.entries.toSet()
|
||||
/**
|
||||
* Allows certain providers to open pages from
|
||||
* library links.
|
||||
**/
|
||||
open val syncIdName: SyncIdName? = null
|
||||
|
||||
/** Modify the current status of an item */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun updateStatus(
|
||||
auth: AuthData?,
|
||||
id: String,
|
||||
newStatus: AbstractSyncStatus
|
||||
): Boolean = throw NotImplementedError()
|
||||
|
||||
/** Get the current status of an item */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? =
|
||||
throw NotImplementedError()
|
||||
|
||||
/** Get metadata about an item */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError()
|
||||
|
||||
/** Search this service for any results for a given query */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? =
|
||||
throw NotImplementedError()
|
||||
|
||||
/** Get the current library/bookmarks of this service */
|
||||
@Throws
|
||||
@WorkerThread
|
||||
open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError()
|
||||
|
||||
/** Helper function, may be used in the future */
|
||||
@Throws
|
||||
open fun urlToId(url: String): String? = null
|
||||
|
||||
data class SyncSearchResult(
|
||||
override val name: String,
|
||||
override val apiName: String,
|
||||
var syncId: String,
|
||||
override val url: String,
|
||||
override var posterUrl: String?,
|
||||
override var type: TvType? = null,
|
||||
override var quality: SearchQuality? = null,
|
||||
override var posterHeaders: Map<String, String>? = null,
|
||||
override var id: Int? = null,
|
||||
override var score: Score? = null,
|
||||
) : SearchResponse
|
||||
|
||||
abstract class AbstractSyncStatus {
|
||||
abstract var status: SyncWatchType
|
||||
abstract var score: Score?
|
||||
abstract var watchedEpisodes: Int?
|
||||
abstract var isFavorite: Boolean?
|
||||
abstract var maxEpisodes: Int?
|
||||
}
|
||||
|
||||
data class SyncStatus(
|
||||
override var status: SyncWatchType,
|
||||
override var score: Score?,
|
||||
override var watchedEpisodes: Int?,
|
||||
override var isFavorite: Boolean? = null,
|
||||
override var maxEpisodes: Int? = null,
|
||||
) : AbstractSyncStatus()
|
||||
|
||||
data class SyncResult(
|
||||
/**Used to verify*/
|
||||
var id: String,
|
||||
|
||||
var totalEpisodes: Int? = null,
|
||||
|
||||
var title: String? = null,
|
||||
var publicScore: Score? = null,
|
||||
/**In minutes*/
|
||||
var duration: Int? = null,
|
||||
var synopsis: String? = null,
|
||||
var airStatus: ShowStatus? = null,
|
||||
var nextAiring: NextAiring? = null,
|
||||
var studio: List<String>? = null,
|
||||
var genres: List<String>? = null,
|
||||
var synonyms: List<String>? = null,
|
||||
var trailers: List<String>? = null,
|
||||
var isAdult: Boolean? = null,
|
||||
var posterUrl: String? = null,
|
||||
var backgroundPosterUrl: String? = null,
|
||||
|
||||
/** In unixtime */
|
||||
var startDate: Long? = null,
|
||||
/** In unixtime */
|
||||
var endDate: Long? = null,
|
||||
var recommendations: List<SyncSearchResult>? = null,
|
||||
var nextSeason: SyncSearchResult? = null,
|
||||
var prevSeason: SyncSearchResult? = null,
|
||||
var actors: List<ActorData>? = null,
|
||||
)
|
||||
|
||||
data class Page(
|
||||
val title: UiText, var items: List<LibraryItem>
|
||||
) {
|
||||
fun sort(method: ListSorting?, query: String? = null) {
|
||||
items = when (method) {
|
||||
ListSorting.Query ->
|
||||
if (query != null) {
|
||||
items.sortedBy {
|
||||
-Levenshtein.partialRatio(
|
||||
query.lowercase(), it.name.lowercase()
|
||||
)
|
||||
}
|
||||
} else items
|
||||
|
||||
ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) }
|
||||
ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) }
|
||||
ListSorting.AlphabeticalA -> items.sortedBy { it.name }
|
||||
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
||||
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
||||
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
|
||||
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
|
||||
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
|
||||
else -> items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LibraryMetadata(
|
||||
val allLibraryLists: List<LibraryList>,
|
||||
val supportedListSorting: Set<ListSorting>
|
||||
)
|
||||
|
||||
data class LibraryList(
|
||||
val name: UiText,
|
||||
val items: List<LibraryItem>
|
||||
)
|
||||
|
||||
data class LibraryItem(
|
||||
override val name: String,
|
||||
override val url: String,
|
||||
/**
|
||||
* Unique unchanging string used for data storage.
|
||||
* This should be the actual id when you change scores and status
|
||||
* since score changes from library might get added in the future.
|
||||
**/
|
||||
val syncId: String,
|
||||
val episodesCompleted: Int?,
|
||||
val episodesTotal: Int?,
|
||||
val personalRating: Score?,
|
||||
val lastUpdatedUnixTime: Long?,
|
||||
override val apiName: String,
|
||||
override var type: TvType?,
|
||||
override var posterUrl: String?,
|
||||
override var posterHeaders: Map<String, String>?,
|
||||
override var quality: SearchQuality?,
|
||||
val releaseDate: Date?,
|
||||
override var id: Int? = null,
|
||||
val plot: String? = null,
|
||||
override var score: Score? = null,
|
||||
val tags: List<String>? = null
|
||||
) : SearchResponse
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
/** Stateless safe abstraction of SyncAPI */
|
||||
class SyncRepo(override val api: SyncAPI) : AuthRepo(api) {
|
||||
val syncIdName = api.syncIdName
|
||||
var requireLibraryRefresh: Boolean
|
||||
get() = api.requireLibraryRefresh
|
||||
set(value) {
|
||||
api.requireLibraryRefresh = value
|
||||
}
|
||||
|
||||
suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result<Boolean> =
|
||||
runCatching {
|
||||
val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus)
|
||||
requireLibraryRefresh = true
|
||||
status
|
||||
}
|
||||
|
||||
suspend fun status(id: String): Result<SyncAPI.AbstractSyncStatus?> = runCatching {
|
||||
api.status(freshAuth(), id)
|
||||
}
|
||||
|
||||
suspend fun load(id: String): Result<SyncAPI.SyncResult?> = runCatching {
|
||||
api.load(freshAuth(), id)
|
||||
}
|
||||
|
||||
suspend fun library(): Result<SyncAPI.LibraryMetadata?> = runCatching {
|
||||
api.library(freshAuth())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import com.lagradost.cloudstream3.AllLanguagesName
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
|
||||
class Addic7ed : SubtitleAPI() {
|
||||
override val name = "Addic7ed"
|
||||
override val idPrefix = "addic7ed"
|
||||
|
||||
override val requiresLogin = false
|
||||
|
||||
companion object {
|
||||
|
|
@ -19,187 +18,92 @@ class Addic7ed : SubtitleAPI() {
|
|||
const val TAG = "ADDIC7ED"
|
||||
}
|
||||
|
||||
private fun String.fixUrl(): String {
|
||||
val url = this
|
||||
private fun fixUrl(url: String): String {
|
||||
return if (url.startsWith("/")) HOST + url
|
||||
else if (!url.startsWith("http")) "$HOST/$url"
|
||||
else url
|
||||
}
|
||||
|
||||
override suspend fun search(
|
||||
auth: AuthData?,
|
||||
query: SubtitleSearch
|
||||
): List<SubtitleEntity>? {
|
||||
val langTagIETF = query.lang ?: AllLanguagesName
|
||||
val langNumAddic7ed =
|
||||
langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0
|
||||
val langName =
|
||||
langTagIETF2Addic7ed[langTagIETF]?.second ?:
|
||||
fromTagToEnglishLanguageName(langTagIETF) ?:
|
||||
"Completed" // this bypasses language filtering
|
||||
val title = query.query.trim()
|
||||
token: AuthToken?,
|
||||
query: AbstractSubtitleEntities.SubtitleSearch
|
||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||
val lang = query.lang
|
||||
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
|
||||
val queryText = query.query.trim()
|
||||
val epNum = query.epNumber ?: 0
|
||||
val seasonNum = query.seasonNumber ?: 0
|
||||
val yearNum = query.year ?: 0
|
||||
val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title
|
||||
var downloadPage = ""
|
||||
|
||||
fun newSubtitleEntity (
|
||||
displayName: String?,
|
||||
link: String?,
|
||||
fun cleanResources(
|
||||
results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
|
||||
name: String,
|
||||
link: String,
|
||||
headers: Map<String, String>,
|
||||
isHearingImpaired: Boolean
|
||||
): SubtitleEntity? {
|
||||
if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null
|
||||
return SubtitleEntity(
|
||||
idPrefix = this.idPrefix,
|
||||
name = displayName,
|
||||
lang = langTagIETF,
|
||||
) {
|
||||
results.add(
|
||||
AbstractSubtitleEntities.SubtitleEntity(
|
||||
idPrefix = idPrefix,
|
||||
name = name,
|
||||
lang = queryLang.toString(),
|
||||
data = link,
|
||||
source = this.name,
|
||||
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
||||
epNumber = epNum,
|
||||
seasonNumber = seasonNum,
|
||||
year = yearNum,
|
||||
headers = mapOf("referer" to "$HOST/"),
|
||||
headers = headers,
|
||||
isHearingImpaired = isHearingImpaired
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search")
|
||||
val hostDocument = response.document
|
||||
|
||||
// 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name
|
||||
if (response.url.contains("/movie/") || response.url.contains("/serie/"))
|
||||
downloadPage = response.url
|
||||
|
||||
// 2nd case: found tv series ep list. Redirected to $HOST/show/1234
|
||||
else if (response.url.contains("/show/")) {
|
||||
val showId = response.url.substringAfterLast("/")
|
||||
val title = queryText.substringBefore("(").trim()
|
||||
val url = "$HOST/search.php?search=${title}&Submit=Search"
|
||||
val hostDocument = app.get(url).document
|
||||
var searchResult = ""
|
||||
if (hostDocument.select("span:contains($title)").isNotEmpty()) searchResult = url
|
||||
else if (hostDocument.select("table.tabel")
|
||||
.isNotEmpty()
|
||||
) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
|
||||
else {
|
||||
val show =
|
||||
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
|
||||
?.substringBefore(",")
|
||||
val doc = app.get(
|
||||
"$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0",
|
||||
"$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
|
||||
referer = "$HOST/"
|
||||
).document
|
||||
|
||||
// get direct subtitles links from list
|
||||
return doc.select("#season tbody tr").mapNotNull { node ->
|
||||
if (node.select("td:eq(1)").text().toIntOrNull() == epNum)
|
||||
newSubtitleEntity(
|
||||
displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(),
|
||||
link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(),
|
||||
isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty()
|
||||
)
|
||||
else null
|
||||
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
|
||||
if (node.selectFirst("td")?.text()
|
||||
?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
|
||||
.text()
|
||||
.toIntOrNull() == epNum
|
||||
) searchResult = fixUrl(node.select("a").attr("href"))
|
||||
}
|
||||
// 3rd case: found several or no results. Still in $HOST/search.php?search=title
|
||||
} else {// (response.url.contains("/search.php"))
|
||||
downloadPage = hostDocument.select("table.tabel a").selectFirst({
|
||||
// tv series
|
||||
if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]"
|
||||
// movie + year
|
||||
else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)"
|
||||
// movie
|
||||
else "a[href~=movie\\/]"
|
||||
}())?.attr("href")?.fixUrl() ?: return null
|
||||
}
|
||||
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
|
||||
val document = app.get(
|
||||
url = fixUrl(searchResult),
|
||||
).document
|
||||
|
||||
// filter download page by language. Do not work for movies :/
|
||||
if (downloadPage.contains("/serie/"))
|
||||
downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed"
|
||||
val doc = app.get(url = downloadPage).document
|
||||
|
||||
// get subtitles links from download page
|
||||
return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node ->
|
||||
val displayName =
|
||||
doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" +
|
||||
document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
|
||||
val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
|
||||
node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
|
||||
val link =
|
||||
node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl()
|
||||
}" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}"
|
||||
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
|
||||
val isHearingImpaired =
|
||||
node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty()
|
||||
|
||||
newSubtitleEntity(displayName, link, isHearingImpaired)
|
||||
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
auth: AuthData?,
|
||||
subtitle: SubtitleEntity
|
||||
token: AuthToken?,
|
||||
data: AbstractSubtitleEntities.SubtitleEntity
|
||||
): String? {
|
||||
return subtitle.data
|
||||
return data.data
|
||||
}
|
||||
|
||||
// Missing (?_?)
|
||||
// Pair("2", ""),
|
||||
// Pair("3", ""),
|
||||
// Pair("33", ""),
|
||||
// Pair("34", ""),
|
||||
// Do not modify unless Addic7ed changes them!
|
||||
// as they are the exact values from their website
|
||||
private val langTagIETF2Addic7ed = mapOf(
|
||||
"ar" to Pair("38", "Arabic"),
|
||||
"az" to Pair("48", "Azerbaijani"),
|
||||
"bg" to Pair("35", "Bulgarian"),
|
||||
"bn" to Pair("47", "Bengali"),
|
||||
"bs" to Pair("44", "Bosnian"),
|
||||
"ca" to Pair("12", "Català"),
|
||||
"cs" to Pair("14", "Czech"),
|
||||
"cy" to Pair("65", "Welsh"),
|
||||
"da" to Pair("30", "Danish"),
|
||||
"de" to Pair("11", "German"),
|
||||
"el" to Pair("27", "Greek"),
|
||||
"en" to Pair("1", "English"),
|
||||
"es-419" to Pair("6", "Spanish (Latin America)"),
|
||||
"es-ar" to Pair("69", "Spanish (Argentina)"),
|
||||
"es-es" to Pair("5", "Spanish (Spain)"),
|
||||
"es" to Pair("4", "Spanish"),
|
||||
"et" to Pair("54", "Estonian"),
|
||||
"eu" to Pair("13", "Euskera"),
|
||||
"fa" to Pair("43", "Persian"),
|
||||
"fi" to Pair("28", "Finnish"),
|
||||
"fr-ca" to Pair("53", "French (Canadian)"),
|
||||
"fr" to Pair("8", "French"),
|
||||
"gl" to Pair("15", "Galego"),
|
||||
"he" to Pair("23", "Hebrew"),
|
||||
"hi" to Pair("55", "Hindi"),
|
||||
"hr" to Pair("31", "Croatian"),
|
||||
"hu" to Pair("20", "Hungarian"),
|
||||
"hy" to Pair("50", "Armenian"),
|
||||
"id" to Pair("37", "Indonesian"),
|
||||
"is" to Pair("56", "Icelandic"),
|
||||
"it" to Pair("7", "Italian"),
|
||||
"ja" to Pair("32", "Japanese"),
|
||||
"kn" to Pair("66", "Kannada"),
|
||||
"ko" to Pair("42", "Korean"),
|
||||
"lt" to Pair("58", "Lithuanian"),
|
||||
"lv" to Pair("57", "Latvian"),
|
||||
"mk" to Pair("49", "Macedonian"),
|
||||
"ml" to Pair("67", "Malayalam"),
|
||||
"mr" to Pair("62", "Marathi"),
|
||||
"ms" to Pair("40", "Malay"),
|
||||
"nl" to Pair("17", "Dutch"),
|
||||
"no" to Pair("29", "Norwegian"),
|
||||
"pl" to Pair("21", "Polish"),
|
||||
"pt-br" to Pair("10", "Portuguese (Brazilian)"),
|
||||
"pt" to Pair("9", "Portuguese"),
|
||||
"ro" to Pair("26", "Romanian"),
|
||||
"ru" to Pair("19", "Russian"),
|
||||
"si" to Pair("60", "Sinhala"),
|
||||
"sk" to Pair("25", "Slovak"),
|
||||
"sl" to Pair("22", "Slovenian"),
|
||||
"sq" to Pair("52", "Albanian"),
|
||||
"sr-latn" to Pair("36", "Serbian (Latin)"),
|
||||
"sr" to Pair("39", "Serbian (Cyrillic)"),
|
||||
"sv" to Pair("18", "Swedish"),
|
||||
"ta" to Pair("59", "Tamil"),
|
||||
"te" to Pair("63", "Telugu"),
|
||||
"th" to Pair("46", "Thai"),
|
||||
"tl" to Pair("68", "Tagalog"),
|
||||
"tlh" to Pair("61", "Klingon"),
|
||||
"tr" to Pair("16", "Turkish"),
|
||||
"uk" to Pair("51", "Ukrainian"),
|
||||
"vi" to Pair("45", "Vietnamese"),
|
||||
"yue" to Pair("64", "Cantonese"),
|
||||
"zh-hans" to Pair("41", "Chinese (Simplified)"),
|
||||
"zh-hant" to Pair("24", "Chinese (Traditional)"),
|
||||
)
|
||||
}
|
||||
|
|
@ -2,13 +2,11 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.Actor
|
||||
import com.lagradost.cloudstream3.ActorData
|
||||
import com.lagradost.cloudstream3.ActorRole
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.NextAiring
|
||||
import com.lagradost.cloudstream3.R
|
||||
|
|
@ -16,7 +14,6 @@ import com.lagradost.cloudstream3.Score
|
|||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||
|
|
@ -27,10 +24,9 @@ import com.lagradost.cloudstream3.ui.library.ListSorting
|
|||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -38,7 +34,7 @@ class AniListApi : SyncAPI() {
|
|||
override var name = "AniList"
|
||||
override val idPrefix = "anilist"
|
||||
|
||||
private val key = BuildConfig.ANILIST_KEY
|
||||
val key = "6871"
|
||||
override val redirectUrlIdentifier = "anilistlogin"
|
||||
override var requireLibraryRefresh = true
|
||||
override val hasOAuth2 = true
|
||||
|
|
@ -53,12 +49,12 @@ class AniListApi : SyncAPI() {
|
|||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
||||
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"],
|
||||
accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(),
|
||||
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
|
||||
)
|
||||
return token
|
||||
val user = getUser(token) ?: throw ErrorLoadingException("Unable to fetch user data")
|
||||
return token.copy(payload = user.id.toString())
|
||||
}
|
||||
|
||||
// https://docs.anilist.co/guide/auth/
|
||||
|
|
@ -82,12 +78,13 @@ class AniListApi : SyncAPI() {
|
|||
override fun urlToId(url: String): String? =
|
||||
url.removePrefix("$mainUrl/anime/").removeSuffix("/")
|
||||
|
||||
|
||||
private fun getUrlFromId(id: Int): String {
|
||||
return "$mainUrl/anime/$id"
|
||||
}
|
||||
|
||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val data = searchShows(query) ?: return null
|
||||
override suspend fun search(token: AuthToken?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val data = searchShows(name) ?: return null
|
||||
return data.data?.page?.media?.map {
|
||||
SyncAPI.SyncSearchResult(
|
||||
it.title.romaji ?: return null,
|
||||
|
|
@ -99,16 +96,17 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
||||
override suspend fun load(token: AuthToken?, id: String): SyncAPI.SyncResult? {
|
||||
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
||||
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
||||
val season = getSeason(internalId).data.media
|
||||
|
||||
return SyncAPI.SyncResult(
|
||||
season.id.toString(),
|
||||
nextAiring = season.nextAiringEpisode?.let {
|
||||
NextAiring(
|
||||
it.episode ?: return@let null,
|
||||
(it.timeUntilAiring ?: return@let null) + APIHolder.unixTime
|
||||
(it.timeUntilAiring ?: return@let null) + unixTime
|
||||
)
|
||||
},
|
||||
title = season.title?.userPreferred,
|
||||
|
|
@ -160,9 +158,10 @@ class AniListApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
override suspend fun status(token: AuthToken?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
val internalId = id.toIntOrNull() ?: return null
|
||||
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
||||
val data = getDataAboutId(token ?: return null, internalId) ?: return null
|
||||
|
||||
return SyncAPI.SyncStatus(
|
||||
score = Score.from100(data.score),
|
||||
watchedEpisodes = data.progress,
|
||||
|
|
@ -173,12 +172,12 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
|
||||
override suspend fun updateStatus(
|
||||
auth: AuthData?,
|
||||
token: AuthToken?,
|
||||
id: String,
|
||||
newStatus: AbstractSyncStatus
|
||||
newStatus: SyncAPI.AbstractSyncStatus
|
||||
): Boolean {
|
||||
return postDataAboutId(
|
||||
auth ?: return false,
|
||||
token ?: return false,
|
||||
id.toIntOrNull() ?: return false,
|
||||
fromIntToAnimeStatus(newStatus.status.internalId),
|
||||
newStatus.score,
|
||||
|
|
@ -258,10 +257,11 @@ class AniListApi : SyncAPI() {
|
|||
val data =
|
||||
mapOf(
|
||||
"query" to query,
|
||||
"variables" to Variables(
|
||||
search = name,
|
||||
page = 1,
|
||||
type = "ANIME",
|
||||
"variables" to
|
||||
mapOf(
|
||||
"search" to name,
|
||||
"page" to 1,
|
||||
"type" to "ANIME"
|
||||
).toJson()
|
||||
)
|
||||
|
||||
|
|
@ -271,11 +271,10 @@ class AniListApi : SyncAPI() {
|
|||
data = data,//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||
timeout = 5000 // REASONABLE TIMEOUT
|
||||
).text.replace("\\", "")
|
||||
return parseJson<GetSearchRoot>(res)
|
||||
return res.toKotlinObject()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -456,11 +455,11 @@ class AniListApi : SyncAPI() {
|
|||
cacheTime = 0,
|
||||
).text
|
||||
|
||||
return tryParseJson<SeasonResponse>(data) ?: throw ErrorLoadingException("Error parsing $data")
|
||||
return tryParseJson(data) ?: throw ErrorLoadingException("Error parsing $data")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? {
|
||||
private suspend fun getDataAboutId(token: AuthToken, id: Int): AniListTitleHolder? {
|
||||
val q =
|
||||
"""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)
|
||||
|
|
@ -479,7 +478,7 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}"""
|
||||
|
||||
val data = postApi(auth.token, q, true)
|
||||
val data = postApi(token, q, true)
|
||||
val d = parseJson<GetDataRoot>(data ?: return null)
|
||||
|
||||
val main = d.data?.media
|
||||
|
|
@ -504,13 +503,14 @@ class AniListApi : SyncAPI() {
|
|||
type = AniListStatusType.None,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
|
||||
return app.post(
|
||||
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? =
|
||||
app.post(
|
||||
"https://graphql.anilist.co/",
|
||||
headers = mapOf(
|
||||
"Authorization" to "Bearer ${token.accessToken ?: return null}",
|
||||
"Authorization" to "Bearer ${token.accessToken}",
|
||||
if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
|
||||
),
|
||||
cacheTime = 0,
|
||||
|
|
@ -522,81 +522,66 @@ class AniListApi : SyncAPI() {
|
|||
), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||
timeout = 5 // REASONABLE TIMEOUT
|
||||
).text.replace("\\/", "/")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Variables(
|
||||
@JsonProperty("search") @SerialName("search") val search: String,
|
||||
@JsonProperty("page") @SerialName("page") val page: Int,
|
||||
@JsonProperty("type") @SerialName("type") val type: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaRecommendation(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("title") @SerialName("title") val title: Title?,
|
||||
@JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
|
||||
@JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage?,
|
||||
@JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("title") val title: Title?,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("coverImage") val coverImage: CoverImage?,
|
||||
@JsonProperty("averageScore") val averageScore: Int?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FullAnilistList(
|
||||
@JsonProperty("data") @SerialName("data") val data: Data?,
|
||||
@JsonProperty("data") val data: Data?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompletedAt(
|
||||
@JsonProperty("year") @SerialName("year") val year: Int,
|
||||
@JsonProperty("month") @SerialName("month") val month: Int,
|
||||
@JsonProperty("day") @SerialName("day") val day: Int,
|
||||
@JsonProperty("year") val year: Int,
|
||||
@JsonProperty("month") val month: Int,
|
||||
@JsonProperty("day") val day: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StartedAt(
|
||||
@JsonProperty("year") @SerialName("year") val year: String?,
|
||||
@JsonProperty("month") @SerialName("month") val month: String?,
|
||||
@JsonProperty("day") @SerialName("day") val day: String?,
|
||||
@JsonProperty("year") val year: String?,
|
||||
@JsonProperty("month") val month: String?,
|
||||
@JsonProperty("day") val day: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Title(
|
||||
@JsonProperty("english") @SerialName("english") val english: String?,
|
||||
@JsonProperty("romaji") @SerialName("romaji") val romaji: String?,
|
||||
@JsonProperty("english") val english: String?,
|
||||
@JsonProperty("romaji") val romaji: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CoverImage(
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String?,
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("extraLarge") @SerialName("extraLarge") val extraLarge: String?,
|
||||
@JsonProperty("medium") val medium: String?,
|
||||
@JsonProperty("large") val large: String?,
|
||||
@JsonProperty("extraLarge") val extraLarge: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Media(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
|
||||
@JsonProperty("season") @SerialName("season") val season: String?,
|
||||
@JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int,
|
||||
@JsonProperty("format") @SerialName("format") val format: String?,
|
||||
@JsonProperty("episodes") @SerialName("episodes") val episodes: Int,
|
||||
@JsonProperty("title") @SerialName("title") val title: Title,
|
||||
@JsonProperty("description") @SerialName("description") val description: String?,
|
||||
@JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage,
|
||||
@JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List<String>,
|
||||
@JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("season") val season: String?,
|
||||
@JsonProperty("seasonYear") val seasonYear: Int,
|
||||
@JsonProperty("format") val format: String?,
|
||||
//@JsonProperty("source") val source: String,
|
||||
@JsonProperty("episodes") val episodes: Int,
|
||||
@JsonProperty("title") val title: Title,
|
||||
@JsonProperty("description") val description: String?,
|
||||
@JsonProperty("coverImage") val coverImage: CoverImage,
|
||||
@JsonProperty("synonyms") val synonyms: List<String>,
|
||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Entries(
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
@JsonProperty("completedAt") @SerialName("completedAt") val completedAt: CompletedAt,
|
||||
@JsonProperty("startedAt") @SerialName("startedAt") val startedAt: StartedAt,
|
||||
@JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: Int,
|
||||
@JsonProperty("progress") @SerialName("progress") val progress: Int,
|
||||
@JsonProperty("score") @SerialName("score") val score: Int,
|
||||
@JsonProperty("private") @SerialName("private") val private: Boolean,
|
||||
@JsonProperty("media") @SerialName("media") val media: Media,
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("completedAt") val completedAt: CompletedAt,
|
||||
@JsonProperty("startedAt") val startedAt: StartedAt,
|
||||
@JsonProperty("updatedAt") val updatedAt: Int,
|
||||
@JsonProperty("progress") val progress: Int,
|
||||
@JsonProperty("score") val score: Int,
|
||||
@JsonProperty("private") val private: Boolean,
|
||||
@JsonProperty("media") val media: Media
|
||||
) {
|
||||
fun toLibraryItem(): SyncAPI.LibraryItem {
|
||||
return SyncAPI.LibraryItem(
|
||||
|
|
@ -623,39 +608,36 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Lists(
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
@JsonProperty("entries") @SerialName("entries") val entries: List<Entries>,
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("entries") val entries: List<Entries>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaListCollection(
|
||||
@JsonProperty("lists") @SerialName("lists") val lists: List<Lists>,
|
||||
@JsonProperty("lists") val lists: List<Lists>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Data(
|
||||
@JsonProperty("MediaListCollection") @SerialName("MediaListCollection") val mediaListCollection: MediaListCollection,
|
||||
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
|
||||
)
|
||||
|
||||
private suspend fun getAniListAnimeListSmart(auth: AuthData): Array<Lists>? {
|
||||
private suspend fun getAniListAnimeListSmart(token: AuthToken): Array<Lists>? {
|
||||
return if (requireLibraryRefresh) {
|
||||
val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray()
|
||||
val list = getFullAniListList(token)?.data?.mediaListCollection?.lists?.toTypedArray()
|
||||
if (list != null) {
|
||||
setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list)
|
||||
setKey(ANILIST_CACHED_LIST, token.accessToken ?: "", list)
|
||||
}
|
||||
list
|
||||
} else {
|
||||
getKey<Array<Lists>>(
|
||||
ANILIST_CACHED_LIST,
|
||||
auth.user.id.toString()
|
||||
token.accessToken ?: ""
|
||||
) as? Array<Lists>
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
|
||||
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
||||
override suspend fun library(token: AuthToken?): SyncAPI.LibraryMetadata? {
|
||||
val list = getAniListAnimeListSmart(token ?: return null)?.groupBy {
|
||||
convertAniListStringToStatus(it.status ?: "").stringRes
|
||||
}?.mapValues { group ->
|
||||
group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
|
||||
|
|
@ -682,9 +664,10 @@ class AniListApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
|
||||
val userID = auth.user.id
|
||||
private suspend fun getFullAniListList(token: AuthToken): FullAnilistList? {
|
||||
val userID = token.payload ?: return null
|
||||
val mediaType = "ANIME"
|
||||
|
||||
val query = """
|
||||
query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) {
|
||||
MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) {
|
||||
|
|
@ -725,11 +708,11 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
"""
|
||||
val text = postApi(auth.token, query)
|
||||
return tryParseJson<FullAnilistList>(text)
|
||||
val text = postApi(token, query)
|
||||
return text?.toKotlinObject()
|
||||
}
|
||||
|
||||
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
|
||||
suspend fun toggleLike(token: AuthToken, id: Int): Boolean {
|
||||
val q = """mutation (${'$'}animeId: Int = $id) {
|
||||
ToggleFavourite (animeId: ${'$'}animeId) {
|
||||
anime {
|
||||
|
|
@ -742,34 +725,24 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
}"""
|
||||
val data = postApi(auth.token, q)
|
||||
val data = postApi(token, q)
|
||||
return data != ""
|
||||
}
|
||||
|
||||
/** Used to query a saved MediaItem on the list to get the id for removal */
|
||||
@Serializable
|
||||
data class MediaListItemRoot(
|
||||
@JsonProperty("data") @SerialName("data") val data: MediaListItem? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaListItem(
|
||||
@JsonProperty("MediaList") @SerialName("MediaList") val mediaList: MediaListId? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaListId(
|
||||
@JsonProperty("id") @SerialName("id") val id: Long? = null,
|
||||
)
|
||||
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
|
||||
data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
|
||||
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||
|
||||
private suspend fun postDataAboutId(
|
||||
auth: AuthData,
|
||||
token: AuthToken,
|
||||
id: Int,
|
||||
type: AniListStatusType,
|
||||
score: Score?,
|
||||
progress: Int?
|
||||
): Boolean {
|
||||
val userID = auth.user.id
|
||||
val userID = token.payload ?: return false
|
||||
|
||||
val q =
|
||||
// Delete item if status type is None
|
||||
if (type == AniListStatusType.None) {
|
||||
|
|
@ -781,7 +754,7 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
"""
|
||||
val response = postApi(auth.token, idQuery)
|
||||
val response = postApi(token, idQuery)
|
||||
val listId =
|
||||
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
|
||||
"""
|
||||
|
|
@ -807,7 +780,7 @@ class AniListApi : SyncAPI() {
|
|||
}"""
|
||||
}
|
||||
|
||||
val data = postApi(auth.token, q)
|
||||
val data = postApi(token, q)
|
||||
return data != ""
|
||||
}
|
||||
|
||||
|
|
@ -861,356 +834,305 @@ class AniListApi : SyncAPI() {
|
|||
return seasons.toList()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SeasonResponse(
|
||||
@JsonProperty("data") @SerialName("data") val data: SeasonData,
|
||||
@JsonProperty("data") val data: SeasonData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeasonData(
|
||||
@JsonProperty("Media") @SerialName("Media") val media: SeasonMedia,
|
||||
@JsonProperty("Media") val media: SeasonMedia,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendedMedia(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("title") @SerialName("title") val title: MediaTitle?,
|
||||
@JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterMedia(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("title") @SerialName("title") val title: MediaTitle?,
|
||||
@JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeasonMedia(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("title") @SerialName("title") val title: MediaTitle?,
|
||||
@JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
|
||||
@JsonProperty("format") @SerialName("format") val format: String?,
|
||||
@JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("relations") @SerialName("relations") val relations: SeasonEdges?,
|
||||
@JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?,
|
||||
@JsonProperty("duration") @SerialName("duration") val duration: Int?,
|
||||
@JsonProperty("episodes") @SerialName("episodes") val episodes: Int?,
|
||||
@JsonProperty("genres") @SerialName("genres") val genres: List<String>?,
|
||||
@JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List<String>?,
|
||||
@JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("isAdult") @SerialName("isAdult") val isAdult: Boolean?,
|
||||
@JsonProperty("trailer") @SerialName("trailer") val trailer: MediaTrailer?,
|
||||
@JsonProperty("description") @SerialName("description") val description: String?,
|
||||
@JsonProperty("characters") @SerialName("characters") val characters: CharacterConnection?,
|
||||
@JsonProperty("recommendations") @SerialName("recommendations") val recommendations: RecommendationConnection?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
@JsonProperty("title") val title: MediaTitle?,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("format") val format: String?,
|
||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("relations") val relations: SeasonEdges?,
|
||||
@JsonProperty("coverImage") val coverImage: MediaCoverImage?,
|
||||
@JsonProperty("duration") val duration: Int?,
|
||||
@JsonProperty("episodes") val episodes: Int?,
|
||||
@JsonProperty("genres") val genres: List<String>?,
|
||||
@JsonProperty("synonyms") val synonyms: List<String>?,
|
||||
@JsonProperty("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean?,
|
||||
@JsonProperty("trailer") val trailer: MediaTrailer?,
|
||||
@JsonProperty("description") val description: String?,
|
||||
@JsonProperty("characters") val characters: CharacterConnection?,
|
||||
@JsonProperty("recommendations") val recommendations: RecommendationConnection?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendationConnection(
|
||||
@JsonProperty("edges") @SerialName("edges") val edges: List<RecommendationEdge> = emptyList(),
|
||||
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<Recommendation> = emptyList(),
|
||||
@JsonProperty("edges") val edges: List<RecommendationEdge> = emptyList(),
|
||||
@JsonProperty("nodes") val nodes: List<Recommendation> = emptyList(),
|
||||
//@JsonProperty("pageInfo") val pageInfo: PageInfo,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendationEdge(
|
||||
@JsonProperty("node") @SerialName("node") val node: Recommendation,
|
||||
//@JsonProperty("rating") val rating: Int,
|
||||
@JsonProperty("node") val node: Recommendation,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Recommendation(
|
||||
@JsonProperty("mediaRecommendation") @SerialName("mediaRecommendation") val mediaRecommendation: RecommendedMedia?,
|
||||
val id: Long,
|
||||
@JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterName(
|
||||
@JsonProperty("name") @SerialName("name") val first: String?,
|
||||
@JsonProperty("middle") @SerialName("middle") val middle: String?,
|
||||
@JsonProperty("last") @SerialName("last") val last: String?,
|
||||
@JsonProperty("full") @SerialName("full") val full: String?,
|
||||
@JsonProperty("native") @SerialName("native") val native: String?,
|
||||
@JsonProperty("alternative") @SerialName("alternative") val alternative: List<String>?,
|
||||
@JsonProperty("alternativeSpoiler") @SerialName("alternativeSpoiler") val alternativeSpoiler: List<String>?,
|
||||
@JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?,
|
||||
@JsonProperty("name") val first: String?,
|
||||
@JsonProperty("middle") val middle: String?,
|
||||
@JsonProperty("last") val last: String?,
|
||||
@JsonProperty("full") val full: String?,
|
||||
@JsonProperty("native") val native: String?,
|
||||
@JsonProperty("alternative") val alternative: List<String>?,
|
||||
@JsonProperty("alternativeSpoiler") val alternativeSpoiler: List<String>?,
|
||||
@JsonProperty("userPreferred") val userPreferred: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterImage(
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String?,
|
||||
@JsonProperty("large") val large: String?,
|
||||
@JsonProperty("medium") val medium: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Character(
|
||||
@JsonProperty("name") @SerialName("name") val name: CharacterName?,
|
||||
@JsonProperty("age") @SerialName("age") val age: String?,
|
||||
@JsonProperty("image") @SerialName("image") val image: CharacterImage?,
|
||||
@JsonProperty("name") val name: CharacterName?,
|
||||
@JsonProperty("age") val age: String?,
|
||||
@JsonProperty("image") val image: CharacterImage?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterEdge(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
/**
|
||||
* MAIN - A primary character role in the media
|
||||
* SUPPORTING - A supporting character role in the media
|
||||
* BACKGROUND - A background character in the media
|
||||
MAIN
|
||||
A primary character role in the media
|
||||
|
||||
SUPPORTING
|
||||
A supporting character role in the media
|
||||
|
||||
BACKGROUND
|
||||
A background character in the media
|
||||
*/
|
||||
@JsonProperty("role") @SerialName("role") val role: String?,
|
||||
@JsonProperty("name") @SerialName("name") val name: String?,
|
||||
@JsonProperty("voiceActors") @SerialName("voiceActors") val voiceActors: List<Staff>?,
|
||||
@JsonProperty("favouriteOrder") @SerialName("favouriteOrder") val favouriteOrder: Int?,
|
||||
@JsonProperty("media") @SerialName("media") val media: List<CharacterMedia>?,
|
||||
@JsonProperty("node") @SerialName("node") val node: Character?,
|
||||
@JsonProperty("role") val role: String?,
|
||||
@JsonProperty("name") val name: String?,
|
||||
@JsonProperty("voiceActors") val voiceActors: List<Staff>?,
|
||||
@JsonProperty("favouriteOrder") val favouriteOrder: Int?,
|
||||
@JsonProperty("media") val media: List<SeasonMedia>?,
|
||||
@JsonProperty("node") val node: Character?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StaffImage(
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String?,
|
||||
@JsonProperty("large") val large: String?,
|
||||
@JsonProperty("medium") val medium: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StaffName(
|
||||
@JsonProperty("name") @SerialName("name") val first: String?,
|
||||
@JsonProperty("middle") @SerialName("middle") val middle: String?,
|
||||
@JsonProperty("last") @SerialName("last") val last: String?,
|
||||
@JsonProperty("full") @SerialName("full") val full: String?,
|
||||
@JsonProperty("native") @SerialName("native") val native: String?,
|
||||
@JsonProperty("alternative") @SerialName("alternative") val alternative: List<String>?,
|
||||
@JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?,
|
||||
@JsonProperty("name") val first: String?,
|
||||
@JsonProperty("middle") val middle: String?,
|
||||
@JsonProperty("last") val last: String?,
|
||||
@JsonProperty("full") val full: String?,
|
||||
@JsonProperty("native") val native: String?,
|
||||
@JsonProperty("alternative") val alternative: List<String>?,
|
||||
@JsonProperty("userPreferred") val userPreferred: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Staff(
|
||||
@JsonProperty("image") @SerialName("image") val image: StaffImage?,
|
||||
@JsonProperty("name") @SerialName("name") val name: StaffName?,
|
||||
@JsonProperty("age") @SerialName("age") val age: Int?,
|
||||
@JsonProperty("image") val image: StaffImage?,
|
||||
@JsonProperty("name") val name: StaffName?,
|
||||
@JsonProperty("age") val age: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterConnection(
|
||||
@JsonProperty("edges") @SerialName("edges") val edges: List<CharacterEdge>?,
|
||||
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<Character>?,
|
||||
@JsonProperty("edges") val edges: List<CharacterEdge>?,
|
||||
@JsonProperty("nodes") val nodes: List<Character>?,
|
||||
//@JsonProperty("pageInfo") pageInfo: PageInfo
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTrailer(
|
||||
@JsonProperty("id") @SerialName("id") val id: String?,
|
||||
@JsonProperty("site") @SerialName("site") val site: String?,
|
||||
@JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: String?,
|
||||
@JsonProperty("id") val id: String?,
|
||||
@JsonProperty("site") val site: String?,
|
||||
@JsonProperty("thumbnail") val thumbnail: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaCoverImage(
|
||||
@JsonProperty("extraLarge") @SerialName("extraLarge") val extraLarge: String?,
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String?,
|
||||
@JsonProperty("color") @SerialName("color") val color: String?,
|
||||
@JsonProperty("extraLarge") val extraLarge: String?,
|
||||
@JsonProperty("large") val large: String?,
|
||||
@JsonProperty("medium") val medium: String?,
|
||||
@JsonProperty("color") val color: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeasonNextAiringEpisode(
|
||||
@JsonProperty("episode") @SerialName("episode") val episode: Int?,
|
||||
@JsonProperty("timeUntilAiring") @SerialName("timeUntilAiring") val timeUntilAiring: Int?,
|
||||
@JsonProperty("episode") val episode: Int?,
|
||||
@JsonProperty("timeUntilAiring") val timeUntilAiring: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeasonEdges(
|
||||
@JsonProperty("edges") @SerialName("edges") val edges: List<SeasonEdge>?,
|
||||
@JsonProperty("edges") val edges: List<SeasonEdge>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeasonEdge(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("relationType") @SerialName("relationType") val relationType: String?,
|
||||
@JsonProperty("node") @SerialName("node") val node: SeasonNode?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
@JsonProperty("relationType") val relationType: String?,
|
||||
@JsonProperty("node") val node: SeasonNode?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListFavoritesMediaConnection(
|
||||
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<LikeNode>,
|
||||
@JsonProperty("nodes") val nodes: List<LikeNode>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListFavourites(
|
||||
@JsonProperty("anime") @SerialName("anime") val anime: AniListFavoritesMediaConnection,
|
||||
@JsonProperty("anime") val anime: AniListFavoritesMediaConnection,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTitle(
|
||||
@JsonProperty("romaji") @SerialName("romaji") val romaji: String?,
|
||||
@JsonProperty("english") @SerialName("english") val english: String?,
|
||||
@JsonProperty("native") @SerialName("native") val native: String?,
|
||||
@JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?,
|
||||
@JsonProperty("romaji") val romaji: String?,
|
||||
@JsonProperty("english") val english: String?,
|
||||
@JsonProperty("native") val native: String?,
|
||||
@JsonProperty("userPreferred") val userPreferred: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeasonNode(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("format") @SerialName("format") val format: String?,
|
||||
@JsonProperty("title") @SerialName("title") val title: Title?,
|
||||
@JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
|
||||
@JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage?,
|
||||
@JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("format") val format: String?,
|
||||
@JsonProperty("title") val title: Title?,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("coverImage") val coverImage: CoverImage?,
|
||||
@JsonProperty("averageScore") val averageScore: Int?
|
||||
// @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListAvatar(
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("large") val large: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListViewer(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("avatar") @SerialName("avatar") val avatar: AniListAvatar?,
|
||||
@JsonProperty("favourites") @SerialName("favourites") val favourites: AniListFavourites?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("avatar") val avatar: AniListAvatar?,
|
||||
@JsonProperty("favourites") val favourites: AniListFavourites?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListData(
|
||||
@JsonProperty("Viewer") @SerialName("Viewer") val viewer: AniListViewer?,
|
||||
@JsonProperty("Viewer") val viewer: AniListViewer?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListRoot(
|
||||
@JsonProperty("data") @SerialName("data") val data: AniListData?,
|
||||
@JsonProperty("data") val data: AniListData?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListUser(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("picture") @SerialName("picture") val picture: String?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("picture") val picture: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikeNode(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
//@JsonProperty("idMal") public int idMal;
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikePageInfo(
|
||||
@JsonProperty("total") @SerialName("total") val total: Int?,
|
||||
@JsonProperty("currentPage") @SerialName("currentPage") val currentPage: Int?,
|
||||
@JsonProperty("lastPage") @SerialName("lastPage") val lastPage: Int?,
|
||||
@JsonProperty("perPage") @SerialName("perPage") val perPage: Int?,
|
||||
@JsonProperty("hasNextPage") @SerialName("hasNextPage") val hasNextPage: Boolean?,
|
||||
@JsonProperty("total") val total: Int?,
|
||||
@JsonProperty("currentPage") val currentPage: Int?,
|
||||
@JsonProperty("lastPage") val lastPage: Int?,
|
||||
@JsonProperty("perPage") val perPage: Int?,
|
||||
@JsonProperty("hasNextPage") val hasNextPage: Boolean?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikeAnime(
|
||||
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<LikeNode>?,
|
||||
@JsonProperty("pageInfo") @SerialName("pageInfo") val pageInfo: LikePageInfo?,
|
||||
@JsonProperty("nodes") val nodes: List<LikeNode>?,
|
||||
@JsonProperty("pageInfo") val pageInfo: LikePageInfo?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikeFavourites(
|
||||
@JsonProperty("anime") @SerialName("anime") val anime: LikeAnime?,
|
||||
@JsonProperty("anime") val anime: LikeAnime?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikeViewer(
|
||||
@JsonProperty("favourites") @SerialName("favourites") val favourites: LikeFavourites?,
|
||||
@JsonProperty("favourites") val favourites: LikeFavourites?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikeData(
|
||||
@JsonProperty("Viewer") @SerialName("Viewer") val viewer: LikeViewer?,
|
||||
@JsonProperty("Viewer") val viewer: LikeViewer?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LikeRoot(
|
||||
@JsonProperty("data") @SerialName("data") val data: LikeData?,
|
||||
@JsonProperty("data") val data: LikeData?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniListTitleHolder(
|
||||
@JsonProperty("title") @SerialName("title") val title: Title?,
|
||||
@JsonProperty("isFavourite") @SerialName("isFavourite") val isFavourite: Boolean?,
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("progress") @SerialName("progress") val progress: Int?,
|
||||
@JsonProperty("episodes") @SerialName("episodes") val episodes: Int?,
|
||||
@JsonProperty("score") @SerialName("score") val score: Int?,
|
||||
@JsonProperty("type") @SerialName("type") val type: AniListStatusType?,
|
||||
@JsonProperty("title") val title: Title?,
|
||||
@JsonProperty("isFavourite") val isFavourite: Boolean?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
@JsonProperty("progress") val progress: Int?,
|
||||
@JsonProperty("episodes") val episodes: Int?,
|
||||
@JsonProperty("score") val score: Int?,
|
||||
@JsonProperty("type") val type: AniListStatusType?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetDataMediaListEntry(
|
||||
@JsonProperty("progress") @SerialName("progress") val progress: Int?,
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
@JsonProperty("score") @SerialName("score") val score: Int?,
|
||||
@JsonProperty("progress") val progress: Int?,
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("score") val score: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Nodes(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("mediaRecommendation") @SerialName("mediaRecommendation") val mediaRecommendation: MediaRecommendation?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
@JsonProperty("mediaRecommendation") val mediaRecommendation: MediaRecommendation?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetDataMedia(
|
||||
@JsonProperty("isFavourite") @SerialName("isFavourite") val isFavourite: Boolean?,
|
||||
@JsonProperty("episodes") @SerialName("episodes") val episodes: Int?,
|
||||
@JsonProperty("title") @SerialName("title") val title: Title?,
|
||||
@JsonProperty("mediaListEntry") @SerialName("mediaListEntry") val mediaListEntry: GetDataMediaListEntry?,
|
||||
@JsonProperty("isFavourite") val isFavourite: Boolean?,
|
||||
@JsonProperty("episodes") val episodes: Int?,
|
||||
@JsonProperty("title") val title: Title?,
|
||||
@JsonProperty("mediaListEntry") val mediaListEntry: GetDataMediaListEntry?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Recommendations(
|
||||
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<Nodes>?,
|
||||
@JsonProperty("nodes") val nodes: List<Nodes>?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetDataData(
|
||||
@JsonProperty("Media") @SerialName("Media") val media: GetDataMedia?,
|
||||
@JsonProperty("Media") val media: GetDataMedia?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetDataRoot(
|
||||
@JsonProperty("data") @SerialName("data") val data: GetDataData?,
|
||||
@JsonProperty("data") val data: GetDataData?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetSearchTitle(
|
||||
@JsonProperty("romaji") @SerialName("romaji") val romaji: String?,
|
||||
@JsonProperty("romaji") val romaji: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TrailerObject(
|
||||
@JsonProperty("id") @SerialName("id") val id: String?,
|
||||
@JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: String?,
|
||||
@JsonProperty("site") @SerialName("site") val site: String?,
|
||||
@JsonProperty("id") val id: String?,
|
||||
@JsonProperty("thumbnail") val thumbnail: String?,
|
||||
@JsonProperty("site") val site: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetSearchMedia(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
|
||||
@JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int,
|
||||
@JsonProperty("title") @SerialName("title") val title: GetSearchTitle,
|
||||
@JsonProperty("startDate") @SerialName("startDate") val startDate: StartedAt,
|
||||
@JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("meanScore") @SerialName("meanScore") val meanScore: Int?,
|
||||
@JsonProperty("bannerImage") @SerialName("bannerImage") val bannerImage: String?,
|
||||
@JsonProperty("trailer") @SerialName("trailer") val trailer: TrailerObject?,
|
||||
@JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("recommendations") @SerialName("recommendations") val recommendations: Recommendations?,
|
||||
@JsonProperty("relations") @SerialName("relations") val relations: SeasonEdges?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("seasonYear") val seasonYear: Int,
|
||||
@JsonProperty("title") val title: GetSearchTitle,
|
||||
@JsonProperty("startDate") val startDate: StartedAt,
|
||||
@JsonProperty("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("meanScore") val meanScore: Int?,
|
||||
@JsonProperty("bannerImage") val bannerImage: String?,
|
||||
@JsonProperty("trailer") val trailer: TrailerObject?,
|
||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("recommendations") val recommendations: Recommendations?,
|
||||
@JsonProperty("relations") val relations: SeasonEdges?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetSearchPage(
|
||||
@JsonProperty("Page") @SerialName("Page") val page: GetSearchData?,
|
||||
@JsonProperty("Page") val page: GetSearchData?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetSearchData(
|
||||
@JsonProperty("media") @SerialName("media") val media: List<GetSearchMedia>?,
|
||||
@JsonProperty("media") val media: List<GetSearchMedia>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetSearchRoot(
|
||||
@JsonProperty("data") @SerialName("data") val data: GetSearchPage?,
|
||||
@JsonProperty("data") val data: GetSearchPage?,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,658 +1,8 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
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.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 kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
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 = APIHolder.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 = APIHolder.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
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KitsuResponse(
|
||||
@JsonProperty("data") @SerialName("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 {
|
||||
return app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer ${token.accessToken}",
|
||||
),
|
||||
interceptor = apiFallbackInterceptor
|
||||
).parsed<KitsuResponse>()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ResponseToken(
|
||||
@JsonProperty("token_type") @SerialName("token_type") val tokenType: String,
|
||||
@JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int,
|
||||
@JsonProperty("access_token") @SerialName("access_token") val accessToken: String,
|
||||
@JsonProperty("refresh_token") @SerialName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuNode(
|
||||
@JsonProperty("id") @SerialName("id") val id: String,
|
||||
@JsonProperty("attributes") @SerialName("attributes") val attributes: KitsuNodeAttributes,
|
||||
/* User list anime node */
|
||||
@JsonProperty("relationships") @SerialName("relationships") val relationships: KitsuRelationships?,
|
||||
@JsonProperty("anime") @SerialName("anime") 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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KitsuAnimeAttributes(
|
||||
@JsonProperty("titles") @SerialName("titles") val titles: KitsuTitles?,
|
||||
@JsonProperty("canonicalTitle") @SerialName("canonicalTitle") val canonicalTitle: String?,
|
||||
@JsonProperty("posterImage") @SerialName("posterImage") val posterImage: KitsuPosterImage?,
|
||||
@JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?,
|
||||
@JsonProperty("startDate") @SerialName("startDate") val startDate: String?,
|
||||
@JsonProperty("endDate") @SerialName("endDate") val endDate: String?,
|
||||
@JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Int?,
|
||||
@JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAnimeData(
|
||||
@JsonProperty("id") @SerialName("id") val id: String,
|
||||
@JsonProperty("attributes") @SerialName("attributes") val attributes: KitsuAnimeAttributes,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuNodeAttributes(
|
||||
/* General attributes */
|
||||
@JsonProperty("titles") @SerialName("titles") val titles: KitsuTitles?,
|
||||
@JsonProperty("canonicalTitle") @SerialName("canonicalTitle") val canonicalTitle: String?,
|
||||
@JsonProperty("posterImage") @SerialName("posterImage") val posterImage: KitsuPosterImage?,
|
||||
@JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?,
|
||||
@JsonProperty("startDate") @SerialName("startDate") val startDate: String?,
|
||||
@JsonProperty("endDate") @SerialName("endDate") val endDate: String?,
|
||||
@JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Int?,
|
||||
@JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Int?,
|
||||
/* User attributes */
|
||||
@JsonProperty("name") @SerialName("name") val name: String?,
|
||||
@JsonProperty("location") @SerialName("location") val location: String?,
|
||||
@JsonProperty("createdAt") @SerialName("createdAt") val createdAt: String?,
|
||||
@JsonProperty("avatar") @SerialName("avatar") val avatar: KitsuUserAvatar?,
|
||||
/* User list anime attributes */
|
||||
@JsonProperty("progress") @SerialName("progress") val progress: Int?,
|
||||
@JsonProperty("ratingTwenty") @SerialName("ratingTwenty") val ratingTwenty: Int?,
|
||||
@JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: String?,
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuRelationships(
|
||||
@JsonProperty("anime") @SerialName("anime") val anime: KitsuRelationshipsAnime?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuRelationshipsAnime(
|
||||
@JsonProperty("links") @SerialName("links") val links: KitsuLinks?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuPosterImage(
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuTitles(
|
||||
@JsonProperty("en_jp") @SerialName("en_jp") val enJp: String?,
|
||||
@JsonProperty("ja_jp") @SerialName("ja_jp") val jaJp: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuUserAvatar(
|
||||
@JsonProperty("original") @SerialName("original") val original: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuLinks(
|
||||
/* Pagination */
|
||||
@JsonProperty("first") @SerialName("first") val first: String?,
|
||||
@JsonProperty("next") @SerialName("next") val next: String?,
|
||||
@JsonProperty("last") @SerialName("last") val last: String?,
|
||||
/* Relationships */
|
||||
@JsonProperty("related") @SerialName("related") val related: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuResponse(
|
||||
@JsonProperty("links") @SerialName("links") val links: KitsuLinks?,
|
||||
@JsonProperty("data") @SerialName("data") val data: List<KitsuNode>,
|
||||
/* When requesting related info (User library entry -> anime) */
|
||||
@JsonProperty("included") @SerialName("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 (_: 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
|
||||
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
||||
|
|
@ -670,7 +20,7 @@ object Kitsu {
|
|||
"https://kitsu.io/api/graphql",
|
||||
headers = headers,
|
||||
data = mapOf("query" to query)
|
||||
).parsed<KitsuResponse>()
|
||||
).parsed()
|
||||
}
|
||||
|
||||
private val cache: MutableMap<Pair<String, String>, Map<Int, KitsuResponse.Node>> =
|
||||
|
|
@ -752,52 +102,44 @@ query {
|
|||
return map
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KitsuResponse(
|
||||
@JsonProperty("data") @SerialName("data") val data: Data? = null,
|
||||
val data: Data? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Data(
|
||||
@JsonProperty("lookupMapping") @SerialName("lookupMapping") val lookupMapping: LookupMapping? = null,
|
||||
val lookupMapping: LookupMapping? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LookupMapping(
|
||||
@JsonProperty("id") @SerialName("id") val id: String? = null,
|
||||
@JsonProperty("episodes") @SerialName("episodes") val episodes: Episodes? = null,
|
||||
val id: String? = null,
|
||||
val episodes: Episodes? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Episodes(
|
||||
@JsonProperty("nodes") @SerialName("nodes") val nodes: List<Node?>? = null,
|
||||
val nodes: List<Node?>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Node(
|
||||
@JsonProperty("number") @SerialName("number") val num: Int? = null,
|
||||
@JsonProperty("titles") @SerialName("titles") val titles: Titles? = null,
|
||||
@JsonProperty("description") @SerialName("description") val description: Description? = null,
|
||||
@JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: Thumbnail? = null,
|
||||
@JsonProperty("number")
|
||||
val num: Int? = null,
|
||||
val titles: Titles? = null,
|
||||
val description: Description? = null,
|
||||
val thumbnail: Thumbnail? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Description(
|
||||
@JsonProperty("en") @SerialName("en") val en: String? = null,
|
||||
val en: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Thumbnail(
|
||||
@JsonProperty("original") @SerialName("original") val original: Original? = null,
|
||||
val original: Original? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Original(
|
||||
@JsonProperty("url") @SerialName("url") val url: String? = null,
|
||||
val url: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Titles(
|
||||
@JsonProperty("canonical") @SerialName("canonical") val canonical: String? = null,
|
||||
val canonical: String? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||
|
|
@ -14,7 +15,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
|||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
|
||||
class LocalList : SyncAPI() {
|
||||
override val name = "Local"
|
||||
|
|
@ -26,7 +26,7 @@ class LocalList : SyncAPI() {
|
|||
override var requireLibraryRefresh = true
|
||||
override val syncIdName = SyncIdName.LocalList
|
||||
|
||||
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
|
||||
override suspend fun library(token: AuthToken?): SyncAPI.LibraryMetadata? {
|
||||
val watchStatusIds = ioWork {
|
||||
getAllWatchStateIds()?.map { id ->
|
||||
Pair(id, getResultWatchState(id))
|
||||
|
|
@ -74,8 +74,8 @@ class LocalList : SyncAPI() {
|
|||
result
|
||||
}
|
||||
|
||||
return LibraryMetadata(
|
||||
list.map { LibraryList(txt(it.key), it.value) },
|
||||
return SyncAPI.LibraryMetadata(
|
||||
list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
||||
setOf(
|
||||
ListSorting.AlphabeticalA,
|
||||
ListSorting.AlphabeticalZ,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.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.syncproviders.AuthData
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||
|
|
@ -21,9 +18,8 @@ import com.lagradost.cloudstream3.ui.SyncWatchType
|
|||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
|
@ -37,7 +33,7 @@ class MALApi : SyncAPI() {
|
|||
override var name = "MAL"
|
||||
override val idPrefix = "mal"
|
||||
|
||||
private val key = BuildConfig.MAL_KEY
|
||||
val key = "1714d6f2f4f7cc19644384f8c4629910"
|
||||
private val apiUrl = "https://api.myanimelist.net"
|
||||
override val hasOAuth2 = true
|
||||
override val redirectUrlIdentifier: String? = "mallogin"
|
||||
|
|
@ -52,17 +48,16 @@ class MALApi : SyncAPI() {
|
|||
SyncWatchType.PLANTOWATCH,
|
||||
SyncWatchType.DROPPED,
|
||||
SyncWatchType.ONHOLD,
|
||||
SyncWatchType.NONE,
|
||||
SyncWatchType.NONE
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
@JsonProperty("requestId") @SerialName("requestId") val requestId: Int,
|
||||
@JsonProperty("codeVerifier") @SerialName("codeVerifier") val codeVerifier: String,
|
||||
data class PayLoad(
|
||||
val requestId: Int,
|
||||
val codeVerifier: String
|
||||
)
|
||||
|
||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
||||
val payloadData = parseJson<Payload>(payload!!)
|
||||
val payloadData = parseJson<PayLoad>(payload!!)
|
||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
||||
val state = sanitizer["state"]!!
|
||||
|
||||
|
|
@ -78,13 +73,13 @@ class MALApi : SyncAPI() {
|
|||
"client_id" to key,
|
||||
"code" to currentCode,
|
||||
"code_verifier" to payloadData.codeVerifier,
|
||||
"grant_type" to "authorization_code",
|
||||
"grant_type" to "authorization_code"
|
||||
)
|
||||
).parsed<ResponseToken>()
|
||||
return AuthToken(
|
||||
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
|
||||
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
|
||||
refreshToken = token.refreshToken,
|
||||
accessToken = token.accessToken,
|
||||
accessToken = token.accessToken
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -98,13 +93,13 @@ class MALApi : SyncAPI() {
|
|||
return AuthUser(
|
||||
id = user.id,
|
||||
name = user.name,
|
||||
profilePicture = user.picture,
|
||||
profilePicture = user.picture
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val auth = auth?.token?.accessToken ?: return null
|
||||
val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||
override suspend fun search(token: AuthToken?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val auth = token?.accessToken ?: return null
|
||||
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||
val res = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer $auth",
|
||||
|
|
@ -117,7 +112,7 @@ class MALApi : SyncAPI() {
|
|||
this.name,
|
||||
node.id.toString(),
|
||||
"$mainUrl/anime/${node.id}/",
|
||||
node.mainPicture?.large ?: node.mainPicture?.medium,
|
||||
node.mainPicture?.large ?: node.mainPicture?.medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -126,12 +121,12 @@ class MALApi : SyncAPI() {
|
|||
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||
|
||||
override suspend fun updateStatus(
|
||||
auth: AuthData?,
|
||||
token: AuthToken?,
|
||||
id: String,
|
||||
newStatus: SyncAPI.AbstractSyncStatus
|
||||
): Boolean {
|
||||
return setScoreRequest(
|
||||
auth?.token ?: return false,
|
||||
token ?: return false,
|
||||
id.toIntOrNull() ?: return false,
|
||||
fromIntToAnimeStatus(newStatus.status),
|
||||
newStatus.score?.toInt(10),
|
||||
|
|
@ -139,89 +134,82 @@ class MALApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MalAnime(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int?,
|
||||
@JsonProperty("title") @SerialName("title") val title: String?,
|
||||
@JsonProperty("main_picture") @SerialName("main_picture") val mainPicture: MainPicture?,
|
||||
@JsonProperty("alternative_titles") @SerialName("alternative_titles") val alternativeTitles: AlternativeTitles?,
|
||||
@JsonProperty("start_date") @SerialName("start_date") val startDate: String?,
|
||||
@JsonProperty("end_date") @SerialName("end_date") val endDate: String?,
|
||||
@JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?,
|
||||
@JsonProperty("mean") @SerialName("mean") val mean: Double?,
|
||||
@JsonProperty("rank") @SerialName("rank") val rank: Int?,
|
||||
@JsonProperty("popularity") @SerialName("popularity") val popularity: Int?,
|
||||
@JsonProperty("num_list_users") @SerialName("num_list_users") val numListUsers: Int?,
|
||||
@JsonProperty("num_scoring_users") @SerialName("num_scoring_users") val numScoringUsers: Int?,
|
||||
@JsonProperty("nsfw") @SerialName("nsfw") val nsfw: String?,
|
||||
@JsonProperty("created_at") @SerialName("created_at") val createdAt: String?,
|
||||
@JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String?,
|
||||
@JsonProperty("media_type") @SerialName("media_type") val mediaType: String?,
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
@JsonProperty("genres") @SerialName("genres") val genres: ArrayList<Genres>?,
|
||||
@JsonProperty("my_list_status") @SerialName("my_list_status") val myListStatus: MyListStatus?,
|
||||
@JsonProperty("num_episodes") @SerialName("num_episodes") val numEpisodes: Int?,
|
||||
@JsonProperty("start_season") @SerialName("start_season") val startSeason: StartSeason?,
|
||||
@JsonProperty("broadcast") @SerialName("broadcast") val broadcast: Broadcast?,
|
||||
@JsonProperty("source") @SerialName("source") val source: String?,
|
||||
@JsonProperty("average_episode_duration") @SerialName("average_episode_duration") val averageEpisodeDuration: Int?,
|
||||
@JsonProperty("rating") @SerialName("rating") val rating: String?,
|
||||
@JsonProperty("pictures") @SerialName("pictures") val pictures: ArrayList<MainPicture>?,
|
||||
@JsonProperty("background") @SerialName("background") val background: String?,
|
||||
@JsonProperty("related_anime") @SerialName("related_anime") val relatedAnime: ArrayList<RelatedAnime>?,
|
||||
@JsonProperty("related_manga") @SerialName("related_manga") val relatedManga: ArrayList<String>?,
|
||||
@JsonProperty("recommendations") @SerialName("recommendations") val recommendations: ArrayList<Recommendations>?,
|
||||
@JsonProperty("studios") @SerialName("studios") val studios: ArrayList<Studios>?,
|
||||
@JsonProperty("statistics") @SerialName("statistics") val statistics: Statistics?,
|
||||
@JsonProperty("id") val id: Int?,
|
||||
@JsonProperty("title") val title: String?,
|
||||
@JsonProperty("main_picture") val mainPicture: MainPicture?,
|
||||
@JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
|
||||
@JsonProperty("start_date") val startDate: String?,
|
||||
@JsonProperty("end_date") val endDate: String?,
|
||||
@JsonProperty("synopsis") val synopsis: String?,
|
||||
@JsonProperty("mean") val mean: Double?,
|
||||
@JsonProperty("rank") val rank: Int?,
|
||||
@JsonProperty("popularity") val popularity: Int?,
|
||||
@JsonProperty("num_list_users") val numListUsers: Int?,
|
||||
@JsonProperty("num_scoring_users") val numScoringUsers: Int?,
|
||||
@JsonProperty("nsfw") val nsfw: String?,
|
||||
@JsonProperty("created_at") val createdAt: String?,
|
||||
@JsonProperty("updated_at") val updatedAt: String?,
|
||||
@JsonProperty("media_type") val mediaType: String?,
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("genres") val genres: ArrayList<Genres>?,
|
||||
@JsonProperty("my_list_status") val myListStatus: MyListStatus?,
|
||||
@JsonProperty("num_episodes") val numEpisodes: Int?,
|
||||
@JsonProperty("start_season") val startSeason: StartSeason?,
|
||||
@JsonProperty("broadcast") val broadcast: Broadcast?,
|
||||
@JsonProperty("source") val source: String?,
|
||||
@JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
|
||||
@JsonProperty("rating") val rating: String?,
|
||||
@JsonProperty("pictures") val pictures: ArrayList<MainPicture>?,
|
||||
@JsonProperty("background") val background: String?,
|
||||
@JsonProperty("related_anime") val relatedAnime: ArrayList<RelatedAnime>?,
|
||||
@JsonProperty("related_manga") val relatedManga: ArrayList<String>?,
|
||||
@JsonProperty("recommendations") val recommendations: ArrayList<Recommendations>?,
|
||||
@JsonProperty("studios") val studios: ArrayList<Studios>?,
|
||||
@JsonProperty("statistics") val statistics: Statistics?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Recommendations(
|
||||
@JsonProperty("node") @SerialName("node") val node: Node? = null,
|
||||
@JsonProperty("num_recommendations") @SerialName("num_recommendations") val numRecommendations: Int? = null,
|
||||
@JsonProperty("node") val node: Node? = null,
|
||||
@JsonProperty("num_recommendations") val numRecommendations: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Studios(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int? = null,
|
||||
@JsonProperty("name") @SerialName("name") val name: String? = null,
|
||||
@JsonProperty("id") val id: Int? = null,
|
||||
@JsonProperty("name") val name: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyListStatus(
|
||||
@JsonProperty("status") @SerialName("status") val status: String? = null,
|
||||
@JsonProperty("score") @SerialName("score") val score: Int? = null,
|
||||
@JsonProperty("num_episodes_watched") @SerialName("num_episodes_watched") val numEpisodesWatched: Int? = null,
|
||||
@JsonProperty("is_rewatching") @SerialName("is_rewatching") val isRewatching: Boolean? = null,
|
||||
@JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String? = null,
|
||||
@JsonProperty("status") val status: String? = null,
|
||||
@JsonProperty("score") val score: Int? = null,
|
||||
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int? = null,
|
||||
@JsonProperty("is_rewatching") val isRewatching: Boolean? = null,
|
||||
@JsonProperty("updated_at") val updatedAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RelatedAnime(
|
||||
@JsonProperty("node") @SerialName("node") val node: Node? = null,
|
||||
@JsonProperty("relation_type") @SerialName("relation_type") val relationType: String? = null,
|
||||
@JsonProperty("relation_type_formatted") @SerialName("relation_type_formatted") val relationTypeFormatted: String? = null,
|
||||
@JsonProperty("node") val node: Node? = null,
|
||||
@JsonProperty("relation_type") val relationType: String? = null,
|
||||
@JsonProperty("relation_type_formatted") val relationTypeFormatted: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
@JsonProperty("watching") @SerialName("watching") val watching: String? = null,
|
||||
@JsonProperty("completed") @SerialName("completed") val completed: String? = null,
|
||||
@JsonProperty("on_hold") @SerialName("on_hold") val onHold: String? = null,
|
||||
@JsonProperty("dropped") @SerialName("dropped") val dropped: String? = null,
|
||||
@JsonProperty("plan_to_watch") @SerialName("plan_to_watch") val planToWatch: String? = null,
|
||||
@JsonProperty("watching") val watching: String? = null,
|
||||
@JsonProperty("completed") val completed: String? = null,
|
||||
@JsonProperty("on_hold") val onHold: String? = null,
|
||||
@JsonProperty("dropped") val dropped: String? = null,
|
||||
@JsonProperty("plan_to_watch") val planToWatch: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Statistics(
|
||||
@JsonProperty("status") @SerialName("status") val status: Status? = null,
|
||||
@JsonProperty("num_list_users") @SerialName("num_list_users") val numListUsers: Int? = null,
|
||||
@JsonProperty("status") val status: Status? = null,
|
||||
@JsonProperty("num_list_users") val numListUsers: Int? = null
|
||||
)
|
||||
|
||||
private fun parseDate(string: String?): Long? {
|
||||
return try {
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -232,12 +220,12 @@ class MALApi : SyncAPI() {
|
|||
apiName = this.name,
|
||||
syncId = node.id.toString(),
|
||||
url = "$mainUrl/anime/${node.id}",
|
||||
posterUrl = node.mainPicture?.large,
|
||||
posterUrl = node.mainPicture?.large
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
||||
val auth = auth?.token?.accessToken ?: return null
|
||||
override suspend fun load(token: AuthToken?, id: String): SyncAPI.SyncResult? {
|
||||
val auth = token?.accessToken ?: return null
|
||||
val internalId = id.toIntOrNull() ?: return null
|
||||
val url =
|
||||
"$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics"
|
||||
|
|
@ -282,8 +270,8 @@ class MALApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
val auth = auth?.token?.accessToken ?: return null
|
||||
override suspend fun status(token: AuthToken?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
val auth = token?.accessToken ?: return null
|
||||
|
||||
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
|
||||
val url =
|
||||
|
|
@ -345,7 +333,7 @@ class MALApi : SyncAPI() {
|
|||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse(
|
||||
string ?: return null
|
||||
)?.time?.div(1000)
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -355,10 +343,12 @@ class MALApi : SyncAPI() {
|
|||
val codeVerifier = generateCodeVerifier()
|
||||
val requestId = ++requestIdCounter
|
||||
val codeChallenge = codeVerifier
|
||||
val request = "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
|
||||
val request =
|
||||
"$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
|
||||
|
||||
return AuthLoginPage(
|
||||
url = request,
|
||||
payload = Payload(requestId, codeVerifier).toJson(),
|
||||
payload = PayLoad(requestId, codeVerifier).toJson()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -368,71 +358,69 @@ class MALApi : SyncAPI() {
|
|||
data = mapOf(
|
||||
"client_id" to key,
|
||||
"grant_type" to "refresh_token",
|
||||
"refresh_token" to token.refreshToken!!,
|
||||
"refresh_token" to token.refreshToken!!
|
||||
)
|
||||
).parsed<ResponseToken>()
|
||||
|
||||
return AuthToken(
|
||||
accessToken = res.accessToken,
|
||||
refreshToken = res.refreshToken,
|
||||
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong(),
|
||||
accessTokenLifetime = unixTime + res.expiresIn.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
private var requestIdCounter = 0
|
||||
|
||||
|
||||
private val allTitles = hashMapOf<Int, MalTitleHolder>()
|
||||
|
||||
@Serializable
|
||||
data class MalList(
|
||||
@JsonProperty("data") @SerialName("data") val data: List<Data>,
|
||||
@JsonProperty("paging") @SerialName("paging") val paging: Paging,
|
||||
@JsonProperty("data") val data: List<Data>,
|
||||
@JsonProperty("paging") val paging: Paging
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MainPicture(
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String,
|
||||
@JsonProperty("large") @SerialName("large") val large: String,
|
||||
@JsonProperty("medium") val medium: String,
|
||||
@JsonProperty("large") val large: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Node(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("title") @SerialName("title") val title: String,
|
||||
@JsonProperty("main_picture") @SerialName("main_picture") val mainPicture: MainPicture?,
|
||||
@JsonProperty("alternative_titles") @SerialName("alternative_titles") val alternativeTitles: AlternativeTitles?,
|
||||
@JsonProperty("media_type") @SerialName("media_type") val mediaType: String?,
|
||||
@JsonProperty("num_episodes") @SerialName("num_episodes") val numEpisodes: Int?,
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
@JsonProperty("start_date") @SerialName("start_date") val startDate: String?,
|
||||
@JsonProperty("end_date") @SerialName("end_date") val endDate: String?,
|
||||
@JsonProperty("average_episode_duration") @SerialName("average_episode_duration") val averageEpisodeDuration: Int?,
|
||||
@JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?,
|
||||
@JsonProperty("mean") @SerialName("mean") val mean: Double?,
|
||||
@JsonProperty("genres") @SerialName("genres") val genres: List<Genres>?,
|
||||
@JsonProperty("rank") @SerialName("rank") val rank: Int?,
|
||||
@JsonProperty("popularity") @SerialName("popularity") val popularity: Int?,
|
||||
@JsonProperty("num_list_users") @SerialName("num_list_users") val numListUsers: Int?,
|
||||
@JsonProperty("num_favorites") @SerialName("num_favorites") val numFavorites: Int?,
|
||||
@JsonProperty("num_scoring_users") @SerialName("num_scoring_users") val numScoringUsers: Int?,
|
||||
@JsonProperty("start_season") @SerialName("start_season") val startSeason: StartSeason?,
|
||||
@JsonProperty("broadcast") @SerialName("broadcast") val broadcast: Broadcast?,
|
||||
@JsonProperty("nsfw") @SerialName("nsfw") val nsfw: String?,
|
||||
@JsonProperty("created_at") @SerialName("created_at") val createdAt: String?,
|
||||
@JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("main_picture") val mainPicture: MainPicture?,
|
||||
@JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
|
||||
@JsonProperty("media_type") val mediaType: String?,
|
||||
@JsonProperty("num_episodes") val numEpisodes: Int?,
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("start_date") val startDate: String?,
|
||||
@JsonProperty("end_date") val endDate: String?,
|
||||
@JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
|
||||
@JsonProperty("synopsis") val synopsis: String?,
|
||||
@JsonProperty("mean") val mean: Double?,
|
||||
@JsonProperty("genres") val genres: List<Genres>?,
|
||||
@JsonProperty("rank") val rank: Int?,
|
||||
@JsonProperty("popularity") val popularity: Int?,
|
||||
@JsonProperty("num_list_users") val numListUsers: Int?,
|
||||
@JsonProperty("num_favorites") val numFavorites: Int?,
|
||||
@JsonProperty("num_scoring_users") val numScoringUsers: Int?,
|
||||
@JsonProperty("start_season") val startSeason: StartSeason?,
|
||||
@JsonProperty("broadcast") val broadcast: Broadcast?,
|
||||
@JsonProperty("nsfw") val nsfw: String?,
|
||||
@JsonProperty("created_at") val createdAt: String?,
|
||||
@JsonProperty("updated_at") val updatedAt: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ListStatus(
|
||||
@JsonProperty("status") @SerialName("status") val status: String?,
|
||||
@JsonProperty("score") @SerialName("score") val score: Int,
|
||||
@JsonProperty("num_episodes_watched") @SerialName("num_episodes_watched") val numEpisodesWatched: Int,
|
||||
@JsonProperty("is_rewatching") @SerialName("is_rewatching") val isRewatching: Boolean,
|
||||
@JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String,
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("score") val score: Int,
|
||||
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
|
||||
@JsonProperty("is_rewatching") val isRewatching: Boolean,
|
||||
@JsonProperty("updated_at") val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Data(
|
||||
@JsonProperty("node") @SerialName("node") val node: Node,
|
||||
@JsonProperty("list_status") @SerialName("list_status") val listStatus: ListStatus?,
|
||||
@JsonProperty("node") val node: Node,
|
||||
@JsonProperty("list_status") val listStatus: ListStatus?,
|
||||
) {
|
||||
fun toLibraryItem(): SyncAPI.LibraryItem {
|
||||
return SyncAPI.LibraryItem(
|
||||
|
|
@ -463,38 +451,33 @@ class MALApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Paging(
|
||||
@JsonProperty("next") @SerialName("next") val next: String?,
|
||||
@JsonProperty("next") val next: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlternativeTitles(
|
||||
@JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List<String>,
|
||||
@JsonProperty("en") @SerialName("en") val en: String,
|
||||
@JsonProperty("ja") @SerialName("ja") val ja: String,
|
||||
@JsonProperty("synonyms") val synonyms: List<String>,
|
||||
@JsonProperty("en") val en: String,
|
||||
@JsonProperty("ja") val ja: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Genres(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("name") val name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StartSeason(
|
||||
@JsonProperty("year") @SerialName("year") val year: Int,
|
||||
@JsonProperty("season") @SerialName("season") val season: String,
|
||||
@JsonProperty("year") val year: Int,
|
||||
@JsonProperty("season") val season: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Broadcast(
|
||||
@JsonProperty("day_of_the_week") @SerialName("day_of_the_week") val dayOfTheWeek: String?,
|
||||
@JsonProperty("start_time") @SerialName("start_time") val startTime: String?,
|
||||
@JsonProperty("day_of_the_week") val dayOfTheWeek: String?,
|
||||
@JsonProperty("start_time") val startTime: String?
|
||||
)
|
||||
|
||||
override suspend fun library(auth: AuthData?): LibraryMetadata? {
|
||||
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
||||
override suspend fun library(token: AuthToken?): LibraryMetadata? {
|
||||
val list = getMalAnimeListSmart(token ?: return null)?.groupBy {
|
||||
convertToStatus(it.listStatus?.status ?: "").stringRes
|
||||
}?.mapValues { group ->
|
||||
group.value.map { it.toLibraryItem() }
|
||||
|
|
@ -521,13 +504,13 @@ class MALApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
|
||||
private suspend fun getMalAnimeListSmart(token: AuthToken): Array<Data>? {
|
||||
return if (requireLibraryRefresh) {
|
||||
val list = getMalAnimeList(auth.token)
|
||||
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
|
||||
val list = getMalAnimeList(token)
|
||||
setKey(MAL_CACHED_LIST, token.accessToken ?: "", list)
|
||||
list
|
||||
} else {
|
||||
getKey<Array<Data>>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array<Data>
|
||||
getKey<Array<Data>>(MAL_CACHED_LIST, token.accessToken ?: "") as? Array<Data>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -536,7 +519,7 @@ class MALApi : SyncAPI() {
|
|||
val fullList = mutableListOf<Data>()
|
||||
val offsetRegex = Regex("""offset=(\d+)""")
|
||||
while (true) {
|
||||
val data: MalList = getMalAnimeListSlice(token, offset)
|
||||
val data: MalList = getMalAnimeListSlice(token, offset) ?: break
|
||||
fullList.addAll(data.data)
|
||||
offset =
|
||||
data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() }
|
||||
|
|
@ -545,17 +528,18 @@ class MALApi : SyncAPI() {
|
|||
return fullList.toTypedArray()
|
||||
}
|
||||
|
||||
private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList {
|
||||
private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? {
|
||||
val user = "@me"
|
||||
// Very lackluster docs
|
||||
// https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get
|
||||
val url = "$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset"
|
||||
val url =
|
||||
"$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset"
|
||||
val res = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer ${token.accessToken}",
|
||||
), cacheTime = 0
|
||||
).text
|
||||
return parseJson<MalList>(res)
|
||||
return res.toKotlinObject()
|
||||
}
|
||||
|
||||
private suspend fun setScoreRequest(
|
||||
|
|
@ -598,7 +582,7 @@ class MALApi : SyncAPI() {
|
|||
val data = mapOf(
|
||||
"status" to status,
|
||||
"score" to score?.toString(),
|
||||
"num_watched_episodes" to numWatchedEpisodes?.toString(),
|
||||
"num_watched_episodes" to numWatchedEpisodes?.toString()
|
||||
).filterValues { it != null } as Map<String, String>
|
||||
|
||||
return app.put(
|
||||
|
|
@ -610,73 +594,70 @@ class MALApi : SyncAPI() {
|
|||
).text
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
data class ResponseToken(
|
||||
@JsonProperty("token_type") @SerialName("token_type") val tokenType: String,
|
||||
@JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int,
|
||||
@JsonProperty("access_token") @SerialName("access_token") val accessToken: String,
|
||||
@JsonProperty("refresh_token") @SerialName("refresh_token") val refreshToken: String,
|
||||
@JsonProperty("token_type") val tokenType: String,
|
||||
@JsonProperty("expires_in") val expiresIn: Int,
|
||||
@JsonProperty("access_token") val accessToken: String,
|
||||
@JsonProperty("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalRoot(
|
||||
@JsonProperty("data") @SerialName("data") val data: List<MalDatum>,
|
||||
@JsonProperty("data") val data: List<MalDatum>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalDatum(
|
||||
@JsonProperty("node") @SerialName("node") val node: MalNode,
|
||||
@JsonProperty("list_status") @SerialName("list_status") val listStatus: MalStatus,
|
||||
@JsonProperty("node") val node: MalNode,
|
||||
@JsonProperty("list_status") val listStatus: MalStatus,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalNode(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("title") @SerialName("title") val title: String,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("title") val title: String,
|
||||
/*
|
||||
also, but not used
|
||||
main_picture ->
|
||||
public string medium;
|
||||
public string large;
|
||||
*/
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalStatus(
|
||||
@JsonProperty("status") @SerialName("status") val status: String,
|
||||
@JsonProperty("score") @SerialName("score") val score: Int,
|
||||
@JsonProperty("num_episodes_watched") @SerialName("num_episodes_watched") val numEpisodesWatched: Int,
|
||||
@JsonProperty("is_rewatching") @SerialName("is_rewatching") val isRewatching: Boolean,
|
||||
@JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String,
|
||||
@JsonProperty("status") val status: String,
|
||||
@JsonProperty("score") val score: Int,
|
||||
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
|
||||
@JsonProperty("is_rewatching") val isRewatching: Boolean,
|
||||
@JsonProperty("updated_at") val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalUser(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("location") @SerialName("location") val location: String,
|
||||
@JsonProperty("joined_at") @SerialName("joined_at") val joinedAt: String,
|
||||
@JsonProperty("picture") @SerialName("picture") val picture: String?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("location") val location: String,
|
||||
@JsonProperty("joined_at") val joinedAt: String,
|
||||
@JsonProperty("picture") val picture: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalMainPicture(
|
||||
@JsonProperty("large") @SerialName("large") val large: String?,
|
||||
@JsonProperty("medium") @SerialName("medium") val medium: String?,
|
||||
@JsonProperty("large") val large: String?,
|
||||
@JsonProperty("medium") val medium: String?,
|
||||
)
|
||||
|
||||
// Used for getDataAboutId()
|
||||
@Serializable
|
||||
data class SmallMalAnime(
|
||||
@JsonProperty("id") @SerialName("id") val id: Int,
|
||||
@JsonProperty("title") @SerialName("title") val title: String?,
|
||||
@JsonProperty("num_episodes") @SerialName("num_episodes") val numEpisodes: Int,
|
||||
@JsonProperty("my_list_status") @SerialName("my_list_status") val myListStatus: MalStatus?,
|
||||
@JsonProperty("main_picture") @SerialName("main_picture") val mainPicture: MalMainPicture?,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("title") val title: String?,
|
||||
@JsonProperty("num_episodes") val numEpisodes: Int,
|
||||
@JsonProperty("my_list_status") val myListStatus: MalStatus?,
|
||||
@JsonProperty("main_picture") val mainPicture: MalMainPicture?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalSearchNode(
|
||||
@JsonProperty("node") @SerialName("node") val node: Node,
|
||||
@JsonProperty("node") val node: Node,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalSearch(
|
||||
@JsonProperty("data") @SerialName("data") val data: List<MalSearchNode>,
|
||||
@JsonProperty("data") val data: List<MalSearchNode>,
|
||||
//paging
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,26 +2,19 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
|||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
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.SubtitleAPI
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class OpenSubtitlesApi : SubtitleAPI() {
|
||||
override val name = "OpenSubtitles"
|
||||
|
|
@ -47,17 +40,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
}
|
||||
|
||||
private fun canDoRequest(): Boolean {
|
||||
return unixTimeMS > currentCoolDown
|
||||
return unixTimeMs > currentCoolDown
|
||||
}
|
||||
|
||||
private fun throwIfCantDoRequest() {
|
||||
if (!canDoRequest()) {
|
||||
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s")
|
||||
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s")
|
||||
}
|
||||
}
|
||||
|
||||
private fun throwGotTooManyRequests() {
|
||||
currentCoolDown = unixTimeMS + COOLDOWN_DURATION
|
||||
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
|
||||
throw ErrorLoadingException("Too many requests")
|
||||
}
|
||||
|
||||
|
|
@ -93,21 +86,39 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
accessToken = response.token
|
||||
?: throw ErrorLoadingException("Invalid password or username"),
|
||||
/// JWT token is valid 24 hours after successfully authentication of user
|
||||
accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24,
|
||||
accessTokenLifetime = unixTime + 60 * 60 * 24,
|
||||
payload = form.toJson()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Some languages do not use the normal country codes on OpenSubtitles
|
||||
* */
|
||||
private val languageExceptions = mapOf<String, String>(
|
||||
// "pt" to "pt-PT",
|
||||
// "pt" to "pt-BR"
|
||||
)
|
||||
|
||||
private fun fixLanguage(language: String?): String? {
|
||||
return languageExceptions[language] ?: language
|
||||
}
|
||||
|
||||
// O(n) but good enough, BiMap did not want to work properly
|
||||
private fun fixLanguageReverse(language: String?): String? {
|
||||
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch subtitles using token authenticated on previous method (see authorize).
|
||||
* Returns list of Subtitles which user can select to download (see load).
|
||||
*/
|
||||
* */
|
||||
override suspend fun search(
|
||||
auth : AuthData?,
|
||||
token: AuthToken?,
|
||||
query: AbstractSubtitleEntities.SubtitleSearch
|
||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||
throwIfCantDoRequest()
|
||||
val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: ""
|
||||
val fixedLang = fixLanguage(query.lang)
|
||||
|
||||
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
|
||||
val queryText = query.query
|
||||
|
|
@ -120,8 +131,8 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
|
||||
val searchQueryUrl = when (imdbId > 0) {
|
||||
//Use imdb_id to search if its valid
|
||||
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
|
||||
false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
|
||||
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||
}
|
||||
|
||||
val req = app.get(
|
||||
|
|
@ -130,7 +141,6 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
Pair("Content-Type", "application/json")
|
||||
) + headers,
|
||||
)
|
||||
Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
|
||||
Log.i(TAG, "Search Req => ${req.text}")
|
||||
if (!req.isSuccessful) {
|
||||
if (req.code == 429)
|
||||
|
|
@ -151,7 +161,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
//Use any valid name/title in hierarchy
|
||||
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
||||
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
||||
val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: ""
|
||||
val lang = fixLanguageReverse(attr.language) ?: ""
|
||||
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
||||
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
||||
val year = featureDetails?.year ?: query.year
|
||||
|
|
@ -165,7 +175,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
AbstractSubtitleEntities.SubtitleEntity(
|
||||
idPrefix = this.idPrefix,
|
||||
name = name,
|
||||
lang = langTagIETF,
|
||||
lang = lang,
|
||||
data = resultData,
|
||||
type = type,
|
||||
source = this.name,
|
||||
|
|
@ -181,15 +191,15 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Process data returned from search.
|
||||
* Returns string url for the subtitle file.
|
||||
/*
|
||||
Process data returned from search.
|
||||
Returns string url for the subtitle file.
|
||||
*/
|
||||
|
||||
override suspend fun load(
|
||||
auth : AuthData?,
|
||||
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
||||
token: AuthToken?,
|
||||
data: AbstractSubtitleEntities.SubtitleEntity
|
||||
): String? {
|
||||
if (auth == null) return null
|
||||
throwIfCantDoRequest()
|
||||
|
||||
val req = app.post(
|
||||
|
|
@ -197,13 +207,13 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
headers = mapOf(
|
||||
Pair(
|
||||
"Authorization",
|
||||
"Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
|
||||
"Bearer ${token?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
|
||||
),
|
||||
Pair("Content-Type", "application/json"),
|
||||
Pair("Accept", "*/*")
|
||||
) + headers,
|
||||
data = mapOf(
|
||||
Pair("file_id", subtitle.data)
|
||||
Pair("file_id", data.data)
|
||||
)
|
||||
)
|
||||
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
|
||||
|
|
@ -221,64 +231,57 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
|||
return null
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class OAuthToken(
|
||||
@JsonProperty("token") @SerialName("token") var token: String? = null,
|
||||
@JsonProperty("status") @SerialName("status") var status: Int? = null,
|
||||
@JsonProperty("token") var token: String? = null,
|
||||
@JsonProperty("status") var status: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Results(
|
||||
@JsonProperty("data") @SerialName("data") var data: List<ResultData>? = listOf(),
|
||||
@JsonProperty("data") var data: List<ResultData>? = listOf()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResultData(
|
||||
@JsonProperty("id") @SerialName("id") var id: String? = null,
|
||||
@JsonProperty("type") @SerialName("type") var type: String? = null,
|
||||
@JsonProperty("attributes") @SerialName("attributes") var attributes: ResultAttributes? = ResultAttributes(),
|
||||
@JsonProperty("id") var id: String? = null,
|
||||
@JsonProperty("type") var type: String? = null,
|
||||
@JsonProperty("attributes") var attributes: ResultAttributes? = ResultAttributes()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResultAttributes(
|
||||
@JsonProperty("subtitle_id") @SerialName("subtitle_id") var subtitleId: String? = null,
|
||||
@JsonProperty("language") @SerialName("language") var language: String? = null,
|
||||
@JsonProperty("release") @SerialName("release") var release: String? = null,
|
||||
@JsonProperty("url") @SerialName("url") var url: String? = null,
|
||||
@JsonProperty("files") @SerialName("files") var files: List<ResultFiles>? = listOf(),
|
||||
@JsonProperty("feature_details") @SerialName("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
|
||||
@JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Boolean? = null,
|
||||
@JsonProperty("subtitle_id") var subtitleId: String? = null,
|
||||
@JsonProperty("language") var language: String? = null,
|
||||
@JsonProperty("release") var release: String? = null,
|
||||
@JsonProperty("url") var url: String? = null,
|
||||
@JsonProperty("files") var files: List<ResultFiles>? = listOf(),
|
||||
@JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
|
||||
@JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResultFiles(
|
||||
@JsonProperty("file_id") @SerialName("file_id") var fileId: Int? = null,
|
||||
@JsonProperty("file_name") @SerialName("file_name") var fileName: String? = null,
|
||||
@JsonProperty("file_id") var fileId: Int? = null,
|
||||
@JsonProperty("file_name") var fileName: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResultDownloadLink(
|
||||
@JsonProperty("link") @SerialName("link") var link: String? = null,
|
||||
@JsonProperty("file_name") @SerialName("file_name") var fileName: String? = null,
|
||||
@JsonProperty("requests") @SerialName("requests") var requests: Int? = null,
|
||||
@JsonProperty("remaining") @SerialName("remaining") var remaining: Int? = null,
|
||||
@JsonProperty("message") @SerialName("message") var message: String? = null,
|
||||
@JsonProperty("reset_time") @SerialName("reset_time") var resetTime: String? = null,
|
||||
@JsonProperty("reset_time_utc") @SerialName("reset_time_utc") var resetTimeUtc: String? = null,
|
||||
@JsonProperty("link") var link: String? = null,
|
||||
@JsonProperty("file_name") var fileName: String? = null,
|
||||
@JsonProperty("requests") var requests: Int? = null,
|
||||
@JsonProperty("remaining") var remaining: Int? = null,
|
||||
@JsonProperty("message") var message: String? = null,
|
||||
@JsonProperty("reset_time") var resetTime: String? = null,
|
||||
@JsonProperty("reset_time_utc") var resetTimeUtc: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResultFeatureDetails(
|
||||
@JsonProperty("year") @SerialName("year") var year: Int? = null,
|
||||
@JsonProperty("title") @SerialName("title") var title: String? = null,
|
||||
@JsonProperty("movie_name") @SerialName("movie_name") var movieName: String? = null,
|
||||
@JsonProperty("imdb_id") @SerialName("imdb_id") var imdbId: Int? = null,
|
||||
@JsonProperty("tmdb_id") @SerialName("tmdb_id") var tmdbId: Int? = null,
|
||||
@JsonProperty("season_number") @SerialName("season_number") var seasonNumber: Int? = null,
|
||||
@JsonProperty("episode_number") @SerialName("episode_number") var episodeNumber: Int? = null,
|
||||
@JsonProperty("parent_imdb_id") @SerialName("parent_imdb_id") var parentImdbId: Int? = null,
|
||||
@JsonProperty("parent_title") @SerialName("parent_title") var parentTitle: String? = null,
|
||||
@JsonProperty("parent_tmdb_id") @SerialName("parent_tmdb_id") var parentTmdbId: Int? = null,
|
||||
@JsonProperty("parent_feature_id") @SerialName("parent_feature_id") var parentFeatureId: Int? = null,
|
||||
@JsonProperty("year") var year: Int? = null,
|
||||
@JsonProperty("title") var title: String? = null,
|
||||
@JsonProperty("movie_name") var movieName: String? = null,
|
||||
@JsonProperty("imdb_id") var imdbId: Int? = null,
|
||||
@JsonProperty("tmdb_id") var tmdbId: Int? = null,
|
||||
@JsonProperty("season_number") var seasonNumber: Int? = null,
|
||||
@JsonProperty("episode_number") var episodeNumber: Int? = null,
|
||||
@JsonProperty("parent_imdb_id") var parentImdbId: Int? = null,
|
||||
@JsonProperty("parent_title") var parentTitle: String? = null,
|
||||
@JsonProperty("parent_tmdb_id") var parentTmdbId: Int? = null,
|
||||
@JsonProperty("parent_feature_id") var parentFeatureId: Int? = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,12 +5,11 @@ import com.lagradost.cloudstream3.TvType
|
|||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SubSourceApi : SubtitleAPI() {
|
||||
override val name = "SubSource"
|
||||
|
|
@ -19,186 +18,150 @@ class SubSourceApi : SubtitleAPI() {
|
|||
override val requiresLogin = false
|
||||
|
||||
companion object {
|
||||
const val APIURL = "https://api.subsource.net/v1"
|
||||
const val APIURL = "https://api.subsource.net/api"
|
||||
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
|
||||
}
|
||||
|
||||
override suspend fun search(
|
||||
auth: AuthData?,
|
||||
token: AuthToken?,
|
||||
query: AbstractSubtitleEntities.SubtitleSearch
|
||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||
|
||||
//Only supports Imdb Id search for now
|
||||
if (query.imdbId == null) return null
|
||||
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang)
|
||||
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
|
||||
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||
|
||||
val searchResponse = app.post(
|
||||
url = "$APIURL/movie/search",
|
||||
json = mapOf(
|
||||
"includeSeasons" to false,
|
||||
"limit" to 15,
|
||||
"query" to query.imdbId!!,
|
||||
"signal" to "{}"
|
||||
),
|
||||
cacheTime = 120,
|
||||
cacheUnit = TimeUnit.MINUTES,
|
||||
).parsedSafe<SearchRoot>() ?: return null
|
||||
val searchRes = app.post(
|
||||
url = "$APIURL/searchMovie",
|
||||
data = mapOf(
|
||||
"query" to query.imdbId!!
|
||||
)
|
||||
).parsedSafe<ApiSearch>() ?: return null
|
||||
|
||||
val firstResult = searchResponse.results.firstOrNull() ?: return null
|
||||
|
||||
val apiResponse = app.get(
|
||||
url = "$APIURL${firstResult.link.replace("series", "subtitles")}",
|
||||
cacheTime = 120,
|
||||
cacheUnit = TimeUnit.MINUTES,
|
||||
).parsedSafe<ItemRoot>() ?: return null
|
||||
|
||||
val filteredSubtitles = apiResponse.subtitles.filter { sub ->
|
||||
sub.releaseType != "trailer" &&
|
||||
sub.language.equals(queryLang, true)
|
||||
val postData = if (type == TvType.TvSeries) {
|
||||
mapOf(
|
||||
"langs" to "[]",
|
||||
"movieName" to searchRes.found.first().linkName,
|
||||
"season" to "season-${query.seasonNumber}"
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
"langs" to "[]",
|
||||
"movieName" to searchRes.found.first().linkName,
|
||||
)
|
||||
}
|
||||
|
||||
val getMovieRes = app.post(
|
||||
url = "$APIURL/getMovie",
|
||||
data = postData
|
||||
).parsedSafe<ApiResponse>().let {
|
||||
// api doesn't has episode number or lang filtering
|
||||
val subtitles = if (type == TvType.Movie) {
|
||||
filteredSubtitles
|
||||
if (type == TvType.Movie) {
|
||||
it?.subs?.filter { sub ->
|
||||
sub.lang == queryLang
|
||||
}
|
||||
} else {
|
||||
val shouldContain = String.format(
|
||||
it?.subs?.filter { sub ->
|
||||
sub.releaseName!!.contains(
|
||||
String.format(
|
||||
null,
|
||||
"E%02d",
|
||||
query.epNumber
|
||||
)
|
||||
filteredSubtitles.filter { sub ->
|
||||
sub.releaseInfo.contains(
|
||||
shouldContain
|
||||
)
|
||||
) && sub.lang == queryLang
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return subtitles.map { subtitle ->
|
||||
return getMovieRes.map { subtitle ->
|
||||
AbstractSubtitleEntities.SubtitleEntity(
|
||||
idPrefix = this.idPrefix,
|
||||
name = subtitle.releaseInfo,
|
||||
lang = subtitle.language,
|
||||
data = subtitle.link,
|
||||
name = subtitle.releaseName!!,
|
||||
lang = subtitle.lang!!,
|
||||
data = SubData(
|
||||
movie = subtitle.linkName!!,
|
||||
lang = subtitle.lang,
|
||||
id = subtitle.subId.toString(),
|
||||
).toJson(),
|
||||
type = type,
|
||||
source = this.name,
|
||||
epNumber = query.epNumber,
|
||||
seasonNumber = query.seasonNumber,
|
||||
isHearingImpaired = subtitle.hearingImpaired == 1,
|
||||
isHearingImpaired = subtitle.hi == 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun SubtitleResource.getResources(
|
||||
auth: AuthData?,
|
||||
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
||||
token: AuthToken?,
|
||||
data: AbstractSubtitleEntities.SubtitleEntity
|
||||
) {
|
||||
val data = app.get("$APIURL/subtitle/${subtitle.data}")
|
||||
.parsedSafe<DownloadRoot>()
|
||||
?: return
|
||||
val parsedSub = parseJson<SubData>(data.data)
|
||||
|
||||
val subRes = app.post(
|
||||
url = "$APIURL/getSub",
|
||||
data = mapOf(
|
||||
"movie" to parsedSub.movie,
|
||||
"lang" to data.lang,
|
||||
"id" to parsedSub.id
|
||||
)
|
||||
).parsedSafe<SubTitleLink>() ?: return
|
||||
|
||||
this.addZipUrl(
|
||||
"$APIURL/subtitle/download/${data.subtitle.downloadToken}"
|
||||
"$DOWNLOADENDPOINT/${subRes.sub.downloadToken}"
|
||||
) { name, _ ->
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
data class SearchRoot(
|
||||
@JsonProperty("success") @SerialName("success") var success: Boolean? = null,
|
||||
@JsonProperty("results") @SerialName("results") var results: ArrayList<Results> = arrayListOf(),
|
||||
@JsonProperty("users") @SerialName("users") var users: ArrayList<Users> = arrayListOf()
|
||||
data class ApiSearch(
|
||||
@JsonProperty("success") val success: Boolean,
|
||||
@JsonProperty("found") val found: List<Found>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Users(
|
||||
|
||||
@JsonProperty("id") @SerialName("id") var id: Int? = null,
|
||||
@JsonProperty("displayname") @SerialName("displayname") var displayname: String? = null,
|
||||
@JsonProperty("avatar") @SerialName("avatar") var avatar: String? = null,
|
||||
@JsonProperty("badges") @SerialName("badges") var badges: ArrayList<String> = arrayListOf()
|
||||
|
||||
data class Found(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("seasons") val seasons: Long,
|
||||
@JsonProperty("type") val type: String,
|
||||
@JsonProperty("releaseYear") val releaseYear: Long,
|
||||
@JsonProperty("linkName") val linkName: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Results(
|
||||
@JsonProperty("id") @SerialName("id") var id: Int? = null,
|
||||
@JsonProperty("title") @SerialName("title") var title: String? = null,
|
||||
@JsonProperty("type") @SerialName("type") var type: String? = null,
|
||||
@JsonProperty("link") @SerialName("link") var link: String,
|
||||
@JsonProperty("releaseYear") @SerialName("releaseYear") var releaseYear: Int? = null,
|
||||
@JsonProperty("poster") @SerialName("poster") var poster: String? = null,
|
||||
@JsonProperty("subtitleCount") @SerialName("subtitleCount") var subtitleCount: String? = null,
|
||||
@JsonProperty("rating") @SerialName("rating") var rating: Double? = null,
|
||||
@JsonProperty("cast") @SerialName("cast") var cast: ArrayList<String> = arrayListOf(),
|
||||
@JsonProperty("genres") @SerialName("genres") var genres: ArrayList<String> = arrayListOf(),
|
||||
@JsonProperty("score") @SerialName("score") var score: Double? = null
|
||||
data class ApiResponse(
|
||||
@JsonProperty("success") val success: Boolean,
|
||||
@JsonProperty("movie") val movie: Movie,
|
||||
@JsonProperty("subs") val subs: List<Sub>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
data class ItemRoot(
|
||||
|
||||
// @SerialName("media_type" ) var mediaType : String? = null,
|
||||
@JsonProperty("subtitles") @SerialName("subtitles") var subtitles: ArrayList<Subtitles>,
|
||||
//@SerialName("movie" ) var movie : Movie? = Movie()
|
||||
|
||||
data class Movie(
|
||||
@JsonProperty("id") val id: Long? = null,
|
||||
@JsonProperty("type") val type: String? = null,
|
||||
@JsonProperty("year") val year: Long? = null,
|
||||
@JsonProperty("fullName") val fullName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Subtitles(
|
||||
|
||||
@JsonProperty("id") @SerialName("id") var id: Int? = null,
|
||||
@JsonProperty("language") @SerialName("language") var language: String,
|
||||
@JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null,
|
||||
@JsonProperty("release_info") @SerialName("release_info") var releaseInfo: String,
|
||||
@JsonProperty("upload_date") @SerialName("upload_date") var uploadDate: String? = null,
|
||||
@JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null,
|
||||
@JsonProperty("caption") @SerialName("caption") var caption: String? = null,
|
||||
@JsonProperty("rating") @SerialName("rating") var rating: String? = null,
|
||||
@JsonProperty("uploader_id") @SerialName("uploader_id") var uploaderId: Int? = null,
|
||||
@JsonProperty("uploader_displayname") @SerialName("uploader_displayname") var uploaderDisplayname: String? = null,
|
||||
@JsonProperty("uploader_badges") @SerialName("uploader_badges") var uploaderBadges: ArrayList<String> = arrayListOf(),
|
||||
@JsonProperty("link") @SerialName("link") var link: String,
|
||||
@JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null,
|
||||
@JsonProperty("last_subtitle") @SerialName("last_subtitle") var lastSubtitle: Boolean? = null
|
||||
|
||||
data class Sub(
|
||||
@JsonProperty("hi") val hi: Int? = null,
|
||||
@JsonProperty("fullLink") val fullLink: String? = null,
|
||||
@JsonProperty("linkName") val linkName: String? = null,
|
||||
@JsonProperty("lang") val lang: String? = null,
|
||||
@JsonProperty("releaseName") val releaseName: String? = null,
|
||||
@JsonProperty("subId") val subId: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DownloadRoot(
|
||||
@JsonProperty("subtitle") @SerialName("subtitle") var subtitle: Subtitle,
|
||||
//@SerializedName("movie" ) var movie : Movie? = Movie(),
|
||||
//@SerializedName("donationLinks" ) var donationLinks : DonationLinks? = DonationLinks(),
|
||||
//@SerializedName("isDownloaded" ) var isDownloaded : Boolean? = null,
|
||||
//@SerializedName("user_rated" ) var userRated : String? = null
|
||||
data class SubData(
|
||||
@JsonProperty("movie") val movie: String,
|
||||
@JsonProperty("lang") val lang: String,
|
||||
@JsonProperty("id") val id: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Subtitle(
|
||||
|
||||
@JsonProperty("id") @SerialName("id") var id: Int? = null,
|
||||
@JsonProperty("uploaded_at") @SerialName("uploaded_at") var uploadedAt: String? = null,
|
||||
@JsonProperty("language") @SerialName("language") var language: String? = null,
|
||||
@JsonProperty("rating") @SerialName("rating") var rating: String? = null,
|
||||
//SerialName("rates" ) var rates : Rates? = Rates(),
|
||||
@JsonProperty("uploaded_by") @SerialName("uploaded_by") var uploadedBy: Int? = null,
|
||||
//@SerialName("contribs" ) var contribs : ArrayList<Contribs> = arrayListOf(),
|
||||
@JsonProperty("release_info") @SerialName("release_info") var releaseInfo: ArrayList<String> = arrayListOf(),
|
||||
@JsonProperty("commentary") @SerialName("commentary") var commentary: String? = null,
|
||||
@JsonProperty("files") @SerialName("files") var files: String? = null,
|
||||
@JsonProperty("size") @SerialName("size") var size: String? = null,
|
||||
@JsonProperty("downloads") @SerialName("downloads") var downloads: Int? = null,
|
||||
@JsonProperty("comments") @SerialName("comments") var comments: Int? = null,
|
||||
@JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null,
|
||||
@JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null,
|
||||
@JsonProperty("episode") @SerialName("episode") var episode: String? = null,
|
||||
@JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null,
|
||||
@JsonProperty("foreign_parts") @SerialName("foreign_parts") var foreignParts: String? = null,
|
||||
@JsonProperty("framerate") @SerialName("framerate") var framerate: String? = null,
|
||||
@JsonProperty("preview") @SerialName("preview") var preview: String? = null,
|
||||
@JsonProperty("user_uploaded") @SerialName("user_uploaded") var userUploaded: Boolean? = null,
|
||||
@JsonProperty("download_token") @SerialName("download_token") var downloadToken: String
|
||||
data class SubTitleLink(
|
||||
@JsonProperty("sub") val sub: SubToken,
|
||||
)
|
||||
|
||||
data class SubToken(
|
||||
@JsonProperty("downloadToken") val downloadToken: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||
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.SubtitleAPI
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class SubDlApi : SubtitleAPI() {
|
||||
override val name = "SubDL"
|
||||
|
|
@ -26,7 +23,7 @@ class SubDlApi : SubtitleAPI() {
|
|||
override val createAccountUrl = "https://subdl.com/panel/register"
|
||||
|
||||
companion object {
|
||||
const val APIURL = "https://api.subdl.com"
|
||||
const val APIURL = "https://apiold.subdl.com"
|
||||
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
|
||||
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
|
||||
}
|
||||
|
|
@ -58,16 +55,15 @@ class SubDlApi : SubtitleAPI() {
|
|||
}
|
||||
|
||||
override suspend fun search(
|
||||
auth : AuthData?,
|
||||
token: AuthToken?,
|
||||
query: AbstractSubtitleEntities.SubtitleSearch
|
||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||
if (auth == null) return null
|
||||
val apiKey = auth.token.accessToken ?: return null
|
||||
if (token == null) return null
|
||||
|
||||
val queryText = query.query
|
||||
val epNum = query.epNumber ?: 0
|
||||
val seasonNum = query.seasonNumber ?: 0
|
||||
val yearNum = query.year ?: 0
|
||||
val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang
|
||||
|
||||
val idQuery = when {
|
||||
query.imdbId != null -> "&imdb_id=${query.imdbId}"
|
||||
|
|
@ -81,8 +77,8 @@ class SubDlApi : SubtitleAPI() {
|
|||
|
||||
val searchQueryUrl = when (idQuery) {
|
||||
//Use imdb/tmdb id to search if its valid
|
||||
null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery"
|
||||
else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery"
|
||||
null -> "$APIENDPOINT?api_key=${token.accessToken}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||
else -> "$APIENDPOINT?api_key=${token.accessToken}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||
}
|
||||
|
||||
val req = app.get(
|
||||
|
|
@ -94,9 +90,7 @@ class SubDlApi : SubtitleAPI() {
|
|||
|
||||
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
|
||||
|
||||
val langTagIETF =
|
||||
langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?:
|
||||
subtitle.lang
|
||||
val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
|
||||
val resEpNum = subtitle.episode ?: query.epNumber
|
||||
val resSeasonNum = subtitle.season ?: query.seasonNumber
|
||||
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||
|
|
@ -104,7 +98,7 @@ class SubDlApi : SubtitleAPI() {
|
|||
AbstractSubtitleEntities.SubtitleEntity(
|
||||
idPrefix = this.idPrefix,
|
||||
name = subtitle.releaseName,
|
||||
lang = langTagIETF,
|
||||
lang = lang,
|
||||
data = "${DOWNLOADENDPOINT}${subtitle.url}",
|
||||
type = type,
|
||||
source = this.name,
|
||||
|
|
@ -116,154 +110,76 @@ class SubDlApi : SubtitleAPI() {
|
|||
}
|
||||
|
||||
override suspend fun SubtitleResource.getResources(
|
||||
auth: AuthData?,
|
||||
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
||||
token: AuthToken?,
|
||||
data: AbstractSubtitleEntities.SubtitleEntity
|
||||
) {
|
||||
this.addZipUrl(subtitle.data) { name, _ ->
|
||||
this.addZipUrl(data.data) { name, _ ->
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SubtitleOAuthEntity(
|
||||
@JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String,
|
||||
@JsonProperty("pass") @SerialName("pass") var pass: String,
|
||||
@JsonProperty("name") @SerialName("name") var name: String? = null,
|
||||
@JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null,
|
||||
@JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null,
|
||||
@JsonProperty("userEmail") var userEmail: String,
|
||||
@JsonProperty("pass") var pass: String,
|
||||
@JsonProperty("name") var name: String? = null,
|
||||
@JsonProperty("accessToken") var accessToken: String? = null,
|
||||
@JsonProperty("apiKey") var apiKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OAuthTokenResponse(
|
||||
@JsonProperty("token") @SerialName("token") val token: String,
|
||||
@JsonProperty("userData") @SerialName("userData") val userData: UserData? = null,
|
||||
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
|
||||
@JsonProperty("message") @SerialName("message") val message: String? = null,
|
||||
@JsonProperty("token") val token: String,
|
||||
@JsonProperty("userData") val userData: UserData? = null,
|
||||
@JsonProperty("status") val status: Boolean? = null,
|
||||
@JsonProperty("message") val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserData(
|
||||
@JsonProperty("email") @SerialName("email") val email: String,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("country") @SerialName("country") val country: String,
|
||||
@JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String,
|
||||
@JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean,
|
||||
@JsonProperty("username") @SerialName("username") val username: String? = null,
|
||||
@JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String,
|
||||
@JsonProperty("email") val email: String,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("country") val country: String,
|
||||
@JsonProperty("scStepCode") val scStepCode: String,
|
||||
@JsonProperty("scVerified") val scVerified: Boolean,
|
||||
@JsonProperty("username") val username: String? = null,
|
||||
@JsonProperty("scUsername") val scUsername: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiKeyResponse(
|
||||
@JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false,
|
||||
@JsonProperty("api_key") @SerialName("api_key") val apiKey: String,
|
||||
@JsonProperty("usage") @SerialName("usage") val usage: Usage? = null,
|
||||
@JsonProperty("ok") val ok: Boolean? = false,
|
||||
@JsonProperty("api_key") val apiKey: String,
|
||||
@JsonProperty("usage") val usage: Usage? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Usage(
|
||||
@JsonProperty("total") @SerialName("total") val total: Long? = 0,
|
||||
@JsonProperty("today") @SerialName("today") val today: Long? = 0,
|
||||
@JsonProperty("total") val total: Long? = 0,
|
||||
@JsonProperty("today") val today: Long? = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiResponse(
|
||||
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
|
||||
@JsonProperty("results") @SerialName("results") val results: List<Result>? = null,
|
||||
@JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List<Subtitle>? = null,
|
||||
@JsonProperty("status") val status: Boolean? = null,
|
||||
@JsonProperty("results") val results: List<Result>? = null,
|
||||
@JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
@JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null,
|
||||
@JsonProperty("type") @SerialName("type") val type: String? = null,
|
||||
@JsonProperty("name") @SerialName("name") val name: String? = null,
|
||||
@JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null,
|
||||
@JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null,
|
||||
@JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null,
|
||||
@JsonProperty("year") @SerialName("year") val year: Int? = null,
|
||||
@JsonProperty("sd_id") val sdId: Int? = null,
|
||||
@JsonProperty("type") val type: String? = null,
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("imdb_id") val imdbId: String? = null,
|
||||
@JsonProperty("tmdb_id") val tmdbId: Long? = null,
|
||||
@JsonProperty("first_air_date") val firstAirDate: String? = null,
|
||||
@JsonProperty("year") val year: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Subtitle(
|
||||
@JsonProperty("release_name") @SerialName("release_name") val releaseName: String,
|
||||
@JsonProperty("name") @SerialName("name") val name: String,
|
||||
@JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code
|
||||
@JsonProperty("author") @SerialName("author") val author: String? = null,
|
||||
@JsonProperty("url") @SerialName("url") val url: String? = null,
|
||||
@JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null,
|
||||
@JsonProperty("season") @SerialName("season") val season: Int? = null,
|
||||
@JsonProperty("episode") @SerialName("episode") val episode: Int? = null,
|
||||
@JsonProperty("language") @SerialName("language") val language: String? = null, // full language name
|
||||
@JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null,
|
||||
)
|
||||
|
||||
// https://subdl.com/api-files/language_list.json
|
||||
// most of it is IETF BPC 47 conformant tag
|
||||
// but there are some exceptions
|
||||
private val langTagIETF2subdl = mapOf(
|
||||
"en-bg" to "BG_EN", // "Bulgarian_English"
|
||||
"en-de" to "EN_DE", // "English_German"
|
||||
"en-hu" to "HU_EN", // "Hungarian_English"
|
||||
"en-nl" to "NL_EN", // "Dutch_English"
|
||||
"pt-br" to "BR_PT", // "Brazillian Portuguese"
|
||||
"zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?)
|
||||
// "ar" to "AR", // "Arabic"
|
||||
// "az" to "AZ", // "Azerbaijani"
|
||||
// "be" to "BE", // "Belarusian"
|
||||
// "bg" to "BG", // "Bulgarian"
|
||||
// "bn" to "BN", // "Bengali"
|
||||
// "bs" to "BS", // "Bosnian"
|
||||
// "ca" to "CA", // "Catalan"
|
||||
// "cs" to "CS", // "Czech"
|
||||
// "da" to "DA", // "Danish"
|
||||
// "de" to "DE", // "German"
|
||||
// "el" to "EL", // "Greek"
|
||||
// "en" to "EN", // "English"
|
||||
// "eo" to "EO", // "Esperanto"
|
||||
// "es" to "ES", // "Spanish"
|
||||
// "et" to "ET", // "Estonian"
|
||||
// "fa" to "FA", // "Farsi_Persian"
|
||||
// "fi" to "FI", // "Finnish"
|
||||
// "fr" to "FR", // "French"
|
||||
// "he" to "HE", // "Hebrew"
|
||||
// "hi" to "HI", // "Hindi"
|
||||
// "hr" to "HR", // "Croatian"
|
||||
// "hu" to "HU", // "Hungarian"
|
||||
// "id" to "ID", // "Indonesian"
|
||||
// "is" to "IS", // "Icelandic"
|
||||
// "it" to "IT", // "Italian"
|
||||
// "ja" to "JA", // "Japanese"
|
||||
// "ka" to "KA", // "Georgian"
|
||||
// "kl" to "KL", // "Greenlandic"
|
||||
// "ko" to "KO", // "Korean"
|
||||
// "ku" to "KU", // "Kurdish"
|
||||
// "lt" to "LT", // "Lithuanian"
|
||||
// "lv" to "LV", // "Latvian"
|
||||
// "mk" to "MK", // "Macedonian"
|
||||
// "ml" to "ML", // "Malayalam"
|
||||
// "mni" to "MNI", // "Manipuri"
|
||||
// "ms" to "MS", // "Malay"
|
||||
// "my" to "MY", // "Burmese"
|
||||
// "nl" to "NL", // "Dutch"
|
||||
// "no" to "NO", // "Norwegian"
|
||||
// "pl" to "PL", // "Polish"
|
||||
// "pt" to "PT", // "Portuguese"
|
||||
// "ro" to "RO", // "Romanian"
|
||||
// "ru" to "RU", // "Russian"
|
||||
// "si" to "SI", // "Sinhala"
|
||||
// "sk" to "SK", // "Slovak"
|
||||
// "sl" to "SL", // "Slovenian"
|
||||
// "sq" to "SQ", // "Albanian"
|
||||
// "sr" to "SR", // "Serbian"
|
||||
// "sv" to "SV", // "Swedish"
|
||||
// "ta" to "TA", // "Tamil"
|
||||
// "te" to "TE", // "Telugu"
|
||||
// "th" to "TH", // "Thai"
|
||||
// "tl" to "TL", // "Tagalog"
|
||||
// "tr" to "TR", // "Turkish"
|
||||
// "uk" to "UK", // "Ukranian"
|
||||
// "ur" to "UR", // "Urdu"
|
||||
// "vi" to "VI", // "Vietnamese"
|
||||
// "zh" to "ZH", // "Chinese BG code"
|
||||
@JsonProperty("release_name") val releaseName: String,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("lang") val lang: String,
|
||||
@JsonProperty("author") val author: String? = null,
|
||||
@JsonProperty("url") val url: String? = null,
|
||||
@JsonProperty("subtitlePage") val subtitlePage: String? = null,
|
||||
@JsonProperty("season") val season: Int? = null,
|
||||
@JsonProperty("episode") val episode: Int? = null,
|
||||
@JsonProperty("language") val language: String? = null,
|
||||
@JsonProperty("hi") val hearingImpaired: Boolean? = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.Score
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginPage
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||
|
||||
/* https://trakt.docs.apiary.io */
|
||||
class TraktApi : SyncAPI() {
|
||||
override val name = "Trakt"
|
||||
override val idPrefix = "trakt"
|
||||
|
||||
override val mainUrl = "https://trakt.tv"
|
||||
val api = "https://api.trakt.tv"
|
||||
|
||||
override val supportedWatchTypes: Set<SyncWatchType> = emptySet()
|
||||
|
||||
override val icon = R.drawable.trakt
|
||||
override val hasOAuth2 = true
|
||||
override val redirectUrlIdentifier = "NONE"
|
||||
val redirectUri = "cloudstreamapp://$redirectUrlIdentifier"
|
||||
|
||||
companion object {
|
||||
val id: String get() = throw NotImplementedError()
|
||||
val secret: String get() = throw NotImplementedError()
|
||||
|
||||
fun getHeaders(token: AuthToken) = mapOf(
|
||||
"Authorization" to "Bearer ${token.accessToken}",
|
||||
"Content-Type" to "application/json",
|
||||
"trakt-api-version" to "2",
|
||||
"trakt-api-key" to id,
|
||||
)
|
||||
}
|
||||
|
||||
data class TokenRoot(
|
||||
@JsonProperty("access_token")
|
||||
val accessToken: String,
|
||||
@JsonProperty("token_type")
|
||||
val tokenType: String,
|
||||
@JsonProperty("expires_in")
|
||||
val expiresIn: Long,
|
||||
@JsonProperty("refresh_token")
|
||||
val refreshToken: String,
|
||||
@JsonProperty("scope")
|
||||
val scope: String,
|
||||
@JsonProperty("created_at")
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
data class UserRoot(
|
||||
@JsonProperty("username")
|
||||
val username: String,
|
||||
@JsonProperty("private")
|
||||
val private: Boolean?,
|
||||
@JsonProperty("name")
|
||||
val name: String,
|
||||
@JsonProperty("vip")
|
||||
val vip: Boolean?,
|
||||
@JsonProperty("vip_ep")
|
||||
val vipEp: Boolean?,
|
||||
@JsonProperty("ids")
|
||||
val ids: Ids?,
|
||||
@JsonProperty("joined_at")
|
||||
val joinedAt: String?,
|
||||
@JsonProperty("location")
|
||||
val location: String?,
|
||||
@JsonProperty("about")
|
||||
val about: String?,
|
||||
@JsonProperty("gender")
|
||||
val gender: String?,
|
||||
@JsonProperty("age")
|
||||
val age: Long?,
|
||||
@JsonProperty("images")
|
||||
val images: Images?,
|
||||
) {
|
||||
data class Ids(
|
||||
@JsonProperty("slug")
|
||||
val slug: String,
|
||||
)
|
||||
|
||||
data class Images(
|
||||
@JsonProperty("avatar")
|
||||
val avatar: Avatar,
|
||||
)
|
||||
|
||||
data class Avatar(
|
||||
@JsonProperty("full")
|
||||
val full: String,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun user(token: AuthToken?): AuthUser? {
|
||||
if (token == null) return null
|
||||
// https://trakt.docs.apiary.io/#reference/users/profile/get-user-profile
|
||||
|
||||
val userData = app.get(
|
||||
"$api/users/me?extended=full", headers = getHeaders(token)
|
||||
).parsed<UserRoot>()
|
||||
|
||||
return AuthUser(
|
||||
name = userData.name,
|
||||
id = userData.username.hashCode(),
|
||||
profilePicture = userData.images?.avatar?.full
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
||||
val sanitizer =
|
||||
splitRedirectUrl(redirectUrl)
|
||||
|
||||
if (sanitizer["state"] != payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
// https://trakt.docs.apiary.io/#reference/authentication-oauth/get-token/exchange-code-for-access_token
|
||||
val tokenData = app.post(
|
||||
"$api/oauth/token",
|
||||
json = mapOf(
|
||||
"code" to (sanitizer["code"] ?: throw ErrorLoadingException("No code")),
|
||||
"client_id" to id,
|
||||
"client_secret" to secret,
|
||||
"redirect_uri" to redirectUri,
|
||||
"grant_type" to "authorization_code"
|
||||
)
|
||||
).parsed<TokenRoot>()
|
||||
|
||||
return AuthToken(
|
||||
accessToken = tokenData.accessToken,
|
||||
refreshToken = tokenData.refreshToken,
|
||||
accessTokenLifetime = unixTime + tokenData.expiresIn
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun refreshToken(token: AuthToken): AuthToken? {
|
||||
// https://trakt.docs.apiary.io/#reference/authentication-oauth/get-token/exchange-refresh_token-for-access_token
|
||||
val tokenData = app.post(
|
||||
"$api/oauth/token",
|
||||
json = mapOf(
|
||||
"refresh_token" to (token.refreshToken
|
||||
?: throw ErrorLoadingException("No refreshtoken")),
|
||||
"client_id" to id,
|
||||
"client_secret" to secret,
|
||||
"redirect_uri" to redirectUri,
|
||||
"grant_type" to "refresh_token",
|
||||
)
|
||||
).parsed<TokenRoot>()
|
||||
|
||||
return AuthToken(
|
||||
accessToken = tokenData.accessToken,
|
||||
refreshToken = tokenData.refreshToken,
|
||||
accessTokenLifetime = unixTime + tokenData.expiresIn
|
||||
)
|
||||
}
|
||||
|
||||
override fun loginRequest(): AuthLoginPage? {
|
||||
// https://trakt.docs.apiary.io/#reference/authentication-oauth/authorize/authorize-application
|
||||
val codeChallenge = generateCodeVerifier()
|
||||
return AuthLoginPage(
|
||||
"$mainUrl/oauth/authorize?client_id=$id&response_type=code&redirect_uri=$redirectUri&state=$codeChallenge",
|
||||
payload = codeChallenge
|
||||
)
|
||||
}
|
||||
|
||||
data class RatingRoot(
|
||||
@JsonProperty("rated_at")
|
||||
val ratedAt: String?,
|
||||
@JsonProperty("rating")
|
||||
val rating: Int?,
|
||||
@JsonProperty("type")
|
||||
val type: String,
|
||||
@JsonProperty("season")
|
||||
val season: Season?,
|
||||
@JsonProperty("show")
|
||||
val show: Show?,
|
||||
@JsonProperty("movie")
|
||||
val movie: Movie?,
|
||||
) {
|
||||
data class Season(
|
||||
@JsonProperty("number")
|
||||
val number: Long?,
|
||||
@JsonProperty("ids")
|
||||
val ids: Ids?,
|
||||
)
|
||||
|
||||
data class Show(
|
||||
@JsonProperty("title")
|
||||
val title: String?,
|
||||
@JsonProperty("year")
|
||||
val year: Long?,
|
||||
@JsonProperty("ids")
|
||||
val ids: Ids?,
|
||||
)
|
||||
|
||||
data class Movie(
|
||||
@JsonProperty("title")
|
||||
val title: String?,
|
||||
@JsonProperty("year")
|
||||
val year: Long?,
|
||||
@JsonProperty("ids")
|
||||
val ids: Ids?,
|
||||
)
|
||||
|
||||
data class Ids(
|
||||
@JsonProperty("trakt")
|
||||
val trakt: String?,
|
||||
@JsonProperty("slug")
|
||||
val slug: String?,
|
||||
@JsonProperty("tvdb")
|
||||
val tvdb: String?,
|
||||
@JsonProperty("imdb")
|
||||
val imdb: String?,
|
||||
@JsonProperty("tmdb")
|
||||
val tmdb: String?,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
data class TraktSyncStatus(
|
||||
override var status: SyncWatchType = SyncWatchType.NONE,
|
||||
override var score: Score?,
|
||||
override var watchedEpisodes: Int? = null,
|
||||
override var isFavorite: Boolean? = null,
|
||||
override var maxEpisodes: Int? = null,
|
||||
val type: String,
|
||||
) : AbstractSyncStatus()
|
||||
|
||||
override suspend fun status(token: AuthToken?, id: String): AbstractSyncStatus? {
|
||||
if (token == null) return null
|
||||
|
||||
val response = app.get("$api/sync/ratings/all", headers = getHeaders(token))
|
||||
.parsed<Array<RatingRoot>>()
|
||||
|
||||
// This is criminally wrong, but there is no api to get the rating directly
|
||||
for (x in response) {
|
||||
if (x.show?.ids?.trakt == id || x.movie?.ids?.trakt == id || x.season?.ids?.trakt == id) {
|
||||
return TraktSyncStatus(score = Score.from10(x.rating), type = x.type)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncStatus(SyncWatchType.NONE, null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,15 +9,14 @@ import com.lagradost.cloudstream3.LoadResponse
|
|||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.MainPageRequest
|
||||
import com.lagradost.cloudstream3.SearchResponseList
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.SubtitleFile
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.fixUrl
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.newSearchResponseList
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -55,7 +54,7 @@ class APIRepository(val api: MainAPI) {
|
|||
val hash: Pair<String, String>
|
||||
)
|
||||
|
||||
private val cache = atomicListOf<SavedLoadResponse>()
|
||||
private val cache = threadSafeListOf<SavedLoadResponse>()
|
||||
private var cacheIndex: Int = 0
|
||||
const val CACHE_SIZE = 20
|
||||
|
||||
|
|
@ -66,9 +65,11 @@ class APIRepository(val api: MainAPI) {
|
|||
|
||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||
if (forceReload) {
|
||||
synchronized(cache) {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||
|
|
@ -89,25 +90,21 @@ class APIRepository(val api: MainAPI) {
|
|||
val fixedUrl = api.fixUrl(url)
|
||||
val lookingForHash = Pair(api.name, fixedUrl)
|
||||
|
||||
val cached = cache.withLock {
|
||||
var found: LoadResponse? = null
|
||||
synchronized(cache) {
|
||||
for (item in cache) {
|
||||
// 10 min save
|
||||
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
||||
found = item.response
|
||||
break
|
||||
return@withTimeout item.response
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (cached != null) return@withTimeout cached
|
||||
api.load(fixedUrl)?.also { response ->
|
||||
// Remove all blank tags as early as possible
|
||||
response.tags = response.tags?.filter { it.isNotBlank() }
|
||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||
|
||||
cache.withLock {
|
||||
synchronized(cache) {
|
||||
if (cache.size > CACHE_SIZE) {
|
||||
cache[cacheIndex] = add // rolling cache
|
||||
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
||||
|
|
@ -120,29 +117,27 @@ class APIRepository(val api: MainAPI) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String, page: Int): Resource<SearchResponseList> {
|
||||
suspend fun search(query: String): Resource<List<SearchResponse>> {
|
||||
if (query.isEmpty())
|
||||
return Resource.Success(newSearchResponseList(emptyList()))
|
||||
return Resource.Success(emptyList())
|
||||
|
||||
return safeApiCall {
|
||||
withTimeout(getTimeout(api.searchTimeoutMs)) {
|
||||
(api.search(query, page)
|
||||
(api.search(query)
|
||||
?: throw ErrorLoadingException())
|
||||
// .filter { typesActive.contains(it.type) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickSearch(query: String): Resource<SearchResponseList> {
|
||||
suspend fun quickSearch(query: String): Resource<List<SearchResponse>> {
|
||||
if (query.isEmpty())
|
||||
return Resource.Success(newSearchResponseList(emptyList()))
|
||||
return Resource.Success(emptyList())
|
||||
|
||||
return safeApiCall {
|
||||
withTimeout(getTimeout(api.quickSearchTimeoutMs)) {
|
||||
newSearchResponseList(
|
||||
api.quickSearch(query) ?: throw ErrorLoadingException(),
|
||||
false
|
||||
)
|
||||
api.quickSearch(query) ?: throw ErrorLoadingException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,34 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.core.view.children
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import coil3.dispose
|
||||
import java.util.WeakHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
||||
open fun save(): T? = null
|
||||
open fun restore(state: T) = Unit
|
||||
open fun onViewAttachedToWindow() = Unit
|
||||
open fun onViewDetachedFromWindow() = Unit
|
||||
open fun onViewRecycled() = Unit
|
||||
}
|
||||
|
||||
abstract class NoStateAdapter<T : Any>(
|
||||
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
||||
) : 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)
|
||||
})
|
||||
}
|
||||
// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154
|
||||
class StateViewModel : ViewModel() {
|
||||
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
||||
}
|
||||
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
|
||||
|
||||
/**
|
||||
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
||||
|
|
@ -70,14 +49,13 @@ fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.Recyc
|
|||
abstract class BaseAdapter<
|
||||
T : Any,
|
||||
S : Any>(
|
||||
fragment: Fragment,
|
||||
val id: Int = 0,
|
||||
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
||||
) : RecyclerView.Adapter<ViewHolderState<S>>() {
|
||||
open val footers: Int = 0
|
||||
open val headers: Int = 0
|
||||
|
||||
val immutableCurrentList: List<T> get() = mDiffer.currentList
|
||||
|
||||
fun getItem(position: Int): T {
|
||||
return mDiffer.currentList[position]
|
||||
}
|
||||
|
|
@ -107,33 +85,9 @@ abstract class BaseAdapter<
|
|||
AsyncDifferConfig.Builder(diffCallback).build()
|
||||
)
|
||||
|
||||
/**
|
||||
* Instantly submits a **new and fresh** list. This means that no changes like moves are done as
|
||||
* we assume the new list is not the same thing as the old list, nothing is shared.
|
||||
*
|
||||
* The views are rendered instantly as a result, so no fade/pop-ins or similar.
|
||||
*
|
||||
* Use `submitList` for general use, as that can reuse old views.
|
||||
* */
|
||||
open fun submitIncomparableList(list: List<T>?, commitCallback : Runnable? = null) {
|
||||
// This leverages a quirk in the submitList function that has a fast case for null arrays
|
||||
// What this implies is that as long as we do a double submit we can ensure no pop-ins,
|
||||
// as the changes are the entire list instead of calculating deltas
|
||||
submitList(null)
|
||||
submitList(list, commitCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param commitCallback Optional runnable that is executed when the List is committed, if it is committed.
|
||||
* This is needed for some tasks as submitList will use a background thread for diff
|
||||
* */
|
||||
open fun submitList(list: Collection<T>?, commitCallback : Runnable? = null) {
|
||||
open fun submitList(list: List<T>?) {
|
||||
// deep copy at least the top list, because otherwise adapter can go crazy
|
||||
if (list.isNullOrEmpty()) {
|
||||
mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList()
|
||||
} else {
|
||||
mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback)
|
||||
}
|
||||
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
|
|
@ -147,25 +101,16 @@ abstract class BaseAdapter<
|
|||
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
|
||||
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
|
||||
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||
open fun onCreateCustomContent(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = onCreateContent(parent)
|
||||
|
||||
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||
open fun onCreateCustomFooter(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = onCreateFooter(parent)
|
||||
|
||||
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||
open fun onCreateCustomHeader(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = onCreateHeader(parent)
|
||||
|
||||
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {}
|
||||
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {}
|
||||
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
|
||||
holder.onViewAttachedToWindow()
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
|
||||
holder.onViewDetachedFromWindow()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun save(recyclerView: RecyclerView) {
|
||||
|
|
@ -176,20 +121,21 @@ abstract class BaseAdapter<
|
|||
}
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
layoutManagerStates[id]?.clear()
|
||||
fun clear() {
|
||||
stateViewModel.layoutManagerStates[id]?.clear()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getState(holder: ViewHolderState<S>): S? =
|
||||
layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
|
||||
stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
|
||||
|
||||
private fun setState(holder: ViewHolderState<S>) {
|
||||
if(id == 0) return
|
||||
if (!layoutManagerStates.contains(id)) {
|
||||
layoutManagerStates[id] = HashMap()
|
||||
|
||||
if (!stateViewModel.layoutManagerStates.contains(id)) {
|
||||
stateViewModel.layoutManagerStates[id] = HashMap()
|
||||
}
|
||||
layoutManagerStates[id]?.let { map ->
|
||||
stateViewModel.layoutManagerStates[id]?.let { map ->
|
||||
map[holder.absoluteAdapterPosition] = holder.save()
|
||||
}
|
||||
}
|
||||
|
|
@ -212,40 +158,30 @@ abstract class BaseAdapter<
|
|||
super.onDetachedFromRecyclerView(recyclerView)
|
||||
}
|
||||
|
||||
open fun customContentViewType(item: T): Int = 0
|
||||
open fun customFooterViewType(): Int = 0
|
||||
open fun customHeaderViewType(): Int = 0
|
||||
|
||||
final override fun getItemViewType(position: Int): Int {
|
||||
if (position < headers) {
|
||||
return HEADER or customHeaderViewType()
|
||||
return HEADER
|
||||
}
|
||||
val realPosition = position - headers
|
||||
if (realPosition >= mDiffer.currentList.size) {
|
||||
return FOOTER or customFooterViewType()
|
||||
if (position - headers >= mDiffer.currentList.size) {
|
||||
return FOOTER
|
||||
}
|
||||
return CONTENT or customContentViewType(getItem(realPosition))
|
||||
|
||||
return CONTENT
|
||||
}
|
||||
|
||||
private val stateViewModel: StateViewModel by fragment.viewModels()
|
||||
|
||||
final override fun onViewRecycled(holder: ViewHolderState<S>) {
|
||||
setState(holder)
|
||||
onClearView(holder)
|
||||
holder.onViewRecycled()
|
||||
super.onViewRecycled(holder)
|
||||
}
|
||||
|
||||
/** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data.
|
||||
*
|
||||
* If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.
|
||||
*
|
||||
* Use this with `clearImage`
|
||||
* */
|
||||
open fun onClearView(holder: ViewHolderState<S>) {}
|
||||
|
||||
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
|
||||
return when (viewType and TYPE_MASK) {
|
||||
CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK)
|
||||
HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK)
|
||||
FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK)
|
||||
return when (viewType) {
|
||||
CONTENT -> onCreateContent(parent)
|
||||
HEADER -> onCreateHeader(parent)
|
||||
FOOTER -> onCreateFooter(parent)
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
|
|
@ -260,7 +196,7 @@ abstract class BaseAdapter<
|
|||
super.onBindViewHolder(holder, position, payloads)
|
||||
return
|
||||
}
|
||||
when (getItemViewType(position) and TYPE_MASK) {
|
||||
when (getItemViewType(position)) {
|
||||
CONTENT -> {
|
||||
val realPosition = position - headers
|
||||
val item = getItem(realPosition)
|
||||
|
|
@ -278,7 +214,7 @@ abstract class BaseAdapter<
|
|||
}
|
||||
|
||||
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
|
||||
when (getItemViewType(position) and TYPE_MASK) {
|
||||
when (getItemViewType(position)) {
|
||||
CONTENT -> {
|
||||
val realPosition = position - headers
|
||||
val item = getItem(realPosition)
|
||||
|
|
@ -300,20 +236,9 @@ abstract class BaseAdapter<
|
|||
}
|
||||
|
||||
companion object {
|
||||
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
||||
fun clearImage(image: ImageView?) {
|
||||
image?.dispose()
|
||||
}
|
||||
|
||||
// Use the lowermost MASK_SIZE bits for the custom content,
|
||||
// use the uppermost 32 - MASK_SIZE to the type
|
||||
private const val MASK_SIZE = 28
|
||||
private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1
|
||||
private const val TYPE_MASK = CUSTOM_MASK.inv()
|
||||
const val HEADER: Int = 3 shl MASK_SIZE
|
||||
const val FOOTER: Int = 2 shl MASK_SIZE
|
||||
/** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */
|
||||
const val CONTENT: Int = 1 shl MASK_SIZE
|
||||
private const val HEADER: Int = 1
|
||||
private const val FOOTER: Int = 2
|
||||
private const val CONTENT: Int = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,5 +248,5 @@ class BaseDiffCallback<T : Any>(
|
|||
) : DiffUtil.ItemCallback<T>() {
|
||||
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
|
||||
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
|
||||
override fun getChangePayload(oldItem: T, newItem: T): Any? = Any()
|
||||
override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
|
||||
/**
|
||||
* A base Fragment class that simplifies ViewBinding usage and handles view inflation safely.
|
||||
*
|
||||
* This class allows two modes of creating ViewBinding:
|
||||
* 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes.
|
||||
* 2. Bind: Using `bind()` on an existing root view.
|
||||
*
|
||||
* It also provides hooks for:
|
||||
* - Safe initialization of the binding (`onBindingCreated`)
|
||||
* - Automatic padding adjustment for system bars (`fixPadding`)
|
||||
* - Optional layout resource selection via `pickLayout()`
|
||||
*
|
||||
* @param T The type of ViewBinding for this Fragment.
|
||||
* @param bindingCreator The strategy used to create the binding instance.
|
||||
*/
|
||||
private interface BaseFragmentHelper<T : ViewBinding> {
|
||||
val bindingCreator: BaseFragment.BindingCreator<T>
|
||||
|
||||
var _binding: T?
|
||||
val binding: T? get() = _binding
|
||||
|
||||
fun createBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val layoutId = pickLayout()
|
||||
val root: View? = layoutId?.let { inflater.inflate(it, container, false) }
|
||||
_binding = try {
|
||||
when (val creator = bindingCreator) {
|
||||
is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false)
|
||||
is BaseFragment.BindingCreator.Bind -> {
|
||||
if (root != null) creator.fn(root)
|
||||
else throw IllegalStateException("Root view is null for bind()")
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
showToast(
|
||||
txt(R.string.unable_to_inflate, t.message ?: ""),
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
logError(t)
|
||||
null
|
||||
}
|
||||
|
||||
return _binding?.root ?: root
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the fragment's view has been created.
|
||||
*
|
||||
* This method is `final` to ensure that the binding is properly initialized and
|
||||
* system bar padding adjustments are applied before any subclass logic runs.
|
||||
* Subclasses should use [onBindingCreated] instead of overriding this method directly.
|
||||
*/
|
||||
fun onViewReady(view: View, savedInstanceState: Bundle?) {
|
||||
fixLayout(view)
|
||||
binding?.let { onBindingCreated(it, savedInstanceState) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the binding is safely created and view is ready.
|
||||
* Can be overridden to provide fragment-specific initialization.
|
||||
*
|
||||
* @param binding The safely created ViewBinding.
|
||||
* @param savedInstanceState Saved state bundle or null.
|
||||
*/
|
||||
fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
|
||||
onBindingCreated(binding)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the binding is safely created and view is ready.
|
||||
* Overload without savedInstanceState for convenience.
|
||||
*
|
||||
* @param binding The safely created ViewBinding.
|
||||
*/
|
||||
fun onBindingCreated(binding: T) {}
|
||||
|
||||
/**
|
||||
* Pick a layout resource ID for the fragment.
|
||||
*
|
||||
* Return `null` by default. Override to provide a layout resource when using
|
||||
* `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`.
|
||||
*
|
||||
* @return Layout resource ID or null.
|
||||
*/
|
||||
@LayoutRes
|
||||
fun pickLayout(): Int? = null
|
||||
|
||||
/**
|
||||
* Ensures the layout of the root view is correctly adjusted for the current configuration.
|
||||
*
|
||||
* This may include applying padding for system bars, adjusting insets, or performing other
|
||||
* layout updates. `fixLayout` should remain idempotent, as it can be called multiple
|
||||
* times on the same view, such as during configuration changes (e.g. device rotation) or when
|
||||
* the view is recreated.
|
||||
*
|
||||
* @param view The root view to adjust.
|
||||
*/
|
||||
fun fixLayout(view: View)
|
||||
}
|
||||
|
||||
abstract class BaseFragment<T : ViewBinding>(
|
||||
override val bindingCreator: BindingCreator<T>
|
||||
) : Fragment(), BaseFragmentHelper<T> {
|
||||
override var _binding: T? = null
|
||||
|
||||
/** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */
|
||||
fun dispatchBackPressed() {
|
||||
try {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
} catch (_: IllegalStateException) {
|
||||
// FragmentManager is already executing transactions, so try again
|
||||
delayedDispatchBackPressed(5)
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursive back press when available */
|
||||
private fun delayedDispatchBackPressed(remaining: Int) {
|
||||
if (remaining <= 0) return
|
||||
binding?.root?.postDelayed({
|
||||
try {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
} catch (_: IllegalStateException) {
|
||||
// FragmentManager is already executing transactions, so try again
|
||||
delayedDispatchBackPressed(remaining - 1)
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? = createBinding(inflater, container, savedInstanceState)
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
onViewReady(view, savedInstanceState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the device configuration changes (e.g., orientation).
|
||||
* Re-applies system bar padding fixes to the root view to ensure it
|
||||
* readjusts for orientation changes.
|
||||
*/
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
view?.let { fixLayout(it) }
|
||||
}
|
||||
|
||||
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class representing the two strategies for creating a ViewBinding instance.
|
||||
*/
|
||||
sealed class BindingCreator<T : ViewBinding> {
|
||||
|
||||
/**
|
||||
* Use the standard inflate() method for creating the binding.
|
||||
*
|
||||
* @param fn Lambda that inflates the binding.
|
||||
*/
|
||||
class Inflate<T : ViewBinding>(
|
||||
val fn: (LayoutInflater, ViewGroup?, Boolean) -> T
|
||||
) : BindingCreator<T>()
|
||||
|
||||
/**
|
||||
* Use bind() on an existing root view to create the binding. This should
|
||||
* be used if you are differing per device layouts, such as different
|
||||
* layouts for TV and Phone.
|
||||
*
|
||||
* @param fn Lambda that binds the root view.
|
||||
*/
|
||||
class Bind<T : ViewBinding>(
|
||||
val fn: (View) -> T
|
||||
) : BindingCreator<T>()
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseDialogFragment<T : ViewBinding>(
|
||||
override val bindingCreator: BaseFragment.BindingCreator<T>
|
||||
) : DialogFragment(), BaseFragmentHelper<T> {
|
||||
override var _binding: T? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? = createBinding(inflater, container, savedInstanceState)
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
onViewReady(view, savedInstanceState)
|
||||
}
|
||||
|
||||
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
view?.let { fixLayout(it) }
|
||||
}
|
||||
|
||||
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseBottomSheetDialogFragment<T : ViewBinding>(
|
||||
override val bindingCreator: BaseFragment.BindingCreator<T>
|
||||
) : BottomSheetDialogFragment(), BaseFragmentHelper<T> {
|
||||
override var _binding: T? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? = createBinding(inflater, container, savedInstanceState)
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
onViewReady(view, savedInstanceState)
|
||||
}
|
||||
|
||||
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
view?.let { fixLayout(it) }
|
||||
}
|
||||
|
||||
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setSystemBarsPadding()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
setSystemBarsPadding()
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ import android.widget.ImageView
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.ListView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||
import com.google.android.gms.cast.MediaLoadOptions
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.cast.MediaSeekOptions
|
||||
|
|
@ -35,24 +37,35 @@ import com.lagradost.cloudstream3.ui.player.SubtitleData
|
|||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
|
||||
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.json.JSONObject
|
||||
|
||||
/*class SkipOpController(val view: ImageView) : UIController() {
|
||||
init {
|
||||
view.setImageResource(R.drawable.exo_controls_fastforward)
|
||||
view.setOnClickListener {
|
||||
remoteMediaClient?.let {
|
||||
val options = MediaSeekOptions.Builder()
|
||||
.setPosition(it.approximateStreamPosition + 85000)
|
||||
it.seek(options.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
private fun RemoteMediaClient.getItemIndex(): Int? {
|
||||
return try {
|
||||
val index = this.mediaQueue.itemIds.indexOf(this.currentItem?.itemId ?: 0)
|
||||
if (index < 0) null else index
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -79,41 +92,51 @@ class SkipNextEpisodeController(val view: ImageView) : UIController() {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MetadataHolder(
|
||||
@JsonProperty("apiName") @SerialName("apiName") val apiName: String,
|
||||
@JsonProperty("isMovie") @SerialName("isMovie") val isMovie: Boolean,
|
||||
@JsonProperty("title") @SerialName("title") val title: String?,
|
||||
@JsonProperty("poster") @SerialName("poster") val poster: String?,
|
||||
@JsonProperty("currentEpisodeIndex") @SerialName("currentEpisodeIndex") val currentEpisodeIndex: Int,
|
||||
@JsonProperty("episodes") @SerialName("episodes") val episodes: List<ResultEpisode>,
|
||||
@JsonProperty("currentLinks") @SerialName("currentLinks") val currentLinks: List<ExtractorLink>,
|
||||
@JsonProperty("currentSubtitles") @SerialName("currentSubtitles") val currentSubtitles: List<SubtitleData>,
|
||||
val apiName: String,
|
||||
val isMovie: Boolean,
|
||||
val title: String?,
|
||||
val poster: String?,
|
||||
val currentEpisodeIndex: Int,
|
||||
val episodes: List<ResultEpisode>,
|
||||
val currentLinks: List<ExtractorLink>,
|
||||
val currentSubtitles: List<SubtitleData>
|
||||
)
|
||||
|
||||
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() {
|
||||
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
||||
UIController() {
|
||||
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||
|
||||
init {
|
||||
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
||||
view.setOnClickListener {
|
||||
// lateinit var dialog: AlertDialog
|
||||
val holder = getCurrentMetaData()
|
||||
|
||||
if (holder != null) {
|
||||
val items = holder.currentLinks
|
||||
if (items.isNotEmpty() && remoteMediaClient?.currentItem != null) {
|
||||
val subTracks = remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT }
|
||||
val subTracks =
|
||||
remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT }
|
||||
?: ArrayList()
|
||||
|
||||
val bottomSheetDialogBuilder = AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack)
|
||||
val bottomSheetDialogBuilder =
|
||||
AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack)
|
||||
bottomSheetDialogBuilder.setView(R.layout.sort_bottom_sheet)
|
||||
|
||||
val bottomSheetDialog = bottomSheetDialogBuilder.create()
|
||||
bottomSheetDialog.show()
|
||||
|
||||
val providerList = bottomSheetDialog.findViewById<ListView>(R.id.sort_providers)!!
|
||||
val subtitleList = bottomSheetDialog.findViewById<ListView>(R.id.sort_subtitles)!!
|
||||
// bottomSheetDialog.setContentView(R.layout.sort_bottom_sheet)
|
||||
val providerList =
|
||||
bottomSheetDialog.findViewById<ListView>(R.id.sort_providers)!!
|
||||
val subtitleList =
|
||||
bottomSheetDialog.findViewById<ListView>(R.id.sort_subtitles)!!
|
||||
if (subTracks.isEmpty()) {
|
||||
bottomSheetDialog.findViewById<LinearLayout>(R.id.sort_subtitles_holder)?.visibility = GONE
|
||||
bottomSheetDialog.findViewById<LinearLayout>(R.id.sort_subtitles_holder)?.visibility =
|
||||
GONE
|
||||
} else {
|
||||
val arrayAdapter = ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice)
|
||||
val arrayAdapter =
|
||||
ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice)
|
||||
arrayAdapter.add(view.context.getString(R.string.no_subtitles))
|
||||
arrayAdapter.addAll(subTracks.mapNotNull { it.name })
|
||||
|
||||
|
|
@ -121,7 +144,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
subtitleList.adapter = arrayAdapter
|
||||
|
||||
val currentTracks = remoteMediaClient?.mediaStatus?.activeTrackIds
|
||||
val subtitleIndex = if (currentTracks == null) 0 else subTracks.map { it.id }
|
||||
|
||||
val subtitleIndex =
|
||||
if (currentTracks == null) 0 else subTracks.map { it.id }
|
||||
.indexOfFirst { currentTracks.contains(it) } + 1
|
||||
|
||||
subtitleList.setSelection(subtitleIndex)
|
||||
|
|
@ -134,7 +159,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
ChromecastSubtitlesFragment.getCurrentSavedStyle().apply {
|
||||
val font = TextTrackStyle()
|
||||
font.setFontFamily(fontFamily ?: "Google Sans")
|
||||
fontGenericFamily?.let { font.fontGenericFamily = it }
|
||||
fontGenericFamily?.let {
|
||||
font.fontGenericFamily = it
|
||||
}
|
||||
font.windowColor = windowColor
|
||||
font.backgroundColor = backgroundColor
|
||||
|
||||
|
|
@ -164,11 +191,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
val contentUrl = (remoteMediaClient?.currentItem?.media?.contentUrl
|
||||
?: remoteMediaClient?.currentItem?.media?.contentId)
|
||||
|
||||
val sortingMethods = items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" }
|
||||
val sortingMethods =
|
||||
items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" }
|
||||
.toTypedArray()
|
||||
val sotringIndex = items.indexOfFirst { it.url == contentUrl }
|
||||
|
||||
val arrayAdapter = ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice)
|
||||
val arrayAdapter =
|
||||
ArrayAdapter<String>(view.context, R.layout.sort_bottom_single_choice)
|
||||
arrayAdapter.addAll(sortingMethods.toMutableList())
|
||||
|
||||
providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE
|
||||
|
|
@ -178,8 +207,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
|
||||
providerList.setOnItemClickListener { _, _, which, _ ->
|
||||
val epData = holder.episodes[holder.currentEpisodeIndex]
|
||||
|
||||
fun loadMirror(index: Int) {
|
||||
if (holder.currentLinks.size <= index) return
|
||||
|
||||
val mediaItem = getMediaInfo(
|
||||
epData,
|
||||
holder,
|
||||
|
|
@ -189,35 +220,36 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
)
|
||||
|
||||
val startAt = remoteMediaClient?.approximateStreamPosition ?: 0
|
||||
|
||||
//remoteMediaClient.load(mediaItem, true, startAt)
|
||||
try { // THIS IS VERY IMPORTANT BECAUSE WE NEVER WANT TO AUTOLOAD THE NEXT EPISODE
|
||||
val currentIdIndex = remoteMediaClient?.getItemIndex()
|
||||
|
||||
val nextId = remoteMediaClient?.mediaQueue?.itemIds?.get(
|
||||
currentIdIndex?.plus(1) ?: 0
|
||||
)
|
||||
|
||||
if (currentIdIndex == null && nextId != null) {
|
||||
awaitLinks(
|
||||
remoteMediaClient?.queueInsertAndPlayItem(
|
||||
MediaQueueItem.Builder(mediaItem).build(),
|
||||
nextId,
|
||||
startAt,
|
||||
JSONObject(),
|
||||
JSONObject()
|
||||
)
|
||||
) { loadMirror(index + 1) }
|
||||
) {
|
||||
loadMirror(index + 1)
|
||||
}
|
||||
} else {
|
||||
val mediaLoadOptions =
|
||||
MediaLoadOptions.Builder()
|
||||
.setPlayPosition(startAt)
|
||||
.setAutoplay(true)
|
||||
.build()
|
||||
awaitLinks(
|
||||
remoteMediaClient?.load(
|
||||
mediaItem,
|
||||
mediaLoadOptions
|
||||
)
|
||||
) { loadMirror(index + 1) }
|
||||
awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
|
||||
loadMirror(index + 1)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val mediaLoadOptions =
|
||||
MediaLoadOptions.Builder()
|
||||
.setPlayPosition(startAt)
|
||||
|
|
@ -228,8 +260,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMirror(which)
|
||||
|
||||
bottomSheetDialog.dismissSafe(activity)
|
||||
}
|
||||
}
|
||||
|
|
@ -239,19 +271,23 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
|
||||
private fun getCurrentMetaData(): MetadataHolder? {
|
||||
return try {
|
||||
val data = remoteMediaClient?.mediaInfo?.customData?.toString() ?: return null
|
||||
parseJson<MetadataHolder>(data)
|
||||
} catch (_: Exception) {
|
||||
val data = remoteMediaClient?.mediaInfo?.customData?.toString()
|
||||
data?.toKotlinObject()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
var isLoadingMore = false
|
||||
|
||||
|
||||
override fun onMediaStatusUpdated() {
|
||||
super.onMediaStatusUpdated()
|
||||
val meta = getCurrentMetaData()
|
||||
view.visibility = if ((meta?.currentLinks?.size ?: 0) > 1) VISIBLE else INVISIBLE
|
||||
|
||||
view.visibility = if ((meta?.currentLinks?.size
|
||||
?: 0) > 1
|
||||
) VISIBLE else INVISIBLE
|
||||
try {
|
||||
if (meta != null && meta.episodes.size > meta.currentEpisodeIndex + 1) {
|
||||
val currentIdIndex = remoteMediaClient?.getItemIndex() ?: return
|
||||
|
|
@ -263,13 +299,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
val currentDuration = remoteMediaClient?.streamDuration
|
||||
val currentPosition = remoteMediaClient?.approximateStreamPosition
|
||||
if (currentDuration != null && currentPosition != null)
|
||||
DataStoreHelper.setViewPosAndResume(
|
||||
epData.id,
|
||||
currentPosition,
|
||||
currentDuration,
|
||||
epData,
|
||||
meta.episodes.getOrNull(index + 1),
|
||||
)
|
||||
DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
}
|
||||
|
|
@ -279,11 +309,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
ioSafe {
|
||||
val currentLinks = mutableSetOf<ExtractorLink>()
|
||||
val currentSubs = mutableSetOf<SubtitleData>()
|
||||
|
||||
val generator = RepoLinkGenerator(listOf(epData))
|
||||
|
||||
val isSuccessful = safeApiCall {
|
||||
generator.generateLinks(
|
||||
clearCache = false,
|
||||
sourceTypes = LOADTYPE_CHROMECAST,
|
||||
allowedTypes = LOADTYPE_CHROMECAST,
|
||||
callback = {
|
||||
it.first?.let { link ->
|
||||
currentLinks.add(link)
|
||||
|
|
@ -291,9 +323,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
}, subtitleCallback = {
|
||||
currentSubs.add(it)
|
||||
},
|
||||
offset = 0,
|
||||
isCasting = true,
|
||||
)
|
||||
isCasting = true)
|
||||
}
|
||||
|
||||
val sortedLinks = sortUrls(currentLinks)
|
||||
|
|
@ -303,18 +333,32 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
val jsonCopy = meta.copy(
|
||||
currentLinks = sortedLinks,
|
||||
currentSubtitles = sortedSubs,
|
||||
currentEpisodeIndex = index,
|
||||
currentEpisodeIndex = index
|
||||
)
|
||||
|
||||
val done = JSONObject(jsonCopy.toJson())
|
||||
val done =
|
||||
JSONObject(jsonCopy.toJson())
|
||||
|
||||
val mediaInfo = getMediaInfo(
|
||||
epData,
|
||||
jsonCopy,
|
||||
0,
|
||||
done,
|
||||
sortedSubs,
|
||||
sortedSubs
|
||||
)
|
||||
|
||||
/*fun loadIndex(index: Int) {
|
||||
println("LOAD INDEX::::: $index")
|
||||
if (meta.currentLinks.size <= index) return
|
||||
val info = getMediaInfo(
|
||||
epData,
|
||||
meta,
|
||||
index,
|
||||
done)
|
||||
awaitLinks(remoteMediaClient?.load(info, true, 0)) {
|
||||
loadIndex(index + 1)
|
||||
}
|
||||
}*/
|
||||
activity.runOnUiThread {
|
||||
awaitLinks(
|
||||
remoteMediaClient?.queueAppendItem(
|
||||
|
|
@ -323,6 +367,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
)
|
||||
) {
|
||||
println("FAILED TO LOAD NEXT ITEM")
|
||||
// loadIndex(1)
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
|
@ -345,7 +390,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
|
||||
class SkipTimeController(val view: ImageView, forwards: Boolean) : UIController() {
|
||||
init {
|
||||
//val settingsManager = PreferenceManager.getDefaultSharedPreferences()
|
||||
//val time = settingsManager?.getInt("chromecast_tap_time", 30) ?: 30
|
||||
val time = 30
|
||||
//view.setImageResource(if (forwards) R.drawable.netflix_skip_forward else R.drawable.netflix_skip_back)
|
||||
view.setImageResource(if (forwards) R.drawable.go_forward_30 else R.drawable.go_back_30)
|
||||
view.setOnClickListener {
|
||||
remoteMediaClient?.let {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui
|
|||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
|
@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
|
|||
|
||||
init {
|
||||
if (attrs != null) {
|
||||
context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) {
|
||||
columnWidth = getDimensionPixelSize(0, -1)
|
||||
}
|
||||
val attrsArray = intArrayOf(android.R.attr.columnWidth)
|
||||
val array = context.obtainStyledAttributes(attrs, attrsArray)
|
||||
columnWidth = array.getDimensionPixelSize(0, -1)
|
||||
array.recycle()
|
||||
}
|
||||
|
||||
layoutManager = manager
|
||||
|
|
|
|||
|
|
@ -4,13 +4,17 @@ import android.animation.Animator
|
|||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding
|
||||
|
|
@ -22,9 +26,10 @@ import kotlinx.coroutines.isActive
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlin.random.Random
|
||||
|
||||
class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||
BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate)
|
||||
) {
|
||||
class EasterEggMonkeFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentEasterEggMonkeBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
// planet of monks
|
||||
private val monkeys: List<Int> = listOf(
|
||||
|
|
@ -46,20 +51,27 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
|||
private val activeMonkeys = mutableListOf<ImageView>()
|
||||
private var spawningJob: Job? = null
|
||||
|
||||
override fun fixLayout(view: View) = Unit
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentEasterEggMonkeBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) {
|
||||
activity?.hideSystemUI()
|
||||
spawningJob = lifecycleScope.launch {
|
||||
delay(1000)
|
||||
while (isActive) {
|
||||
spawnMonkey(binding)
|
||||
spawnMonkey()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) {
|
||||
private fun spawnMonkey() {
|
||||
val newMonkey = ImageView(context ?: return).apply {
|
||||
setImageResource(monkeys.random())
|
||||
isVisible = true
|
||||
|
|
@ -90,12 +102,12 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
|||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) }
|
||||
newMonkey.setOnTouchListener { view, event -> handleTouch(view, event) }
|
||||
|
||||
startFloatingAnimation(newMonkey, binding)
|
||||
startFloatingAnimation(newMonkey)
|
||||
}
|
||||
|
||||
private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) {
|
||||
private fun startFloatingAnimation(monkey: ImageView) {
|
||||
val floatUpAnimator = ObjectAnimator.ofFloat(
|
||||
monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat()
|
||||
).apply {
|
||||
|
|
@ -105,20 +117,19 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
|||
|
||||
floatUpAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
// necessary check because binding becomes null but monkes are still moving until onDestroy()
|
||||
if (_binding != null) {
|
||||
binding.frame.removeView(monkey)
|
||||
activeMonkeys.remove(monkey)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
floatUpAnimator.start()
|
||||
monkey.tag = floatUpAnimator
|
||||
}
|
||||
|
||||
private fun handleTouch(
|
||||
view: View,
|
||||
event: MotionEvent,
|
||||
binding: FragmentEasterEggMonkeBinding
|
||||
): Boolean {
|
||||
private fun handleTouch(view: View, event: MotionEvent): Boolean {
|
||||
val monkey = view as ImageView
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
|
|
@ -132,17 +143,17 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
|||
monkey.y = event.rawY - monkey.height / 2
|
||||
|
||||
// Check if monkey touches the screen edge
|
||||
if (isTouchingEdge(monkey, binding)) {
|
||||
removeMonkey(monkey, binding)
|
||||
if (isTouchingEdge(monkey)) {
|
||||
removeMonkey(monkey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
if (isTouchingEdge(monkey, binding)) {
|
||||
removeMonkey(monkey, binding)
|
||||
if (isTouchingEdge(monkey)) {
|
||||
removeMonkey(monkey)
|
||||
} else {
|
||||
startFloatingAnimation(monkey, binding)
|
||||
startFloatingAnimation(monkey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -150,12 +161,12 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
|||
return false
|
||||
}
|
||||
|
||||
private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean {
|
||||
private fun isTouchingEdge(monkey: ImageView): Boolean {
|
||||
return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width ||
|
||||
monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height
|
||||
}
|
||||
|
||||
private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) {
|
||||
private fun removeMonkey(monkey: ImageView) {
|
||||
// Fade out and remove the monkey
|
||||
ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply {
|
||||
duration = 300
|
||||
|
|
@ -173,5 +184,6 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
|||
super.onDestroyView()
|
||||
activity?.showSystemUI()
|
||||
spawningJob?.cancel()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() {
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDraw(c, parent, state)
|
||||
customView.layout(parent.left, 0, parent.right, customView.measuredHeight)
|
||||
for (i in 0 until parent.childCount) {
|
||||
val view = parent.getChildAt(i)
|
||||
if (parent.getChildAdapterPosition(view) == 0) {
|
||||
c.save()
|
||||
val height = customView.measuredHeight
|
||||
val top = view.top - height
|
||||
c.translate(0f, top.toFloat())
|
||||
customView.draw(c)
|
||||
c.restore()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (parent.getChildAdapterPosition(view) == 0) {
|
||||
customView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST),
|
||||
View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST)
|
||||
)
|
||||
outRect.set(0, customView.measuredHeight, 0, 0)
|
||||
} else {
|
||||
outRect.setEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,12 @@ import android.view.View
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
|
||||
class MyMiniControllerFragment : MiniControllerFragment() {
|
||||
|
|
@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() {
|
|||
|
||||
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
|
||||
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
|
||||
if (currentColor == 0) {
|
||||
context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) {
|
||||
if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) {
|
||||
currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
WeakReference(
|
||||
context.obtainStyledAttributes(
|
||||
attributeSet,
|
||||
R.styleable.CustomCast
|
||||
)
|
||||
).apply {
|
||||
if (get()
|
||||
?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true
|
||||
) {
|
||||
currentColor =
|
||||
get()
|
||||
?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0
|
||||
}
|
||||
get()?.recycle()
|
||||
}.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
|
|
@ -14,18 +19,19 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
|
|||
import com.lagradost.cloudstream3.network.WebViewResolver
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
|
||||
|
||||
class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
|
||||
BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate)
|
||||
) {
|
||||
|
||||
override fun fixLayout(view: View) = Unit
|
||||
class WebviewFragment : Fragment() {
|
||||
|
||||
override fun onBindingCreated(binding: FragmentWebviewBinding) {
|
||||
var binding: FragmentWebviewBinding? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.webView.webViewClient = object : WebViewClient() {
|
||||
binding?.webView?.webViewClient = object : WebViewClient() {
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
|
|
@ -40,17 +46,28 @@ class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
|
|||
return super.shouldOverrideUrlLoading(view, request)
|
||||
}
|
||||
}
|
||||
|
||||
binding.webView.apply {
|
||||
binding?.webView?.apply {
|
||||
WebViewResolver.webViewUserAgent = settings.userAgentString
|
||||
|
||||
addJavascriptInterface(RepoApi(activity), "RepoApi")
|
||||
settings.javaScriptEnabled = true
|
||||
settings.userAgentString = USER_AGENT
|
||||
settings.domStorageEnabled = true
|
||||
// WebView.setWebContentsDebuggingEnabled(true)
|
||||
|
||||
loadUrl(url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
|
||||
binding = localBinding
|
||||
// Inflate the layout for this fragment
|
||||
return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
package com.lagradost.cloudstream3.ui.account
|
||||
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import coil3.transform.RoundedCornersTransformation
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
|
||||
import com.lagradost.cloudstream3.databinding.AccountListItemBinding
|
||||
import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
|
||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
|
|
@ -20,39 +19,34 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
|
|||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
|
||||
class AccountAdapter(
|
||||
private val accounts: List<DataStoreHelper.Account>,
|
||||
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
|
||||
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
|
||||
private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
|
||||
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
|
||||
) : NoStateAdapter<DataStoreHelper.Account>() {
|
||||
) : RecyclerView.Adapter<AccountAdapter.AccountViewHolder>() {
|
||||
|
||||
companion object {
|
||||
const val VIEW_TYPE_SELECT_ACCOUNT = 0
|
||||
const val VIEW_TYPE_ADD_ACCOUNT = 1
|
||||
const val VIEW_TYPE_EDIT_ACCOUNT = 2
|
||||
}
|
||||
|
||||
inner class AccountViewHolder(private val binding: ViewBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override val footers: Int = 1
|
||||
var viewType = VIEW_TYPE_SELECT_ACCOUNT
|
||||
|
||||
override fun customContentViewType(item: DataStoreHelper.Account): Int {
|
||||
return viewType
|
||||
}
|
||||
|
||||
override fun onBindContent(
|
||||
holder: ViewHolderState<Any>,
|
||||
item: DataStoreHelper.Account,
|
||||
position: Int
|
||||
) {
|
||||
when (val binding = holder.view) {
|
||||
fun bind(account: DataStoreHelper.Account?) {
|
||||
when (binding) {
|
||||
is AccountListItemBinding -> binding.apply {
|
||||
if (account == null) return@apply
|
||||
|
||||
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
||||
|
||||
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||
|
||||
accountName.text = item.name
|
||||
accountImage.loadImage(item.image)
|
||||
lockIcon.isVisible = item.lockPin != null
|
||||
accountName.text = account.name
|
||||
accountImage.loadImage(account.image)
|
||||
lockIcon.isVisible = account.lockPin != null
|
||||
outline.isVisible = !isTv && isLastUsedAccount
|
||||
|
||||
if (isTv) {
|
||||
|
|
@ -62,28 +56,18 @@ class AccountAdapter(
|
|||
root.requestFocus()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
root.foreground = ContextCompat.getDrawable(
|
||||
root.context,
|
||||
R.drawable.outline_drawable
|
||||
)
|
||||
}
|
||||
} else {
|
||||
root.setOnLongClickListener {
|
||||
showAccountEditDialog(
|
||||
context = root.context,
|
||||
account = item,
|
||||
account = account,
|
||||
isNewAccount = false,
|
||||
accountEditCallback = { account ->
|
||||
accountEditCallback.invoke(
|
||||
account
|
||||
)
|
||||
},
|
||||
accountDeleteCallback = { account ->
|
||||
accountDeleteCallback.invoke(
|
||||
account
|
||||
)
|
||||
}
|
||||
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
||||
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
|
||||
)
|
||||
|
||||
true
|
||||
|
|
@ -91,20 +75,22 @@ class AccountAdapter(
|
|||
}
|
||||
|
||||
root.setOnClickListener {
|
||||
accountSelectCallback.invoke(item)
|
||||
accountSelectCallback.invoke(account)
|
||||
}
|
||||
}
|
||||
|
||||
is AccountListItemEditBinding -> binding.apply {
|
||||
if (account == null) return@apply
|
||||
|
||||
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
||||
|
||||
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||
|
||||
accountName.text = item.name
|
||||
accountImage.loadImage(item.image) {
|
||||
accountName.text = account.name
|
||||
accountImage.loadImage(account.image) {
|
||||
RoundedCornersTransformation(10f)
|
||||
}
|
||||
lockIcon.isVisible = item.lockPin != null
|
||||
lockIcon.isVisible = account.lockPin != null
|
||||
outline.isVisible = !isTv && isLastUsedAccount
|
||||
|
||||
if (isTv) {
|
||||
|
|
@ -114,47 +100,31 @@ class AccountAdapter(
|
|||
root.requestFocus()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
root.foreground = ContextCompat.getDrawable(
|
||||
root.context,
|
||||
R.drawable.outline_drawable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
root.setOnClickListener {
|
||||
showAccountEditDialog(
|
||||
context = root.context,
|
||||
account = item,
|
||||
account = account,
|
||||
isNewAccount = false,
|
||||
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
||||
accountDeleteCallback = { account ->
|
||||
accountDeleteCallback.invoke(
|
||||
account
|
||||
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindFooter(holder: ViewHolderState<Any>) {
|
||||
val binding = holder.view as? AccountListItemAddBinding ?: return
|
||||
binding.apply {
|
||||
is AccountListItemAddBinding -> binding.apply {
|
||||
root.setOnClickListener {
|
||||
val accounts = this@AccountAdapter.immutableCurrentList
|
||||
|
||||
val remainingImages =
|
||||
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
|
||||
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }
|
||||
.toSet()
|
||||
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
|
||||
|
||||
val image =
|
||||
DataStoreHelper.profileImages.indexOf(
|
||||
remainingImages.randomOrNull()
|
||||
?: DataStoreHelper.profileImages.random()
|
||||
)
|
||||
DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
|
||||
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
|
||||
|
||||
val accountName = root.context.getString(R.string.account)
|
||||
|
|
@ -174,20 +144,12 @@ class AccountAdapter(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateFooter(parent: ViewGroup): ViewHolderState<Any> {
|
||||
return ViewHolderState(
|
||||
AccountListItemAddBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
||||
return ViewHolderState(
|
||||
when (viewType) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
|
||||
AccountViewHolder(
|
||||
binding = when (viewType) {
|
||||
VIEW_TYPE_SELECT_ACCOUNT -> {
|
||||
AccountListItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
|
|
@ -195,7 +157,13 @@ class AccountAdapter(
|
|||
false
|
||||
)
|
||||
}
|
||||
|
||||
VIEW_TYPE_ADD_ACCOUNT -> {
|
||||
AccountListItemAddBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
}
|
||||
VIEW_TYPE_EDIT_ACCOUNT -> {
|
||||
AccountListItemEditBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
|
|
@ -203,9 +171,28 @@ class AccountAdapter(
|
|||
false
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid view type")
|
||||
}
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
|
||||
holder.bind(accounts.getOrNull(position))
|
||||
}
|
||||
|
||||
var viewType = 0
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
if (viewType != 0 && position != accounts.count()) {
|
||||
return viewType
|
||||
}
|
||||
|
||||
return when (position) {
|
||||
accounts.count() -> VIEW_TYPE_ADD_ACCOUNT
|
||||
else -> VIEW_TYPE_SELECT_ACCOUNT
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return accounts.count() + 1
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import android.text.Editable
|
|||
import android.view.LayoutInflater
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
|
|
@ -17,17 +16,12 @@ import androidx.core.widget.doOnTextChanged
|
|||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowHardware
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding
|
||||
import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding
|
||||
import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding
|
||||
import com.lagradost.cloudstream3.databinding.LockPinDialogBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
|
|
@ -37,10 +31,8 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
|
|||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideProgress
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.showProgress
|
||||
|
||||
object AccountHelper {
|
||||
fun showAccountEditDialog(
|
||||
|
|
@ -102,7 +94,6 @@ object AccountHelper {
|
|||
binding.accountImage.loadImage(account.image)
|
||||
binding.accountImage.setOnClickListener {
|
||||
// Roll the image forwards once
|
||||
currentEditAccount = currentEditAccount.copy(customImage = null)
|
||||
currentEditAccount =
|
||||
currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size)
|
||||
binding.accountImage.loadImage(currentEditAccount.image)
|
||||
|
|
@ -165,57 +156,6 @@ object AccountHelper {
|
|||
}
|
||||
|
||||
canSetPin = true
|
||||
|
||||
binding.editProfilePhotoButton.setOnClickListener {
|
||||
val bottomSheetDialog = BottomSheetDialog(context)
|
||||
val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context))
|
||||
bottomSheetDialog.setContentView(sheetBinding.root)
|
||||
bottomSheetDialog.show()
|
||||
|
||||
sheetBinding.apply {
|
||||
text1.text = context.getString(R.string.edit_profile_image_title)
|
||||
nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint)
|
||||
|
||||
applyBtt.setOnClickListener {
|
||||
val url = sheetBinding.nginxTextInput.text.toString()
|
||||
if (url.isEmpty()) {
|
||||
showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT)
|
||||
return@setOnClickListener
|
||||
}
|
||||
applyBtt.showProgress()
|
||||
val imageLoader = ImageLoader(context)
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.allowHardware(false)
|
||||
.listener(
|
||||
onSuccess = { _, _ ->
|
||||
currentEditAccount = currentEditAccount.copy(customImage = url)
|
||||
binding.accountImage.loadImage(url)
|
||||
showToast(
|
||||
R.string.edit_profile_image_success,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
bottomSheetDialog.dismissSafe()
|
||||
},
|
||||
onError = { _, _ ->
|
||||
showToast(
|
||||
R.string.edit_profile_image_error_invalid,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
applyBtt.hideProgress()
|
||||
},
|
||||
onCancel = {
|
||||
applyBtt.hideProgress()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
imageLoader.enqueue(request)
|
||||
}
|
||||
sheetBinding.cancelBtt.setOnClickListener {
|
||||
bottomSheetDialog.dismissSafe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showPinInputDialog(
|
||||
|
|
@ -398,6 +338,7 @@ object AccountHelper {
|
|||
|
||||
activity.observe(viewModel.accounts) { liveAccounts ->
|
||||
recyclerView.adapter = AccountAdapter(
|
||||
liveAccounts,
|
||||
accountSelectCallback = { account ->
|
||||
viewModel.handleAccountSelect(account, activity)
|
||||
builder.dismissSafe()
|
||||
|
|
@ -405,9 +346,7 @@ object AccountHelper {
|
|||
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
|
||||
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
|
||||
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
|
||||
).apply {
|
||||
submitList(liveAccounts)
|
||||
}
|
||||
)
|
||||
|
||||
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
|
||||
// Scroll to current account (which is focused by default)
|
||||
|
|
|
|||
|
|
@ -31,22 +31,20 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut
|
|||
import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.openActivity
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
||||
|
||||
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||
|
||||
companion object {
|
||||
var hasLoggedIn: Boolean = false
|
||||
}
|
||||
|
||||
val accountViewModel: AccountViewModel by viewModels()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
loadThemes(this)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
|
||||
|
||||
// Are we editing and coming from MainActivity?
|
||||
val isEditingFromMainActivity = intent.getBooleanExtra(
|
||||
|
|
@ -54,22 +52,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
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 skipStartup = settingsManager.getBoolean(
|
||||
getString(R.string.skip_startup_account_select_key), false
|
||||
val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
|
||||
) || accounts.count() <= 1
|
||||
|
||||
fun askBiometricAuth() {
|
||||
|
|
@ -105,12 +89,10 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
accountViewModel.handleAccountSelect(currentAccount, this, true)
|
||||
} else {
|
||||
if (accounts.count() > 1) {
|
||||
showToast(
|
||||
this, getString(
|
||||
showToast(this, getString(
|
||||
R.string.logged_account,
|
||||
currentAccount?.name
|
||||
)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
navigateToMainActivity()
|
||||
|
|
@ -123,12 +105,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
|
||||
val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
fixSystemBarsPadding(binding.root, padTop = false)
|
||||
|
||||
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
|
||||
|
||||
observe(accountViewModel.accounts) { liveAccounts ->
|
||||
val adapter = AccountAdapter(
|
||||
liveAccounts,
|
||||
// Handle the selected account
|
||||
accountSelectCallback = {
|
||||
accountViewModel.handleAccountSelect(it, this)
|
||||
|
|
@ -136,6 +118,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) },
|
||||
accountEditCallback = {
|
||||
accountViewModel.handleAccountUpdate(it, this)
|
||||
|
||||
// We came from MainActivity, return there
|
||||
// and switch to the edited account
|
||||
if (isEditingFromMainActivity) {
|
||||
|
|
@ -144,9 +127,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
}
|
||||
},
|
||||
accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) }
|
||||
).apply {
|
||||
submitList(liveAccounts)
|
||||
}
|
||||
)
|
||||
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
|
|
@ -201,11 +182,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
askBiometricAuth()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeIntentLaunch")
|
||||
private fun navigateToMainActivity() {
|
||||
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)
|
||||
openActivity(MainActivity::class.java)
|
||||
finish() // Finish the account selection activity
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import android.content.Context
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
|
|
@ -8,18 +7,19 @@ import android.widget.CheckBox
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
|
||||
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||
import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
|
||||
const val DOWNLOAD_ACTION_PLAY_FILE = 0
|
||||
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
||||
|
|
@ -27,7 +27,6 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
|
|||
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
|
||||
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
||||
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_LOAD_RESULT = 1
|
||||
|
|
@ -35,22 +34,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
|||
sealed class VisualDownloadCached {
|
||||
abstract val currentBytes: Long
|
||||
abstract val totalBytes: Long
|
||||
abstract val data: DownloadObjects.DownloadCached
|
||||
abstract val data: VideoDownloadHelper.DownloadCached
|
||||
abstract var isSelected: Boolean
|
||||
|
||||
data class Child(
|
||||
override val currentBytes: Long,
|
||||
override val totalBytes: Long,
|
||||
override val data: DownloadObjects.DownloadEpisodeCached,
|
||||
override val data: VideoDownloadHelper.DownloadEpisodeCached,
|
||||
override var isSelected: Boolean,
|
||||
) : VisualDownloadCached()
|
||||
|
||||
data class Header(
|
||||
override val currentBytes: Long,
|
||||
override val totalBytes: Long,
|
||||
override val data: DownloadObjects.DownloadHeaderCached,
|
||||
override val data: VideoDownloadHelper.DownloadHeaderCached,
|
||||
override var isSelected: Boolean,
|
||||
val child: DownloadObjects.DownloadEpisodeCached?,
|
||||
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
||||
val currentOngoingDownloads: Int,
|
||||
val totalDownloads: Int,
|
||||
) : VisualDownloadCached()
|
||||
|
|
@ -58,19 +57,19 @@ sealed class VisualDownloadCached {
|
|||
|
||||
data class DownloadClickEvent(
|
||||
val action: Int,
|
||||
val data: DownloadObjects.DownloadEpisodeCached
|
||||
val data: VideoDownloadHelper.DownloadEpisodeCached
|
||||
)
|
||||
|
||||
data class DownloadHeaderClickEvent(
|
||||
val action: Int,
|
||||
val data: DownloadObjects.DownloadHeaderCached
|
||||
val data: VideoDownloadHelper.DownloadHeaderCached
|
||||
)
|
||||
|
||||
class DownloadAdapter(
|
||||
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
|
||||
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
|
||||
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
|
||||
) : NoStateAdapter<VisualDownloadCached>(DiffCallback()) {
|
||||
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
|
||||
|
||||
private var isMultiDeleteState: Boolean = false
|
||||
|
||||
|
|
@ -79,8 +78,18 @@ class DownloadAdapter(
|
|||
private const val VIEW_TYPE_CHILD = 1
|
||||
}
|
||||
|
||||
inner class DownloadViewHolder(
|
||||
private val binding: ViewBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) {
|
||||
fun bind(card: VisualDownloadCached?) {
|
||||
when (binding) {
|
||||
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
|
||||
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindHeader(card: VisualDownloadCached.Header?) {
|
||||
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
|
||||
|
||||
val data = card.data
|
||||
|
|
@ -90,16 +99,12 @@ class DownloadAdapter(
|
|||
setOnClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
}
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
setOnLongClickListener {
|
||||
onItemSelectionChanged.invoke(data.id, true)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downloadHeaderPoster.apply {
|
||||
|
|
@ -125,7 +130,7 @@ class DownloadAdapter(
|
|||
}
|
||||
}
|
||||
downloadHeaderTitle.text = data.name
|
||||
val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes)
|
||||
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
|
||||
|
||||
if (card.child != null) {
|
||||
handleChildDownload(card, formattedSize)
|
||||
|
|
@ -152,26 +157,15 @@ class DownloadAdapter(
|
|||
downloadHeaderGotoChild.isVisible = false
|
||||
|
||||
val posDur = getViewPos(card.data.id)
|
||||
watchProgressContainer.isVisible = true
|
||||
downloadHeaderEpisodeProgress.apply {
|
||||
isVisible = posDur != null
|
||||
posDur?.let {
|
||||
val max = (it.duration / 1000).toInt()
|
||||
val progress = (it.position / 1000).toInt()
|
||||
|
||||
if (max > 0 && progress >= (0.95 * max).toInt()) {
|
||||
playIcon.setImageResource(R.drawable.ic_baseline_check_24)
|
||||
isVisible = false
|
||||
} else {
|
||||
playIcon.setImageResource(R.drawable.netflix_play)
|
||||
this.max = max
|
||||
this.progress = progress
|
||||
isVisible = true
|
||||
}
|
||||
val visualPos = it.fixVisual()
|
||||
max = (visualPos.duration / 1000).toInt()
|
||||
progress = (visualPos.position / 1000).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
downloadButton.resetView()
|
||||
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
||||
if (status == DownloadStatusTell.IsDone) {
|
||||
// We do this here instead if we are finished downloading
|
||||
|
|
@ -189,6 +183,7 @@ class DownloadAdapter(
|
|||
} else {
|
||||
// We need to make sure we restore the correct progress
|
||||
// when we refresh data in the adapter.
|
||||
downloadButton.resetView()
|
||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||
ContextCompat.getDrawable(downloadButton.context, it)
|
||||
}
|
||||
|
|
@ -200,7 +195,6 @@ class DownloadAdapter(
|
|||
)
|
||||
}
|
||||
|
||||
downloadHeaderInfo.isVisible = true
|
||||
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
|
||||
downloadButton.isVisible = !isMultiDeleteState
|
||||
|
||||
|
|
@ -220,14 +214,11 @@ class DownloadAdapter(
|
|||
card: VisualDownloadCached.Header,
|
||||
formattedSize: String
|
||||
) {
|
||||
downloadButton.resetViewData()
|
||||
watchProgressContainer.isVisible = false
|
||||
downloadButton.isVisible = false
|
||||
downloadHeaderEpisodeProgress.isVisible = false
|
||||
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
|
||||
|
||||
try {
|
||||
downloadHeaderInfo.isVisible = true
|
||||
downloadHeaderInfo.text =
|
||||
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
|
||||
card.totalDownloads,
|
||||
|
|
@ -254,7 +245,7 @@ class DownloadAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) {
|
||||
private fun bindChild(card: VisualDownloadCached.Child?) {
|
||||
if (binding !is DownloadChildEpisodeBinding || card == null) return
|
||||
|
||||
val data = card.data
|
||||
|
|
@ -263,22 +254,12 @@ class DownloadAdapter(
|
|||
downloadChildEpisodeProgress.apply {
|
||||
isVisible = posDur != null
|
||||
posDur?.let {
|
||||
val max = (it.duration / 1000).toInt()
|
||||
val progress = (it.position / 1000).toInt()
|
||||
|
||||
if (max > 0 && progress >= (0.95 * max).toInt()) {
|
||||
downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24)
|
||||
isVisible = false
|
||||
} else {
|
||||
downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent)
|
||||
this.max = max
|
||||
this.progress = progress
|
||||
isVisible = true
|
||||
}
|
||||
val visualPos = it.fixVisual()
|
||||
max = (visualPos.duration / 1000).toInt()
|
||||
progress = (visualPos.position / 1000).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
downloadButton.resetView()
|
||||
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
||||
if (status == DownloadStatusTell.IsDone) {
|
||||
// We do this here instead if we are finished downloading
|
||||
|
|
@ -297,6 +278,7 @@ class DownloadAdapter(
|
|||
} else {
|
||||
// We need to make sure we restore the correct progress
|
||||
// when we refresh data in the adapter.
|
||||
downloadButton.resetView()
|
||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||
ContextCompat.getDrawable(downloadButton.context, it)
|
||||
}
|
||||
|
|
@ -330,10 +312,6 @@ class DownloadAdapter(
|
|||
setOnClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
}
|
||||
setOnLongClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
|
@ -345,14 +323,14 @@ class DownloadAdapter(
|
|||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
onItemSelectionChanged.invoke(data.id, true)
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiDeleteState) {
|
||||
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
|
|
@ -366,47 +344,50 @@ class DownloadAdapter(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState<Any> {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = when (viewType) {
|
||||
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
|
||||
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
|
||||
else -> throw IllegalArgumentException("Invalid view type")
|
||||
}
|
||||
return ViewHolderState(binding)
|
||||
return DownloadViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindContent(
|
||||
holder: ViewHolderState<Any>,
|
||||
item: VisualDownloadCached,
|
||||
position: Int
|
||||
) {
|
||||
when (val binding = holder.view) {
|
||||
is DownloadHeaderEpisodeBinding -> bindHeader(
|
||||
binding,
|
||||
item as? VisualDownloadCached.Header
|
||||
)
|
||||
|
||||
is DownloadChildEpisodeBinding -> bindChild(
|
||||
binding,
|
||||
item as? VisualDownloadCached.Child
|
||||
)
|
||||
}
|
||||
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
override fun customContentViewType(item: VisualDownloadCached): Int {
|
||||
return when (item) {
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (getItem(position)) {
|
||||
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
|
||||
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
|
||||
else -> throw IllegalArgumentException("Invalid data type at position $position")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun setIsMultiDeleteState(value: Boolean) {
|
||||
if (isMultiDeleteState == value) return
|
||||
isMultiDeleteState = value
|
||||
notifyDataSetChanged() // This is shit, but what can you do?
|
||||
notifyItemRangeChanged(0, itemCount)
|
||||
}
|
||||
|
||||
fun notifyAllSelected() {
|
||||
currentList.indices.forEach { index ->
|
||||
if (!currentList[index].isSelected) {
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifySelectionStates() {
|
||||
currentList.indices.forEach { index ->
|
||||
if (currentList[index].isSelected) {
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import android.content.DialogInterface
|
|||
import android.net.Uri
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
|
||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
|
@ -18,9 +18,8 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
|||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.coroutines.MainScope
|
||||
|
||||
object DownloadButtonSetup {
|
||||
|
|
@ -83,7 +82,7 @@ object DownloadButtonSetup {
|
|||
} else {
|
||||
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
|
||||
if (pkg != null) {
|
||||
DownloadQueueManager.addToQueue(pkg.toWrapper())
|
||||
VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
|
||||
} else {
|
||||
VideoDownloadManager.downloadEvent.invoke(
|
||||
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
|
||||
|
|
@ -96,7 +95,7 @@ object DownloadButtonSetup {
|
|||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||
activity?.let { act ->
|
||||
val length =
|
||||
VideoDownloadManager.getDownloadFileInfo(
|
||||
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
|
||||
act,
|
||||
click.data.id
|
||||
)?.fileLength
|
||||
|
|
@ -111,31 +110,24 @@ object DownloadButtonSetup {
|
|||
}
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_CANCEL_PENDING -> {
|
||||
DownloadQueueManager.cancelDownload(id)
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_PLAY_FILE -> {
|
||||
activity?.let { act ->
|
||||
val parent = getKey<DownloadObjects.DownloadHeaderCached>(
|
||||
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
click.data.parentId.toString()
|
||||
) ?: return
|
||||
|
||||
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||
?.mapNotNull {
|
||||
getKey<DownloadObjects.DownloadEpisodeCached>(it)
|
||||
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
|
||||
}
|
||||
?.filter { it.parentId == click.data.parentId }
|
||||
|
||||
val items = mutableListOf<ExtractorUri>()
|
||||
val allRelevantEpisodes =
|
||||
episodes?.sortedWith(compareBy<DownloadObjects.DownloadEpisodeCached> {
|
||||
it.season ?: 0
|
||||
}.thenBy { it.episode })
|
||||
val allRelevantEpisodes = episodes?.sortedWith(compareBy<VideoDownloadHelper.DownloadEpisodeCached> { it.season ?: 0 }.thenBy { it.episode })
|
||||
|
||||
allRelevantEpisodes?.forEach {
|
||||
val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>(
|
||||
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
|
||||
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
||||
it.id.toString()
|
||||
) ?: return@forEach
|
||||
|
|
@ -149,7 +141,7 @@ object DownloadButtonSetup {
|
|||
uri = Uri.EMPTY,
|
||||
id = it.id,
|
||||
parentId = it.parentId,
|
||||
name = it.name ?: act.getString(R.string.downloaded_file),
|
||||
name = act.getString(R.string.downloaded_file),
|
||||
season = it.season,
|
||||
episode = it.episode,
|
||||
headerName = parent.name,
|
||||
|
|
@ -162,8 +154,7 @@ object DownloadButtonSetup {
|
|||
}
|
||||
act.navigate(
|
||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||
DownloadFileGenerator(items),
|
||||
items.indexOfFirst { it.id == click.data.id }
|
||||
DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,32 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.view.isGone
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||
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.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||
|
||||
class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
||||
BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate)
|
||||
) {
|
||||
|
||||
private val downloadViewModel: DownloadViewModel by activityViewModels()
|
||||
class DownloadChildFragment : Fragment() {
|
||||
private lateinit var downloadsViewModel: DownloadViewModel
|
||||
private var binding: FragmentChildDownloadsBinding? = null
|
||||
|
||||
companion object {
|
||||
fun newInstance(headerName: String, folder: String): Bundle {
|
||||
|
|
@ -42,104 +39,99 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
|||
|
||||
override fun onDestroyView() {
|
||||
activity?.detachBackPressedCallback("Downloads")
|
||||
downloadViewModel.clearChildren()
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun fixLayout(view: View) {
|
||||
fixSystemBarsPadding(
|
||||
view,
|
||||
padBottom = isLandscape(),
|
||||
padLeft = isLayout(TV or EMULATOR)
|
||||
)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
|
||||
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
|
||||
binding = localBinding
|
||||
return localBinding.root
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: FragmentChildDownloadsBinding) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
/**
|
||||
* We never want to retain multi-delete state
|
||||
* when navigating to downloads. Setting this state
|
||||
* immediately can sometimes result in the observer
|
||||
* not being notified in time to update the UI.
|
||||
*
|
||||
* By posting to the main looper, we ensure that this
|
||||
* operation is executed after the view has been fully created
|
||||
* and all initializations are completed, allowing the
|
||||
* observer to properly receive and handle the state change.
|
||||
*/
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* We have to make sure selected items are
|
||||
* cleared here as well so we don't run in an
|
||||
* inconsistent state where selected items do
|
||||
* not match the multi delete state we are in.
|
||||
*/
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
|
||||
val folder = arguments?.getString("folder")
|
||||
val name = arguments?.getString("name")
|
||||
if (folder == null) {
|
||||
dispatchBackPressed()
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
return
|
||||
}
|
||||
|
||||
context?.let { downloadViewModel.updateChildList(it, folder) }
|
||||
|
||||
binding.downloadChildToolbar.apply {
|
||||
binding?.downloadChildToolbar?.apply {
|
||||
title = name
|
||||
if (isLayout(PHONE or EMULATOR)) {
|
||||
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
||||
setNavigationOnClickListener {
|
||||
dispatchBackPressed()
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
setAppBarNoScrollFlagsOnTV()
|
||||
}
|
||||
|
||||
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
|
||||
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||
|
||||
observe(downloadViewModel.childCards) { cards ->
|
||||
when (cards) {
|
||||
is Resource.Success -> {
|
||||
if (cards.value.isEmpty()) {
|
||||
dispatchBackPressed()
|
||||
}
|
||||
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value)
|
||||
observe(downloadsViewModel.childCards) {
|
||||
if (it.isEmpty()) {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
return@observe
|
||||
}
|
||||
|
||||
else -> {
|
||||
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null)
|
||||
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observe(downloadViewModel.selectedBytes) {
|
||||
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||
}
|
||||
|
||||
|
||||
binding.apply {
|
||||
btnDelete.setOnClickListener { view ->
|
||||
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
|
||||
}
|
||||
|
||||
btnCancel.setOnClickListener {
|
||||
downloadViewModel.cancelSelection()
|
||||
}
|
||||
|
||||
btnToggleAll.setOnClickListener {
|
||||
val allSelected = downloadViewModel.isAllChildrenSelected()
|
||||
if (allSelected) {
|
||||
downloadViewModel.clearSelectedItems()
|
||||
} else {
|
||||
downloadViewModel.selectAllChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observeNullable(downloadViewModel.selectedItemIds) { selection ->
|
||||
val isMultiDeleteState = selection != null
|
||||
val adapter = binding.downloadChildList.adapter as? DownloadAdapter
|
||||
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
|
||||
binding.downloadChildToolbar.isGone = isMultiDeleteState
|
||||
|
||||
if (selection == null) {
|
||||
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||
if (!isMultiDeleteState) {
|
||||
activity?.detachBackPressedCallback("Downloads")
|
||||
return@observeNullable
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
binding?.downloadChildToolbar?.isVisible = true
|
||||
}
|
||||
activity?.attachBackPressedCallback("Downloads") {
|
||||
downloadViewModel.cancelSelection()
|
||||
}
|
||||
observe(downloadsViewModel.selectedBytes) {
|
||||
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||
}
|
||||
observe(downloadsViewModel.selectedItemIds) {
|
||||
handleSelectedChange(it)
|
||||
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||
|
||||
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
|
||||
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||
|
||||
binding.btnDelete.isVisible = selection.isNotEmpty()
|
||||
binding.selectItemsText.isVisible = selection.isEmpty()
|
||||
|
||||
val allSelected = downloadViewModel.isAllChildrenSelected()
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
if (allSelected) {
|
||||
binding.btnToggleAll.setText(R.string.deselect_all)
|
||||
} else binding.btnToggleAll.setText(R.string.select_all)
|
||||
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||
}
|
||||
|
||||
val adapter = DownloadAdapter(
|
||||
|
|
@ -147,18 +139,18 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
|||
{ click ->
|
||||
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
context?.let { ctx ->
|
||||
downloadViewModel.handleSingleDelete(ctx, click.data.id)
|
||||
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||
}
|
||||
} else handleDownloadClick(click)
|
||||
},
|
||||
{ itemId, isChecked ->
|
||||
if (isChecked) {
|
||||
downloadViewModel.addSelected(itemId)
|
||||
} else downloadViewModel.removeSelected(itemId)
|
||||
downloadsViewModel.addSelected(itemId)
|
||||
} else downloadsViewModel.removeSelected(itemId)
|
||||
}
|
||||
)
|
||||
|
||||
binding.downloadChildList.apply {
|
||||
binding?.downloadChildList?.apply {
|
||||
setHasFixedSize(true)
|
||||
setItemViewCacheSize(20)
|
||||
this.adapter = adapter
|
||||
|
|
@ -168,6 +160,43 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
|||
nextDown = FOCUS_SELF,
|
||||
)
|
||||
}
|
||||
|
||||
context?.let { downloadsViewModel.updateChildList(it, folder) }
|
||||
fixPaddingStatusbar(binding?.downloadChildRoot)
|
||||
}
|
||||
|
||||
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||
if (selected.isNotEmpty()) {
|
||||
binding?.downloadDeleteAppbar?.isVisible = true
|
||||
binding?.downloadChildToolbar?.isVisible = false
|
||||
activity?.attachBackPressedCallback("Downloads") {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnDelete?.setOnClickListener {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.handleMultiDelete(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
binding?.btnCancel?.setOnClickListener {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnToggleAll?.setOnClickListener {
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||
if (allSelected) {
|
||||
adapter?.notifySelectionStates()
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
} else {
|
||||
adapter?.notifyAllSelected()
|
||||
downloadsViewModel.selectAllItems()
|
||||
}
|
||||
}
|
||||
|
||||
downloadsViewModel.setIsMultiDeleteState(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
|
|
@ -17,28 +22,23 @@ import androidx.annotation.StringRes
|
|||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
|
||||
import com.lagradost.cloudstream3.databinding.StreamInputBinding
|
||||
import com.lagradost.cloudstream3.isEpisodeBased
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
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.GeneratorPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
||||
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
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.AppContextUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
|
|
@ -46,7 +46,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres
|
|||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||
|
|
@ -54,12 +54,9 @@ import java.net.URI
|
|||
|
||||
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
|
||||
|
||||
class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||
BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate)
|
||||
) {
|
||||
|
||||
private val downloadViewModel: DownloadViewModel by activityViewModels()
|
||||
private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
|
||||
class DownloadFragment : Fragment() {
|
||||
private lateinit var downloadsViewModel: DownloadViewModel
|
||||
private var binding: FragmentDownloadsBinding? = null
|
||||
|
||||
private fun View.setLayoutWidth(weight: Long) {
|
||||
val param = LinearLayout.LayoutParams(
|
||||
|
|
@ -72,135 +69,117 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
|
||||
override fun onDestroyView() {
|
||||
activity?.detachBackPressedCallback("Downloads")
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun fixLayout(view: View) {
|
||||
fixSystemBarsPadding(
|
||||
view,
|
||||
padBottom = isLandscape(),
|
||||
padLeft = isLayout(TV or EMULATOR)
|
||||
)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
|
||||
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
|
||||
binding = localBinding
|
||||
return localBinding.root
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDownloadsBinding) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
hideKeyboard()
|
||||
binding.downloadAppbar.setAppBarNoScrollFlagsOnTV()
|
||||
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
|
||||
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||
|
||||
observe(downloadViewModel.headerCards) { cards ->
|
||||
when (cards) {
|
||||
is Resource.Success -> {
|
||||
(binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value)
|
||||
binding.textNoDownloads.isVisible = cards.value.isEmpty()
|
||||
binding.downloadLoading.isVisible = false
|
||||
binding.downloadList.isVisible = true
|
||||
/**
|
||||
* We never want to retain multi-delete state
|
||||
* when navigating to downloads. Setting this state
|
||||
* immediately can sometimes result in the observer
|
||||
* not being notified in time to update the UI.
|
||||
*
|
||||
* By posting to the main looper, we ensure that this
|
||||
* operation is executed after the view has been fully created
|
||||
* and all initializations are completed, allowing the
|
||||
* observer to properly receive and handle the state change.
|
||||
*/
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
is Resource.Loading -> {
|
||||
binding.downloadList.isVisible = false
|
||||
binding.downloadLoading.isVisible = true
|
||||
}
|
||||
/**
|
||||
* We have to make sure selected items are
|
||||
* cleared here as well so we don't run in an
|
||||
* inconsistent state where selected items do
|
||||
* not match the multi delete state we are in.
|
||||
*/
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
|
||||
is Resource.Failure -> {
|
||||
binding.downloadList.isVisible = true
|
||||
binding.downloadLoading.isVisible = false
|
||||
observe(downloadsViewModel.headerCards) {
|
||||
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||
binding?.downloadLoading?.isVisible = false
|
||||
binding?.textNoDownloads?.isVisible = it.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observe(downloadViewModel.availableBytes) {
|
||||
observe(downloadsViewModel.availableBytes) {
|
||||
updateStorageInfo(
|
||||
binding.root.context,
|
||||
view.context,
|
||||
it,
|
||||
R.string.free_storage,
|
||||
binding.downloadFreeTxt,
|
||||
binding.downloadFree
|
||||
binding?.downloadFreeTxt,
|
||||
binding?.downloadFree
|
||||
)
|
||||
}
|
||||
observe(downloadViewModel.usedBytes) {
|
||||
observe(downloadsViewModel.usedBytes) {
|
||||
updateStorageInfo(
|
||||
binding.root.context,
|
||||
view.context,
|
||||
it,
|
||||
R.string.used_storage,
|
||||
binding.downloadUsedTxt,
|
||||
binding.downloadUsed
|
||||
binding?.downloadUsedTxt,
|
||||
binding?.downloadUsed
|
||||
)
|
||||
|
||||
val hasBytes = it > 0
|
||||
if (hasBytes) {
|
||||
binding.downloadLoadingBytes.stopShimmer()
|
||||
} else binding.downloadLoadingBytes.startShimmer()
|
||||
|
||||
binding.downloadBytesBar.isVisible = hasBytes
|
||||
binding.downloadLoadingBytes.isGone = hasBytes
|
||||
// Prevent race condition and make sure
|
||||
// we don't display it early
|
||||
if (
|
||||
downloadsViewModel.isMultiDeleteState.value == null ||
|
||||
downloadsViewModel.isMultiDeleteState.value == false
|
||||
) binding?.downloadStorageAppbar?.isVisible = it > 0
|
||||
}
|
||||
observe(downloadViewModel.downloadBytes) {
|
||||
observe(downloadsViewModel.downloadBytes) {
|
||||
updateStorageInfo(
|
||||
binding.root.context,
|
||||
view.context,
|
||||
it,
|
||||
R.string.app_storage,
|
||||
binding.downloadAppTxt,
|
||||
binding.downloadApp
|
||||
binding?.downloadAppTxt,
|
||||
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(downloadsViewModel.selectedBytes) {
|
||||
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||
}
|
||||
}
|
||||
|
||||
observe(downloadViewModel.selectedBytes) {
|
||||
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||
}
|
||||
|
||||
binding.apply {
|
||||
btnDelete.setOnClickListener { view ->
|
||||
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
|
||||
}
|
||||
|
||||
btnCancel.setOnClickListener {
|
||||
downloadViewModel.cancelSelection()
|
||||
}
|
||||
|
||||
btnToggleAll.setOnClickListener {
|
||||
val allSelected = downloadViewModel.isAllHeadersSelected()
|
||||
if (allSelected) {
|
||||
downloadViewModel.clearSelectedItems()
|
||||
} else {
|
||||
downloadViewModel.selectAllHeaders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observeNullable(downloadViewModel.selectedItemIds) { selection ->
|
||||
val isMultiDeleteState = selection != null
|
||||
val adapter = binding.downloadList.adapter as? DownloadAdapter
|
||||
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
|
||||
binding.downloadAppbar.isGone = isMultiDeleteState
|
||||
|
||||
if (selection == null) {
|
||||
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||
if (!isMultiDeleteState) {
|
||||
activity?.detachBackPressedCallback("Downloads")
|
||||
return@observeNullable
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
// Prevent race condition and make sure
|
||||
// we don't display it early
|
||||
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
|
||||
binding?.downloadStorageAppbar?.isVisible = true
|
||||
}
|
||||
activity?.attachBackPressedCallback("Downloads") {
|
||||
downloadViewModel.cancelSelection()
|
||||
}
|
||||
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
|
||||
}
|
||||
observe(downloadsViewModel.selectedItemIds) {
|
||||
handleSelectedChange(it)
|
||||
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||
|
||||
binding.btnDelete.isVisible = selection.isNotEmpty()
|
||||
binding.selectItemsText.isVisible = selection.isEmpty()
|
||||
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||
|
||||
val allSelected = downloadViewModel.isAllHeadersSelected()
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
if (allSelected) {
|
||||
binding.btnToggleAll.setText(R.string.deselect_all)
|
||||
} else binding.btnToggleAll.setText(R.string.select_all)
|
||||
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||
}
|
||||
|
||||
val adapter = DownloadAdapter(
|
||||
|
|
@ -208,29 +187,29 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
{ click ->
|
||||
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
context?.let { ctx ->
|
||||
downloadViewModel.handleSingleDelete(ctx, click.data.id)
|
||||
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||
}
|
||||
} else handleDownloadClick(click)
|
||||
},
|
||||
{ itemId, isChecked ->
|
||||
if (isChecked) {
|
||||
downloadViewModel.addSelected(itemId)
|
||||
} else downloadViewModel.removeSelected(itemId)
|
||||
downloadsViewModel.addSelected(itemId)
|
||||
} else downloadsViewModel.removeSelected(itemId)
|
||||
}
|
||||
)
|
||||
|
||||
binding.downloadList.apply {
|
||||
binding?.downloadList?.apply {
|
||||
setHasFixedSize(true)
|
||||
setItemViewCacheSize(20)
|
||||
this.adapter = adapter
|
||||
setLinearListLayout(
|
||||
isHorizontal = false,
|
||||
nextRight = FOCUS_SELF,
|
||||
nextDown = R.id.download_queue_button,
|
||||
nextDown = FOCUS_SELF,
|
||||
)
|
||||
}
|
||||
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
openLocalVideoButton.apply {
|
||||
isGone = isLayout(TV)
|
||||
setOnClickListener { openLocalVideo() }
|
||||
|
|
@ -239,25 +218,16 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
isGone = isLayout(TV)
|
||||
setOnClickListener { showStreamInputDialog(it.context) }
|
||||
}
|
||||
|
||||
downloadQueueButton.setOnClickListener {
|
||||
activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
|
||||
}
|
||||
|
||||
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
|
||||
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
|
||||
|
||||
downloadStreamButtonTv.setOnClickListener { showStreamInputDialog(it.context) }
|
||||
steamImageviewHolder.isVisible = isLayout(TV)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
handleScroll(scrollY - oldScrollY)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let { downloadViewModel.updateHeaderList(it) }
|
||||
context?.let { downloadsViewModel.updateHeaderList(it) }
|
||||
fixPaddingStatusbar(binding?.downloadRoot)
|
||||
}
|
||||
|
||||
private fun handleItemClick(click: DownloadHeaderClickEvent) {
|
||||
|
|
@ -279,6 +249,40 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||
if (selected.isNotEmpty()) {
|
||||
binding?.downloadDeleteAppbar?.isVisible = true
|
||||
binding?.downloadStorageAppbar?.isVisible = false
|
||||
activity?.attachBackPressedCallback("Downloads") {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnDelete?.setOnClickListener {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.handleMultiDelete(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
binding?.btnCancel?.setOnClickListener {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnToggleAll?.setOnClickListener {
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||
if (allSelected) {
|
||||
adapter?.notifySelectionStates()
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
} else {
|
||||
adapter?.notifyAllSelected()
|
||||
downloadsViewModel.selectAllItems()
|
||||
}
|
||||
}
|
||||
|
||||
downloadsViewModel.setIsMultiDeleteState(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||
val formattedSize = formatShortFileSize(context, selectedBytes)
|
||||
binding?.btnDelete?.text =
|
||||
|
|
@ -349,8 +353,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
listOf(BasicLink(url)),
|
||||
extract = true,
|
||||
refererUrl = referer,
|
||||
id = url.hashCode()
|
||||
), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
dialog.dismissSafe(activity)
|
||||
|
|
@ -381,7 +384,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val selectedVideoUri = result.data?.data ?: return@registerForActivityResult
|
||||
val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
|
||||
playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,132 +5,91 @@ import android.content.DialogInterface
|
|||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.api.Log
|
||||
import com.lagradost.cloudstream3.CloudStreamApp
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.isEpisodeBased
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||
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.setDefaultFocus
|
||||
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_BACKUP
|
||||
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.getKey
|
||||
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.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadDeleteEvent
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DownloadViewModel : ViewModel() {
|
||||
companion object {
|
||||
const val TAG = "DownloadViewModel"
|
||||
}
|
||||
|
||||
private val _headerCards =
|
||||
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
|
||||
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
|
||||
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
|
||||
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
|
||||
|
||||
private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading())
|
||||
val childCards: LiveData<Resource<List<VisualDownloadCached.Child>>> = _childCards
|
||||
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
|
||||
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
|
||||
|
||||
private val _usedBytes = ConsistentLiveData<Long>()
|
||||
private val _usedBytes = MutableLiveData<Long>()
|
||||
val usedBytes: LiveData<Long> = _usedBytes
|
||||
|
||||
private val _availableBytes = ConsistentLiveData<Long>()
|
||||
private val _availableBytes = MutableLiveData<Long>()
|
||||
val availableBytes: LiveData<Long> = _availableBytes
|
||||
|
||||
private val _downloadBytes = ConsistentLiveData<Long>()
|
||||
private val _downloadBytes = MutableLiveData<Long>()
|
||||
val downloadBytes: LiveData<Long> = _downloadBytes
|
||||
|
||||
private val _selectedBytes = ConsistentLiveData<Long>(0)
|
||||
private val _selectedBytes = MutableLiveData<Long>(0)
|
||||
val selectedBytes: LiveData<Long> = _selectedBytes
|
||||
|
||||
private val _selectedItemIds = ConsistentLiveData<Set<Int>?>(null)
|
||||
val selectedItemIds: LiveData<Set<Int>?> = _selectedItemIds
|
||||
private val _isMultiDeleteState = MutableLiveData(false)
|
||||
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
|
||||
|
||||
init {
|
||||
// Keep the Downloads list in sync when a download is deleted/cancelled from
|
||||
// anywhere in the app (result page button, queue, notification, etc.). See
|
||||
// onDownloadDeleted for the rationale (issue #1227).
|
||||
downloadDeleteEvent += ::onDownloadDeleted
|
||||
}
|
||||
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
|
||||
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
|
||||
|
||||
override fun onCleared() {
|
||||
downloadDeleteEvent -= ::onDownloadDeleted
|
||||
super.onCleared()
|
||||
}
|
||||
private var previousVisual: List<VisualDownloadCached>? = null
|
||||
|
||||
fun cancelSelection() {
|
||||
updateSelectedItems { null }
|
||||
fun setIsMultiDeleteState(value: Boolean) {
|
||||
_isMultiDeleteState.postValue(value)
|
||||
}
|
||||
|
||||
fun addSelected(itemId: Int) {
|
||||
updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) }
|
||||
updateSelectedItems { it.add(itemId) }
|
||||
}
|
||||
|
||||
fun removeSelected(itemId: Int) {
|
||||
updateSelectedItems { it?.minus(itemId) ?: emptySet() }
|
||||
updateSelectedItems { it.remove(itemId) }
|
||||
}
|
||||
|
||||
fun selectAllHeaders() {
|
||||
updateSelectedItems {
|
||||
_headerCards.success.orEmpty()
|
||||
.map { item -> item.data.id }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAllChildren() {
|
||||
updateSelectedItems {
|
||||
_childCards.success.orEmpty()
|
||||
.map { item -> item.data.id }.toSet()
|
||||
}
|
||||
fun selectAllItems() {
|
||||
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
|
||||
}
|
||||
|
||||
fun clearSelectedItems() {
|
||||
// We need this to be done immediately
|
||||
// so we can't use postValue
|
||||
updateSelectedItems { emptySet() }
|
||||
_selectedItemIds.value = mutableSetOf()
|
||||
updateSelectedItems { it.clear() }
|
||||
}
|
||||
|
||||
fun isAllChildrenSelected(): Boolean {
|
||||
fun isAllSelected(): Boolean {
|
||||
val currentSelected = selectedItemIds.value ?: return false
|
||||
val children = _childCards.success.orEmpty()
|
||||
return currentSelected.size == children.size && children.all { it.data.id in currentSelected }
|
||||
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
|
||||
}
|
||||
|
||||
fun isAllHeadersSelected(): Boolean {
|
||||
val currentSelected = selectedItemIds.value ?: return false
|
||||
val headers = _headerCards.success.orEmpty()
|
||||
return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected }
|
||||
}
|
||||
|
||||
private fun updateSelectedItems(action: (Set<Int>?) -> Set<Int>?) {
|
||||
val currentSelected = action(selectedItemIds.value)
|
||||
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
|
||||
val currentSelected = selectedItemIds.value ?: mutableSetOf()
|
||||
action(currentSelected)
|
||||
_selectedItemIds.postValue(currentSelected)
|
||||
postHeaders()
|
||||
postChildren()
|
||||
updateSelectedBytes()
|
||||
updateSelectedCards()
|
||||
}
|
||||
|
||||
private fun updateSelectedBytes() = viewModelScope.launchSafe {
|
||||
|
|
@ -139,173 +98,61 @@ class DownloadViewModel : ViewModel() {
|
|||
_selectedBytes.postValue(totalSelectedBytes)
|
||||
}
|
||||
|
||||
private fun updateSelectedCards() = viewModelScope.launchSafe {
|
||||
val currentSelected = selectedItemIds.value ?: return@launchSafe
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
headerCards.value?.let { headers ->
|
||||
headers.forEach { header ->
|
||||
header.isSelected = header.data.id in currentSelected
|
||||
}
|
||||
_headerCards.postValue(headers)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
childCards.value?.let { children ->
|
||||
children.forEach { child ->
|
||||
child.isSelected = child.data.id in currentSelected
|
||||
}
|
||||
_childCards.postValue(children)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
||||
// Do not push loading as it interrupts the UI
|
||||
//_headerCards.postValue(Resource.Loading())
|
||||
|
||||
val visual = ioWork {
|
||||
val visual = withContext(Dispatchers.IO) {
|
||||
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||
.mapNotNull { context.getKey<DownloadObjects.DownloadEpisodeCached>(it) }
|
||||
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
|
||||
.distinctBy { it.id } // Remove duplicates
|
||||
|
||||
val isCurrentlyDownloading =
|
||||
DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty()
|
||||
|
||||
val downloadStats =
|
||||
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
|
||||
calculateDownloadStats(context, children)
|
||||
|
||||
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
||||
.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)
|
||||
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
|
||||
|
||||
createVisualDownloadList(
|
||||
context,
|
||||
cached,
|
||||
downloadStats.totalBytesUsedByChild,
|
||||
downloadStats.currentBytesUsedByChild,
|
||||
downloadStats.totalDownloads
|
||||
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
|
||||
)
|
||||
}
|
||||
|
||||
if (visual != previousVisual) {
|
||||
previousVisual = visual
|
||||
updateStorageStats(visual)
|
||||
postHeaders(visual)
|
||||
_headerCards.postValue(visual)
|
||||
}
|
||||
|
||||
fun postHeaders(newValue: List<VisualDownloadCached.Header>? = null) {
|
||||
val newValue = newValue ?: _headerCards.success ?: return
|
||||
val selection = selectedItemIds.value ?: emptySet()
|
||||
_headerCards.postValue(Resource.Success(newValue.map {
|
||||
it.copy(
|
||||
isSelected = selection.contains(
|
||||
it.data.id
|
||||
)
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fun postChildren(newValue: List<VisualDownloadCached.Child>? = null) {
|
||||
val newValue = newValue ?: _childCards.success ?: return
|
||||
val selection = selectedItemIds.value ?: emptySet()
|
||||
_childCards.postValue(Resource.Success(newValue.map {
|
||||
it.copy(
|
||||
isSelected = selection.contains(
|
||||
it.data.id
|
||||
)
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
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(
|
||||
context: Context,
|
||||
children: List<DownloadObjects.DownloadEpisodeCached>
|
||||
): DownloadStats {
|
||||
children: List<VideoDownloadHelper.DownloadEpisodeCached>
|
||||
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
|
||||
// parentId : bytes
|
||||
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||
// parentId : bytes
|
||||
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||
// parentId : downloadsCount
|
||||
val totalDownloads = mutableMapOf<Int, Int>()
|
||||
val redundantDownloads = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
children.forEach { child ->
|
||||
val childFile = getDownloadFileInfo(context, child.id)
|
||||
|
||||
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
|
||||
}
|
||||
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
|
||||
if (childFile.fileLength <= 1) return@forEach
|
||||
|
||||
val len = childFile.totalBytes
|
||||
|
|
@ -315,17 +162,12 @@ class DownloadViewModel : ViewModel() {
|
|||
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
||||
totalDownloads.merge(child.parentId, 1, Int::plus)
|
||||
}
|
||||
return DownloadStats(
|
||||
totalBytesUsedByChild,
|
||||
currentBytesUsedByChild,
|
||||
totalDownloads,
|
||||
redundantDownloads
|
||||
)
|
||||
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
|
||||
}
|
||||
|
||||
private fun createVisualDownloadList(
|
||||
context: Context,
|
||||
cached: List<DownloadObjects.DownloadHeaderCached>,
|
||||
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
|
||||
totalBytesUsedByChild: Map<Int, Long>,
|
||||
currentBytesUsedByChild: Map<Int, Long>,
|
||||
totalDownloads: Map<Int, Int>
|
||||
|
|
@ -334,14 +176,10 @@ class DownloadViewModel : ViewModel() {
|
|||
val downloads = totalDownloads[it.id] ?: 0
|
||||
val bytes = totalBytesUsedByChild[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 movieEpisode =
|
||||
if (it.type.isEpisodeBased()) null else context.getKey<DownloadObjects.DownloadEpisodeCached>(
|
||||
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||
DOWNLOAD_EPISODE_CACHE,
|
||||
getFolderName(it.id.toString(), it.id.toString())
|
||||
)
|
||||
|
|
@ -370,14 +208,12 @@ class DownloadViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
|
||||
_childCards.postValue(Resource.Loading()) // always push loading
|
||||
|
||||
val visual = withContext(Dispatchers.IO) {
|
||||
context.getKeys(folder).mapNotNull { key ->
|
||||
context.getKey<DownloadObjects.DownloadEpisodeCached>(key)
|
||||
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
||||
}.mapNotNull {
|
||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||
val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null
|
||||
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
|
||||
VisualDownloadCached.Child(
|
||||
currentBytes = info.fileLength,
|
||||
totalBytes = info.totalBytes,
|
||||
|
|
@ -385,33 +221,24 @@ class DownloadViewModel : ViewModel() {
|
|||
data = it,
|
||||
)
|
||||
}
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
}.sortedWith(compareBy(
|
||||
// Sort by season first, and then by episode number,
|
||||
// to ensure sorting is consistent.
|
||||
{ it.data.season ?: 0 },
|
||||
{ it.data.episode }
|
||||
))
|
||||
|
||||
postChildren(visual)
|
||||
if (previousVisual != visual) {
|
||||
previousVisual = visual
|
||||
_childCards.postValue(visual)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
|
||||
_selectedItemIds.postValue(null)
|
||||
postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove })
|
||||
postChildren(_childCards.success?.filter { it.data.id !in idsToRemove })
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the Downloads screen in real time when a download is deleted/cancelled.
|
||||
*/
|
||||
private fun onDownloadDeleted(id: Int) {
|
||||
// Keep multi-select state consistent: forget the removed id if it was selected.
|
||||
updateSelectedItems { it?.minus(id) }
|
||||
|
||||
val context = CloudStreamApp.context ?: return
|
||||
updateHeaderList(context)
|
||||
postChildren(_childCards.success?.filterNot { it.data.id == id })
|
||||
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||
_headerCards.postValue(updatedHeaders)
|
||||
_childCards.postValue(updatedChildren)
|
||||
}
|
||||
|
||||
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
|
||||
|
|
@ -465,7 +292,7 @@ class DownloadViewModel : ViewModel() {
|
|||
if (item.data.type.isEpisodeBased()) {
|
||||
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||
.mapNotNull {
|
||||
context.getKey<DownloadObjects.DownloadEpisodeCached>(
|
||||
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||
it
|
||||
)
|
||||
}
|
||||
|
|
@ -489,7 +316,7 @@ class DownloadViewModel : ViewModel() {
|
|||
|
||||
is VisualDownloadCached.Child -> {
|
||||
ids.add(item.data.id)
|
||||
val parent = context.getKey<DownloadObjects.DownloadHeaderCached>(
|
||||
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
item.data.parentId.toString()
|
||||
)
|
||||
|
|
@ -518,16 +345,16 @@ class DownloadViewModel : ViewModel() {
|
|||
.joinToString(separator = "\n") { "• $it" }
|
||||
|
||||
return when {
|
||||
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
|
||||
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
|
||||
}
|
||||
|
||||
data.ids.count() == 1 -> {
|
||||
context.getString(R.string.delete_message).format(
|
||||
data.names.firstOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
|
||||
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
|
||||
}
|
||||
|
||||
data.parentName != null && data.names.isNotEmpty() -> {
|
||||
context.getString(R.string.delete_message_series_episodes)
|
||||
.format(data.parentName, formattedNames)
|
||||
|
|
@ -556,6 +383,7 @@ class DownloadViewModel : ViewModel() {
|
|||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
viewModelScope.launchSafe {
|
||||
setIsMultiDeleteState(false)
|
||||
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
|
||||
// We always remove parent because if we are deleting from here
|
||||
// and we have it as non-empty, it was triggered on
|
||||
|
|
@ -586,8 +414,8 @@ class DownloadViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
|
||||
val headers = _headerCards.success.orEmpty()
|
||||
val children = _childCards.success.orEmpty()
|
||||
val headers = headerCards.value.orEmpty()
|
||||
val children = childCards.value.orEmpty()
|
||||
|
||||
return selectedItemIds.value?.mapNotNull { id ->
|
||||
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
|
||||
|
|
@ -595,11 +423,10 @@ class DownloadViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
|
||||
return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId }
|
||||
}
|
||||
val headers = headerCards.value.orEmpty()
|
||||
val children = childCards.value.orEmpty()
|
||||
|
||||
fun clearChildren() {
|
||||
_childCards.postValue(Resource.Loading())
|
||||
return (headers + children).filter { it.data.id == itemId }
|
||||
}
|
||||
|
||||
private data class DeleteData(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
|
||||
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
|
||||
|
||||
|
|
@ -62,7 +62,6 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
|
||||
open fun resetViewData() {
|
||||
// lastRequest = null
|
||||
progressText = null
|
||||
isZeroBytes = true
|
||||
doSetProgress = true
|
||||
persistentId = null
|
||||
|
|
@ -76,10 +75,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
currentMetaData.id = id
|
||||
|
||||
if (!doSetProgress) return
|
||||
val appContext = context.applicationContext
|
||||
|
||||
ioSafe {
|
||||
val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
|
||||
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
|
||||
|
||||
mainWork {
|
||||
if (savedData != null) {
|
||||
val downloadedBytes = savedData.fileLength
|
||||
|
|
@ -87,7 +86,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
|
||||
setProgress(downloadedBytes, totalBytes)
|
||||
applyMetaData(id, downloadedBytes, totalBytes)
|
||||
}
|
||||
} else run { resetView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,11 +163,9 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
}
|
||||
}
|
||||
|
||||
fun downloadDeleteEvent(data: Int) {
|
||||
if (data == persistentId) {
|
||||
resetView()
|
||||
}
|
||||
}
|
||||
/*fun downloadDeleteEvent(data: Int) {
|
||||
|
||||
}*/
|
||||
|
||||
/*fun downloadEvent(data: Pair<Int, VideoDownloadManager.DownloadActionType>) {
|
||||
val (id, action) = data
|
||||
|
|
@ -187,7 +184,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
|
||||
override fun onAttachedToWindow() {
|
||||
VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent
|
||||
VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
|
||||
// VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
|
||||
// VideoDownloadManager.downloadEvent += ::downloadEvent
|
||||
VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent
|
||||
|
||||
|
|
@ -202,7 +199,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
|
||||
override fun onDetachedFromWindow() {
|
||||
VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent
|
||||
VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
|
||||
// VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
|
||||
// VideoDownloadManager.downloadEvent -= ::downloadEvent
|
||||
VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import androidx.core.view.isVisible
|
|||
import com.google.android.material.button.MaterialButton
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
|
||||
class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||
PieFetchButton(context, attributeSet) {
|
||||
|
|
@ -18,7 +18,6 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
|||
super.onAttachedToWindow()
|
||||
progressText = findViewById(R.id.result_movie_download_text_precentage)
|
||||
mainText = findViewById(R.id.result_movie_download_text)
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
override fun setStatus(status: DownloadStatusTell?) {
|
||||
|
|
@ -36,7 +35,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
|||
}
|
||||
|
||||
override fun setDefaultClickListener(
|
||||
card: DownloadObjects.DownloadEpisodeCached,
|
||||
card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||
textView: TextView?,
|
||||
callback: (DownloadClickEvent) -> Unit
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -10,14 +10,11 @@ import android.widget.ImageView
|
|||
import android.widget.TextView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.R
|
||||
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_DOWNLOAD
|
||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||
|
|
@ -26,10 +23,9 @@ 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.DownloadClickEvent
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
|
||||
|
||||
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||
BaseFetchButton(context, attributeSet) {
|
||||
|
|
@ -67,7 +63,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
open fun onInflate() {}
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) {
|
||||
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
|
||||
try {
|
||||
inflate(
|
||||
overrideLayout ?: getResourceId(
|
||||
|
|
@ -76,7 +72,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
recycle() // Manually call recycle first to avoid memory leaks
|
||||
Log.e(
|
||||
"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"
|
||||
|
|
@ -84,6 +79,11 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
throw e
|
||||
}
|
||||
|
||||
|
||||
progressBar = findViewById(R.id.progress_downloaded)
|
||||
progressBarBackground = findViewById(R.id.progress_downloaded_background)
|
||||
statusView = findViewById(R.id.image_download_status)
|
||||
|
||||
animateWaiting = getBoolean(
|
||||
R.styleable.PieFetchButton_download_animate_waiting,
|
||||
true
|
||||
|
|
@ -92,13 +92,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
R.styleable.PieFetchButton_download_hide_when_icon,
|
||||
true
|
||||
)
|
||||
|
||||
waitingAnimation = getResourceId(
|
||||
R.styleable.PieFetchButton_download_waiting_animation,
|
||||
R.anim.rotate_around_center_point
|
||||
)
|
||||
|
||||
activeOutline = getResourceId(
|
||||
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
|
||||
)
|
||||
|
||||
nonActiveOutline = getResourceId(
|
||||
R.styleable.PieFetchButton_download_outline_non_active,
|
||||
R.drawable.circle_shape_dotted
|
||||
|
|
@ -126,27 +129,17 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
)
|
||||
|
||||
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
|
||||
|
||||
progressDrawable = getResourceId(
|
||||
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
recycle()
|
||||
}
|
||||
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
// Re-run all animations when the view gets visible.
|
||||
// Otherwise views may run without animations after recycled
|
||||
setStatusInternal(currentStatus)
|
||||
resetView()
|
||||
onInflate()
|
||||
}
|
||||
|
||||
private var currentStatus: DownloadStatusTell? = null
|
||||
|
|
@ -169,31 +162,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
}*/
|
||||
|
||||
protected fun setDefaultClickListener(
|
||||
view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached,
|
||||
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||
callback: (DownloadClickEvent) -> Unit
|
||||
) {
|
||||
this.progressText = textView
|
||||
this.setPersistentId(card.id)
|
||||
view.setOnClickListener {
|
||||
if (isZeroBytes) {
|
||||
val localQueue = queue.value
|
||||
val localInstances = downloadInstances.value
|
||||
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))
|
||||
}
|
||||
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
|
||||
} else {
|
||||
val list = arrayListOf(
|
||||
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
||||
|
|
@ -234,7 +212,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
}
|
||||
|
||||
open fun setDefaultClickListener(
|
||||
card: DownloadObjects.DownloadEpisodeCached,
|
||||
card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||
textView: TextView?,
|
||||
callback: (DownloadClickEvent) -> Unit
|
||||
) {
|
||||
|
|
@ -304,8 +282,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
override fun setStatus(status: DownloadStatusTell?) {
|
||||
currentStatus = status
|
||||
|
||||
// Runs on the main thread, but also instant if it already is.
|
||||
if (Looper.getMainLooper().isCurrentThread) {
|
||||
// Runs on the main thread, but also instant if it already is
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
try {
|
||||
setStatusInternal(status)
|
||||
} catch (t: Throwable) {
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.lagradost.cloudstream3.R
|
||||
|
|
@ -14,9 +13,7 @@ import com.lagradost.cloudstream3.databinding.HomeRemoveGridExpandedBinding
|
|||
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
|
||||
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
|
||||
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||
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.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||
|
|
@ -44,11 +41,13 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(vi
|
|||
}
|
||||
|
||||
class ResumeItemAdapter(
|
||||
fragment: Fragment,
|
||||
nextFocusUp: Int? = null,
|
||||
nextFocusDown: Int? = null,
|
||||
clickCallback: (SearchClickCallback) -> Unit,
|
||||
private val removeCallback: (View) -> Unit,
|
||||
) : HomeChildItemAdapter(
|
||||
fragment = fragment,
|
||||
id = "resumeAdapter".hashCode(),
|
||||
nextFocusUp = nextFocusUp,
|
||||
nextFocusDown = nextFocusDown,
|
||||
|
|
@ -68,32 +67,20 @@ class ResumeItemAdapter(
|
|||
return HomeScrollViewHolderState(binding)
|
||||
}
|
||||
|
||||
override fun onClearView(holder: ViewHolderState<Boolean>) {
|
||||
// Clear the image, idk if this saves ram or not, but I guess?
|
||||
clearImage(holder.view.root.findViewById(R.id.imageView))
|
||||
}
|
||||
|
||||
override fun onBindFooter(holder: ViewHolderState<Boolean>) {
|
||||
this.applyBinding(holder, false)
|
||||
when (val binding = holder.view) {
|
||||
is HomeRemoveGridBinding -> {
|
||||
updateLayoutParms(binding.backgroundCard, setWidth, setHeight)
|
||||
}
|
||||
|
||||
is HomeRemoveGridExpandedBinding -> {
|
||||
updateLayoutParms(binding.backgroundCard, setWidth, setHeight)
|
||||
}
|
||||
}
|
||||
holder.itemView.apply {
|
||||
if (isLayout(TV)) {
|
||||
isFocusableInTouchMode = true
|
||||
isFocusable = true
|
||||
}
|
||||
nextFocusUp?.let {
|
||||
nextFocusUpId = it
|
||||
|
||||
if (nextFocusUp != null) {
|
||||
nextFocusUpId = nextFocusUp
|
||||
}
|
||||
nextFocusDown?.let {
|
||||
nextFocusDownId = it
|
||||
|
||||
if (nextFocusDown != null) {
|
||||
nextFocusDownId = nextFocusDown
|
||||
}
|
||||
|
||||
setOnClickListener { v ->
|
||||
|
|
@ -103,49 +90,16 @@ class ResumeItemAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
/** Remember to set `updatePosterSize` to cache the poster size,
|
||||
* otherwise the width and height is unset */
|
||||
open class HomeChildItemAdapter(
|
||||
fragment: Fragment,
|
||||
id: Int,
|
||||
var nextFocusUp: Int? = null,
|
||||
var nextFocusDown: Int? = null,
|
||||
var clickCallback: (SearchClickCallback) -> Unit,
|
||||
protected val nextFocusUp: Int? = null,
|
||||
protected val nextFocusDown: Int? = null,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
) :
|
||||
BaseAdapter<SearchResponse, Boolean>(
|
||||
id, diffCallback = BaseDiffCallback(
|
||||
itemSame = { a, b ->
|
||||
a.url == b.url && a.name == b.name
|
||||
},
|
||||
contentSame = { a, b ->
|
||||
a == b
|
||||
})
|
||||
) {
|
||||
var hasNext: Boolean = false
|
||||
BaseAdapter<SearchResponse, Boolean>(fragment, id) {
|
||||
var isHorizontal: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
updateCachedPosterSize()
|
||||
}
|
||||
|
||||
private fun updateCachedPosterSize() {
|
||||
setWidth = if (!isHorizontal) {
|
||||
minPosterSize
|
||||
} else {
|
||||
maxPosterSize
|
||||
}
|
||||
setHeight = if (!isHorizontal) {
|
||||
maxPosterSize
|
||||
} else {
|
||||
minPosterSize
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
updateCachedPosterSize()
|
||||
}
|
||||
|
||||
protected var setWidth = 0
|
||||
protected var setHeight = 0
|
||||
var hasNext: Boolean = false
|
||||
|
||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
|
||||
val expanded = parent.context.isBottomLayout()
|
||||
|
|
@ -158,43 +112,52 @@ open class HomeChildItemAdapter(
|
|||
return HomeScrollViewHolderState(binding)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The vast majority of the lag comes from creating the view
|
||||
// This simply shares the views between all HomeChildItemAdapter
|
||||
val sharedPool =
|
||||
newSharedPool { setMaxRecycledViews(CONTENT, 20) }
|
||||
|
||||
var minPosterSize: Int = 0
|
||||
var maxPosterSize: Int = 0
|
||||
|
||||
fun updatePosterSize(context: Context, value: Int? = null) {
|
||||
val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context)
|
||||
protected fun applyBinding(holder: ViewHolderState<Boolean>, isFirstItem: Boolean) {
|
||||
val context = holder.view.root.context
|
||||
val scale = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0
|
||||
// Scale by +10% per step
|
||||
val mul = 1.0f + scale * 0.1f
|
||||
minPosterSize = (114.toPx.toFloat() * mul).toInt()
|
||||
maxPosterSize = (180.toPx.toFloat() * mul).toInt()
|
||||
}
|
||||
val min = (114.toPx.toFloat() * mul).toInt()
|
||||
val max = (180.toPx.toFloat() * mul).toInt()
|
||||
|
||||
fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) {
|
||||
val params = layout.layoutParams
|
||||
if (params.height == height && params.width == width) return
|
||||
|
||||
params.width = width
|
||||
params.height = height
|
||||
|
||||
layout.layoutParams = params
|
||||
}
|
||||
}
|
||||
|
||||
protected fun applyBinding(holder: ViewHolderState<Boolean>, isFirstItem: Boolean) {
|
||||
when (val binding = holder.view) {
|
||||
is HomeResultGridBinding -> {
|
||||
updateLayoutParms(binding.backgroundCard, setWidth, setHeight)
|
||||
binding.backgroundCard.apply {
|
||||
|
||||
layoutParams =
|
||||
layoutParams.apply {
|
||||
width = if (!isHorizontal) {
|
||||
min
|
||||
} else {
|
||||
max
|
||||
}
|
||||
height = if (!isHorizontal) {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is HomeResultGridExpandedBinding -> {
|
||||
updateLayoutParms(binding.backgroundCard, setWidth, setHeight)
|
||||
binding.backgroundCard.apply {
|
||||
|
||||
layoutParams =
|
||||
layoutParams.apply {
|
||||
width = if (!isHorizontal) {
|
||||
min
|
||||
} else {
|
||||
max
|
||||
}
|
||||
height = if (!isHorizontal) {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstItem) { // to fix tv
|
||||
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
|
||||
|
|
|
|||
|
|
@ -5,38 +5,26 @@ import android.app.Activity
|
|||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AbsListView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.lagradost.api.Log
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.AllLanguagesName
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
|
||||
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
|
||||
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
|
||||
|
|
@ -45,21 +33,15 @@ import com.lagradost.cloudstream3.mvvm.Resource
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||
import com.lagradost.cloudstream3.plugins.Plugin
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
|
||||
import com.lagradost.cloudstream3.ui.account.AccountViewModel
|
||||
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.SearchAdapter
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import com.lagradost.cloudstream3.ui.search.*
|
||||
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.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.AppContextUtils.filterProviderByPreferredMedia
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||
|
|
@ -69,30 +51,22 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
|
||||
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.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.EmptyEvent
|
||||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
|
||||
import com.lagradost.cloudstream3.utils.TvChannelUtils
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import java.util.*
|
||||
|
||||
private const val TAG = "HomeFragment"
|
||||
|
||||
class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||
BindingCreator.Bind(FragmentHomeBinding::bind)
|
||||
) {
|
||||
class HomeFragment : Fragment() {
|
||||
companion object {
|
||||
// Used for configuration changed events to fix any popups that are not attached to a fragment
|
||||
val configEvent = EmptyEvent()
|
||||
val configEvent = Event<Int>()
|
||||
var currentSpan = 1
|
||||
val listHomepageItems = mutableListOf<SearchResponse>()
|
||||
|
||||
private val errorProfilePics = listOf(
|
||||
R.drawable.monke_benene,
|
||||
|
|
@ -121,7 +95,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
//}
|
||||
|
||||
// returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView
|
||||
|
||||
fun Activity.loadHomepageList(
|
||||
expand: HomeViewModel.ExpandableHomepageList,
|
||||
deleteCallback: (() -> Unit)? = null,
|
||||
|
|
@ -203,17 +176,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
|
||||
|
||||
// Span settings
|
||||
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages)
|
||||
binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool)
|
||||
binding.homeExpandedRecycler.spanCount = currentSpan
|
||||
|
||||
binding.homeExpandedRecycler.adapter =
|
||||
SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback ->
|
||||
SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
|
||||
handleSearchClickCallback(callback)
|
||||
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
||||
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
|
||||
//bottomSheetDialogBuilder.dismissSafe(this)
|
||||
}
|
||||
}.apply {
|
||||
submitList(item.list)
|
||||
hasNext = expand.hasNext
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +209,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
expandCallback?.invoke(name)?.let { newExpand ->
|
||||
(recyclerView.adapter as? SearchAdapter?)?.apply {
|
||||
hasNext = newExpand.hasNext
|
||||
submitList(newExpand.list.list)
|
||||
updateList(newExpand.list.list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,12 +217,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
}
|
||||
})
|
||||
|
||||
val spanListener = Runnable {
|
||||
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages)
|
||||
// We want to rebind everything to update the UI, however we also want to avoid
|
||||
// any animations ect, this is the easiest way to do this, and the most correct
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
binding.homeExpandedRecycler.adapter?.notifyDataSetChanged()
|
||||
val spanListener = { span: Int ->
|
||||
binding.homeExpandedRecycler.spanCount = span
|
||||
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
}
|
||||
|
||||
configEvent += spanListener
|
||||
|
|
@ -320,7 +289,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
val pairList = getPairList(header)
|
||||
for ((button, types) in pairList) {
|
||||
button?.isChecked =
|
||||
button.isVisible && selectedTypes.any { types.contains(it) }
|
||||
button?.isVisible == true && selectedTypes.any { types.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,43 +383,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
|
||||
val listView = dialog.findViewById<ListView>(R.id.listview1)
|
||||
|
||||
val arrayAdapter = object : ArrayAdapter<String>(
|
||||
this, R.layout.sort_bottom_single_provider_choice,
|
||||
val arrayAdapter = object : ArrayAdapter<String>(this, R.layout.sort_bottom_single_provider_choice,
|
||||
mutableListOf()
|
||||
) {
|
||||
override fun getView(
|
||||
position: Int,
|
||||
convertView: View?,
|
||||
parent: ViewGroup
|
||||
): View {
|
||||
val view = convertView ?: LayoutInflater.from(context)
|
||||
.inflate(R.layout.sort_bottom_single_provider_choice, parent, false)
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.sort_bottom_single_provider_choice, parent, false)
|
||||
val titleText = view.findViewById<TextView>(R.id.text1)
|
||||
val pinIcon = view.findViewById<ImageView>(R.id.pinicon)
|
||||
val settingsIcon = view.findViewById<ImageView>(R.id.action_settings)
|
||||
|
||||
val name = getItem(position)
|
||||
titleText?.text = name
|
||||
val providerApi = currentValidApis[position]
|
||||
val isPinned =
|
||||
pinnedphashset.contains(providerApi.name)
|
||||
val isPinned = pinnedphashset.contains(currentValidApis[position].name ?: "")
|
||||
pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE
|
||||
|
||||
val pluginInstance = providerApi.sourcePlugin?.let { PluginManager.plugins[it] } as? Plugin
|
||||
val isDownloadedPluginWithSettings = pluginInstance?.openSettings != null && !isLayout(TV)
|
||||
|
||||
settingsIcon.visibility = if (isDownloadedPluginWithSettings) View.VISIBLE else View.GONE
|
||||
if (isDownloadedPluginWithSettings) {
|
||||
settingsIcon.setOnClickListener {
|
||||
try {
|
||||
val activityContext = it.context.getActivity() ?: it.context
|
||||
pluginInstance.openSettings?.invoke(activityContext)
|
||||
} catch (e: Throwable) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
|
@ -461,7 +404,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
if (currentValidApis.isNotEmpty()) {
|
||||
currentApiName = currentValidApis[i].name
|
||||
//to switch to apply simply remove this
|
||||
currentApiName.let(callback)
|
||||
currentApiName?.let(callback)
|
||||
dialog.dismissSafe()
|
||||
}
|
||||
}
|
||||
|
|
@ -472,18 +415,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
pinnedphashset = pinnedp.toHashSet()
|
||||
arrayAdapter.clear()
|
||||
val sortedApis = validAPIs
|
||||
.filter {
|
||||
val isPinned = pinnedphashset.contains(it.name)
|
||||
|
||||
// Hide pinned NSFW when NSFW not selected. NSFW is distracting when not chosen.
|
||||
if (isPinned && !preSelectedTypes.contains(TvType.NSFW)) {
|
||||
if (it.supportedTypes.all { type -> type == TvType.NSFW }) return@filter false
|
||||
}
|
||||
|
||||
it.hasMainPage && (isPinned || it.supportedTypes.any(
|
||||
preSelectedTypes::contains
|
||||
))
|
||||
}
|
||||
.filter { it.hasMainPage && it.supportedTypes.any(preSelectedTypes::contains) }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
|
||||
val sortedApiMap = LinkedHashMap<String, MainAPI>().apply {
|
||||
|
|
@ -540,71 +472,47 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
}
|
||||
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
private val accountViewModel: AccountViewModel by activityViewModels()
|
||||
|
||||
fun addMovies(cards: List<SearchResponse>) {
|
||||
val ctx = context ?: run {
|
||||
Log.e(TAG, "Context is null, aborting addMovies")
|
||||
return
|
||||
}
|
||||
var binding: FragmentHomeBinding? = null
|
||||
|
||||
try {
|
||||
val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name))
|
||||
if (existingId != null) {
|
||||
Log.d(TAG, "Channel ID: $existingId")
|
||||
|
||||
val programCards = cards
|
||||
|
||||
TvChannelUtils.addPrograms(
|
||||
context = ctx,
|
||||
channelId = existingId,
|
||||
items = programCards
|
||||
)
|
||||
} else {
|
||||
Log.d(TAG, "Channel does not exist")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding movies: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteAll() {
|
||||
val ctx = context ?: run {
|
||||
Log.e(TAG, "Context is null, aborting deleteAll")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name))
|
||||
if (existingId != null) {
|
||||
Log.d(TAG, "Channel ID: $existingId")
|
||||
TvChannelUtils.deleteStoredPrograms(ctx)
|
||||
} else {
|
||||
Log.d(TAG, "Channel does not exist")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error deleting programs: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun pickLayout(): Int? =
|
||||
if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
//homeViewModel =
|
||||
// ViewModelProvider(this).get(HomeViewModel::class.java)
|
||||
|
||||
bottomSheetDialog?.ownShow()
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
val layout =
|
||||
if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home
|
||||
val root = inflater.inflate(layout, container, false)
|
||||
binding = try {
|
||||
FragmentHomeBinding.bind(root)
|
||||
} catch (t: Throwable) {
|
||||
showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
|
||||
logError(t)
|
||||
null
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
(activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress")
|
||||
|
||||
bottomSheetDialog?.ownHide()
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun fixGrid() {
|
||||
activity?.getSpanCount()?.let {
|
||||
currentSpan = it
|
||||
}
|
||||
configEvent.invoke(currentSpan)
|
||||
}
|
||||
|
||||
private val apiChangeClickListener = View.OnClickListener { view ->
|
||||
view.context.selectHomepage(currentApiName) { api ->
|
||||
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
||||
|
|
@ -618,110 +526,48 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
}*/
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
//(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged()
|
||||
fixGrid()
|
||||
}
|
||||
|
||||
private var currentApiName: String? = null
|
||||
private var toggleRandomButton = false
|
||||
|
||||
private var bottomSheetDialog: BottomSheetDialog? = null
|
||||
private var homeMasterAdapter: HomeParentItemAdapterPreview? = null
|
||||
|
||||
var lastSavedHomepage: String? = null
|
||||
|
||||
fun saveHomepageToTV(page: Map<String, HomeViewModel.ExpandableHomepageList>) {
|
||||
// No need to update for phone
|
||||
if (isLayout(PHONE)) {
|
||||
return
|
||||
}
|
||||
val (name, data) = page.entries.firstOrNull() ?: return
|
||||
// Modifying homepage is an expensive operation, and therefore we avoid it at all cost
|
||||
if (name == lastSavedHomepage) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Adding programs $name to TV")
|
||||
lastSavedHomepage = name
|
||||
ioSafe {
|
||||
// empty the channel
|
||||
deleteAll()
|
||||
// insert the program from first array
|
||||
addMovies(data.list.list)
|
||||
}
|
||||
}
|
||||
|
||||
override fun fixLayout(view: View) {
|
||||
fixSystemBarsPadding(
|
||||
view,
|
||||
padTop = false,
|
||||
padBottom = isLandscape(),
|
||||
padLeft = isLayout(TV or EMULATOR)
|
||||
)
|
||||
|
||||
// Fix grid
|
||||
configEvent.invoke()
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindingCreated(binding: FragmentHomeBinding) {
|
||||
context?.let { HomeChildItemAdapter.updatePosterSize(it) }
|
||||
(activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") {
|
||||
handleTvBackPress(this)
|
||||
}
|
||||
binding.apply {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
fixGrid()
|
||||
|
||||
binding?.apply {
|
||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||
homeApiFab.setOnClickListener(apiChangeClickListener)
|
||||
homeApiFab.setOnLongClickListener {
|
||||
if (currentApiName == noneApi.name) return@setOnLongClickListener false
|
||||
homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true)
|
||||
showToast(R.string.action_reload, Toast.LENGTH_SHORT)
|
||||
true
|
||||
}
|
||||
homeChangeApi.setOnClickListener(apiChangeClickListener)
|
||||
homeSwitchAccount.setOnClickListener {
|
||||
activity?.showAccountSelectLinear()
|
||||
}
|
||||
|
||||
homeRandom.setOnClickListener {
|
||||
if (listHomepageItems.isNotEmpty()) {
|
||||
activity.loadSearchResult(listHomepageItems.random())
|
||||
}
|
||||
}
|
||||
homeMasterAdapter = HomeParentItemAdapterPreview(
|
||||
homeViewModel, accountViewModel
|
||||
fragment = this@HomeFragment,
|
||||
homeViewModel,
|
||||
)
|
||||
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
||||
homeMasterRecycler.adapter = homeMasterAdapter
|
||||
//fixPaddingStatusbar(homeLoadingStatusbar)
|
||||
|
||||
homeApiFab.isVisible = isLayout(PHONE)
|
||||
|
||||
homePreviewReloadProvider.setOnClickListener {
|
||||
homeViewModel.loadAndCancel(
|
||||
homeViewModel.apiName.value ?: noneApi.name,
|
||||
forceReload = true,
|
||||
fromUI = true
|
||||
)
|
||||
showToast(R.string.action_reload, Toast.LENGTH_SHORT)
|
||||
}
|
||||
|
||||
homePreviewSearchButton.setOnClickListener { _ ->
|
||||
// Open blank screen.
|
||||
homeViewModel.queryTextSubmit("")
|
||||
}
|
||||
|
||||
// Load value for toggling Tv layout real time clock. Hide by default at startup
|
||||
// set visibility first, to apply a scroll effect later
|
||||
context?.let {
|
||||
if (isLayout(TV)) {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
||||
val toggleClock =
|
||||
settingsManager.getBoolean(
|
||||
getString(R.string.tv_layout_clock_key),
|
||||
false
|
||||
)
|
||||
binding.homeClock.isVisible = toggleClock
|
||||
} else {
|
||||
binding.homeClock.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
if (isLayout(PHONE)) {
|
||||
// Fab is only relevant to Phone
|
||||
if (dy > 0) { //check for scroll down
|
||||
homeApiFab.shrink() // hide
|
||||
homeRandom.shrink()
|
||||
|
|
@ -731,51 +577,13 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
homeRandom.extend()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Header scrolling is only relevant to TV/Emulator
|
||||
|
||||
val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView
|
||||
val scrollParent = binding.homeApiHolder
|
||||
|
||||
if (view == null) {
|
||||
// The first view is not visible, so we can assume we have scrolled past it
|
||||
scrollParent.isVisible = false
|
||||
} else {
|
||||
// A bit weird, but this is a major limitation we are working around here
|
||||
// 1. We cant have a real parent to the recyclerview as android cant layout that without lagging
|
||||
// 2. We cant put the view in the recyclerview, as it should always be shown
|
||||
// 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view
|
||||
//
|
||||
// This means that if we want to have a parent view to the recyclerview we are out of luck
|
||||
// Instead this uses getLocationInWindow to calculate how much the view should be scrolled
|
||||
// as recyclerView has no scrollY (always 0)
|
||||
//
|
||||
// Then it manually "scrolls" it to the correct position
|
||||
//
|
||||
// Hopefully getLocationInWindow acts correctly on all devices
|
||||
val rect = IntArray(2)
|
||||
view.getLocationInWindow(rect)
|
||||
scrollParent.isVisible = true
|
||||
scrollParent.translationY = rect[1].toFloat() - 60.toPx
|
||||
|
||||
// Move the TV layout real time clock out of the way too
|
||||
// We check if we have the correct layout and if the clock is enabled
|
||||
if(isLayout(TV) && binding.homeClock.isVisible) {
|
||||
val scrollParent = binding.homeClock
|
||||
|
||||
val rect = IntArray(2)
|
||||
view.getLocationInWindow(rect)
|
||||
scrollParent.isVisible = true
|
||||
scrollParent.translationY = rect[1].toFloat() - 60.toPx
|
||||
}
|
||||
}
|
||||
}
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
//Load value for toggling Random button. Hide at startup
|
||||
context?.let {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
||||
|
|
@ -783,56 +591,46 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
settingsManager.getBoolean(
|
||||
getString(R.string.random_button_key),
|
||||
false
|
||||
)
|
||||
binding.homeRandom.visibility = View.GONE
|
||||
binding.homeRandomButtonTv.visibility = View.GONE
|
||||
) && isLayout(PHONE)
|
||||
binding?.homeRandom?.visibility = View.GONE
|
||||
}
|
||||
|
||||
observe(homeViewModel.apiName) { apiName ->
|
||||
currentApiName = apiName
|
||||
binding.apply {
|
||||
homeApiFab.text = apiName
|
||||
homeChangeApi.text = apiName
|
||||
homePreviewReloadProvider.isGone = (apiName == noneApi.name)
|
||||
homePreviewSearchButton.isGone = (apiName == noneApi.name)
|
||||
}
|
||||
binding?.homeApiFab?.text = apiName
|
||||
binding?.homeChangeApi?.text = apiName
|
||||
}
|
||||
|
||||
observe(homeViewModel.page) { data ->
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
when (data) {
|
||||
is Resource.Success -> {
|
||||
homeLoadingShimmer.stopShimmer()
|
||||
|
||||
val d = data.value
|
||||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||
listHomepageItems.clear()
|
||||
|
||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
|
||||
it.copy(
|
||||
list = it.list.copy(list = it.list.list.toMutableList())
|
||||
)
|
||||
})
|
||||
|
||||
saveHomepageToTV(d)
|
||||
}.toMutableList())
|
||||
|
||||
homeLoading.isVisible = false
|
||||
homeLoadingError.isVisible = false
|
||||
homeMasterRecycler.isVisible = true
|
||||
homeLoadingShimmer.stopShimmer()
|
||||
//home_loaded?.isVisible = true
|
||||
if (toggleRandomButton) {
|
||||
val distinct = d.values
|
||||
.flatMap { it.list.list }
|
||||
.distinctBy { it.url }
|
||||
val hasItems = distinct.isNotEmpty()
|
||||
val isPhone = isLayout(PHONE)
|
||||
val randomClickListener = View.OnClickListener {
|
||||
distinct.randomOrNull()?.let { activity.loadSearchResult(it) }
|
||||
//Flatten list
|
||||
d.values.forEach { dlist ->
|
||||
mutableListOfResponse.addAll(dlist.list.list)
|
||||
}
|
||||
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
|
||||
|
||||
homeRandom.isVisible = isPhone && hasItems
|
||||
homeRandom.setOnClickListener(randomClickListener)
|
||||
homeRandomButtonTv.isVisible = !isPhone && hasItems
|
||||
homeRandomButtonTv.setOnClickListener(randomClickListener)
|
||||
homeRandom.isVisible = listHomepageItems.isNotEmpty()
|
||||
} else {
|
||||
homeRandom.isGone = true
|
||||
homeRandomButtonTv.isGone = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -850,7 +648,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
}) {
|
||||
try {
|
||||
val i = Intent(Intent.ACTION_VIEW)
|
||||
i.data = validAPIs[itemId].mainUrl.toUri()
|
||||
i.data = Uri.parse(validAPIs[itemId].mainUrl)
|
||||
startActivity(i)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
|
|
@ -860,7 +658,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
|
||||
homeLoading.isVisible = false
|
||||
homeLoadingError.isVisible = true
|
||||
homeMasterRecycler.isInvisible = true
|
||||
homeMasterRecycler.isVisible = false
|
||||
|
||||
// Based on https://github.com/recloudstream/cloudstream/pull/1438
|
||||
val hasNoNetworkConnection = context?.isNetworkAvailable() == false
|
||||
|
|
@ -882,28 +680,24 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
homeReloadConnectionGoToDownloads.setOnClickListener {
|
||||
activity.navigate(R.id.navigation_downloads)
|
||||
}
|
||||
|
||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
|
||||
submitList(null)
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
is Resource.Loading -> {
|
||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
|
||||
homeLoadingShimmer.startShimmer()
|
||||
homeLoading.isVisible = true
|
||||
homeLoadingError.isVisible = false
|
||||
homeMasterRecycler.isInvisible = true
|
||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
|
||||
submitList(null)
|
||||
clearState()
|
||||
}
|
||||
homeMasterRecycler.isVisible = false
|
||||
//home_loaded?.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//context?.fixPaddingStatusbarView(home_statusbar)
|
||||
//context?.fixPaddingStatusbar(home_padding)
|
||||
|
||||
observeNullable(homeViewModel.popup) { item ->
|
||||
if (item == null) {
|
||||
bottomSheetDialog?.dismissSafe()
|
||||
|
|
@ -948,44 +742,4 @@ 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,8 +6,10 @@ import android.os.Parcelable
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.lagradost.cloudstream3.HomePageList
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
||||
|
|
@ -15,11 +17,9 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||
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.setLinearListLayout
|
||||
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.PHONE
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
|
|
@ -34,11 +34,13 @@ class LoadClickCallback(
|
|||
)
|
||||
|
||||
open class ParentItemAdapter(
|
||||
open val fragment: Fragment,
|
||||
id: Int,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||
private val expandCallback: ((String) -> Unit)? = null,
|
||||
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
|
||||
fragment,
|
||||
id,
|
||||
diffCallback = BaseDiffCallback(
|
||||
itemSame = { a, b -> a.list.name == b.list.name },
|
||||
|
|
@ -46,11 +48,6 @@ open class ParentItemAdapter(
|
|||
a.list.list == b.list.list
|
||||
})
|
||||
) {
|
||||
companion object {
|
||||
val sharedPool =
|
||||
newSharedPool { setMaxRecycledViews(CONTENT, 4) }
|
||||
}
|
||||
|
||||
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
|
||||
override fun save(): Bundle = Bundle().apply {
|
||||
val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview
|
||||
|
|
@ -68,11 +65,8 @@ open class ParentItemAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
override fun submitList(
|
||||
list: Collection<HomeViewModel.ExpandableHomepageList>?,
|
||||
commitCallback: Runnable?
|
||||
) {
|
||||
super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback)
|
||||
override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
|
||||
super.submitList(list?.sortedBy { it.list.list.isEmpty() })
|
||||
}
|
||||
|
||||
override fun onUpdateContent(
|
||||
|
|
@ -96,10 +90,8 @@ open class ParentItemAdapter(
|
|||
if (binding !is HomepageParentBinding) return
|
||||
val info = item.list
|
||||
binding.apply {
|
||||
val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter
|
||||
if (currentAdapter == null) {
|
||||
homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
|
||||
homeChildRecyclerview.adapter = HomeChildItemAdapter(
|
||||
fragment = fragment,
|
||||
id = id + position + 100,
|
||||
clickCallback = clickCallback,
|
||||
nextFocusUp = homeChildRecyclerview.nextFocusUpId,
|
||||
|
|
@ -109,17 +101,6 @@ open class ParentItemAdapter(
|
|||
hasNext = item.hasNext
|
||||
submitList(item.list.list)
|
||||
}
|
||||
} else {
|
||||
currentAdapter.apply {
|
||||
isHorizontal = info.isHorizontalImages
|
||||
hasNext = item.hasNext
|
||||
this.clickCallback = this@ParentItemAdapter.clickCallback
|
||||
nextFocusUp = homeChildRecyclerview.nextFocusUpId
|
||||
nextFocusDown = homeChildRecyclerview.nextFocusDownId
|
||||
submitIncomparableList(item.list.list)
|
||||
}
|
||||
}
|
||||
|
||||
homeChildRecyclerview.setLinearListLayout(
|
||||
isHorizontal = true,
|
||||
nextLeft = startFocus,
|
||||
|
|
@ -185,6 +166,11 @@ open class ParentItemAdapter(
|
|||
|
||||
return ParentItemHolder(binding)
|
||||
}
|
||||
|
||||
fun updateList(newList: List<HomePageList>) {
|
||||
submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
|
||||
.toMutableList())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
|
|
@ -20,8 +18,9 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import com.google.android.material.navigation.NavigationBarItemView
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.HomePageList
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
|
|
@ -35,11 +34,9 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
|
||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
|
||||
import com.lagradost.cloudstream3.ui.account.AccountViewModel
|
||||
import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage
|
||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
||||
import com.lagradost.cloudstream3.ui.result.getId
|
||||
|
|
@ -50,23 +47,19 @@ import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
|||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
||||
import androidx.core.graphics.toColorInt
|
||||
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||
|
||||
class HomeParentItemAdapterPreview(
|
||||
override val fragment: Fragment,
|
||||
private val viewModel: HomeViewModel,
|
||||
private val accountViewModel: AccountViewModel
|
||||
) : ParentItemAdapter(
|
||||
id = "HomeParentItemAdapterPreview".hashCode(),
|
||||
fragment, id = "HomeParentItemAdapterPreview".hashCode(),
|
||||
clickCallback = {
|
||||
viewModel.click(it)
|
||||
}, moreInfoClickCallback = {
|
||||
|
|
@ -104,33 +97,15 @@ class HomeParentItemAdapterPreview(
|
|||
)
|
||||
}
|
||||
|
||||
return HeaderViewHolder(binding, viewModel, accountViewModel)
|
||||
return HeaderViewHolder(binding, viewModel, fragment = fragment)
|
||||
}
|
||||
|
||||
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
|
||||
(holder as? HeaderViewHolder)?.bind()
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(holder: ViewHolderState<Bundle>) {
|
||||
when (holder) {
|
||||
is HeaderViewHolder -> {
|
||||
holder.onViewDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: ViewHolderState<Bundle>) {
|
||||
when (holder) {
|
||||
is HeaderViewHolder -> {
|
||||
holder.onViewAttachedToWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HeaderViewHolder(
|
||||
val binding: ViewBinding,
|
||||
val viewModel: HomeViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment,
|
||||
) :
|
||||
ViewHolderState<Bundle>(binding) {
|
||||
|
||||
|
|
@ -156,13 +131,9 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
}
|
||||
|
||||
val previewAdapter = HomeScrollAdapter { view, position, item ->
|
||||
viewModel.click(
|
||||
LoadClickCallback(0, view, position, item)
|
||||
)
|
||||
}
|
||||
|
||||
val previewAdapter = HomeScrollAdapter(fragment = fragment)
|
||||
private val resumeAdapter = ResumeItemAdapter(
|
||||
fragment,
|
||||
nextFocusUp = itemView.nextFocusUpId,
|
||||
nextFocusDown = itemView.nextFocusDownId,
|
||||
removeCallback = { v ->
|
||||
|
|
@ -245,6 +216,7 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
})
|
||||
private val bookmarkAdapter = HomeChildItemAdapter(
|
||||
fragment,
|
||||
id = "bookmarkAdapter".hashCode(),
|
||||
nextFocusUp = itemView.nextFocusUpId,
|
||||
nextFocusDown = itemView.nextFocusDownId
|
||||
|
|
@ -321,14 +293,9 @@ class HomeParentItemAdapterPreview(
|
|||
private val bookmarkRecyclerView: RecyclerView =
|
||||
itemView.findViewById(R.id.home_bookmarked_child_recyclerview)
|
||||
|
||||
private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic)
|
||||
private val headProfilePicCard: View? =
|
||||
itemView.findViewById(R.id.home_head_profile_padding)
|
||||
|
||||
private val alternateHeadProfilePic: ImageView? =
|
||||
itemView.findViewById(R.id.alternate_home_head_profile_pic)
|
||||
private val alternateHeadProfilePicCard: View? =
|
||||
itemView.findViewById(R.id.alternate_home_head_profile_padding)
|
||||
private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account)
|
||||
private val alternativeHomeAccount: View? =
|
||||
itemView.findViewById(R.id.alternative_switch_account)
|
||||
|
||||
private val topPadding: View? = itemView.findViewById(R.id.home_padding)
|
||||
|
||||
|
|
@ -339,73 +306,38 @@ class HomeParentItemAdapterPreview(
|
|||
|
||||
fun onSelect(item: LoadResponse, position: Int) {
|
||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
||||
homePreviewDescription.isGone = item.plot.isNullOrBlank()
|
||||
homePreviewDescription.text = item.plot?.html() ?: ""
|
||||
homePreviewDescription.isGone =
|
||||
item.plot.isNullOrBlank()
|
||||
homePreviewDescription.text =
|
||||
item.plot ?: ""
|
||||
|
||||
val scoreText = item.score?.toStringNull(0.1, 10, 1, false)
|
||||
|
||||
scoreText?.let { score ->
|
||||
homePreviewScore.text =
|
||||
homePreviewScore.context.getString(R.string.extension_rating, score)
|
||||
|
||||
// while it should never fail, we do this just in case
|
||||
val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0
|
||||
|
||||
val color = when {
|
||||
rating < 5.0 -> "#eb2f2f".toColorInt() // Red
|
||||
rating < 8.0 -> "#eda009".toColorInt() // Yellow
|
||||
else -> "#3bb33b".toColorInt() // Green
|
||||
}
|
||||
homePreviewScore.backgroundTintList =
|
||||
android.content.res.ColorStateList.valueOf(color)
|
||||
}
|
||||
homePreviewScore.isGone = scoreText == null
|
||||
|
||||
item.year?.let { year ->
|
||||
homePreviewYear.text = year.toString()
|
||||
}
|
||||
homePreviewYear.isGone = item.year == null
|
||||
|
||||
val duration = item.duration
|
||||
duration?.let { min ->
|
||||
homePreviewDuration.text =
|
||||
homePreviewDuration.context.getString(R.string.duration_format, min)
|
||||
}
|
||||
homePreviewDuration.isGone = duration == null || duration <= 0
|
||||
|
||||
val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name }
|
||||
if (!castText.isNullOrBlank()) {
|
||||
homePreviewCast.text =
|
||||
homePreviewCast.context.getString(R.string.cast_format, castText)
|
||||
homePreviewCast.isVisible = true
|
||||
} else {
|
||||
homePreviewCast.isVisible = false
|
||||
}
|
||||
|
||||
homePreviewText.text = item.name.html()
|
||||
homePreviewText.text = item.name
|
||||
populateChips(
|
||||
homePreviewTags,
|
||||
item.tags?.take(6) ?: emptyList(),
|
||||
R.style.ChipFilledSemiTransparent,
|
||||
null
|
||||
)
|
||||
|
||||
|
||||
bindLogo(
|
||||
url = item.logoUrl,
|
||||
headers = item.posterHeaders,
|
||||
titleView = homePreviewText,
|
||||
logoView = homeBackgroundPosterWatermarkBadgeHolder
|
||||
R.style.ChipFilledSemiTransparent
|
||||
)
|
||||
|
||||
homePreviewTags.isGone =
|
||||
item.tags.isNullOrEmpty()
|
||||
|
||||
homePreviewPlayBtt.setOnClickListener { view ->
|
||||
viewModel.click(
|
||||
LoadClickCallback(
|
||||
START_ACTION_RESUME_LATEST,
|
||||
view,
|
||||
position,
|
||||
item
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
homePreviewInfoBtt.setOnClickListener { view ->
|
||||
viewModel.click(
|
||||
LoadClickCallback(0, view, position, item)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
(binding as? FragmentHomeHeadBinding)?.apply {
|
||||
//homePreviewImage.setImage(item.posterUrl, item.posterHeaders)
|
||||
|
|
@ -490,7 +422,7 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
}
|
||||
|
||||
fun onViewDetachedFromWindow() {
|
||||
override fun onViewDetachedFromWindow() {
|
||||
previewViewpager.unregisterOnPageChangeCallback(previewCallback)
|
||||
}
|
||||
|
||||
|
|
@ -511,14 +443,12 @@ class HomeParentItemAdapterPreview(
|
|||
|
||||
previewViewpager.adapter = previewAdapter
|
||||
resumeRecyclerView.adapter = resumeAdapter
|
||||
bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
|
||||
bookmarkRecyclerView.adapter = bookmarkAdapter
|
||||
|
||||
resumeRecyclerView.setLinearListLayout(
|
||||
nextLeft = R.id.nav_rail_view,
|
||||
nextRight = FOCUS_SELF
|
||||
)
|
||||
|
||||
bookmarkRecyclerView.setLinearListLayout(
|
||||
nextLeft = R.id.nav_rail_view,
|
||||
nextRight = FOCUS_SELF
|
||||
|
|
@ -539,80 +469,36 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
}
|
||||
|
||||
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
||||
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
||||
homeAccount?.isGone = isLayout(TV or EMULATOR)
|
||||
|
||||
(headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
|
||||
headProfilePic?.loadImage(currentAccount?.image)
|
||||
alternateHeadProfilePic?.loadImage(currentAccount?.image)
|
||||
}
|
||||
|
||||
headProfilePicCard?.setOnClickListener {
|
||||
homeAccount?.setOnClickListener {
|
||||
activity?.showAccountSelectLinear()
|
||||
}
|
||||
|
||||
fun showAccountEditBox(context: Context): Boolean {
|
||||
val currentAccount = DataStoreHelper.getCurrentAccount()
|
||||
return if (currentAccount != null) {
|
||||
showAccountEditDialog(
|
||||
context = context,
|
||||
account = currentAccount,
|
||||
isNewAccount = false,
|
||||
accountEditCallback = { accountViewModel.handleAccountUpdate(it, context) },
|
||||
accountDeleteCallback = {
|
||||
accountViewModel.handleAccountDelete(
|
||||
it,
|
||||
context
|
||||
)
|
||||
}
|
||||
)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
alternateHeadProfilePicCard?.setOnLongClickListener {
|
||||
showAccountEditBox(it.context)
|
||||
}
|
||||
headProfilePicCard?.setOnLongClickListener {
|
||||
showAccountEditBox(it.context)
|
||||
}
|
||||
|
||||
alternateHeadProfilePicCard?.setOnClickListener {
|
||||
alternativeHomeAccount?.setOnClickListener {
|
||||
activity?.showAccountSelectLinear()
|
||||
}
|
||||
|
||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
||||
/*homePreviewChangeApi.setOnClickListener { view ->
|
||||
homePreviewChangeApi.setOnClickListener { view ->
|
||||
view.context.selectHomepage(viewModel.repo?.name) { api ->
|
||||
viewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
||||
}
|
||||
}
|
||||
homePreviewReloadProvider.setOnClickListener {
|
||||
viewModel.loadAndCancel(
|
||||
viewModel.apiName.value ?: noneApi.name,
|
||||
forceReload = true,
|
||||
fromUI = true
|
||||
)
|
||||
showToast(R.string.action_reload, Toast.LENGTH_SHORT)
|
||||
true
|
||||
}
|
||||
|
||||
homePreviewSearchButton.setOnClickListener { _ ->
|
||||
// Open blank screen.
|
||||
viewModel.queryTextSubmit("")
|
||||
}*/
|
||||
|
||||
// A workaround to the focus problem of always centering the view on focus
|
||||
// as that causes higher android versions to stretch the ui when switching between shows
|
||||
var lastFocusTimeoutMs = 0L
|
||||
homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus ->
|
||||
val lastFocusMs = lastFocusTimeoutMs
|
||||
// Always reset timer, as we only want to update
|
||||
// it if we have not interacted in half a second
|
||||
lastFocusTimeoutMs = System.currentTimeMillis()
|
||||
if (!hasFocus) return@setOnFocusChangeListener
|
||||
if (lastFocusMs + 500L < System.currentTimeMillis()) {
|
||||
MainActivity.centerView(view)
|
||||
}
|
||||
|
||||
// This makes the hidden next buttons only available when on the info button
|
||||
// Otherwise you might be able to go to the next item without being at the info button
|
||||
homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus ->
|
||||
homePreviewHiddenNextFocus.isFocusable = hasFocus
|
||||
}
|
||||
|
||||
homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus ->
|
||||
homePreviewHiddenPrevFocus.isFocusable = hasFocus
|
||||
}
|
||||
|
||||
homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus ->
|
||||
|
|
@ -630,8 +516,7 @@ class HomeParentItemAdapterPreview(
|
|||
)?.requestFocus()
|
||||
} else {
|
||||
previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true)
|
||||
binding.homePreviewInfoBtt.requestFocus()
|
||||
//binding.homePreviewPlayBtt.requestFocus()
|
||||
binding.homePreviewPlayBtt.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -658,7 +543,9 @@ class HomeParentItemAdapterPreview(
|
|||
params.height = 0
|
||||
layoutParams = params
|
||||
}
|
||||
} else fixPaddingStatusbarView(homeNonePadding)
|
||||
} else {
|
||||
fixPaddingStatusbarView(homeNonePadding)
|
||||
}
|
||||
|
||||
when (preview) {
|
||||
is Resource.Success -> {
|
||||
|
|
@ -682,15 +569,6 @@ class HomeParentItemAdapterPreview(
|
|||
previewViewpager.isVisible = true
|
||||
previewViewpagerText.isVisible = true
|
||||
alternativeAccountPadding?.isVisible = false
|
||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
||||
homePreviewInfoBtt.isVisible = true
|
||||
}
|
||||
// Explicitly bind the current item to ensure instant loading
|
||||
val currentPos = previewViewpager.currentItem
|
||||
val item = preview.value.second.getOrNull(currentPos)
|
||||
if (item != null) {
|
||||
onSelect(item, currentPos)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
|
@ -699,9 +577,6 @@ class HomeParentItemAdapterPreview(
|
|||
previewViewpager.isVisible = false
|
||||
previewViewpagerText.isVisible = false
|
||||
alternativeAccountPadding?.isVisible = true
|
||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
||||
homePreviewInfoBtt.isVisible = false
|
||||
}
|
||||
//previewHeader.isVisible = false
|
||||
}
|
||||
}
|
||||
|
|
@ -770,19 +645,18 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
}
|
||||
|
||||
fun onViewAttachedToWindow() {
|
||||
override fun onViewAttachedToWindow() {
|
||||
previewViewpager.registerOnPageChangeCallback(previewCallback)
|
||||
|
||||
previewViewpager.apply {
|
||||
binding.root.findViewTreeLifecycleOwner()?.apply {
|
||||
observe(viewModel.preview) {
|
||||
updatePreview(it)
|
||||
}
|
||||
/*if (binding is FragmentHomeHeadTvBinding) {
|
||||
if (binding is FragmentHomeHeadTvBinding) {
|
||||
observe(viewModel.apiName) { name ->
|
||||
binding.homePreviewChangeApi.text = name
|
||||
binding.homePreviewReloadProvider.isGone = (name == noneApi.name)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
observe(viewModel.resumeWatching) {
|
||||
updateResume(it)
|
||||
}
|
||||
|
|
@ -798,7 +672,7 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
toggleListHolder?.isGone = visible.isEmpty()
|
||||
}
|
||||
}
|
||||
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,23 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
|
||||
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
|
||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
|
||||
class HomeScrollAdapter(
|
||||
val callback: ((View, Int, LoadResponse) -> Unit)
|
||||
) : NoStateAdapter<LoadResponse>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
|
||||
a.uniqueUrl == b.uniqueUrl && a.name == b.name
|
||||
})) {
|
||||
fragment: Fragment
|
||||
) : NoStateAdapter<LoadResponse>(fragment) {
|
||||
var hasMoreItems: Boolean = false
|
||||
|
||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
||||
|
|
@ -35,51 +31,33 @@ class HomeScrollAdapter(
|
|||
return ViewHolderState(binding)
|
||||
}
|
||||
|
||||
override fun onClearView(holder: ViewHolderState<Any>) {
|
||||
when (val binding = holder.view) {
|
||||
is HomeScrollViewBinding -> {
|
||||
clearImage(binding.homeScrollPreview)
|
||||
}
|
||||
|
||||
is HomeScrollViewTvBinding -> {
|
||||
clearImage(binding.homeScrollPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindContent(
|
||||
holder: ViewHolderState<Any>,
|
||||
item: LoadResponse,
|
||||
position: Int,
|
||||
) {
|
||||
val binding = holder.view
|
||||
val itemView = holder.itemView
|
||||
val isHorizontal =
|
||||
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val posterUrl = item.backgroundPosterUrl ?: item.posterUrl
|
||||
val posterUrl =
|
||||
if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl
|
||||
?: item.backgroundPosterUrl
|
||||
|
||||
when (binding) {
|
||||
is HomeScrollViewBinding -> {
|
||||
binding.homeScrollPreview.loadImage(posterUrl, item.posterHeaders)
|
||||
binding.homeScrollPreview.loadImage(posterUrl)
|
||||
binding.homeScrollPreviewTags.apply {
|
||||
text = item.tags?.joinToString(" • ") ?: ""
|
||||
isGone = item.tags.isNullOrEmpty()
|
||||
maxLines = 2
|
||||
}
|
||||
binding.homeScrollPreviewTitle.text = item.name.html()
|
||||
|
||||
bindLogo(
|
||||
url = item.logoUrl,
|
||||
headers = item.posterHeaders,
|
||||
titleView = binding.homeScrollPreviewTitle,
|
||||
logoView = binding.homePreviewLogo
|
||||
)
|
||||
binding.homeScrollPreviewTitle.text = item.name
|
||||
}
|
||||
|
||||
is HomeScrollViewTvBinding -> {
|
||||
binding.homeScrollPreview.isFocusable = false
|
||||
binding.homeScrollPreview.setOnClickListener { view ->
|
||||
callback.invoke(view ?: return@setOnClickListener, position, item)
|
||||
}
|
||||
binding.homeScrollPreview.loadImage(posterUrl, item.posterHeaders)
|
||||
binding.homeScrollPreview.loadImage(posterUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||
import com.lagradost.cloudstream3.HomePageList
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.amap
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
|
|
@ -40,7 +40,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
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.deleteAllResumeStateIds
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
||||
|
|
@ -50,12 +49,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount
|
|||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.collections.set
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
companion object {
|
||||
|
|
@ -67,27 +67,12 @@ class HomeViewModel : ViewModel() {
|
|||
}
|
||||
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
||||
resumeWatching?.mapNotNull { resume ->
|
||||
val headerCache = getKey<DownloadObjects.DownloadHeaderCached>(
|
||||
|
||||
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
resume.parentId.toString()
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
DataStoreHelper.ResumeWatchingResult(
|
||||
|
|
@ -133,7 +118,7 @@ class HomeViewModel : ViewModel() {
|
|||
private var currentShuffledList: List<SearchResponse> = listOf()
|
||||
|
||||
private fun autoloadRepo(): APIRepository {
|
||||
return APIRepository(apis.withLock { apis.first { it.hasMainPage } })
|
||||
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
|
||||
}
|
||||
|
||||
private val _availableWatchStatusTypes =
|
||||
|
|
@ -535,18 +520,18 @@ class HomeViewModel : ViewModel() {
|
|||
} else if (api == null) {
|
||||
// API is not found aka not loaded or removed, post the loading
|
||||
// progress if waiting for plugins, otherwise nothing
|
||||
if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) {
|
||||
if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
|
||||
loadAndCancel(noneApi)
|
||||
} else {
|
||||
_page.postValue(Resource.Loading())
|
||||
if (preferredApiName != null)
|
||||
_apiName.postValue(preferredApiName)
|
||||
_apiName.postValue(preferredApiName!!)
|
||||
}
|
||||
} else {
|
||||
// if the api is found, then set it to it and save key
|
||||
if (fromUI) DataStoreHelper.currentHomePage = api.name
|
||||
loadAndCancel(api)
|
||||
}
|
||||
reloadAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,59 +7,65 @@ import android.content.res.Configuration
|
|||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
|
||||
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
import android.view.animation.AlphaAnimation
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.allViews
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.CommonActivity
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.abs
|
||||
|
||||
const val LIBRARY_FOLDER = "library_folder"
|
||||
|
||||
|
||||
enum class LibraryOpenerType(@StringRes val stringRes: Int) {
|
||||
Default(R.string.action_default),
|
||||
Provider(R.string.none),
|
||||
|
|
@ -69,21 +75,19 @@ enum class LibraryOpenerType(@StringRes val stringRes: Int) {
|
|||
}
|
||||
|
||||
/** Used to store how the user wants to open said poster */
|
||||
@Serializable
|
||||
data class LibraryOpener(
|
||||
@JsonProperty("openType") @SerialName("openType") val openType: LibraryOpenerType,
|
||||
@JsonProperty("providerData") @SerialName("providerData") val providerData: ProviderLibraryData?,
|
||||
val openType: LibraryOpenerType,
|
||||
val providerData: ProviderLibraryData?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProviderLibraryData(
|
||||
@JsonProperty("apiName") @SerialName("apiName") val apiName: String,
|
||||
val apiName: String
|
||||
)
|
||||
|
||||
class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
|
||||
) {
|
||||
class LibraryFragment : Fragment() {
|
||||
companion object {
|
||||
|
||||
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
|
||||
fun newInstance() = LibraryFragment()
|
||||
|
||||
/**
|
||||
|
|
@ -94,10 +98,35 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
|
||||
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
||||
|
||||
var binding: FragmentLibraryBinding? = null
|
||||
private var toggleRandomButton = false
|
||||
|
||||
override fun pickLayout(): Int? =
|
||||
if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
val layout =
|
||||
if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library
|
||||
val root = inflater.inflate(layout, container, false)
|
||||
binding = try {
|
||||
FragmentLibraryBinding.bind(root)
|
||||
} catch (t: Throwable) {
|
||||
CommonActivity.showToast(
|
||||
txt(R.string.unable_to_inflate, t.message ?: ""),
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
logError(t)
|
||||
null
|
||||
}
|
||||
|
||||
return root
|
||||
|
||||
//return inflater.inflate(R.layout.fragment_library, container, false)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
binding?.viewpager?.currentItem?.let { currentItem ->
|
||||
|
|
@ -106,52 +135,48 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun updateRandomVisibility(binding: FragmentLibraryBinding) {
|
||||
if (!toggleRandomButton) {
|
||||
binding.libraryRandom.isGone = true
|
||||
binding.libraryRandomButtonTv.isGone = true
|
||||
return
|
||||
}
|
||||
private fun updateRandom() {
|
||||
val position = libraryViewModel.currentPage.value ?: 0
|
||||
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
|
||||
val hasItems = pages[position].items.isNotEmpty()
|
||||
val isPhone = isLayout(PHONE)
|
||||
|
||||
binding.libraryRandom.isVisible = isPhone && hasItems
|
||||
binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems
|
||||
if (toggleRandomButton) {
|
||||
listLibraryItems.clear()
|
||||
listLibraryItems.addAll(pages[position].items)
|
||||
binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty()
|
||||
} else {
|
||||
binding?.libraryRandom?.isGone = true
|
||||
}
|
||||
|
||||
override fun fixLayout(view: View) {
|
||||
fixSystemBarsPadding(
|
||||
view,
|
||||
padBottom = isLandscape(),
|
||||
padLeft = !isLayout(PHONE)
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ResourceType", "CutPasteId")
|
||||
override fun onBindingCreated(
|
||||
binding: FragmentLibraryBinding,
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
binding.sortFab.setOnClickListener(sortChangeClickListener)
|
||||
binding.librarySort.setOnClickListener(sortChangeClickListener)
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
fixPaddingStatusbar(binding?.searchStatusBarPadding)
|
||||
|
||||
binding.libraryRoot.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)
|
||||
?.apply {
|
||||
binding?.sortFab?.setOnClickListener(sortChangeClickListener)
|
||||
binding?.librarySort?.setOnClickListener(sortChangeClickListener)
|
||||
|
||||
binding?.libraryRoot?.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)?.apply {
|
||||
tag = "tv_no_focus_tag"
|
||||
//Expand the Appbar when search bar is focused, fixing scroll up issue
|
||||
setOnFocusChangeListener { _, _ ->
|
||||
binding.searchBar.setExpanded(true)
|
||||
binding?.searchBar?.setExpanded(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the color for the search exit icon to the correct theme text color
|
||||
val searchExitIcon =
|
||||
binding?.mainSearch?.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
|
||||
val searchExitIconColor = TypedValue()
|
||||
|
||||
activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true)
|
||||
searchExitIcon?.setColorFilter(searchExitIconColor.data)
|
||||
|
||||
val searchCallback = Runnable {
|
||||
val newText = binding.mainSearch.query.toString()
|
||||
val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable
|
||||
libraryViewModel.sort(ListSorting.Query, newText)
|
||||
}
|
||||
|
||||
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
libraryViewModel.sort(ListSorting.Query, query)
|
||||
return true
|
||||
|
|
@ -167,11 +192,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
return true
|
||||
}
|
||||
|
||||
binding.mainSearch.removeCallbacks(searchCallback)
|
||||
binding?.mainSearch?.removeCallbacks(searchCallback)
|
||||
|
||||
// Delay the execution of the search operation by 1 second (adjust as needed)
|
||||
// this prevents running search when the user is typing
|
||||
binding.mainSearch.postDelayed(searchCallback, 1000)
|
||||
binding?.mainSearch?.postDelayed(searchCallback, 1000)
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -179,12 +204,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
|
||||
libraryViewModel.reloadPages(false)
|
||||
|
||||
binding.listSelector.setOnClickListener {
|
||||
binding?.listSelector?.setOnClickListener {
|
||||
val items = libraryViewModel.availableApiNames
|
||||
val currentItem = libraryViewModel.currentApiName.value
|
||||
|
||||
activity?.showBottomDialog(
|
||||
items,
|
||||
activity?.showBottomDialog(items,
|
||||
items.indexOf(currentItem),
|
||||
txt(R.string.select_library).asString(it.context),
|
||||
false,
|
||||
|
|
@ -201,9 +225,17 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
settingsManager.getBoolean(
|
||||
getString(R.string.random_button_key),
|
||||
false
|
||||
)
|
||||
binding.libraryRandom.visibility = View.GONE
|
||||
binding.libraryRandomButtonTv.visibility = View.GONE
|
||||
) && isLayout(PHONE)
|
||||
binding?.libraryRandom?.visibility = View.GONE
|
||||
}
|
||||
|
||||
binding?.libraryRandom?.setOnClickListener {
|
||||
if (listLibraryItems.isNotEmpty()) {
|
||||
val listLibraryItem = listLibraryItems.random()
|
||||
libraryViewModel.currentSyncApi?.syncIdName?.let {
|
||||
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -214,13 +246,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
syncId: SyncIdName,
|
||||
apiName: String? = null,
|
||||
) {
|
||||
val availableProviders = allProviders.filter {
|
||||
val availableProviders = synchronized(allProviders) {
|
||||
allProviders.filter {
|
||||
it.supportedSyncNames.contains(syncId)
|
||||
}.map { it.name } +
|
||||
// Add the api if it exists
|
||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
||||
?: emptyList())
|
||||
|
||||
}
|
||||
val baseOptions = listOf(
|
||||
LibraryOpenerType.Default,
|
||||
LibraryOpenerType.None,
|
||||
|
|
@ -272,21 +305,22 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
}
|
||||
}
|
||||
|
||||
binding.providerSelector.setOnClickListener {
|
||||
binding?.providerSelector?.setOnClickListener {
|
||||
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
||||
activity?.showPluginSelectionDialog(syncName.name, syncName)
|
||||
}
|
||||
|
||||
binding.viewpager.setPageTransformer(LibraryScrollTransformer())
|
||||
binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
|
||||
|
||||
binding.viewpager.adapter = ViewpagerAdapter(
|
||||
binding?.viewpager?.adapter = ViewpagerAdapter(
|
||||
fragment = this,
|
||||
{ isScrollingDown: Boolean ->
|
||||
if (isScrollingDown) {
|
||||
binding.sortFab.shrink()
|
||||
binding.libraryRandom.shrink()
|
||||
binding?.sortFab?.shrink()
|
||||
binding?.libraryRandom?.shrink()
|
||||
} else {
|
||||
binding.sortFab.extend()
|
||||
binding.libraryRandom.extend()
|
||||
binding?.sortFab?.extend()
|
||||
binding?.libraryRandom?.extend()
|
||||
}
|
||||
}) callback@{ searchClickCallback ->
|
||||
// To prevent future accidents
|
||||
|
|
@ -319,15 +353,15 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
}
|
||||
}
|
||||
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
viewpager.offscreenPageLimit = 2
|
||||
viewpager.reduceDragSensitivity()
|
||||
searchBar.setExpanded(true)
|
||||
}
|
||||
|
||||
val startLoading = Runnable {
|
||||
binding.apply {
|
||||
gridview.numColumns = root.context.getSpanCount()
|
||||
binding?.apply {
|
||||
gridview.numColumns = context?.getSpanCount() ?: 3
|
||||
gridview.adapter =
|
||||
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
||||
libraryLoadingOverlay.isVisible = true
|
||||
|
|
@ -337,7 +371,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
}
|
||||
|
||||
val stopLoading = Runnable {
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
gridview.adapter = null
|
||||
libraryLoadingOverlay.isVisible = false
|
||||
libraryLoadingShimmer.stopShimmer()
|
||||
|
|
@ -353,7 +387,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
val pages = resource.value
|
||||
val showNotice = pages.all { it.items.isEmpty() }
|
||||
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
emptyListTextview.isVisible = showNotice
|
||||
if (showNotice) {
|
||||
if (libraryViewModel.availableApiNames.size > 1) {
|
||||
|
|
@ -381,23 +415,10 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
)*/
|
||||
|
||||
libraryViewModel.currentPage.value?.let { page ->
|
||||
binding.viewpager.setCurrentItem(page, false)
|
||||
binding.searchBar.setExpanded(true)
|
||||
binding?.viewpager?.setCurrentItem(page, false)
|
||||
}
|
||||
|
||||
// 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)
|
||||
updateRandom()
|
||||
|
||||
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
||||
// Without this there would be a flashing effect:
|
||||
|
|
@ -438,20 +459,21 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
tab.view.nextFocusDownId = R.id.search_result_root
|
||||
|
||||
tab.view.setOnClickListener {
|
||||
val currentItem = binding.viewpager.currentItem
|
||||
val currentItem =
|
||||
binding?.viewpager?.currentItem ?: return@setOnClickListener
|
||||
val distance = abs(position - currentItem)
|
||||
hideViewpager(distance)
|
||||
}
|
||||
//Expand the appBar on tab focus
|
||||
tab.view.setOnFocusChangeListener { _, _ ->
|
||||
binding.searchBar.setExpanded(true)
|
||||
binding?.searchBar?.setExpanded(true)
|
||||
}
|
||||
}.attach()
|
||||
|
||||
binding.libraryTabLayout.addOnTabSelectedListener(object :
|
||||
binding?.libraryTabLayout?.addOnTabSelectedListener(object :
|
||||
TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||
binding.libraryTabLayout.selectedTabPosition.let { page ->
|
||||
binding?.libraryTabLayout?.selectedTabPosition?.let { page ->
|
||||
libraryViewModel.switchPage(page)
|
||||
}
|
||||
}
|
||||
|
|
@ -476,11 +498,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
}
|
||||
|
||||
observe(libraryViewModel.currentPage) { position ->
|
||||
updateRandomVisibility(binding)
|
||||
val all = binding.viewpager.allViews.toList()
|
||||
.filterIsInstance<AutofitRecyclerView>()
|
||||
updateRandom()
|
||||
val all = binding?.viewpager?.allViews?.toList()
|
||||
?.filterIsInstance<AutofitRecyclerView>()
|
||||
|
||||
all.forEach { view ->
|
||||
all?.forEach { view ->
|
||||
view.isVisible = view.tag == position
|
||||
view.isFocusable = view.tag == position
|
||||
|
||||
|
|
@ -490,6 +512,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||
}
|
||||
}
|
||||
|
||||
/*binding?.viewpager?.registerOnPageChangeCallback(object :
|
||||
ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
|
||||
super.onPageSelected(position)
|
||||
}
|
||||
})*/
|
||||
}
|
||||
|
||||
private fun loadLibraryItem(
|
||||
|
|
@ -548,10 +578,10 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
binding?.viewpager?.adapter?.notifyDataSetChanged()
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val adapter = binding?.viewpager?.adapter ?: return
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
|
||||
private val sortChangeClickListener = View.OnClickListener { view ->
|
||||
|
|
@ -559,8 +589,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
txt(it.stringRes).asString(view.context)
|
||||
}
|
||||
|
||||
activity?.showBottomDialog(
|
||||
methods,
|
||||
activity?.showBottomDialog(methods,
|
||||
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
|
||||
txt(R.string.sort_by).asString(view.context),
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import androidx.annotation.StringRes
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
|
|
|
|||
|
|
@ -1,34 +1,31 @@
|
|||
package com.lagradost.cloudstream3.ui.library
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class PageAdapter(
|
||||
override val items: MutableList<SyncAPI.LibraryItem>,
|
||||
private val resView: AutofitRecyclerView,
|
||||
val clickCallback: (SearchClickCallback) -> Unit
|
||||
) :
|
||||
NoStateAdapter<SyncAPI.LibraryItem>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
|
||||
if (a.id != null || b.id != null) {
|
||||
a.id == b.id
|
||||
} else {
|
||||
a.name == b.name && a.url == b.url
|
||||
}
|
||||
})) {
|
||||
private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt()
|
||||
AppContextUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
|
||||
|
||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
||||
return ViewHolderState(
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return LibraryItemViewHolder(
|
||||
SearchResultGridExpandedBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
|
|
@ -37,45 +34,86 @@ class PageAdapter(
|
|||
)
|
||||
}
|
||||
|
||||
override fun onClearView(holder: ViewHolderState<Any>) {
|
||||
when (val binding = holder.view) {
|
||||
is SearchResultGridExpandedBinding -> {
|
||||
clearImage(binding.imageView)
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is LibraryItemViewHolder -> {
|
||||
holder.bind(items[position], position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindContent(
|
||||
holder: ViewHolderState<Any>,
|
||||
item: SyncAPI.LibraryItem,
|
||||
position: Int
|
||||
) {
|
||||
val binding = holder.view as? SearchResultGridExpandedBinding ?: return
|
||||
private fun isDark(color: Int): Boolean {
|
||||
return ColorUtils.calculateLuminance(color) < 0.5
|
||||
}
|
||||
|
||||
fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int {
|
||||
return if (isDark(color)) {
|
||||
ColorUtils.blendARGB(color, Color.WHITE, ratio)
|
||||
} else {
|
||||
ColorUtils.blendARGB(color, Color.BLACK, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
private val compactView = false//itemView.context.getGridIsCompact()
|
||||
private val coverHeight: Int =
|
||||
if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt()
|
||||
|
||||
fun bind(item: SyncAPI.LibraryItem, position: Int) {
|
||||
/** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */
|
||||
|
||||
SearchResultBuilder.bind(
|
||||
this@PageAdapter.clickCallback,
|
||||
item,
|
||||
position,
|
||||
holder.itemView,
|
||||
itemView,
|
||||
/*colorCallback = { palette ->
|
||||
AcraApplication.context?.let { ctx ->
|
||||
val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg)
|
||||
var bg = palette.getDarkVibrantColor(defColor)
|
||||
if (bg == defColor) {
|
||||
bg = palette.getDarkMutedColor(defColor)
|
||||
}
|
||||
if (bg == defColor) {
|
||||
bg = palette.getVibrantColor(defColor)
|
||||
}
|
||||
|
||||
val fg =
|
||||
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
|
||||
binding.textRating.apply {
|
||||
setTextColor(ColorStateList.valueOf(fg))
|
||||
}
|
||||
binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg)
|
||||
binding.textRating.backgroundTintList = ColorStateList.valueOf(bg)
|
||||
binding.watchProgress.apply {
|
||||
progressTintList = ColorStateList.valueOf(fg)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
)
|
||||
|
||||
// See searchAdaptor for this, it basically fixes the height
|
||||
val params = FrameLayout.LayoutParams(
|
||||
if (!compactView) {
|
||||
binding.imageView.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
coverHeight
|
||||
)
|
||||
if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) {
|
||||
binding.imageView.layoutParams = params
|
||||
}
|
||||
}
|
||||
|
||||
val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null
|
||||
val showProgress = item.episodesCompleted != null && item.episodesTotal != null
|
||||
binding.watchProgress.isVisible = showProgress
|
||||
if (showProgress) {
|
||||
binding.watchProgress.max = item.episodesTotal
|
||||
binding.watchProgress.progress = item.episodesCompleted
|
||||
binding.watchProgress.max = item.episodesTotal!!
|
||||
binding.watchProgress.progress = item.episodesCompleted!!
|
||||
}
|
||||
|
||||
binding.imageText.text = item.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,9 +40,10 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding)
|
|||
}
|
||||
|
||||
class ViewpagerAdapter(
|
||||
fragment: Fragment,
|
||||
val scrollCallback: (isScrollingDown: Boolean) -> Unit,
|
||||
val clickCallback: (SearchClickCallback) -> Unit
|
||||
) : BaseAdapter<SyncAPI.Page, Bundle>(
|
||||
) : BaseAdapter<SyncAPI.Page, Bundle>(fragment,
|
||||
id = "ViewpagerAdapter".hashCode(),
|
||||
diffCallback = BaseDiffCallback(
|
||||
itemSame = { a, b ->
|
||||
|
|
@ -52,7 +53,6 @@ class ViewpagerAdapter(
|
|||
a.items == b.items && a.title == b.title
|
||||
}
|
||||
)) {
|
||||
|
||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Bundle> {
|
||||
return ViewpagerAdapterViewHolderState(
|
||||
LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
|
@ -66,8 +66,7 @@ class ViewpagerAdapter(
|
|||
) {
|
||||
val binding = holder.view
|
||||
if (binding !is LibraryViewpagerPageBinding) return
|
||||
(binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items)
|
||||
binding.pageRecyclerview.scrollToPosition(0)
|
||||
(binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items)
|
||||
}
|
||||
|
||||
override fun onBindContent(holder: ViewHolderState<Bundle>, item: SyncAPI.Page, position: Int) {
|
||||
|
|
@ -76,21 +75,21 @@ class ViewpagerAdapter(
|
|||
|
||||
binding.pageRecyclerview.tag = position
|
||||
binding.pageRecyclerview.apply {
|
||||
spanCount = binding.root.context.getSpanCount()
|
||||
spanCount =
|
||||
binding.root.context.getSpanCount() ?: 3
|
||||
if (adapter == null) { // || rebind
|
||||
// Only add the items after it has been attached since the items rely on ItemWidth
|
||||
// Which is only determined after the recyclerview is attached.
|
||||
// If this fails then item height becomes 0 when there is only one item
|
||||
doOnAttach {
|
||||
adapter = PageAdapter(
|
||||
item.items.toMutableList(),
|
||||
this,
|
||||
clickCallback
|
||||
).apply {
|
||||
submitList(item.items)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(adapter as? PageAdapter)?.submitList(item.items)
|
||||
(adapter as? PageAdapter)?.updateList(item.items)
|
||||
// scrollToPosition(0)
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +100,7 @@ class ViewpagerAdapter(
|
|||
//Expand the top Appbar based on scroll direction up/down, simulate phone behavior
|
||||
if (isLayout(TV or EMULATOR)) {
|
||||
binding.root.rootView.findViewById<AppBarLayout>(R.id.search_bar)
|
||||
?.apply {
|
||||
.apply {
|
||||
if (diff <= 0)
|
||||
setExpanded(true)
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,16 +1,61 @@
|
|||
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.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.OptIn
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
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.viewbinding.ViewBinding
|
||||
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.canEnterPipMode
|
||||
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.ui.BaseFragment
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
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) {
|
||||
Fit(R.string.resize_fit),
|
||||
|
|
@ -30,132 +75,669 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90
|
|||
// when the player should sync the progress of "watched", TODO MAKE SETTING
|
||||
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
abstract class AbstractPlayerFragment<T : ViewBinding>(
|
||||
bindingCreator: BindingCreator<T>
|
||||
) : BaseFragment<T>(bindingCreator), PlayerView.Callbacks {
|
||||
abstract class AbstractPlayerFragment(
|
||||
var player: IPlayer = CS3IPlayer()
|
||||
) : Fragment() {
|
||||
var resizeMode: Int = 0
|
||||
var subView: SubtitleView? = null
|
||||
var isBuffering = true
|
||||
protected open var hasPipModeSupport = true
|
||||
|
||||
// Stored pre-initialization so subclasses can set them before onBindingCreated.
|
||||
private var _player: IPlayer = CS3IPlayer()
|
||||
var playerPausePlayHolderHolder: FrameLayout? = null
|
||||
var playerPausePlay: ImageView? = null
|
||||
var playerBuffering: ProgressBar? = null
|
||||
var playerView: PlayerView? = null
|
||||
var piphide: FrameLayout? = null
|
||||
var subtitleHolder: FrameLayout? = null
|
||||
|
||||
/** The shared [PlayerView] host that owns all player state and view references. */
|
||||
protected var playerHostView: PlayerView? = null
|
||||
@LayoutRes
|
||||
protected open var layout: Int = R.layout.fragment_player
|
||||
|
||||
var player: IPlayer
|
||||
get() = playerHostView?.player ?: _player
|
||||
set(value) {
|
||||
_player = value
|
||||
playerHostView?.player = value
|
||||
}
|
||||
|
||||
val subView: SubtitleView? get() = playerHostView?.subView
|
||||
val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
|
||||
|
||||
/** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
|
||||
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() {
|
||||
open fun nextEpisode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun prevEpisode() {
|
||||
open fun prevEpisode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun playerPositionChanged(position: Long, duration: Long) {
|
||||
open fun playerPositionChanged(position: Long, duration: Long) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||
open fun playerStatusChanged() {}
|
||||
|
||||
open fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun subtitlesChanged() {
|
||||
open fun subtitlesChanged() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
||||
open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun onTracksInfoChanged() {
|
||||
open fun onTracksInfoChanged() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun exitedPipMode() {
|
||||
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
||||
|
||||
}
|
||||
|
||||
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
|
||||
|
||||
}
|
||||
|
||||
open fun exitedPipMode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun hasNextMirror(): Boolean {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun nextMirror() {
|
||||
throw NotImplementedError()
|
||||
private fun updateIsPlaying(
|
||||
wasPlaying: CSPlayerLoading,
|
||||
isPlaying: CSPlayerLoading
|
||||
) {
|
||||
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
||||
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
|
||||
|
||||
keepScreenOn(!isPausedRightNow)
|
||||
|
||||
isBuffering = CSPlayerLoading.IsBuffering == isPlaying
|
||||
if (isBuffering) {
|
||||
playerPausePlayHolderHolder?.isVisible = false
|
||||
playerBuffering?.isVisible = true
|
||||
} else {
|
||||
playerPausePlayHolderHolder?.isVisible = true
|
||||
playerBuffering?.isVisible = false
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** Delegates to [PlayerView.playerError] by default; override to customize. */
|
||||
override fun playerError(exception: Throwable) {
|
||||
playerHostView?.playerError(exception)
|
||||
if (drawable is AnimatedVectorDrawable) {
|
||||
drawable.start()
|
||||
startedAnimation = true
|
||||
}
|
||||
|
||||
/** 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
canEnterPipMode = isPlayingRightNow && hasPipModeSupport
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity?.let { act ->
|
||||
PlayerPipHelper.updatePIPModeActions(
|
||||
act,
|
||||
isPlayingRightNow,
|
||||
player.getAspectRatio()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pipReceiver: BroadcastReceiver? = null
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, 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()
|
||||
filter.addAction(ACTION_MEDIA_CONTROL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else 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_PARSING_CONTAINER_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
|
||||
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 ->
|
||||
// Resets subtitle delay on ended video
|
||||
player.setSubtitleOffset(0)
|
||||
|
||||
// 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() {
|
||||
playerHostView?.release()
|
||||
playerEventListener = null
|
||||
keyEventListener = null
|
||||
canEnterPipMode = false
|
||||
mMediaSession?.release()
|
||||
mMediaSession = null
|
||||
playerView?.player = null
|
||||
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
|
||||
|
||||
keepScreenOn(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
playerHostView?.releaseKeyEventListener()
|
||||
super.onPause()
|
||||
fun nextResize() {
|
||||
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
|
||||
resize(resizeMode, true)
|
||||
}
|
||||
|
||||
fun resize(resize: Int, showToast: Boolean) {
|
||||
resize(PlayerResize.entries[resize], showToast)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
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() {
|
||||
playerHostView?.onStop()
|
||||
player.onStop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
context?.let { ctx ->
|
||||
playerHostView?.onResume(ctx)
|
||||
player.onResume(ctx)
|
||||
}
|
||||
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
fun nextResize() {
|
||||
playerHostView?.nextResize()
|
||||
}
|
||||
|
||||
open fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||
playerHostView?.resize(resize, showToast)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
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
|
||||
}
|
||||
}
|
||||
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