mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-06-20 20:15:40 +00:00
Compare commits
1 commit
master
...
navrailtes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693f69f25f |
592 changed files with 21382 additions and 35996 deletions
30
.github/locales.py
vendored
30
.github/locales.py
vendored
|
|
@ -1,13 +1,14 @@
|
||||||
import re
|
import re
|
||||||
import glob
|
import glob
|
||||||
import requests
|
import requests
|
||||||
|
import os
|
||||||
import lxml.etree as ET # builtin library doesn't preserve comments
|
import lxml.etree as ET # builtin library doesn't preserve comments
|
||||||
|
|
||||||
|
|
||||||
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
||||||
START_MARKER = "/* begin language list */"
|
START_MARKER = "/* begin language list */"
|
||||||
END_MARKER = "/* end 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"
|
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
|
||||||
INDENT = " "*4
|
INDENT = " "*4
|
||||||
|
|
||||||
|
|
@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER)
|
||||||
|
|
||||||
# Load already added langs
|
# Load already added langs
|
||||||
languages = {}
|
languages = {}
|
||||||
for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
|
for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
|
||||||
name, iso = lang.groups()
|
flag, name, iso = lang.groups()
|
||||||
languages[iso] = name
|
languages[iso] = (flag, name)
|
||||||
|
|
||||||
# Add not yet added langs
|
# Add not yet added langs
|
||||||
for folder in glob.glob(f"{XML_NAME}*"):
|
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():
|
if iso not in languages.keys():
|
||||||
entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found
|
entry = iso_map.get(iso.lower(),{'nativeName':iso})
|
||||||
languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple
|
languages[iso] = ("", entry['nativeName'].split(',')[0])
|
||||||
|
|
||||||
# Create pairs
|
# Create triples
|
||||||
pairs = []
|
triples = []
|
||||||
for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name
|
for iso in sorted(languages.keys()):
|
||||||
name = languages[iso]
|
flag, name = languages[iso]
|
||||||
pairs.append(f'{INDENT}Pair("{name}", "{iso}"),')
|
triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
|
||||||
|
|
||||||
# Update settings file
|
# Update settings file
|
||||||
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
||||||
before_src +
|
before_src +
|
||||||
START_MARKER +
|
START_MARKER +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\n".join(pairs) +
|
"\n".join(triples) +
|
||||||
"\n" +
|
"\n" +
|
||||||
END_MARKER +
|
END_MARKER +
|
||||||
after_src
|
after_src
|
||||||
|
|
@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
|
||||||
with open(file, 'wb') as fp:
|
with open(file, 'wb') as fp:
|
||||||
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
|
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)
|
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:
|
except ET.ParseError as ex:
|
||||||
print(f"[{file}] {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'
|
- '**/wcokey.txt'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "Archive-build"
|
group: "Archive-build"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
@ -27,7 +24,6 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/secrets"
|
repository: "recloudstream/secrets"
|
||||||
|
|
||||||
- name: Generate access token (archive)
|
- name: Generate access token (archive)
|
||||||
id: generate_archive_token
|
id: generate_archive_token
|
||||||
uses: tibdex/github-app-token@v2
|
uses: tibdex/github-app-token@v2
|
||||||
|
|
@ -35,18 +31,14 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/cloudstream-archive"
|
repository: "recloudstream/cloudstream-archive"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
java-version: '17'
|
||||||
java-version: 17
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Fetch keystore
|
- name: Fetch keystore
|
||||||
id: fetch_keystore
|
id: fetch_keystore
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -57,33 +49,24 @@ jobs:
|
||||||
KEY_PWD="$(cat keystore_password.txt)"
|
KEY_PWD="$(cat keystore_password.txt)"
|
||||||
echo "::add-mask::${KEY_PWD}"
|
echo "::add-mask::${KEY_PWD}"
|
||||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
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
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseRelease
|
run: |
|
||||||
|
./gradlew assemblePrerelease
|
||||||
env:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
- uses: actions/checkout@v4
|
||||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
|
||||||
MAL_KEY: ${{ secrets.MAL_KEY }}
|
|
||||||
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
|
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
with:
|
||||||
repository: "recloudstream/cloudstream-archive"
|
repository: "recloudstream/cloudstream-archive"
|
||||||
token: ${{ steps.generate_archive_token.outputs.token }}
|
token: ${{ steps.generate_archive_token.outputs.token }}
|
||||||
path: "archive"
|
path: "archive"
|
||||||
|
|
||||||
- name: Move build
|
- 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
|
- name: Push archive
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
39
.github/workflows/generate_dokka.yml
vendored
39
.github/workflows/generate_dokka.yml
vendored
|
|
@ -1,18 +1,19 @@
|
||||||
name: Dokka
|
name: Dokka
|
||||||
|
|
||||||
on:
|
# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
paths-ignore:
|
|
||||||
- '*.md'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "dokka"
|
group: "dokka"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
# choose your default branch
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -24,14 +25,13 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/dokka"
|
repository: "recloudstream/dokka"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@master
|
||||||
with:
|
with:
|
||||||
path: "src"
|
path: "src"
|
||||||
|
|
||||||
- name: Checkout dokka
|
- name: Checkout dokka
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@master
|
||||||
with:
|
with:
|
||||||
repository: "recloudstream/dokka"
|
repository: "recloudstream/dokka"
|
||||||
path: "dokka"
|
path: "dokka"
|
||||||
|
|
@ -43,16 +43,14 @@ jobs:
|
||||||
rm -rf "./app"
|
rm -rf "./app"
|
||||||
rm -rf "./library"
|
rm -rf "./library"
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Setup JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
|
||||||
java-version: 17
|
java-version: 17
|
||||||
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Android SDK
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
|
||||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
- name: Generate Dokka
|
- name: Generate Dokka
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -61,7 +59,8 @@ jobs:
|
||||||
./gradlew docs:dokkaGeneratePublicationHtml
|
./gradlew docs:dokkaGeneratePublicationHtml
|
||||||
|
|
||||||
- name: Copy Dokka
|
- 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
|
- name: Push builds
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
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"
|
group: "pre-release"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -26,18 +23,14 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/secrets"
|
repository: "recloudstream/secrets"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
java-version: '17'
|
||||||
java-version: 17
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Fetch keystore
|
- name: Fetch keystore
|
||||||
id: fetch_keystore
|
id: fetch_keystore
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -48,27 +41,19 @@ jobs:
|
||||||
KEY_PWD="$(cat keystore_password.txt)"
|
KEY_PWD="$(cat keystore_password.txt)"
|
||||||
echo "::add-mask::${KEY_PWD}"
|
echo "::add-mask::${KEY_PWD}"
|
||||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
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
|
- 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:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
|
||||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||||
MAL_KEY: ${{ secrets.MAL_KEY }}
|
|
||||||
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
|
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
uses: marvinpinto/action-automatic-releases@latest
|
uses: "marvinpinto/action-automatic-releases@latest"
|
||||||
with:
|
with:
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
automatic_release_tag: "pre-release"
|
automatic_release_tag: "pre-release"
|
||||||
|
|
|
||||||
25
.github/workflows/pull_request.yml
vendored
25
.github/workflows/pull_request.yml
vendored
|
|
@ -2,35 +2,22 @@ name: Artifact Build
|
||||||
|
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
java-version: '17'
|
||||||
java-version: 17
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x 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: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseDebug lint check
|
run: ./gradlew assemblePrereleaseDebug
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pull-request-build
|
name: pull-request-build
|
||||||
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
||||||
|
|
|
||||||
20
.github/workflows/update_locales.yml
vendored
20
.github/workflows/update_locales.yml
vendored
|
|
@ -1,19 +1,17 @@
|
||||||
name: Fix locale issues
|
name: Fix locale issues
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
|
||||||
paths:
|
paths:
|
||||||
- '**.xml'
|
- '**.xml'
|
||||||
workflow_dispatch:
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "locale"
|
group: "locale"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create:
|
create:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -25,17 +23,15 @@ jobs:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/cloudstream"
|
repository: "recloudstream/cloudstream"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip3 install lxml requests
|
run: |
|
||||||
|
pip3 install lxml
|
||||||
- name: Edit files
|
- name: Edit files
|
||||||
run: python3 .github/locales.py
|
run: |
|
||||||
|
python3 .github/locales.py
|
||||||
- name: Commit to the repo
|
- name: Commit to the repo
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
|
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
|
||||||
|
|
|
||||||
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.
|
|
||||||
|
|
@ -1,96 +1,53 @@
|
||||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||||
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
|
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
|
||||||
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
|
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.dsl.JvmTarget
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
id("com.android.application")
|
||||||
alias(libs.plugins.dokka)
|
id("kotlin-android")
|
||||||
alias(libs.plugins.kotlin.serialization)
|
id("org.jetbrains.dokka")
|
||||||
}
|
}
|
||||||
|
|
||||||
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||||
|
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
||||||
|
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
||||||
|
|
||||||
abstract class GenerateGitHashTask : DefaultTask() {
|
fun getGitCommitHash(): String {
|
||||||
|
return try {
|
||||||
|
val headFile = file("${project.rootDir}/.git/HEAD")
|
||||||
|
|
||||||
@get:InputFile
|
// Read the commit hash from .git/HEAD
|
||||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
if (headFile.exists()) {
|
||||||
abstract val headFile: RegularFileProperty
|
val headContent = headFile.readText().trim()
|
||||||
|
if (headContent.startsWith("ref:")) {
|
||||||
@get:InputDirectory
|
val refPath = headContent.substring(5) // e.g., refs/heads/main
|
||||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
val commitFile = file("${project.rootDir}/.git/$refPath")
|
||||||
abstract val headsDir: DirectoryProperty
|
if (commitFile.exists()) commitFile.readText().trim() else ""
|
||||||
|
} else headContent // If it's a detached HEAD (commit hash directly)
|
||||||
@get:OutputDirectory
|
} else {
|
||||||
abstract val outputDir: DirectoryProperty
|
"" // If .git/HEAD doesn't exist
|
||||||
|
}.take(7) // Return the short commit hash
|
||||||
@TaskAction
|
} catch (_: Throwable) {
|
||||||
fun generate() {
|
"" // Just return an empty string if any exception occurs
|
||||||
val head = headFile.get().asFile
|
|
||||||
|
|
||||||
val hash = try {
|
|
||||||
if (head.exists()) {
|
|
||||||
// Read the commit hash from .git/HEAD
|
|
||||||
val headContent = head.readText().trim()
|
|
||||||
if (headContent.startsWith("ref:")) {
|
|
||||||
val refPath = headContent.substring(5) // e.g., refs/heads/main
|
|
||||||
val commitFile = File(head.parentFile, refPath)
|
|
||||||
if (commitFile.exists()) commitFile.readText().trim() else ""
|
|
||||||
} else headContent // If it's a detached HEAD (commit hash directly)
|
|
||||||
} else "" // If .git/HEAD doesn't exist
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
"" // Just set to an empty string if any exception occurs
|
|
||||||
}.take(7) // Get the short commit hash
|
|
||||||
|
|
||||||
val outFile = outputDir.file("git-hash.txt").get().asFile
|
|
||||||
outFile.parentFile.mkdirs()
|
|
||||||
outFile.writeText(hash)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val generateGitHash = tasks.register<GenerateGitHashTask>("generateGitHash") {
|
|
||||||
val gitDir = layout.projectDirectory.dir("../.git")
|
|
||||||
|
|
||||||
headFile.set(gitDir.file("HEAD"))
|
|
||||||
headsDir.set(gitDir.dir("refs/heads"))
|
|
||||||
|
|
||||||
outputDir.set(layout.buildDirectory.dir("generated/git"))
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@Suppress("UnstableApiUsage")
|
@Suppress("UnstableApiUsage")
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
|
viewBinding {
|
||||||
dependenciesInfo {
|
enable = true
|
||||||
// Disables dependency metadata when building APKs.
|
|
||||||
includeInApk = false
|
|
||||||
// Disables dependency metadata when building Android App Bundles.
|
|
||||||
includeInBundle = false
|
|
||||||
}
|
|
||||||
|
|
||||||
androidComponents {
|
|
||||||
onVariants { variant ->
|
|
||||||
variant.sources.assets?.addGeneratedSourceDirectory(
|
|
||||||
generateGitHash,
|
|
||||||
GenerateGitHashTask::outputDir
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
// We just use SIGNING_KEY_ALIAS here since it won't change
|
if (prereleaseStoreFile != null) {
|
||||||
// so won't kill the configuration cache.
|
|
||||||
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
|
|
||||||
create("prerelease") {
|
create("prerelease") {
|
||||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
storeFile = file(prereleaseStoreFile)
|
||||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
|
||||||
|
|
||||||
storeFile = prereleaseStoreFile?.let { file(it) }
|
|
||||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||||
|
|
@ -104,10 +61,12 @@ android {
|
||||||
applicationId = "com.lagradost.cloudstream3"
|
applicationId = "com.lagradost.cloudstream3"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = libs.versions.versionCode.get().toInt()
|
versionCode = 66
|
||||||
versionName = libs.versions.versionName.get()
|
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
|
// Reads local.properties
|
||||||
val localProperties = gradleLocalProperties(rootDir, project.providers)
|
val localProperties = gradleLocalProperties(rootDir, project.providers)
|
||||||
|
|
@ -127,16 +86,6 @@ android {
|
||||||
"SIMKL_CLIENT_SECRET",
|
"SIMKL_CLIENT_SECRET",
|
||||||
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,9 +113,12 @@ android {
|
||||||
productFlavors {
|
productFlavors {
|
||||||
create("stable") {
|
create("stable") {
|
||||||
dimension = "state"
|
dimension = "state"
|
||||||
|
resValue("bool", "is_prerelease", "false")
|
||||||
}
|
}
|
||||||
create("prerelease") {
|
create("prerelease") {
|
||||||
dimension = "state"
|
dimension = "state"
|
||||||
|
resValue("bool", "is_prerelease", "true")
|
||||||
|
buildConfigField("boolean", "BETA", "true")
|
||||||
applicationIdSuffix = ".prerelease"
|
applicationIdSuffix = ".prerelease"
|
||||||
if (signingConfigs.names.contains("prerelease")) {
|
if (signingConfigs.names.contains("prerelease")) {
|
||||||
signingConfig = signingConfigs.getByName("prerelease")
|
signingConfig = signingConfigs.getByName("prerelease")
|
||||||
|
|
@ -184,29 +136,13 @@ android {
|
||||||
targetCompatibility = JavaVersion.toVersion(javaTarget.target)
|
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 {
|
lint {
|
||||||
|
abortOnError = false
|
||||||
checkReleaseBuilds = false
|
checkReleaseBuilds = false
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
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"
|
namespace = "com.lagradost.cloudstream3"
|
||||||
|
|
@ -217,46 +153,43 @@ dependencies {
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.json)
|
testImplementation(libs.json)
|
||||||
androidTestImplementation(libs.core)
|
androidTestImplementation(libs.core)
|
||||||
androidTestImplementation(libs.espresso.core)
|
implementation(libs.junit.ktx)
|
||||||
androidTestImplementation(libs.ext.junit)
|
androidTestImplementation(libs.ext.junit)
|
||||||
androidTestImplementation(libs.instancio.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
androidTestImplementation(libs.junit.ktx)
|
|
||||||
androidTestImplementation(libs.kotlin.test)
|
|
||||||
|
|
||||||
// Android Core & Lifecycle
|
// Android Core & Lifecycle
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
implementation(libs.activity.ktx)
|
|
||||||
implementation(libs.annotation)
|
|
||||||
implementation(libs.appcompat)
|
implementation(libs.appcompat)
|
||||||
implementation(libs.fragment.ktx)
|
implementation(libs.bundles.navigationKtx)
|
||||||
implementation(libs.bundles.lifecycle)
|
implementation(libs.lifecycle.livedata.ktx)
|
||||||
implementation(libs.bundles.navigation)
|
implementation(libs.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.kotlinx.collections.immutable)
|
|
||||||
implementation(libs.kotlinx.serialization.json) // JSON Parser
|
|
||||||
|
|
||||||
// Design & UI
|
// Design & UI
|
||||||
implementation(libs.preference.ktx)
|
implementation(libs.preference.ktx)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.constraintlayout)
|
implementation(libs.constraintlayout)
|
||||||
|
implementation(libs.swiperefreshlayout)
|
||||||
|
|
||||||
// Coil Image Loading
|
// Coil Image Loading
|
||||||
implementation(libs.bundles.coil)
|
implementation(libs.coil)
|
||||||
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|
||||||
// Media 3 (ExoPlayer)
|
// Media 3 (ExoPlayer)
|
||||||
implementation(libs.bundles.media3)
|
implementation(libs.bundles.media3)
|
||||||
implementation(libs.video)
|
implementation(libs.video)
|
||||||
|
|
||||||
// FFmpeg Decoding
|
|
||||||
implementation(libs.bundles.nextlib)
|
|
||||||
|
|
||||||
// Anime-db for filler
|
|
||||||
implementation(libs.anime.db)
|
|
||||||
|
|
||||||
// PlayBack
|
// PlayBack
|
||||||
implementation(libs.colorpicker) // Subtitle Color Picker
|
implementation(libs.colorpicker) // Subtitle Color Picker
|
||||||
implementation(libs.newpipeextractor) // For Trailers
|
implementation(libs.newpipeextractor) // For Trailers
|
||||||
implementation(libs.juniversalchardet) // Subtitle Decoding
|
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
|
// UI Stuff
|
||||||
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
|
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
|
||||||
implementation(libs.palette.ktx) // Palette for Images -> Colors
|
implementation(libs.palette.ktx) // Palette for Images -> Colors
|
||||||
|
|
@ -267,34 +200,50 @@ dependencies {
|
||||||
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
|
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
|
||||||
|
|
||||||
// Extensions & Other Libs
|
// Extensions & Other Libs
|
||||||
implementation(libs.jsoup) // HTML Parser
|
|
||||||
implementation(libs.rhino) // Run JavaScript
|
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
|
implementation(libs.safefile) // To Prevent the URI File Fu*kery
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
|
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
|
||||||
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
|
implementation(libs.conscrypt.android) {
|
||||||
implementation(libs.jackson.module.kotlin) // JSON Parser
|
version {
|
||||||
implementation(libs.zipline)
|
strictly("2.5.2")
|
||||||
|
}
|
||||||
// Deprecated; will be removed once extensions have time to migrate from using it
|
because("2.5.3 crashes everything for everyone.")
|
||||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
} // 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
|
// Torrent Support
|
||||||
implementation(libs.torrentserver)
|
implementation(libs.torrentserver)
|
||||||
|
|
||||||
// Downloading & Networking
|
// Downloading & Networking
|
||||||
|
implementation(libs.work.runtime)
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
implementation(libs.nicehttp) // HTTP Lib
|
implementation(libs.nicehttp) // HTTP Lib
|
||||||
|
|
||||||
implementation(project(":library"))
|
implementation(project(":library") {
|
||||||
|
// There does not seem to be a good way of getting the android flavor.
|
||||||
|
val isDebug = gradle.startParameter.taskRequests.any { task ->
|
||||||
|
task.args.any { arg ->
|
||||||
|
arg.contains("debug", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.extra.set("isDebug", isDebug)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Jar>("androidSourcesJar") {
|
tasks.register<Jar>("androidSourcesJar") {
|
||||||
archiveClassifier.set("sources")
|
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") {
|
tasks.register<Copy>("copyJar") {
|
||||||
dependsOn("build", ":library:jvmJar")
|
|
||||||
from(
|
from(
|
||||||
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
|
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
|
||||||
"../library/build/libs"
|
"../library/build/libs"
|
||||||
|
|
@ -321,23 +270,15 @@ tasks.register<Jar>("makeJar") {
|
||||||
tasks.withType<KotlinJvmCompile> {
|
tasks.withType<KotlinJvmCompile> {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(javaTarget)
|
jvmTarget.set(javaTarget)
|
||||||
jvmDefault.set(JvmDefaultMode.ENABLE)
|
freeCompilerArgs.add("-Xjvm-default=all-compatibility")
|
||||||
freeCompilerArgs.add("-Xannotation-default-target=param-property")
|
|
||||||
optIn.addAll(
|
|
||||||
"com.lagradost.cloudstream3.InternalAPI",
|
|
||||||
"com.lagradost.cloudstream3.Prerelease",
|
|
||||||
"kotlin.uuid.ExperimentalUuidApi",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dokka {
|
dokka {
|
||||||
moduleName = "App"
|
moduleName = "App"
|
||||||
dokkaSourceSets {
|
dokkaSourceSets {
|
||||||
configureEach {
|
main {
|
||||||
suppress = name != "prereleaseDebug"
|
|
||||||
analysisPlatform = KotlinPlatform.JVM
|
analysisPlatform = KotlinPlatform.JVM
|
||||||
displayName = "JVM"
|
|
||||||
documentedVisibilities(
|
documentedVisibilities(
|
||||||
VisibilityModifier.Public,
|
VisibilityModifier.Public,
|
||||||
VisibilityModifier.Protected
|
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>
|
|
||||||
|
|
@ -136,14 +136,14 @@ class ExampleInstrumentedTest {
|
||||||
@Test
|
@Test
|
||||||
@Throws(AssertionError::class)
|
@Throws(AssertionError::class)
|
||||||
fun providerCorrectData() {
|
fun providerCorrectData() {
|
||||||
val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
|
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
|
||||||
Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
|
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
|
||||||
for (api in getAllProviders()) {
|
for (api in getAllProviders()) {
|
||||||
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
|
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 does not contain a name", api.name != "NONE")
|
||||||
Assert.assertTrue(
|
Assert.assertTrue(
|
||||||
"Api ${api.name} does not contain a valid language code",
|
"Api ${api.name} does not contain a valid language code",
|
||||||
langTagsIETF.contains(api.lang)
|
isoNames.contains(api.lang)
|
||||||
)
|
)
|
||||||
Assert.assertTrue(
|
Assert.assertTrue(
|
||||||
"Api ${api.name} does not contain any supported types",
|
"Api ${api.name} does not contain any supported types",
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
package com.lagradost.cloudstream3
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
|
||||||
import dalvik.system.DexFile
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.InternalSerializationApi
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.serializer
|
|
||||||
import kotlinx.serialization.serializerOrNull
|
|
||||||
import org.instancio.Instancio
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
import kotlin.reflect.jvm.jvmName
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class SerializationClassTester {
|
|
||||||
// Same as app, or using app reference
|
|
||||||
val jacksonMapper = mapper
|
|
||||||
val kotlinxMapper = json
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isIdenticalSerialization() {
|
|
||||||
val serializableClasses = findSerializableClasses("com.lagradost")
|
|
||||||
println("Number of serializable classes: ${serializableClasses.size}")
|
|
||||||
|
|
||||||
serializableClasses.forEach { kClass ->
|
|
||||||
val instance = Instancio.create(kClass.java)
|
|
||||||
|
|
||||||
val jacksonJson = jacksonMapper.writeValueAsString(instance)
|
|
||||||
val kotlinxJson = serializeWithKotlinx(kClass, instance)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
jacksonJson,
|
|
||||||
kotlinxJson,
|
|
||||||
"""
|
|
||||||
Serialization mismatch for:
|
|
||||||
${kClass.qualifiedName}
|
|
||||||
|
|
||||||
Jackson:
|
|
||||||
$jacksonJson
|
|
||||||
|
|
||||||
Kotlinx:
|
|
||||||
$kotlinxJson
|
|
||||||
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
println("Identical serialization for: ${kClass.jvmName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
|
||||||
@Test
|
|
||||||
fun isIdenticalDeserialization() {
|
|
||||||
val serializableClasses = findSerializableClasses("com.lagradost")
|
|
||||||
println("Number of serializable classes: ${serializableClasses.size}")
|
|
||||||
|
|
||||||
serializableClasses.forEach { kClass ->
|
|
||||||
val instance = Instancio.create(kClass.java)
|
|
||||||
// Convert to JSON to get example JSON object
|
|
||||||
// We prefer jackson here because the app may have many jackson JSON strings in local storage
|
|
||||||
val originalJson = jacksonMapper.writeValueAsString(instance)
|
|
||||||
|
|
||||||
// Create an object from the JSON using kotlinx
|
|
||||||
val serializer =
|
|
||||||
kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
|
|
||||||
assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
|
|
||||||
val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
|
|
||||||
|
|
||||||
// Create an object from the JSON using jackson
|
|
||||||
val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
|
|
||||||
|
|
||||||
|
|
||||||
// Deep inspect both object using the mapper toJson function.
|
|
||||||
// This deep equality check can be performed using other methods, but this just works.
|
|
||||||
val jacksonJson = mapperDecoded.toJson()
|
|
||||||
val kotlinxJson = kotlinxDecoded.toJson()
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
jacksonJson,
|
|
||||||
kotlinxJson,
|
|
||||||
"""
|
|
||||||
Serialization mismatch for:
|
|
||||||
${kClass.qualifiedName}
|
|
||||||
|
|
||||||
Jackson:
|
|
||||||
$jacksonJson
|
|
||||||
|
|
||||||
Kotlinx:
|
|
||||||
$kotlinxJson
|
|
||||||
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
println("Identical deserialization for: ${kClass.jvmName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEX files are the best solution to read all our classes dynamically.
|
|
||||||
// classgraph could be used instead, but it only gives results on the JVM, not Android.
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun findSerializableClasses(packageName: String): List<KClass<*>> {
|
|
||||||
val context = InstrumentationRegistry
|
|
||||||
.getInstrumentation()
|
|
||||||
.targetContext
|
|
||||||
|
|
||||||
val dexFile = DexFile(context.packageCodePath)
|
|
||||||
return dexFile.entries()
|
|
||||||
.toList()
|
|
||||||
.filter { it.startsWith(packageName) }
|
|
||||||
.mapNotNull {
|
|
||||||
runCatching { Class.forName(it).kotlin }.getOrNull()
|
|
||||||
}.filter { kClass ->
|
|
||||||
// Not possible to use .hasAnnotation() on newer Android versions.
|
|
||||||
kClass.java.annotations.any {
|
|
||||||
it is Serializable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(InternalSerializationApi::class)
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun serializeWithKotlinx(
|
|
||||||
kClass: KClass<*>,
|
|
||||||
value: Any
|
|
||||||
): String {
|
|
||||||
val serializer = kClass.serializer() as KSerializer<Any>
|
|
||||||
return kotlinxMapper.encodeToString(serializer, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +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.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.KeepGeneratedSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
@KeepGeneratedSerializer
|
|
||||||
@Serializable(with = NonEmptyData.Serializer::class)
|
|
||||||
data class NonEmptyData(
|
|
||||||
val title: String = "",
|
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
val meta: Map<String, String> = emptyMap(),
|
|
||||||
val name: String = "hello",
|
|
||||||
) {
|
|
||||||
object Serializer : NonEmptySerializer<NonEmptyData>(NonEmptyData.generatedSerializer())
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
@KeepGeneratedSerializer
|
|
||||||
@Serializable(with = WriteOnlyData.Serializer::class)
|
|
||||||
data class WriteOnlyData(
|
|
||||||
val fieldA: String = "",
|
|
||||||
val fieldB: String = "",
|
|
||||||
) {
|
|
||||||
object Serializer : WriteOnlySerializer<WriteOnlyData>(
|
|
||||||
WriteOnlyData.generatedSerializer(),
|
|
||||||
setOf("fieldB"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
@KeepGeneratedSerializer
|
|
||||||
@Serializable(with = MultiWriteOnly.Serializer::class)
|
|
||||||
data class MultiWriteOnly(
|
|
||||||
val fieldA: String = "",
|
|
||||||
val fieldB: String = "",
|
|
||||||
val fieldC: String = "",
|
|
||||||
) {
|
|
||||||
object Serializer : WriteOnlySerializer<MultiWriteOnly>(
|
|
||||||
MultiWriteOnly.generatedSerializer(),
|
|
||||||
setOf("fieldB", "fieldC"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UriData(
|
|
||||||
@Serializable(with = UriSerializer::class)
|
|
||||||
val uri: Uri = Uri.EMPTY,
|
|
||||||
)
|
|
||||||
|
|
||||||
class SerializerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonEmptySerializerOmitsEmptyStrings() {
|
|
||||||
val data = NonEmptyData(title = "", name = "hello")
|
|
||||||
val result = data.toJson()
|
|
||||||
assertFalse(result.contains("title"))
|
|
||||||
assertTrue(result.contains("name"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonEmptySerializerOmitsEmptyLists() {
|
|
||||||
val data = NonEmptyData(tags = emptyList(), name = "hello")
|
|
||||||
val result = data.toJson()
|
|
||||||
assertFalse(result.contains("tags"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonEmptySerializerOmitsEmptyMaps() {
|
|
||||||
val data = NonEmptyData(meta = emptyMap(), name = "hello")
|
|
||||||
val result = data.toJson()
|
|
||||||
assertFalse(result.contains("meta"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonEmptySerializerKeepsNonEmptyFields() {
|
|
||||||
val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
|
|
||||||
val result = data.toJson()
|
|
||||||
assertTrue(result.contains("title"))
|
|
||||||
assertTrue(result.contains("tags"))
|
|
||||||
assertTrue(result.contains("meta"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonEmptySerializerDoesNotAffectDeserialization() {
|
|
||||||
val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
|
|
||||||
val result = parseJson<NonEmptyData>(input)
|
|
||||||
assertEquals("hello", result.title)
|
|
||||||
assertEquals(listOf("a"), result.tags)
|
|
||||||
assertEquals(mapOf("k" to "v"), result.meta)
|
|
||||||
assertEquals("world", result.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun writeOnlySerializerOmitsFieldOnSerialize() {
|
|
||||||
val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
|
|
||||||
val result = data.toJson()
|
|
||||||
assertTrue(result.contains("fieldA"))
|
|
||||||
assertFalse(result.contains("fieldB"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun writeOnlySerializerDeserializesNormally() {
|
|
||||||
val input = """{"fieldA":"hello","fieldB":"secret"}"""
|
|
||||||
val result = parseJson<WriteOnlyData>(input)
|
|
||||||
assertEquals("hello", result.fieldA)
|
|
||||||
assertEquals("secret", result.fieldB)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun writeOnlySerializerDeserializesMissingAsDefault() {
|
|
||||||
val input = """{"fieldA":"hello"}"""
|
|
||||||
val result = parseJson<WriteOnlyData>(input)
|
|
||||||
assertEquals("hello", result.fieldA)
|
|
||||||
assertEquals("", result.fieldB)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun writeOnlySerializerHandlesMultipleKeys() {
|
|
||||||
val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
|
|
||||||
val result = data.toJson()
|
|
||||||
assertTrue(result.contains("fieldA"))
|
|
||||||
assertFalse(result.contains("fieldB"))
|
|
||||||
assertFalse(result.contains("fieldC"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uriSerializerSerializesUriToString() {
|
|
||||||
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
|
|
||||||
val result = data.toJson()
|
|
||||||
assertTrue(result.contains("https://example.com/path?query=1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uriSerializerDeserializesStringToUri() {
|
|
||||||
val input = """{"uri":"https://example.com/path?query=1"}"""
|
|
||||||
val result = parseJson<UriData>(input)
|
|
||||||
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun uriSerializerRoundtripsCorrectly() {
|
|
||||||
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
|
|
||||||
val encoded = data.toJson()
|
|
||||||
val decoded = parseJson<UriData>(encoded)
|
|
||||||
assertEquals(data.uri, decoded.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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.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.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 -->
|
<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.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<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="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 -->
|
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
<queries>
|
|
||||||
<!--
|
|
||||||
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
|
|
||||||
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
|
|
||||||
-->
|
|
||||||
<!-- For external video players -->
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:mimeType="video/*" />
|
|
||||||
</intent>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:mimeType="application/x-mpegURL" />
|
|
||||||
</intent>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:mimeType="application/vnd.apple.mpegurl" />
|
|
||||||
</intent>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:scheme="magnet" />
|
|
||||||
</intent>
|
|
||||||
|
|
||||||
<!-- Common players supported in actions/temp -->
|
|
||||||
<package android:name="org.videolan.vlc" />
|
|
||||||
<package android:name="org.videolan.vlc.debug" />
|
|
||||||
<package android:name="is.xyz.mpv" />
|
|
||||||
<package android:name="is.xyz.mpv.ytdl" />
|
|
||||||
<package android:name="app.marlboroadvance.mpvex" />
|
|
||||||
<package android:name="live.mehiz.mpvkt" />
|
|
||||||
<package android:name="live.mehiz.mpvkt.preview" />
|
|
||||||
<package android:name="com.brouken.player" />
|
|
||||||
<package android:name="dev.anilbeesetti.nextplayer" />
|
|
||||||
<package android:name="com.instantbits.cast.webvideo" />
|
|
||||||
<package android:name="com.gianlu.aria2android" />
|
|
||||||
|
|
||||||
<!-- Torrent clients -->
|
|
||||||
<package android:name="org.proninyaroslav.libretorrent" />
|
|
||||||
<package android:name="com.biglybt.android.client" />
|
|
||||||
</queries>
|
|
||||||
|
|
||||||
<!-- Fixes android tv fuckery -->
|
<!-- Fixes android tv fuckery -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -74,8 +35,9 @@
|
||||||
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
|
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
|
||||||
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
||||||
<application
|
<application
|
||||||
android:name=".CloudStreamApp"
|
android:name=".AcraApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:appCategory="video"
|
android:appCategory="video"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:fullBackupContent="@xml/backup_descriptor"
|
||||||
|
|
@ -83,12 +45,11 @@
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:pageSizeCompat="enabled"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="${target_sdk_version}">
|
tools:targetApi="35">
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
|
@ -149,31 +110,14 @@
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
||||||
no idea about side effects
|
no idea about side effects
|
||||||
|
|
||||||
Not exported to prevent bypassing the AccountSelectActivity
|
|
||||||
-->
|
-->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true" />
|
android:supportsPictureInPicture="true">
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.account.AccountSelectActivity"
|
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter android:exported="true">
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
@ -200,14 +144,7 @@
|
||||||
|
|
||||||
<data android:scheme="cloudstreamrepo" />
|
<data android:scheme="cloudstreamrepo" />
|
||||||
</intent-filter>
|
</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 -->
|
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -231,7 +168,7 @@
|
||||||
<data android:scheme="cloudstreamcontinuewatching" />
|
<data android:scheme="cloudstreamcontinuewatching" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter android:autoVerify="false">
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
@ -244,6 +181,21 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.account.AccountSelectActivity"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:exported="true">
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
|
|
@ -259,12 +211,6 @@
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".services.DownloadQueueService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
|
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
|
|
||||||
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
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
/**
|
import android.app.Activity
|
||||||
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
|
import android.app.Application
|
||||||
* Use CloudStreamApp instead.
|
import android.content.Context
|
||||||
*/
|
import android.content.ContextWrapper
|
||||||
@Deprecated(
|
import android.content.Intent
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
import android.widget.Toast
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
|
import androidx.fragment.app.Fragment
|
||||||
level = DeprecationLevel.WARNING
|
import androidx.fragment.app.FragmentActivity
|
||||||
)
|
import coil3.PlatformContext
|
||||||
class AcraApplication {
|
import coil3.SingletonImageLoader
|
||||||
companion object {
|
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
|
||||||
|
|
||||||
@Deprecated(
|
class CustomReportSender : ReportSender {
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
// Sends all your crashes to google forms
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
|
override fun send(context: Context, errorContent: CrashReportData) {
|
||||||
level = DeprecationLevel.WARNING
|
/*println("Sending report")
|
||||||
)
|
val url =
|
||||||
val context get() = CloudStreamApp.context
|
"https://docs.google.com/forms/d/e/$id/formResponse"
|
||||||
|
val data = mapOf(
|
||||||
|
"entry.$entry" to errorContent.toJSON()
|
||||||
|
)
|
||||||
|
|
||||||
@Deprecated(
|
thread { // to not run it on main thread
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
runBlocking {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
|
safeAsync {
|
||||||
level = DeprecationLevel.WARNING
|
app.post(url, data = data)
|
||||||
)
|
//println("Report response: $post")
|
||||||
fun removeKeys(folder: String): Int? =
|
}
|
||||||
CloudStreamApp.removeKeys(folder)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Deprecated(
|
runOnMainThread { // to run it on main looper
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
safe {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
|
Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
}*/
|
||||||
fun <T> setKey(path: String, value: T) =
|
}
|
||||||
CloudStreamApp.setKey(path, value)
|
}
|
||||||
|
|
||||||
@Deprecated(
|
class CustomSenderFactory : ReportSenderFactory {
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
override fun create(context: Context, config: CoreConfiguration): ReportSender {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
|
return CustomReportSender()
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
|
||||||
fun <T> setKey(folder: String, path: String, value: T) =
|
override fun enabled(config: CoreConfiguration): Boolean {
|
||||||
CloudStreamApp.setKey(folder, path, value)
|
return true
|
||||||
|
}
|
||||||
@Deprecated(
|
}
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
|
class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
|
||||||
level = DeprecationLevel.WARNING
|
Thread.UncaughtExceptionHandler {
|
||||||
)
|
override fun uncaughtException(thread: Thread, error: Throwable) {
|
||||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
|
ACRA.errorReporter.handleException(error)
|
||||||
CloudStreamApp.getKey(path, defVal)
|
try {
|
||||||
|
PrintStream(errorFile).use { ps ->
|
||||||
@Deprecated(
|
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
|
error.printStackTrace(ps)
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
} catch (ignored: FileNotFoundException) {
|
||||||
inline fun <reified T : Any> getKey(path: String): T? =
|
}
|
||||||
CloudStreamApp.getKey(path)
|
try {
|
||||||
|
onError.invoke()
|
||||||
@Deprecated(
|
} catch (ignored: Exception) {
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
}
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
|
exitProcess(1)
|
||||||
level = DeprecationLevel.WARNING
|
}
|
||||||
)
|
|
||||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
|
}
|
||||||
CloudStreamApp.getKey(folder, path)
|
|
||||||
|
class AcraApplication : Application(), SingletonImageLoader.Factory {
|
||||||
@Deprecated(
|
|
||||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
override fun onCreate() {
|
||||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
|
super.onCreate()
|
||||||
level = DeprecationLevel.WARNING
|
// if we want to initialise coil at earliest
|
||||||
)
|
// (maybe when loading an image or gif using in splash screen activity)
|
||||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
|
//ImageLoader.buildImageLoader(applicationContext)
|
||||||
CloudStreamApp.getKey(folder, path, defVal)
|
|
||||||
}
|
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
|
||||||
|
|
||||||
|
/** 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(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 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
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
|
@ -27,41 +24,35 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.core.view.isNotEmpty
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.gms.cast.framework.CastSession
|
import com.google.android.gms.cast.framework.CastSession
|
||||||
import com.google.android.material.chip.ChipGroup
|
import com.google.android.material.chip.ChipGroup
|
||||||
import com.google.android.material.navigationrail.NavigationRailView
|
import com.google.android.material.navigationrail.NavigationRailView
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
import com.lagradost.cloudstream3.databinding.ToastBinding
|
import com.lagradost.cloudstream3.databinding.ToastBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
|
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
||||||
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
|
|
||||||
import com.lagradost.cloudstream3.ui.player.Torrent
|
import com.lagradost.cloudstream3.ui.player.Torrent
|
||||||
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
|
|
||||||
import com.lagradost.cloudstream3.ui.result.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.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.Globals.updateTv
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Event
|
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.UIHelper.toPx
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
|
|
||||||
enum class FocusDirection {
|
enum class FocusDirection {
|
||||||
Start,
|
Start,
|
||||||
|
|
@ -110,15 +101,15 @@ object CommonActivity {
|
||||||
return displayMetrics.heightPixels
|
return displayMetrics.heightPixels
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPipDesired: Boolean = false
|
var canEnterPipMode: Boolean = false
|
||||||
|
var canShowPipMode: Boolean = false
|
||||||
var isInPIPMode: Boolean = false
|
var isInPIPMode: Boolean = false
|
||||||
|
|
||||||
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
||||||
val onDialogDismissedEvent = Event<Int>()
|
val onDialogDismissedEvent = Event<Int>()
|
||||||
|
|
||||||
|
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
||||||
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
||||||
var appliedTheme: Int = 0
|
|
||||||
var appliedColor: Int = 0
|
|
||||||
|
|
||||||
private var currentToast: Toast? = null
|
private var currentToast: Toast? = null
|
||||||
|
|
||||||
|
|
@ -191,35 +182,23 @@ object CommonActivity {
|
||||||
currentToast = toast
|
currentToast = toast
|
||||||
toast.show()
|
toast.show()
|
||||||
|
|
||||||
val handler = Handler(Looper.getMainLooper())
|
|
||||||
val ref = WeakReference(toast)
|
|
||||||
|
|
||||||
/* Clean up activity leak */
|
|
||||||
handler.postDelayed({
|
|
||||||
if (ref.get() == currentToast) {
|
|
||||||
currentToast = null
|
|
||||||
}
|
|
||||||
}, 10_000)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set locale
|
* Not all languages can be fetched from locale with a code.
|
||||||
* @param languageTag shall a IETF BCP 47 conformant tag.
|
* This map allows sidestepping the default Locale(languageCode)
|
||||||
* Check [com.lagradost.cloudstream3.utils.SubtitleHelper].
|
* when setting the app language.
|
||||||
*
|
**/
|
||||||
* See locales on:
|
val appLanguageExceptions = hashMapOf(
|
||||||
* https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json
|
"zh-rTW" to Locale.TRADITIONAL_CHINESE
|
||||||
* 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?, languageCode: String?) {
|
||||||
*/
|
if (context == null || languageCode == null) return
|
||||||
fun setLocale(context: Context?, languageTag: String?) {
|
val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
|
||||||
if (context == null || languageTag == null) return
|
|
||||||
val locale = Locale.forLanguageTag(languageTag)
|
|
||||||
val resources: Resources = context.resources
|
val resources: Resources = context.resources
|
||||||
val config = resources.configuration
|
val config = resources.configuration
|
||||||
Locale.setDefault(locale)
|
Locale.setDefault(locale)
|
||||||
|
|
@ -227,7 +206,6 @@ object CommonActivity {
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||||
context.createConfigurationContext(config)
|
context.createConfigurationContext(config)
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
resources.updateConfiguration(
|
resources.updateConfiguration(
|
||||||
config,
|
config,
|
||||||
|
|
@ -244,8 +222,16 @@ object CommonActivity {
|
||||||
fun init(act: Activity) {
|
fun init(act: Activity) {
|
||||||
setActivityInstance(act)
|
setActivityInstance(act)
|
||||||
ioSafe { Torrent.deleteAllFiles() }
|
ioSafe { Torrent.deleteAllFiles() }
|
||||||
|
|
||||||
val componentActivity = activity as? ComponentActivity ?: return
|
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.updateLocale()
|
||||||
componentActivity.updateTv()
|
componentActivity.updateTv()
|
||||||
AccountManager.initMainAPI()
|
AccountManager.initMainAPI()
|
||||||
|
|
@ -261,7 +247,7 @@ object CommonActivity {
|
||||||
?: return@registerForActivityResult
|
?: return@registerForActivityResult
|
||||||
action.onResultSafe(act, result.data)
|
action.onResultSafe(act, result.data)
|
||||||
removeKey("last_click_action")
|
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() {
|
private fun Activity.enterPIPMode() {
|
||||||
if (!isPipDesired || !this.isPIPPossible()) return
|
if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
try {
|
try {
|
||||||
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
|
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
// Use fallback just in case
|
// Use fallback just in case
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
enterPictureInPictureMode()
|
enterPictureInPictureMode()
|
||||||
|
|
@ -307,10 +291,10 @@ object CommonActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onUserLeaveHint(act: Activity) {
|
fun onUserLeaveHint(act: Activity?) {
|
||||||
// On Android 12 and later we use setAutoEnterEnabled() instead.
|
if (canEnterPipMode && canShowPipMode) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
|
act?.enterPIPMode()
|
||||||
act.enterPIPMode()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTheme(act: Activity) {
|
fun updateTheme(act: Activity) {
|
||||||
|
|
@ -350,10 +334,6 @@ object CommonActivity {
|
||||||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
R.style.MonetMode else R.style.AppTheme
|
R.style.MonetMode else R.style.AppTheme
|
||||||
|
|
||||||
"Dracula" -> R.style.DraculaMode
|
|
||||||
"Lavender" -> R.style.LavenderMode
|
|
||||||
"SilentBlue" -> R.style.SilentBlueMode
|
|
||||||
|
|
||||||
else -> R.style.AppTheme
|
else -> R.style.AppTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,8 +369,6 @@ object CommonActivity {
|
||||||
|
|
||||||
act.theme.applyStyle(currentTheme, true)
|
act.theme.applyStyle(currentTheme, true)
|
||||||
act.theme.applyStyle(currentOverlayTheme, true)
|
act.theme.applyStyle(currentOverlayTheme, true)
|
||||||
appliedTheme = currentTheme
|
|
||||||
appliedColor = currentOverlayTheme
|
|
||||||
act.updateTv()
|
act.updateTv()
|
||||||
if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
|
if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
|
||||||
act.theme.applyStyle(
|
act.theme.applyStyle(
|
||||||
|
|
@ -423,7 +401,8 @@ object CommonActivity {
|
||||||
|
|
||||||
private fun View.hasContent(): Boolean {
|
private fun View.hasContent(): Boolean {
|
||||||
return isShown && when (this) {
|
return isShown && when (this) {
|
||||||
is ViewGroup -> this.isNotEmpty()
|
//is RecyclerView -> this.childCount > 0
|
||||||
|
is ViewGroup -> this.childCount > 0
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -453,7 +432,7 @@ object CommonActivity {
|
||||||
// if cant focus but visible then break and let android decide
|
// 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
|
// the exception if is the view is a parent and has children that wants focus
|
||||||
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
|
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
|
} ?: false
|
||||||
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
|
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
|
||||||
|
|
||||||
|
|
@ -532,7 +511,87 @@ object CommonActivity {
|
||||||
|
|
||||||
|
|
||||||
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
|
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
|
||||||
|
|
||||||
|
// 149 keycode_numpad 5
|
||||||
|
val playerEvent = when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
|
PlayerEventType.SeekForward
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
|
PlayerEventType.SeekBack
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
|
||||||
|
PlayerEventType.NextEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
|
||||||
|
PlayerEventType.PrevEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||||
|
PlayerEventType.Pause
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
|
||||||
|
PlayerEventType.Play
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
|
||||||
|
PlayerEventType.Lock
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
|
||||||
|
PlayerEventType.ToggleHide
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
||||||
|
PlayerEventType.ToggleMute
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
|
||||||
|
PlayerEventType.ShowMirrors
|
||||||
|
}
|
||||||
|
// OpenSubtitles shortcut
|
||||||
|
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
|
||||||
|
PlayerEventType.SearchSubtitlesOnline
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
|
||||||
|
PlayerEventType.ShowSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
|
||||||
|
PlayerEventType.Resize
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
|
||||||
|
PlayerEventType.SkipOp
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
|
||||||
|
PlayerEventType.SkipCurrentChapter
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
|
||||||
|
PlayerEventType.PlayPauseToggle
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val listener = playerEventListener
|
||||||
|
if (listener != null) {
|
||||||
|
listener.invoke(playerEvent)
|
||||||
|
return true
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
//when (keyCode) {
|
||||||
|
// KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||||
|
// println("DPAD PRESSED")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** overrides focus and custom key events */
|
/** overrides focus and custom key events */
|
||||||
|
|
@ -569,7 +628,6 @@ object CommonActivity {
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
// println("NEXT FOCUS : $nextView")
|
// println("NEXT FOCUS : $nextView")
|
||||||
if (nextView != null) {
|
if (nextView != null) {
|
||||||
nextView.requestFocus()
|
nextView.requestFocus()
|
||||||
|
|
@ -577,15 +635,10 @@ object CommonActivity {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out why removing the check for SearchAutoComplete seems
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
|
||||||
// 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) &&
|
|
||||||
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
||||||
) {
|
) {
|
||||||
showInputMethod(act.currentFocus?.findFocus())
|
UIHelper.showInputMethod(act.currentFocus?.findFocus())
|
||||||
}
|
}
|
||||||
|
|
||||||
//println("Keycode: $keyCode")
|
//println("Keycode: $keyCode")
|
||||||
|
|
@ -594,6 +647,7 @@ object CommonActivity {
|
||||||
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
||||||
// Toast.LENGTH_LONG
|
// Toast.LENGTH_LONG
|
||||||
//)
|
//)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if someone else want to override the focus then don't handle the event as it is already
|
// 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.ColorStateList
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
@ -23,14 +24,14 @@ import android.widget.CheckBox
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.cardview.widget.CardView
|
import androidx.cardview.widget.CardView
|
||||||
import androidx.core.content.edit
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.core.view.get
|
import androidx.core.view.get
|
||||||
import androidx.core.view.isGone
|
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.allProviders
|
||||||
import com.lagradost.cloudstream3.APIHolder.apis
|
import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.initAll
|
import com.lagradost.cloudstream3.APIHolder.initAll
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.loadThemes
|
import com.lagradost.cloudstream3.CommonActivity.loadThemes
|
||||||
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
|
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
|
||||||
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
|
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_REPO
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
|
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_SEARCH
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository
|
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.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
|
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
|
||||||
|
|
@ -157,20 +156,17 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
|
||||||
import com.lagradost.cloudstream3.utils.Event
|
import com.lagradost.cloudstream3.utils.Event
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
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.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
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.changeStatusBarState
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
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.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.getResourceColor
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
||||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||||
|
|
@ -188,9 +184,7 @@ import java.nio.charset.Charset
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
|
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -200,21 +194,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
const val ANIMATED_OUTLINE: Boolean = false
|
const val ANIMATED_OUTLINE: Boolean = false
|
||||||
var lastError: String? = null
|
var lastError: String? = null
|
||||||
|
|
||||||
/** Update lastError variable based on error file, to check if app crashed.
|
|
||||||
* Can be called multiple times without changing the lastError variable changing.
|
|
||||||
**/
|
|
||||||
fun setLastError(context: Context) {
|
|
||||||
if (lastError != null) return
|
|
||||||
|
|
||||||
val errorFile = context.filesDir.resolve("last_error")
|
|
||||||
if (errorFile.exists() && errorFile.isFile) {
|
|
||||||
lastError = errorFile.readText(Charset.defaultCharset())
|
|
||||||
errorFile.delete()
|
|
||||||
} else {
|
|
||||||
lastError = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
|
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
|
||||||
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
|
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
|
||||||
|
|
||||||
|
|
@ -276,6 +255,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
* @return true if the str has launched an app task (be it successful or not)
|
* @return true if the str has launched an app task (be it successful or not)
|
||||||
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
|
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
|
||||||
* */
|
* */
|
||||||
|
@Suppress("DEPRECATION_ERROR")
|
||||||
fun handleAppIntentUrl(
|
fun handleAppIntentUrl(
|
||||||
activity: FragmentActivity?,
|
activity: FragmentActivity?,
|
||||||
str: String?,
|
str: String?,
|
||||||
|
|
@ -352,7 +332,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
|
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
|
||||||
R.id.navigation_search
|
R.id.navigation_search
|
||||||
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
|
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
|
||||||
val uri = str.toUri()
|
val uri = Uri.parse(str)
|
||||||
val name = uri.getQueryParameter("name")
|
val name = uri.getQueryParameter("name")
|
||||||
val url = URLDecoder.decode(uri.authority, "UTF-8")
|
val url = URLDecoder.decode(uri.authority, "UTF-8")
|
||||||
|
|
||||||
|
|
@ -362,8 +342,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
LinkGenerator(
|
LinkGenerator(
|
||||||
listOf(BasicLink(url, name)),
|
listOf(BasicLink(url, name)),
|
||||||
extract = true,
|
extract = true,
|
||||||
id = url.hashCode()
|
)
|
||||||
), 0
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
|
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
|
||||||
|
|
@ -379,20 +358,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
START_ACTION_RESUME_LATEST
|
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) {
|
} else if (!isWebview) {
|
||||||
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
|
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
|
||||||
this.navigate(R.id.navigation_downloads)
|
this.navigate(R.id.navigation_downloads)
|
||||||
|
|
@ -408,39 +373,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
|
synchronized(apis) {
|
||||||
if (matchedApi != null) {
|
for (api in apis) {
|
||||||
loadResult(str, matchedApi.name, "")
|
if (str.startsWith(api.mainUrl)) {
|
||||||
return true
|
loadResult(str, api.name, "")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
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 lastPopup: SearchResponse? = null
|
||||||
var lastPopupJob: Job? = null
|
|
||||||
fun loadPopup(result: SearchResponse, load: Boolean = true) {
|
fun loadPopup(result: SearchResponse, load: Boolean = true) {
|
||||||
lastPopup = result
|
lastPopup = result
|
||||||
val syncName = syncViewModel.syncName(result.apiName)
|
val syncName = syncViewModel.syncName(result.apiName)
|
||||||
|
|
@ -456,8 +404,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
syncViewModel.clear()
|
syncViewModel.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPopupJob?.cancel()
|
if (load) {
|
||||||
lastPopupJob = if (load) {
|
|
||||||
viewModel.load(
|
viewModel.load(
|
||||||
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
||||||
.contains(DubStatus.Dubbed)
|
.contains(DubStatus.Dubbed)
|
||||||
|
|
@ -504,7 +451,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
R.id.navigation_downloads,
|
R.id.navigation_downloads,
|
||||||
R.id.navigation_settings,
|
R.id.navigation_settings,
|
||||||
R.id.navigation_download_child,
|
R.id.navigation_download_child,
|
||||||
R.id.navigation_download_queue,
|
|
||||||
R.id.navigation_subtitles,
|
R.id.navigation_subtitles,
|
||||||
R.id.navigation_chrome_subtitles,
|
R.id.navigation_chrome_subtitles,
|
||||||
R.id.navigation_settings_player,
|
R.id.navigation_settings_player,
|
||||||
|
|
@ -519,7 +465,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
).contains(destination.id)
|
).contains(destination.id)
|
||||||
|
|
||||||
|
|
||||||
/*val dontPush = listOf(
|
val dontPush = listOf(
|
||||||
R.id.navigation_home,
|
R.id.navigation_home,
|
||||||
R.id.navigation_search,
|
R.id.navigation_search,
|
||||||
R.id.navigation_results_phone,
|
R.id.navigation_results_phone,
|
||||||
|
|
@ -550,19 +496,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutParams = params
|
layoutParams = params
|
||||||
}*/
|
}
|
||||||
|
|
||||||
|
val landscape = when (resources.configuration.orientation) {
|
||||||
|
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration.ORIENTATION_PORTRAIT -> {
|
||||||
|
isLayout(TV or EMULATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding?.apply {
|
binding?.apply {
|
||||||
navRailView.isVisible = isNavVisible && isLandscape()
|
navRailView.isVisible = isNavVisible && landscape
|
||||||
navView.isVisible = isNavVisible && !isLandscape()
|
navView.isVisible = isNavVisible && !landscape
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We need to make sure if we return to a sub-fragment,
|
* We need to make sure if we return to a sub-fragment,
|
||||||
|
|
@ -570,11 +522,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
* highlight the wrong one in UI.
|
* highlight the wrong one in UI.
|
||||||
*/
|
*/
|
||||||
when (destination.id) {
|
when (destination.id) {
|
||||||
in listOf(
|
in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
|
||||||
R.id.navigation_downloads,
|
|
||||||
R.id.navigation_download_child,
|
|
||||||
R.id.navigation_download_queue
|
|
||||||
) -> {
|
|
||||||
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||||
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||||
}
|
}
|
||||||
|
|
@ -696,9 +644,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
.setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
|
.setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
|
||||||
.setPositiveButton(R.string.yes) { _, _ ->
|
.setPositiveButton(R.string.yes) { _, _ ->
|
||||||
if (dontShowAgainCheck.isChecked) {
|
if (dontShowAgainCheck.isChecked) {
|
||||||
settingsManager.edit(commit = true) {
|
settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit()
|
||||||
putInt(getString(R.string.confirm_exit_key), 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// finish() causes a bug on some TVs where player
|
// finish() causes a bug on some TVs where player
|
||||||
// may keep playing after closing the app.
|
// may keep playing after closing the app.
|
||||||
|
|
@ -723,11 +669,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
|
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
|
||||||
this.sendBroadcast(broadcastIntent)
|
this.sendBroadcast(broadcastIntent)
|
||||||
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
||||||
detachBackPressedCallback("MainActivityDefault")
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
handleAppIntent(intent)
|
handleAppIntent(intent)
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
@ -736,7 +681,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
val str = intent.dataString
|
val str = intent.dataString
|
||||||
loadCache()
|
loadCache()
|
||||||
|
|
||||||
handleAppIntentUrl(this, str, false, intent.extras)
|
handleAppIntentUrl(this, str, false, intent.extras)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -806,11 +750,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private val pluginsLock = Mutex()
|
private val pluginsLock = Mutex()
|
||||||
private fun onAllPluginsLoaded(success: Boolean = false) {
|
private fun onAllPluginsLoaded(success: Boolean = false) {
|
||||||
ioSafe {
|
ioSafe {
|
||||||
pluginsLock.withLock {
|
pluginsLock.withLock {
|
||||||
allProviders.withLock {
|
synchronized(allProviders) {
|
||||||
// Load cloned sites after plugins have been loaded since clones depend on plugins.
|
// Load cloned sites after plugins have been loaded since clones depend on plugins.
|
||||||
try {
|
try {
|
||||||
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
|
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
|
||||||
|
|
@ -856,8 +801,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
|
|
||||||
private fun hidePreviewPopupDialog() {
|
private fun hidePreviewPopupDialog() {
|
||||||
bottomPreviewPopup.dismissSafe(this)
|
bottomPreviewPopup.dismissSafe(this)
|
||||||
lastPopupJob?.cancel()
|
|
||||||
lastPopupJob = null
|
|
||||||
bottomPreviewPopup = null
|
bottomPreviewPopup = null
|
||||||
bottomPreviewBinding = null
|
bottomPreviewBinding = null
|
||||||
}
|
}
|
||||||
|
|
@ -1177,14 +1120,35 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private fun centerView(view: View?) {
|
||||||
app.initClient(this, ignoreSSL = false)
|
if (view == null) return
|
||||||
@OptIn(UnsafeSSL::class)
|
try {
|
||||||
insecureApp.initClient(this, ignoreSSL = true)
|
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)
|
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()
|
val settingsForProvider = SettingsJson()
|
||||||
settingsForProvider.enableAdult =
|
settingsForProvider.enableAdult =
|
||||||
|
|
@ -1193,8 +1157,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
MainAPI.settingsForProvider = settingsForProvider
|
MainAPI.settingsForProvider = settingsForProvider
|
||||||
|
|
||||||
loadThemes(this)
|
loadThemes(this)
|
||||||
enableEdgeToEdgeCompat()
|
|
||||||
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
|
|
||||||
updateLocale()
|
updateLocale()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
try {
|
try {
|
||||||
|
|
@ -1215,8 +1177,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
|
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
|
||||||
if (appVer != lastAppAutoBackup) {
|
if (appVer != lastAppAutoBackup) {
|
||||||
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
|
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
|
||||||
if (lastAppAutoBackup.isEmpty()) return@safe
|
|
||||||
|
|
||||||
safe {
|
safe {
|
||||||
backup(this)
|
backup(this)
|
||||||
}
|
}
|
||||||
|
|
@ -1248,7 +1208,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
if (isLayout(TV)) {
|
if (isLayout(TV)) {
|
||||||
// Put here any button you don't want focusing it to center the view
|
// Put here any button you don't want focusing it to center the view
|
||||||
val exceptionButtons = listOf(
|
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_info_btt,
|
||||||
R.id.home_preview_hidden_next_focus,
|
R.id.home_preview_hidden_next_focus,
|
||||||
R.id.home_preview_hidden_prev_focus,
|
R.id.home_preview_hidden_prev_focus,
|
||||||
|
|
@ -1280,22 +1240,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
null
|
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
|
// overscan
|
||||||
val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
|
val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
|
||||||
binding?.homeRoot?.setPadding(padding, padding, padding, padding)
|
binding?.homeRoot?.setPadding(padding, padding, padding, padding)
|
||||||
|
|
@ -1386,9 +1330,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add your channel creation here
|
|
||||||
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
||||||
|
|
@ -1653,7 +1594,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
ioSafe {
|
ioSafe {
|
||||||
initAll()
|
initAll()
|
||||||
// No duplicates (which can happen by registerMainAPI)
|
// 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)
|
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
|
||||||
|
|
@ -1676,6 +1619,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
if (navDestination.matchDestination(R.id.navigation_home)) {
|
if (navDestination.matchDestination(R.id.navigation_home)) {
|
||||||
attachBackPressedCallback("MainActivity") {
|
attachBackPressedCallback("MainActivity") {
|
||||||
showConfirmExitDialog(settingsManager)
|
showConfirmExitDialog(settingsManager)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window?.navigationBarColor =
|
||||||
|
colorFromAttribute(R.attr.primaryGrayBackground)
|
||||||
|
updateLocale()
|
||||||
}
|
}
|
||||||
} else detachBackPressedCallback("MainActivity")
|
} else detachBackPressedCallback("MainActivity")
|
||||||
}
|
}
|
||||||
|
|
@ -1707,23 +1654,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
|
|
||||||
binding?.navRailView?.apply {
|
binding?.navRailView?.apply {
|
||||||
if (isLayout(PHONE)) {
|
itemRippleColor = rippleColor
|
||||||
itemRippleColor = rippleColor
|
itemActiveIndicatorColor = 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)
|
setupWithNavController(navController)
|
||||||
/*if (isLayout(TV or EMULATOR)) {
|
if (isLayout(TV or EMULATOR)) {
|
||||||
background?.alpha = 200
|
background?.alpha = 200
|
||||||
} else {
|
} else {
|
||||||
background?.alpha = 255
|
background?.alpha = 255
|
||||||
}*/
|
}
|
||||||
|
|
||||||
setOnItemSelectedListener { item ->
|
setOnItemSelectedListener { item ->
|
||||||
onNavDestinationSelected(
|
onNavDestinationSelected(
|
||||||
|
|
@ -1772,54 +1710,31 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
}
|
}
|
||||||
|
|
||||||
val rail = binding?.navRailView
|
val rail = binding?.navRailView
|
||||||
if (rail != null) {
|
if (rail != null && isLayout(TV)) {
|
||||||
binding?.navRailView?.labelVisibilityMode =
|
val focus = mutableSetOf<Int>()
|
||||||
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(
|
for (id in arrayOf(
|
||||||
R.id.navigation_home,
|
R.id.navigation_home,
|
||||||
R.id.navigation_search,
|
|
||||||
R.id.navigation_library,
|
R.id.navigation_library,
|
||||||
|
R.id.navigation_search,
|
||||||
R.id.navigation_downloads,
|
R.id.navigation_downloads,
|
||||||
R.id.navigation_settings
|
R.id.navigation_settings
|
||||||
)) {
|
)) {
|
||||||
val view = rail.findViewById<View?>(id) ?: continue
|
rail.findViewById<View?>(id)?.onFocusChangeListener =
|
||||||
prevId?.let { view.nextFocusUpId = it }
|
View.OnFocusChangeListener { v, hasFocus ->
|
||||||
prevView?.nextFocusDownId = id
|
if (hasFocus) {
|
||||||
|
focus += id
|
||||||
prevView = view
|
binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_LABELED
|
||||||
prevId = id
|
binding?.navRailView?.expand()
|
||||||
// Uncomment for focus expand
|
} else {
|
||||||
/*if (!isLayout(TV)) {
|
focus -= id
|
||||||
view.onFocusChangeListener = null
|
v.post {
|
||||||
} else {
|
if(focus.isEmpty()) {
|
||||||
view.onFocusChangeListener =
|
binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_UNLABELED
|
||||||
View.OnFocusChangeListener { v, hasFocus ->
|
binding?.navRailView?.collapse()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}*/
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1935,7 +1850,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
fun buildMediaQueueItem(video: String): MediaQueueItem {
|
fun buildMediaQueueItem(video: String): MediaQueueItem {
|
||||||
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
|
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
|
||||||
//movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream")
|
//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)
|
.setStreamType(MediaInfo.STREAM_TYPE_NONE)
|
||||||
.setContentType(MimeTypes.IMAGE_JPEG)
|
.setContentType(MimeTypes.IMAGE_JPEG)
|
||||||
// .setMetadata(movieMetadata).build()
|
// .setMetadata(movieMetadata).build()
|
||||||
|
|
@ -1961,7 +1876,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
var providersAndroidManifestString = "Current androidmanifest should be:\n"
|
var providersAndroidManifestString = "Current androidmanifest should be:\n"
|
||||||
allProviders.withLock {
|
synchronized(allProviders) {
|
||||||
for (api in allProviders) {
|
for (api in allProviders) {
|
||||||
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
|
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
|
||||||
api.mainUrl.removePrefix(
|
api.mainUrl.removePrefix(
|
||||||
|
|
@ -1997,17 +1912,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
migrateResumeWatching()
|
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 ->
|
getKey<String>(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
|
||||||
DataStoreHelper.currentHomePage = homepage
|
DataStoreHelper.currentHomePage = homepage
|
||||||
removeKey(USER_SELECTED_HOMEPAGE_API)
|
removeKey(USER_SELECTED_HOMEPAGE_API)
|
||||||
|
|
@ -2039,14 +1943,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
attachBackPressedCallback("MainActivityDefault") {
|
onBackPressedDispatcher.addCallback(
|
||||||
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
|
this,
|
||||||
updateLocale()
|
object : OnBackPressedCallback(true) {
|
||||||
runDefault()
|
override fun handleOnBackPressed() {
|
||||||
}
|
@Suppress("DEPRECATION")
|
||||||
|
window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
|
||||||
|
updateLocale()
|
||||||
|
|
||||||
// Start the download queue
|
// If we don't disable we end up in a loop with default behavior calling
|
||||||
DownloadQueueManager.init(this)
|
// 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 **/
|
/** Biometric stuff **/
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
|
|
@ -21,8 +21,7 @@ import java.io.File
|
||||||
|
|
||||||
fun updateDurationAndPosition(position: Long, duration: Long) {
|
fun updateDurationAndPosition(position: Long, duration: Long) {
|
||||||
if (position <= 0 || duration <= 0) return
|
if (position <= 0 || duration <= 0) return
|
||||||
val episode = getKey<ResultEpisode>("last_opened") ?: return
|
DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
|
||||||
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
|
|
||||||
ResultFragment.updateUI()
|
ResultFragment.updateUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +98,7 @@ abstract class OpenInAppAction(
|
||||||
intent.component = ComponentName(packageName, intentClass)
|
intent.component = ComponentName(packageName, intentClass)
|
||||||
}
|
}
|
||||||
putExtra(context, intent, video, result, index)
|
putExtra(context, intent, video, result, index)
|
||||||
setKey("last_opened", video)
|
setKey("last_opened_id", video.id)
|
||||||
launchResult(intent)
|
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.CopyClipboardAction
|
||||||
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
|
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
|
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.MpvKtPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
|
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
||||||
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
|
|
||||||
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
||||||
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
|
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
|
||||||
import com.lagradost.cloudstream3.actions.temp.VlcPackage
|
import com.lagradost.cloudstream3.actions.temp.VlcPackage
|
||||||
|
|
@ -34,8 +30,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -45,16 +41,14 @@ import java.util.concurrent.FutureTask
|
||||||
import kotlin.reflect.jvm.jvmName
|
import kotlin.reflect.jvm.jvmName
|
||||||
|
|
||||||
object VideoClickActionHolder {
|
object VideoClickActionHolder {
|
||||||
val allVideoClickActions = atomicListOf(
|
val allVideoClickActions = threadSafeListOf(
|
||||||
// Default
|
// Default
|
||||||
PlayInBrowserAction(),
|
PlayInBrowserAction(),
|
||||||
CopyClipboardAction(),
|
CopyClipboardAction(),
|
||||||
ViewM3U8Action(),
|
ViewM3U8Action(),
|
||||||
PlayMirrorAction(),
|
|
||||||
// main support external apps
|
// main support external apps
|
||||||
VlcPackage(),
|
VlcPackage(),
|
||||||
MpvPackage(),
|
MpvPackage(),
|
||||||
MpvExPackage(),
|
|
||||||
NextPlayerPackage(),
|
NextPlayerPackage(),
|
||||||
JustPlayerPackage(),
|
JustPlayerPackage(),
|
||||||
FcastAction(),
|
FcastAction(),
|
||||||
|
|
@ -66,8 +60,6 @@ object VideoClickActionHolder {
|
||||||
MpvYTDLPackage(),
|
MpvYTDLPackage(),
|
||||||
MpvKtPackage(),
|
MpvKtPackage(),
|
||||||
MpvKtPreviewPackage(),
|
MpvKtPreviewPackage(),
|
||||||
OnlyPlayer(),
|
|
||||||
MpvRxPackage(),
|
|
||||||
// Always Ask option
|
// Always Ask option
|
||||||
AlwaysAskAction(),
|
AlwaysAskAction(),
|
||||||
// added by plugins
|
// added by plugins
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
|
||||||
import com.lagradost.cloudstream3.BuildConfig
|
import com.lagradost.cloudstream3.BuildConfig
|
||||||
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.ui.player.ExtractorUri
|
import com.lagradost.cloudstream3.ui.player.ExtractorUri
|
||||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||||
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
|
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
|
||||||
|
|
@ -18,10 +18,8 @@ import com.lagradost.cloudstream3.utils.DrmExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import com.lagradost.cloudstream3.utils.newExtractorLink
|
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
import com.lagradost.cloudstream3.utils.Qualities
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
|
import com.lagradost.cloudstream3.utils.newExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
|
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -124,9 +122,7 @@ class CloudStreamPackage : OpenInAppAction(
|
||||||
originalName = name ?: "Unknown",
|
originalName = name ?: "Unknown",
|
||||||
headers = headers,
|
headers = headers,
|
||||||
origin = SubtitleOrigin.URL,
|
origin = SubtitleOrigin.URL,
|
||||||
languageCode = fromCodeToLangTagIETF(name) ?:
|
languageCode = null,
|
||||||
fromLanguageToTagIETF(name, true) ?:
|
|
||||||
name,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||||
|
|
@ -44,7 +45,7 @@ open class MpvKtPackage(
|
||||||
|
|
||||||
intent.apply {
|
intent.apply {
|
||||||
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
|
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
|
// m3u8 plays, but changing sources feature is not available
|
||||||
// makeTempM3U8Intent(activity, this, result)
|
// makeTempM3U8Intent(activity, this, result)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.api.Log
|
import com.lagradost.api.Log
|
||||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
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://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://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") {
|
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
|
||||||
override val sourceTypes = setOf(
|
override val sourceTypes = setOf(
|
||||||
ExtractorLinkType.VIDEO,
|
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),
|
txt(appName),
|
||||||
packageName,
|
packageName,
|
||||||
intentClass
|
"is.xyz.mpv.MPVActivity"
|
||||||
) {
|
) {
|
||||||
override val oneSource = true // mpv has poor playlist support on TV
|
override val oneSource = true // mpv has poor playlist support on TV
|
||||||
override suspend fun putExtra(
|
override suspend fun putExtra(
|
||||||
|
|
@ -46,7 +44,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
|
||||||
putExtra("title", video.name)
|
putExtra("title", video.name)
|
||||||
|
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*")
|
setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*")
|
||||||
} else {
|
} else {
|
||||||
makeTempM3U8Intent(context, this, result)
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.net.toUri
|
import android.net.Uri
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||||
|
|
@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() {
|
||||||
) {
|
) {
|
||||||
val link = result.links.getOrNull(index ?: 0) ?: return
|
val link = result.links.getOrNull(index ?: 0) ?: return
|
||||||
val i = Intent(Intent.ACTION_VIEW)
|
val i = Intent(Intent.ACTION_VIEW)
|
||||||
i.data = link.url.toUri()
|
i.data = Uri.parse(link.url)
|
||||||
launch(i)
|
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 android.os.Build
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.api.Log
|
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.OpenInAppAction
|
||||||
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
|
||||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
|
|
@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction(
|
||||||
val link = result.links[index ?: 0]
|
val link = result.links[index ?: 0]
|
||||||
|
|
||||||
intent.apply {
|
intent.apply {
|
||||||
setDataAndType(link.url.toUri(), "video/*")
|
setDataAndType(Uri.parse(link.url), "video/*")
|
||||||
|
|
||||||
val title = video.name ?: video.headerName
|
val title = video.name ?: video.headerName
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package com.lagradost.cloudstream3.actions.temp.fcast
|
package com.lagradost.cloudstream3.actions.temp.fcast
|
||||||
|
|
||||||
import android.content.Context
|
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.R
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickAction
|
import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import android.net.nsd.NsdServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
import android.os.ext.SdkExtensions
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
|
||||||
class FcastManager {
|
class FcastManager {
|
||||||
|
|
@ -73,66 +72,52 @@ class FcastManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
||||||
// Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback
|
if (serviceInfo == null) return
|
||||||
safe {
|
|
||||||
if (serviceInfo == null) return@safe
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
|
||||||
Build.VERSION_CODES.TIRAMISU
|
Build.VERSION_CODES.TIRAMISU) >= 7) {
|
||||||
) >= 7
|
nsdManager?.registerServiceInfoCallback(serviceInfo,
|
||||||
) {
|
Runnable::run,
|
||||||
nsdManager?.registerServiceInfoCallback(
|
object : NsdManager.ServiceInfoCallback {
|
||||||
serviceInfo,
|
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||||
Runnable::run,
|
Log.e(tag, "Service registration failed: $errorCode")
|
||||||
object : NsdManager.ServiceInfoCallback {
|
|
||||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
|
||||||
Log.e(tag, "Service registration failed: $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"Service updated: ${serviceInfo.serviceName}," +
|
|
||||||
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
|
|
||||||
)
|
|
||||||
synchronized(_currentDevices) {
|
|
||||||
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
|
|
||||||
_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 onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
Log.d(tag,
|
||||||
if (serviceInfo == null) return
|
"Service updated: ${serviceInfo.serviceName}," +
|
||||||
|
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
|
||||||
|
)
|
||||||
synchronized(_currentDevices) {
|
synchronized(_currentDevices) {
|
||||||
|
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
|
||||||
_currentDevices.add(PublicDeviceInfo(serviceInfo))
|
_currentDevices.add(PublicDeviceInfo(serviceInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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 onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
||||||
|
if (serviceInfo == null) return
|
||||||
|
|
||||||
|
synchronized(_currentDevices) {
|
||||||
|
_currentDevices.add(PublicDeviceInfo(serviceInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,9 +168,8 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
|
||||||
val host: String? = if (
|
val host: String? = if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||||
SdkExtensions.getExtensionVersion(
|
SdkExtensions.getExtensionVersion(
|
||||||
Build.VERSION_CODES.TIRAMISU
|
Build.VERSION_CODES.TIRAMISU) >= 7
|
||||||
) >= 7
|
) {
|
||||||
) {
|
|
||||||
serviceInfo.hostAddresses.firstOrNull()?.hostAddress
|
serviceInfo.hostAddresses.firstOrNull()?.hostAddress
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.mvvm
|
package com.lagradost.cloudstream3.mvvm
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.core.view.doOnAttach
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
/** NOTE: Only one observer at a time per value */
|
||||||
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: 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) {
|
|
||||||
liveData.removeObservers(this)
|
liveData.removeObservers(this)
|
||||||
liveData.observe(this, action)
|
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
/** NOTE: Only one observer at a time per value */
|
||||||
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||||
observeNullable(liveData) { t -> t?.run(action) }
|
liveData.removeObservers(this)
|
||||||
}
|
liveData.observe(this) { action(it) }
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches an observable to the root binding, instead of the fragment. This is more efficient as
|
|
||||||
* it will not call observe if the view is in the background.
|
|
||||||
*
|
|
||||||
* NOTE: Only one observer at a time per value
|
|
||||||
* */
|
|
||||||
fun <T, V : ViewBinding> BaseFragment<V>.observeNullable(
|
|
||||||
liveData: LiveData<T>, action: (T?) -> Unit
|
|
||||||
) {
|
|
||||||
val root = this.binding?.root
|
|
||||||
if (root == null) {
|
|
||||||
liveData.removeObservers(this)
|
|
||||||
liveData.observe(this, action)
|
|
||||||
} else {
|
|
||||||
root.doOnAttach { view ->
|
|
||||||
// On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
|
|
||||||
val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
|
|
||||||
liveData.removeObservers(owner)
|
|
||||||
liveData.observe(owner, action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
|
||||||
fun <T> View.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
|
||||||
observeNullable(liveData) { t -> t?.run(action) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** NOTE: Only one observer at a time per value */
|
|
||||||
fun <T> View.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
|
||||||
doOnAttach { view ->
|
|
||||||
// On attach should make findViewTreeLifecycleOwner non-null
|
|
||||||
val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
|
|
||||||
if(owner == null) {
|
|
||||||
debugException { "Expected non-null findViewTreeLifecycleOwner" }
|
|
||||||
return@doOnAttach
|
|
||||||
}
|
|
||||||
liveData.removeObservers(owner)
|
|
||||||
liveData.observe(owner, action)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.Prerelease
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
|
|
@ -16,26 +15,11 @@ import org.conscrypt.Conscrypt
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
|
|
||||||
// Backwards compatible constructor, mark as deprecated later
|
|
||||||
fun Requests.initClient(context: Context) {
|
fun Requests.initClient(context: Context) {
|
||||||
this.baseClient = buildDefaultClient(context)
|
this.baseClient = buildDefaultClient(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only use ignoreSSL if you know what you are doing*/
|
|
||||||
@Prerelease
|
|
||||||
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
|
|
||||||
this.baseClient = buildDefaultClient(context, ignoreSSL)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Backwards compatible constructor, mark as deprecated later
|
|
||||||
fun buildDefaultClient(context: Context): OkHttpClient {
|
fun buildDefaultClient(context: Context): OkHttpClient {
|
||||||
return buildDefaultClient(context, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only use ignoreSSL if you know what you are doing*/
|
|
||||||
@Prerelease
|
|
||||||
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
|
|
||||||
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
@ -43,11 +27,7 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
||||||
val baseClient = OkHttpClient.Builder()
|
val baseClient = OkHttpClient.Builder()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
.followSslRedirects(true)
|
.followSslRedirects(true)
|
||||||
.apply {
|
.ignoreAllSSLErrors()
|
||||||
if (ignoreSSL) {
|
|
||||||
ignoreAllSSLErrors()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cache(
|
.cache(
|
||||||
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
||||||
// The server response dictates if and when stuff should be cached.
|
// The server response dictates if and when stuff should be cached.
|
||||||
|
|
@ -72,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
||||||
return baseClient
|
return baseClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//val Request.cookies: Map<String, String>
|
||||||
|
// get() {
|
||||||
|
// return this.headers.getCookies("Cookie")
|
||||||
|
// }
|
||||||
|
|
||||||
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
|
||||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||||
import kotlin.Throws
|
import kotlin.Throws
|
||||||
|
|
||||||
|
|
||||||
abstract class Plugin : BasePlugin() {
|
abstract class Plugin : BasePlugin() {
|
||||||
/**
|
/**
|
||||||
* Called when your Plugin is loaded
|
* Called when your Plugin is loaded
|
||||||
|
|
@ -25,7 +26,9 @@ abstract class Plugin : BasePlugin() {
|
||||||
fun registerVideoClickAction(element: VideoClickAction) {
|
fun registerVideoClickAction(element: VideoClickAction) {
|
||||||
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
||||||
element.sourcePlugin = this.filename
|
element.sourcePlugin = this.filename
|
||||||
VideoClickActionHolder.allVideoClickActions.add(element)
|
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||||
|
VideoClickActionHolder.allVideoClickActions.add(element)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
@ -21,17 +20,15 @@ import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
|
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.AllLanguagesName
|
||||||
import com.lagradost.cloudstream3.AutoDownloadMode
|
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.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.InternalAPI
|
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
|
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
|
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -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.PREBUILT_REPOSITORIES
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||||
|
|
@ -55,7 +51,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
|
||||||
import com.lagradost.cloudstream3.utils.extractorApis
|
import com.lagradost.cloudstream3.utils.extractorApis
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import dalvik.system.PathClassLoader
|
import dalvik.system.PathClassLoader
|
||||||
|
|
@ -80,7 +76,6 @@ data class PluginData(
|
||||||
@JsonProperty("filePath") val filePath: String,
|
@JsonProperty("filePath") val filePath: String,
|
||||||
@JsonProperty("version") val version: Int,
|
@JsonProperty("version") val version: Int,
|
||||||
) {
|
) {
|
||||||
@WorkerThread
|
|
||||||
fun toSitePlugin(): SitePlugin {
|
fun toSitePlugin(): SitePlugin {
|
||||||
return SitePlugin(
|
return SitePlugin(
|
||||||
this.filePath,
|
this.filePath,
|
||||||
|
|
@ -95,9 +90,7 @@ data class PluginData(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
File(this.filePath).length(),
|
File(this.filePath).length()
|
||||||
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
|
|
||||||
null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,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.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||||
@InternalAPI
|
@Deprecated(
|
||||||
|
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||||
|
replaceWith = ReplaceWith("loadPlugin"),
|
||||||
|
level = DeprecationLevel.ERROR
|
||||||
|
)
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
@ -307,7 +304,6 @@ object PluginManager {
|
||||||
downloadPlugin(
|
downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
pluginData.onlineData.second.fileHash,
|
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
File(pluginData.savedData.filePath),
|
File(pluginData.savedData.filePath),
|
||||||
true
|
true
|
||||||
|
|
@ -343,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.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||||
@InternalAPI
|
@Deprecated(
|
||||||
|
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||||
|
replaceWith = ReplaceWith("loadPlugin"),
|
||||||
|
level = DeprecationLevel.ERROR
|
||||||
|
)
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
|
|
@ -419,7 +419,6 @@ object PluginManager {
|
||||||
downloadPlugin(
|
downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
pluginData.onlineData.second.fileHash,
|
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
pluginData.onlineData.first,
|
pluginData.onlineData.first,
|
||||||
!pluginData.isDisabled
|
!pluginData.isDisabled
|
||||||
|
|
@ -454,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.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||||
@InternalAPI
|
@Deprecated(
|
||||||
|
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||||
|
replaceWith = ReplaceWith("loadPlugin"),
|
||||||
|
level = DeprecationLevel.ERROR
|
||||||
|
)
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
@ -476,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.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
@Throws
|
||||||
|
@Deprecated(
|
||||||
|
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||||
|
replaceWith = ReplaceWith("loadPlugin"),
|
||||||
|
level = DeprecationLevel.ERROR
|
||||||
|
)
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
||||||
|
|
@ -497,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.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||||
@InternalAPI
|
@Deprecated(
|
||||||
|
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||||
|
replaceWith = ReplaceWith("loadPlugin"),
|
||||||
|
level = DeprecationLevel.ERROR
|
||||||
|
)
|
||||||
@Throws
|
@Throws
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
@ -561,11 +572,6 @@ object PluginManager {
|
||||||
afterPluginsLoadedEvent.invoke(forceReload)
|
afterPluginsLoadedEvent.invoke(forceReload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return true if safe mode is enabled in any possible way. */
|
|
||||||
fun isSafeMode(): Boolean {
|
|
||||||
return checkSafeModeFile() || lastError != null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This can be used to override any extension loading to fix crashes!
|
* This can be used to override any extension loading to fix crashes!
|
||||||
* @return true if safe mode file is present
|
* @return true if safe mode file is present
|
||||||
|
|
@ -610,7 +616,7 @@ object PluginManager {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
InputStreamReader(stream).use { reader ->
|
InputStreamReader(stream).use { reader ->
|
||||||
manifest = parseJson<BasePlugin.Manifest>(reader.readText())
|
manifest = parseJson(reader, BasePlugin.Manifest::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,15 +657,9 @@ object PluginManager {
|
||||||
context.resources.configuration
|
context.resources.configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
synchronized(plugins) {
|
plugins[filePath] = pluginInstance
|
||||||
plugins[filePath] = pluginInstance
|
classLoaders[loader] = pluginInstance
|
||||||
}
|
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||||
synchronized(classLoaders) {
|
|
||||||
classLoaders[loader] = pluginInstance
|
|
||||||
}
|
|
||||||
synchronized(urlPlugins) {
|
|
||||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
|
||||||
}
|
|
||||||
if (pluginInstance is Plugin) {
|
if (pluginInstance is Plugin) {
|
||||||
pluginInstance.load(context)
|
pluginInstance.load(context)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -695,33 +695,25 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove all registered apis
|
// remove all registered apis
|
||||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
synchronized(APIHolder.apis) {
|
||||||
removePluginMapping(it)
|
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
||||||
|
removePluginMapping(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized(APIHolder.allProviders) {
|
||||||
|
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
|
||||||
}
|
}
|
||||||
|
|
||||||
APIHolder.allProviders.withLock {
|
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
|
||||||
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
|
||||||
|
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||||
|
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
|
||||||
}
|
}
|
||||||
|
|
||||||
extractorApis.withLock {
|
classLoaders.values.removeIf { v -> v == plugin }
|
||||||
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
|
||||||
}
|
|
||||||
|
|
||||||
VideoClickActionHolder.allVideoClickActions.withLock {
|
plugins.remove(absolutePath)
|
||||||
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
|
urlPlugins.values.removeIf { v -> v == plugin }
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(classLoaders) {
|
|
||||||
classLoaders.values.removeIf { v -> v == plugin }
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(plugins) {
|
|
||||||
plugins.remove(absolutePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(urlPlugins) {
|
|
||||||
urlPlugins.values.removeIf { v -> v == plugin }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -751,27 +743,25 @@ object PluginManager {
|
||||||
suspend fun downloadPlugin(
|
suspend fun downloadPlugin(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
pluginHash: String?,
|
|
||||||
internalName: String,
|
internalName: String,
|
||||||
repositoryUrl: String,
|
repositoryUrl: String,
|
||||||
loadPlugin: Boolean
|
loadPlugin: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val file = getPluginPath(activity, internalName, repositoryUrl)
|
val file = getPluginPath(activity, internalName, repositoryUrl)
|
||||||
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
|
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadPlugin(
|
suspend fun downloadPlugin(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
pluginHash: String?,
|
|
||||||
internalName: String,
|
internalName: String,
|
||||||
file: File,
|
file: File,
|
||||||
loadPlugin: Boolean,
|
loadPlugin: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
||||||
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
||||||
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
|
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
|
||||||
|
|
||||||
val data = PluginData(
|
val data = PluginData(
|
||||||
internalName,
|
internalName,
|
||||||
|
|
@ -818,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.
|
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||||
@InternalAPI
|
|
||||||
@Throws
|
@Throws
|
||||||
|
@Deprecated(
|
||||||
|
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||||
|
replaceWith = ReplaceWith("loadPlugin"),
|
||||||
|
level = DeprecationLevel.ERROR
|
||||||
|
)
|
||||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
|
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
|
||||||
assertNonRecursiveCallstack()
|
assertNonRecursiveCallstack()
|
||||||
|
|
||||||
|
|
@ -859,7 +853,6 @@ object PluginManager {
|
||||||
if (downloadPlugin(
|
if (downloadPlugin(
|
||||||
activity,
|
activity,
|
||||||
pluginData.onlineData.second.url,
|
pluginData.onlineData.second.url,
|
||||||
pluginData.onlineData.second.fileHash,
|
|
||||||
pluginData.savedData.internalName,
|
pluginData.savedData.internalName,
|
||||||
existingFile,
|
existingFile,
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package com.lagradost.cloudstream3.plugins
|
package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.amap
|
import com.lagradost.cloudstream3.amap
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
|
|
@ -19,12 +18,10 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.BufferedInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.AtomicMoveNotSupportedException
|
import java.io.InputStream
|
||||||
import java.nio.file.Files
|
import java.io.OutputStream
|
||||||
import java.nio.file.StandardCopyOption
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comes with the app, always available in the app, non removable.
|
* Comes with the app, always available in the app, non removable.
|
||||||
|
|
@ -65,12 +62,10 @@ data class SitePlugin(
|
||||||
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
||||||
// These types are yet to be mapped and used, ignore for now
|
// These types are yet to be mapped and used, ignore for now
|
||||||
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
||||||
// Most often a language tag like "en" or "zh-TW"
|
|
||||||
@JsonProperty("language") val language: String?,
|
@JsonProperty("language") val language: String?,
|
||||||
@JsonProperty("iconUrl") val iconUrl: String?,
|
@JsonProperty("iconUrl") val iconUrl: String?,
|
||||||
// Automatically generated by the gradle plugin
|
// Automatically generated by the gradle plugin
|
||||||
@JsonProperty("fileSize") val fileSize: Long?,
|
@JsonProperty("fileSize") val fileSize: Long?,
|
||||||
@JsonProperty("fileHash") val fileHash: String?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -79,26 +74,7 @@ object RepositoryManager {
|
||||||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||||
}
|
}
|
||||||
private val GH_REGEX =
|
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||||
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
|
||||||
|
|
||||||
|
|
||||||
/** Returns a SHA-256 string of the file content.
|
|
||||||
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
|
|
||||||
@WorkerThread
|
|
||||||
fun sha256(file: File): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
|
|
||||||
file.inputStream().use { fis ->
|
|
||||||
val buffer = ByteArray(8192)
|
|
||||||
var read = fis.read(buffer)
|
|
||||||
while (read != -1) {
|
|
||||||
digest.update(buffer, 0, read)
|
|
||||||
read = fis.read(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
||||||
fun convertRawGitUrl(url: String): String {
|
fun convertRawGitUrl(url: String): String {
|
||||||
|
|
@ -163,52 +139,21 @@ object RepositoryManager {
|
||||||
}.flatten()
|
}.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun downloadPluginToFile(
|
suspend fun downloadPluginToFile(
|
||||||
context: Context,
|
|
||||||
pluginUrl: String,
|
pluginUrl: String,
|
||||||
file: File,
|
file: File
|
||||||
expectedFileHash: String?
|
|
||||||
): File? {
|
): File? {
|
||||||
return safeAsync {
|
return safeAsync {
|
||||||
val parentDir = file.parentFile ?: return@safeAsync null
|
file.mkdirs()
|
||||||
parentDir.mkdirs()
|
|
||||||
|
|
||||||
// Prevent corrupting the plugin file if the operation fails
|
// Overwrite if exists
|
||||||
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
|
if (file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
file.createNewFile()
|
||||||
|
|
||||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||||
|
write(body.byteStream(), file.outputStream())
|
||||||
body.byteStream().use { body ->
|
|
||||||
tempFile.outputStream().use { fileSteam ->
|
|
||||||
body.copyTo(fileSteam)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expectedFileHash != null) {
|
|
||||||
val downloadHash = sha256(tempFile)
|
|
||||||
if (expectedFileHash != downloadHash) {
|
|
||||||
tempFile.delete()
|
|
||||||
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We prefer the operation to be atomic
|
|
||||||
try {
|
|
||||||
Files.move(
|
|
||||||
tempFile.toPath(),
|
|
||||||
file.toPath(),
|
|
||||||
StandardCopyOption.REPLACE_EXISTING,
|
|
||||||
StandardCopyOption.ATOMIC_MOVE
|
|
||||||
)
|
|
||||||
} catch (_: AtomicMoveNotSupportedException) {
|
|
||||||
Files.move(
|
|
||||||
tempFile.toPath(),
|
|
||||||
file.toPath(),
|
|
||||||
StandardCopyOption.REPLACE_EXISTING
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,4 +201,13 @@ object RepositoryManager {
|
||||||
|
|
||||||
PluginManager.deleteRepositoryData(file.absolutePath)
|
PluginManager.deleteRepositoryData(file.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun write(stream: InputStream, output: OutputStream) {
|
||||||
|
val input = BufferedInputStream(stream)
|
||||||
|
val dataBuffer = ByteArray(512)
|
||||||
|
var readBytes: Int
|
||||||
|
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||||
|
output.write(dataBuffer, 0, readBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
|
|
@ -12,76 +12,87 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
object VotingApi {
|
object VotingApi { // please do not cheat the votes lol
|
||||||
|
|
||||||
private const val LOGKEY = "VotingApi"
|
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
|
MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
.digest("${url}#funny-salt".toByteArray())
|
.digest("${url}#funny-salt".toByteArray())
|
||||||
.fold("") { str, it -> str + "%02x".format(it) }
|
.fold("") { str, it -> str + "%02x".format(it) }
|
||||||
|
|
||||||
suspend fun SitePlugin.getVotes(): Int = getVotes(url)
|
suspend fun SitePlugin.getVotes(): Int {
|
||||||
fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
|
return getVotes(url)
|
||||||
suspend fun SitePlugin.vote(): Int = vote(url)
|
}
|
||||||
fun SitePlugin.canVote(): Boolean = canVote(this.url)
|
|
||||||
|
|
||||||
|
fun SitePlugin.hasVoted(): Boolean {
|
||||||
|
return hasVoted(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun SitePlugin.vote(): Int {
|
||||||
|
return vote(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SitePlugin.canVote(): Boolean {
|
||||||
|
return canVote(this.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin url to Int
|
||||||
private val votesCache = mutableMapOf<String, Int>()
|
private val votesCache = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
private fun getRepository(pluginUrl: String) = pluginUrl
|
||||||
|
.split("/")
|
||||||
|
.drop(2)
|
||||||
|
.take(3)
|
||||||
|
.joinToString("-")
|
||||||
|
|
||||||
private suspend fun readVote(pluginUrl: String): Int {
|
private suspend fun readVote(pluginUrl: String): Int {
|
||||||
val id = transformUrl(pluginUrl)
|
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
|
||||||
val url = "$API_DOMAIN/get-total/$id"
|
Log.d(LOGKEY, "Requesting: $url")
|
||||||
Log.d(LOGKEY, "Requesting GET: $url")
|
return app.get(url).parsedSafe<Result>()?.value ?: 0
|
||||||
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeVote(pluginUrl: String): Boolean {
|
private suspend fun writeVote(pluginUrl: String): Boolean {
|
||||||
val id = transformUrl(pluginUrl)
|
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
|
||||||
val url = "$API_DOMAIN/increment/$id"
|
Log.d(LOGKEY, "Requesting: $url")
|
||||||
Log.d(LOGKEY, "Requesting POST: $url")
|
return app.get(url).parsedSafe<Result>()?.value != null
|
||||||
return app.post(url, emptyMap<String, String>())
|
|
||||||
.parsedSafe<CountifyResult>()?.count != null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getVotes(pluginUrl: String): Int =
|
suspend fun getVotes(pluginUrl: String): Int =
|
||||||
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
|
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
|
||||||
votesCache[pluginUrl] = it
|
votesCache[pluginUrl] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasVoted(pluginUrl: String) =
|
fun hasVoted(pluginUrl: String) =
|
||||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||||
|
|
||||||
fun canVote(pluginUrl: String): Boolean =
|
fun canVote(pluginUrl: String): Boolean {
|
||||||
PluginManager.urlPlugins.contains(pluginUrl)
|
return PluginManager.urlPlugins.contains(pluginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
private val voteLock = Mutex()
|
private val voteLock = Mutex()
|
||||||
|
|
||||||
suspend fun vote(pluginUrl: String): Int {
|
suspend fun vote(pluginUrl: String): Int {
|
||||||
|
// Prevent multiple requests at the same time.
|
||||||
voteLock.withLock {
|
voteLock.withLock {
|
||||||
if (!canVote(pluginUrl)) {
|
if (!canVote(pluginUrl)) {
|
||||||
main {
|
main {
|
||||||
Toast.makeText(
|
Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
|
||||||
context,
|
.show()
|
||||||
R.string.extension_install_first,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVoted(pluginUrl)) {
|
if (hasVoted(pluginUrl)) {
|
||||||
main {
|
main {
|
||||||
Toast.makeText(
|
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
|
||||||
context,
|
.show()
|
||||||
R.string.already_voted,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (writeVote(pluginUrl)) {
|
if (writeVote(pluginUrl)) {
|
||||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
||||||
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
||||||
|
|
@ -91,8 +102,7 @@ object VotingApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CountifyResult(
|
private data class Result(
|
||||||
val id: String? = null,
|
val value: Int?
|
||||||
val count: Int? = null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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.getAllSubscriptions
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@Suppress("DEPRECATION_ERROR")
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
try {
|
try {
|
||||||
// println("Update subscriptions!")
|
// println("Update subscriptions!")
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** Handle notification actions such as pause/resume downloads */
|
|
||||||
class VideoDownloadService : Service() {
|
class VideoDownloadService : Service() {
|
||||||
|
|
||||||
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
@ -43,3 +42,19 @@ class VideoDownloadService : Service() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// override fun onHandleIntent(intent: Intent?) {
|
||||||
|
// if (intent != null) {
|
||||||
|
// val id = intent.getIntExtra("id", -1)
|
||||||
|
// val type = intent.getStringExtra("type")
|
||||||
|
// if (id != -1 && type != null) {
|
||||||
|
// val state = when (type) {
|
||||||
|
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
|
||||||
|
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
|
||||||
|
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
|
||||||
|
// else -> return
|
||||||
|
// }
|
||||||
|
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
|
||||||
|
|
@ -13,14 +12,12 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class AccountManager {
|
abstract class AccountManager {
|
||||||
companion object {
|
companion object {
|
||||||
const val NONE_ID: Int = -1
|
const val NONE_ID: Int = -1
|
||||||
val malApi = MALApi()
|
val malApi = MALApi()
|
||||||
val kitsuApi = KitsuApi()
|
|
||||||
val aniListApi = AniListApi()
|
val aniListApi = AniListApi()
|
||||||
val simklApi = SimklApi()
|
val simklApi = SimklApi()
|
||||||
val localListApi = LocalList()
|
val localListApi = LocalList()
|
||||||
|
|
@ -29,7 +26,6 @@ abstract class AccountManager {
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
val subDlApi = SubDlApi()
|
val subDlApi = SubDlApi()
|
||||||
val subSourceApi = SubSourceApi()
|
val subSourceApi = SubSourceApi()
|
||||||
val animeSkipApi = AnimeSkipAuth()
|
|
||||||
|
|
||||||
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
||||||
var cachedAccountIds: MutableMap<String, Int>
|
var cachedAccountIds: MutableMap<String, Int>
|
||||||
|
|
@ -63,14 +59,14 @@ abstract class AccountManager {
|
||||||
|
|
||||||
val allApis = arrayOf(
|
val allApis = arrayOf(
|
||||||
SyncRepo(malApi),
|
SyncRepo(malApi),
|
||||||
SyncRepo(kitsuApi),
|
|
||||||
SyncRepo(aniListApi),
|
SyncRepo(aniListApi),
|
||||||
SyncRepo(simklApi),
|
SyncRepo(simklApi),
|
||||||
SyncRepo(localListApi),
|
SyncRepo(localListApi),
|
||||||
|
|
||||||
SubtitleRepo(openSubtitlesApi),
|
SubtitleRepo(openSubtitlesApi),
|
||||||
SubtitleRepo(addic7ed),
|
SubtitleRepo(addic7ed),
|
||||||
SubtitleRepo(subDlApi),
|
SubtitleRepo(subDlApi),
|
||||||
PlainAuthRepo(animeSkipApi)
|
SubtitleRepo(subSourceApi)
|
||||||
)
|
)
|
||||||
|
|
||||||
fun updateAccountIds() {
|
fun updateAccountIds() {
|
||||||
|
|
@ -112,7 +108,6 @@ abstract class AccountManager {
|
||||||
// accessing other classes
|
// accessing other classes
|
||||||
fun initMainAPI() {
|
fun initMainAPI() {
|
||||||
LoadResponse.malIdPrefix = malApi.idPrefix
|
LoadResponse.malIdPrefix = malApi.idPrefix
|
||||||
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
|
|
||||||
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
LoadResponse.aniListIdPrefix = aniListApi.idPrefix
|
||||||
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
LoadResponse.simklIdPrefix = simklApi.idPrefix
|
||||||
}
|
}
|
||||||
|
|
@ -120,11 +115,11 @@ abstract class AccountManager {
|
||||||
val subtitleProviders = arrayOf(
|
val subtitleProviders = arrayOf(
|
||||||
SubtitleRepo(openSubtitlesApi),
|
SubtitleRepo(openSubtitlesApi),
|
||||||
SubtitleRepo(addic7ed),
|
SubtitleRepo(addic7ed),
|
||||||
SubtitleRepo(subDlApi)
|
SubtitleRepo(subDlApi),
|
||||||
|
SubtitleRepo(subSourceApi)
|
||||||
)
|
)
|
||||||
val syncApis = arrayOf(
|
val syncApis = arrayOf(
|
||||||
SyncRepo(malApi),
|
SyncRepo(malApi),
|
||||||
SyncRepo(kitsuApi),
|
|
||||||
SyncRepo(aniListApi),
|
SyncRepo(aniListApi),
|
||||||
SyncRepo(simklApi),
|
SyncRepo(simklApi),
|
||||||
SyncRepo(localListApi)
|
SyncRepo(localListApi)
|
||||||
|
|
@ -140,8 +135,6 @@ abstract class AccountManager {
|
||||||
// Instantly resume watching a show
|
// Instantly resume watching a show
|
||||||
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
||||||
|
|
||||||
const val APP_STRING_SHARE = "csshare"
|
|
||||||
|
|
||||||
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||||
var secondsLong = seconds.toLong()
|
var secondsLong = seconds.toLong()
|
||||||
val days = TimeUnit.SECONDS
|
val days = TimeUnit.SECONDS
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,52 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.base64Encode
|
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 com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
||||||
|
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.ui.SyncWatchType
|
||||||
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
||||||
import java.net.URI
|
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.security.SecureRandom
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
data class AuthLoginPage(
|
data class AuthLoginPage(
|
||||||
/** The website to open to authenticate */
|
/** The website to open to authenticate */
|
||||||
|
|
@ -45,10 +83,10 @@ data class AuthToken(
|
||||||
val payload: String? = null,
|
val payload: String? = null,
|
||||||
) {
|
) {
|
||||||
fun isAccessTokenExpired(marginSec: Long = 10L) =
|
fun isAccessTokenExpired(marginSec: Long = 10L) =
|
||||||
accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime
|
accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime
|
||||||
|
|
||||||
fun isRefreshTokenExpired(marginSec: Long = 10L) =
|
fun isRefreshTokenExpired(marginSec: Long = 10L) =
|
||||||
refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime
|
refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AuthUser(
|
data class AuthUser(
|
||||||
|
|
@ -143,33 +181,16 @@ abstract class AuthAPI {
|
||||||
open val inAppLoginRequirement: AuthLoginRequirement? = null
|
open val inAppLoginRequirement: AuthLoginRequirement? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Deprecated(
|
|
||||||
message = "Use APIHolder.unixTime instead",
|
|
||||||
replaceWith = ReplaceWith(
|
|
||||||
expression = "APIHolder.unixTime",
|
|
||||||
imports = ["com.lagradost.cloudstream3.APIHolder"]
|
|
||||||
),
|
|
||||||
level = DeprecationLevel.WARNING,
|
|
||||||
)
|
|
||||||
val unixTime: Long
|
val unixTime: Long
|
||||||
get() = APIHolder.unixTime
|
get() = System.currentTimeMillis() / 1000L
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
message = "Use APIHolder.unixTimeMS instead",
|
|
||||||
replaceWith = ReplaceWith(
|
|
||||||
expression = "unixTimeMS",
|
|
||||||
imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"]
|
|
||||||
),
|
|
||||||
level = DeprecationLevel.WARNING,
|
|
||||||
)
|
|
||||||
val unixTimeMs: Long
|
val unixTimeMs: Long
|
||||||
get() = unixTimeMS
|
get() = System.currentTimeMillis()
|
||||||
|
|
||||||
fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
|
fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
|
||||||
return splitQuery(
|
return splitQuery(
|
||||||
URI(
|
URL(
|
||||||
redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
|
redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
|
||||||
).toURL()
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,8 +200,9 @@ abstract class AuthAPI {
|
||||||
val secureRandom = SecureRandom()
|
val secureRandom = SecureRandom()
|
||||||
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
|
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
|
||||||
secureRandom.nextBytes(codeVerifierBytes)
|
secureRandom.nextBytes(codeVerifierBytes)
|
||||||
return base64Encode(codeVerifierBytes).trimEnd('=')
|
return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=')
|
||||||
.replace("+", "-").replace("/", "_").replace("\n", "")
|
.replace("+", "-")
|
||||||
|
.replace("/", "_").replace("\n", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,15 +250,14 @@ abstract class AuthAPI {
|
||||||
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
|
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
|
||||||
|
|
||||||
@Throws
|
@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) {
|
fun toRepo(): AuthRepo = when (this) {
|
||||||
is SubtitleAPI -> SubtitleRepo(this)
|
is SubtitleAPI -> SubtitleRepo(this)
|
||||||
is SyncAPI -> SyncRepo(this)
|
is SyncAPI -> SyncRepo(this)
|
||||||
else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
|
else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION_ERROR")
|
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
|
||||||
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
|
|
||||||
fun loginInfo(): LoginInfo? {
|
fun loginInfo(): LoginInfo? {
|
||||||
return this.toRepo().authUser()?.let { user ->
|
return this.toRepo().authUser()?.let { user ->
|
||||||
LoginInfo(
|
LoginInfo(
|
||||||
|
|
@ -247,16 +268,19 @@ 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? {
|
suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow()
|
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(
|
class LoginInfo(
|
||||||
val profilePicture: String? = null,
|
val profilePicture: String? = null,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val accountIndex: Int,
|
val accountIndex: Int,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -9,9 +9,6 @@ import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
|
|
||||||
/** General-purpose repo */
|
|
||||||
class PlainAuthRepo(api: AuthAPI) : AuthRepo(api)
|
|
||||||
|
|
||||||
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
|
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
|
||||||
abstract class AuthRepo(open val api: AuthAPI) {
|
abstract class AuthRepo(open val api: AuthAPI) {
|
||||||
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||||
|
|
||||||
/** Stateless safe abstraction of SubtitleAPI */
|
/** Stateless safe abstraction of SubtitleAPI */
|
||||||
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
|
|
@ -24,30 +24,26 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// maybe make this a generic struct? right now there is a lot of boilerplate
|
// maybe make this a generic struct? right now there is a lot of boilerplate
|
||||||
private val searchCache = atomicListOf<SavedSearchResponse>()
|
private val searchCache = threadSafeListOf<SavedSearchResponse>()
|
||||||
private var searchCacheIndex: Int = 0
|
private var searchCacheIndex: Int = 0
|
||||||
private val resourceCache = atomicListOf<SavedResourceResponse>()
|
private val resourceCache = threadSafeListOf<SavedResourceResponse>()
|
||||||
private var resourceCacheIndex: Int = 0
|
private var resourceCacheIndex: Int = 0
|
||||||
const val CACHE_SIZE = 20
|
const val CACHE_SIZE = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
|
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
|
||||||
val cached = resourceCache.withLock {
|
synchronized(resourceCache) {
|
||||||
var found: SubtitleResource? = null
|
|
||||||
for (item in resourceCache) {
|
for (item in resourceCache) {
|
||||||
// 20 min save
|
// 20 min save
|
||||||
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
||||||
found = item.response
|
return@runCatching item.response
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
found
|
|
||||||
}
|
}
|
||||||
if (cached != null) return@runCatching cached
|
|
||||||
|
|
||||||
val returnValue = api.resource(freshAuth(), data)
|
val returnValue = api.resource(freshAuth(), data)
|
||||||
resourceCache.withLock {
|
synchronized(resourceCache) {
|
||||||
val add = SavedResourceResponse(unixTime, returnValue, data)
|
val add = SavedResourceResponse(unixTime, returnValue, data)
|
||||||
if (resourceCache.size > CACHE_SIZE) {
|
if (resourceCache.size > CACHE_SIZE) {
|
||||||
resourceCache[resourceCacheIndex] = add // rolling cache
|
resourceCache[resourceCacheIndex] = add // rolling cache
|
||||||
|
|
@ -62,25 +58,22 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
|
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val cached = searchCache.withLock {
|
synchronized(searchCache) {
|
||||||
var found: List<SubtitleEntity>? = null
|
|
||||||
for (item in searchCache) {
|
for (item in searchCache) {
|
||||||
// 120 min save
|
// 120 min save
|
||||||
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
||||||
found = item.response
|
return@runCatching item.response
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cached != null) return@runCatching cached
|
val returnValue =
|
||||||
val returnValue = api.search(freshAuth(), query) ?: emptyList()
|
api.search(freshAuth(), query) ?: throw ErrorLoadingException("Null subtitles")
|
||||||
|
|
||||||
// only cache valid return values
|
// only cache valid return values
|
||||||
if (returnValue.isNotEmpty()) {
|
if (returnValue.isNotEmpty()) {
|
||||||
val add = SavedSearchResponse(unixTime, returnValue, query)
|
val add = SavedSearchResponse(unixTime, returnValue, query)
|
||||||
searchCache.withLock {
|
synchronized(searchCache) {
|
||||||
if (searchCache.size > CACHE_SIZE) {
|
if (searchCache.size > CACHE_SIZE) {
|
||||||
searchCache[searchCacheIndex] = add // rolling cache
|
searchCache[searchCacheIndex] = add // rolling cache
|
||||||
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
||||||
|
|
@ -93,3 +86,4 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.utils.Levenshtein
|
|
||||||
import com.lagradost.cloudstream3.utils.UiText
|
import com.lagradost.cloudstream3.utils.UiText
|
||||||
|
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() {
|
||||||
ListSorting.Query ->
|
ListSorting.Query ->
|
||||||
if (query != null) {
|
if (query != null) {
|
||||||
items.sortedBy {
|
items.sortedBy {
|
||||||
-Levenshtein.partialRatio(
|
-FuzzySearch.partialRatio(
|
||||||
query.lowercase(), it.name.lowercase()
|
query.lowercase(), it.name.lowercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.AllLanguagesName
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName
|
|
||||||
|
|
||||||
class Addic7ed : SubtitleAPI() {
|
class Addic7ed : SubtitleAPI() {
|
||||||
override val name = "Addic7ed"
|
override val name = "Addic7ed"
|
||||||
override val idPrefix = "addic7ed"
|
override val idPrefix = "addic7ed"
|
||||||
|
|
||||||
override val requiresLogin = false
|
override val requiresLogin = false
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -19,8 +18,7 @@ class Addic7ed : SubtitleAPI() {
|
||||||
const val TAG = "ADDIC7ED"
|
const val TAG = "ADDIC7ED"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.fixUrl(): String {
|
private fun fixUrl(url: String): String {
|
||||||
val url = this
|
|
||||||
return if (url.startsWith("/")) HOST + url
|
return if (url.startsWith("/")) HOST + url
|
||||||
else if (!url.startsWith("http")) "$HOST/$url"
|
else if (!url.startsWith("http")) "$HOST/$url"
|
||||||
else url
|
else url
|
||||||
|
|
@ -28,178 +26,84 @@ class Addic7ed : SubtitleAPI() {
|
||||||
|
|
||||||
override suspend fun search(
|
override suspend fun search(
|
||||||
auth: AuthData?,
|
auth: AuthData?,
|
||||||
query: SubtitleSearch
|
query: AbstractSubtitleEntities.SubtitleSearch
|
||||||
): List<SubtitleEntity>? {
|
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
val langTagIETF = query.lang ?: AllLanguagesName
|
val lang = query.lang
|
||||||
val langNumAddic7ed =
|
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
|
||||||
langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0
|
val queryText = query.query.trim()
|
||||||
val langName =
|
|
||||||
langTagIETF2Addic7ed[langTagIETF]?.second ?:
|
|
||||||
fromTagToEnglishLanguageName(langTagIETF) ?:
|
|
||||||
"Completed" // this bypasses language filtering
|
|
||||||
val title = query.query.trim()
|
|
||||||
val epNum = query.epNumber ?: 0
|
val epNum = query.epNumber ?: 0
|
||||||
val seasonNum = query.seasonNumber ?: 0
|
val seasonNum = query.seasonNumber ?: 0
|
||||||
val yearNum = query.year ?: 0
|
val yearNum = query.year ?: 0
|
||||||
val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title
|
|
||||||
var downloadPage = ""
|
|
||||||
|
|
||||||
fun newSubtitleEntity (
|
fun cleanResources(
|
||||||
displayName: String?,
|
results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
|
||||||
link: String?,
|
name: String,
|
||||||
|
link: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
isHearingImpaired: Boolean
|
isHearingImpaired: Boolean
|
||||||
): SubtitleEntity? {
|
) {
|
||||||
if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null
|
results.add(
|
||||||
return SubtitleEntity(
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
idPrefix = this.idPrefix,
|
idPrefix = idPrefix,
|
||||||
name = displayName,
|
name = name,
|
||||||
lang = langTagIETF,
|
lang = queryLang.toString(),
|
||||||
data = link,
|
data = link,
|
||||||
source = this.name,
|
source = this.name,
|
||||||
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
||||||
epNumber = epNum,
|
epNumber = epNum,
|
||||||
seasonNumber = seasonNum,
|
seasonNumber = seasonNum,
|
||||||
year = yearNum,
|
year = yearNum,
|
||||||
headers = mapOf("referer" to "$HOST/"),
|
headers = headers,
|
||||||
isHearingImpaired = isHearingImpaired
|
isHearingImpaired = isHearingImpaired
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search")
|
val title = queryText.substringBefore("(").trim()
|
||||||
val hostDocument = response.document
|
val url = "$HOST/search.php?search=${title}&Submit=Search"
|
||||||
|
val hostDocument = app.get(url).document
|
||||||
// 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name
|
var searchResult = ""
|
||||||
if (response.url.contains("/movie/") || response.url.contains("/serie/"))
|
if (hostDocument.select("span:contains($title)").isNotEmpty()) searchResult = url
|
||||||
downloadPage = response.url
|
else if (hostDocument.select("table.tabel")
|
||||||
|
.isNotEmpty()
|
||||||
// 2nd case: found tv series ep list. Redirected to $HOST/show/1234
|
) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
|
||||||
else if (response.url.contains("/show/")) {
|
else {
|
||||||
val showId = response.url.substringAfterLast("/")
|
val show =
|
||||||
|
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
|
||||||
|
?.substringBefore(",")
|
||||||
val doc = app.get(
|
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/"
|
referer = "$HOST/"
|
||||||
).document
|
).document
|
||||||
|
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
|
||||||
// get direct subtitles links from list
|
if (node.selectFirst("td")?.text()
|
||||||
return doc.select("#season tbody tr").mapNotNull { node ->
|
?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
|
||||||
if (node.select("td:eq(1)").text().toIntOrNull() == epNum)
|
.text()
|
||||||
newSubtitleEntity(
|
.toIntOrNull() == epNum
|
||||||
displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(),
|
) searchResult = fixUrl(node.select("a").attr("href"))
|
||||||
link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(),
|
|
||||||
isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty()
|
|
||||||
)
|
|
||||||
else null
|
|
||||||
}
|
}
|
||||||
// 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 :/
|
document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
|
||||||
if (downloadPage.contains("/serie/"))
|
val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
|
||||||
downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed"
|
node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
|
||||||
val doc = app.get(url = downloadPage).document
|
}" 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"))
|
||||||
// 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" +
|
|
||||||
node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration")
|
|
||||||
val link =
|
|
||||||
node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl()
|
|
||||||
val isHearingImpaired =
|
val isHearingImpaired =
|
||||||
node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty()
|
node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty()
|
||||||
|
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
|
||||||
newSubtitleEntity(displayName, link, isHearingImpaired)
|
|
||||||
}
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
auth: AuthData?,
|
auth: AuthData?,
|
||||||
subtitle: SubtitleEntity
|
subtitle: AbstractSubtitleEntities.SubtitleEntity
|
||||||
): String? {
|
): String? {
|
||||||
return subtitle.data
|
return subtitle.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 androidx.annotation.StringRes
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
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.Actor
|
||||||
import com.lagradost.cloudstream3.ActorData
|
import com.lagradost.cloudstream3.ActorData
|
||||||
import com.lagradost.cloudstream3.ActorRole
|
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.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.NextAiring
|
import com.lagradost.cloudstream3.NextAiring
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -37,7 +35,7 @@ class AniListApi : SyncAPI() {
|
||||||
override var name = "AniList"
|
override var name = "AniList"
|
||||||
override val idPrefix = "anilist"
|
override val idPrefix = "anilist"
|
||||||
|
|
||||||
private val key = BuildConfig.ANILIST_KEY
|
val key = "6871"
|
||||||
override val redirectUrlIdentifier = "anilistlogin"
|
override val redirectUrlIdentifier = "anilistlogin"
|
||||||
override var requireLibraryRefresh = true
|
override var requireLibraryRefresh = true
|
||||||
override val hasOAuth2 = true
|
override val hasOAuth2 = true
|
||||||
|
|
@ -52,10 +50,9 @@ class AniListApi : SyncAPI() {
|
||||||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
||||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
val sanitizer = splitRedirectUrl(redirectUrl)
|
||||||
val token = AuthToken(
|
val token = AuthToken(
|
||||||
accessToken = sanitizer["access_token"]
|
accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"),
|
||||||
?: throw ErrorLoadingException("No access token"),
|
|
||||||
//refreshToken = sanitizer["refresh_token"],
|
//refreshToken = sanitizer["refresh_token"],
|
||||||
accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(),
|
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
@ -86,8 +83,8 @@ class AniListApi : SyncAPI() {
|
||||||
return "$mainUrl/anime/$id"
|
return "$mainUrl/anime/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
val data = searchShows(query) ?: return null
|
val data = searchShows(name) ?: return null
|
||||||
return data.data?.page?.media?.map {
|
return data.data?.page?.media?.map {
|
||||||
SyncAPI.SyncSearchResult(
|
SyncAPI.SyncSearchResult(
|
||||||
it.title.romaji ?: return null,
|
it.title.romaji ?: return null,
|
||||||
|
|
@ -99,7 +96,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
|
||||||
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
||||||
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
||||||
val season = getSeason(internalId).data.media
|
val season = getSeason(internalId).data.media
|
||||||
|
|
@ -109,7 +106,7 @@ class AniListApi : SyncAPI() {
|
||||||
nextAiring = season.nextAiringEpisode?.let {
|
nextAiring = season.nextAiringEpisode?.let {
|
||||||
NextAiring(
|
NextAiring(
|
||||||
it.episode ?: return@let null,
|
it.episode ?: return@let null,
|
||||||
(it.timeUntilAiring ?: return@let null) + APIHolder.unixTime
|
(it.timeUntilAiring ?: return@let null) + unixTime
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = season.title?.userPreferred,
|
title = season.title?.userPreferred,
|
||||||
|
|
@ -161,7 +158,7 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
||||||
|
|
||||||
|
|
@ -462,7 +459,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? {
|
private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? {
|
||||||
val q =
|
val q =
|
||||||
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
||||||
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
||||||
|
|
@ -509,7 +506,7 @@ class AniListApi : SyncAPI() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
|
private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? {
|
||||||
return app.post(
|
return app.post(
|
||||||
"https://graphql.anilist.co/",
|
"https://graphql.anilist.co/",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
|
|
@ -641,7 +638,7 @@ class AniListApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
|
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
|
||||||
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
||||||
convertAniListStringToStatus(it.status ?: "").stringRes
|
convertAniListStringToStatus(it.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
|
|
@ -669,7 +666,7 @@ class AniListApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
|
private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? {
|
||||||
val userID = auth.user.id
|
val userID = auth.user.id
|
||||||
val mediaType = "ANIME"
|
val mediaType = "ANIME"
|
||||||
|
|
||||||
|
|
@ -717,7 +714,7 @@ class AniListApi : SyncAPI() {
|
||||||
return text?.toKotlinObject()
|
return text?.toKotlinObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
|
suspend fun toggleLike(auth : AuthData, id: Int): Boolean {
|
||||||
val q = """mutation (${'$'}animeId: Int = $id) {
|
val q = """mutation (${'$'}animeId: Int = $id) {
|
||||||
ToggleFavourite (animeId: ${'$'}animeId) {
|
ToggleFavourite (animeId: ${'$'}animeId) {
|
||||||
anime {
|
anime {
|
||||||
|
|
@ -740,7 +737,7 @@ class AniListApi : SyncAPI() {
|
||||||
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||||
|
|
||||||
private suspend fun postDataAboutId(
|
private suspend fun postDataAboutId(
|
||||||
auth: AuthData,
|
auth : AuthData,
|
||||||
id: Int,
|
id: Int,
|
||||||
type: AniListStatusType,
|
type: AniListStatusType,
|
||||||
score: Score?,
|
score: Score?,
|
||||||
|
|
@ -789,7 +786,7 @@ class AniListApi : SyncAPI() {
|
||||||
return data != ""
|
return data != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getUser(token: AuthToken): AniListUser? {
|
private suspend fun getUser(token : AuthToken): AniListUser? {
|
||||||
val q = """
|
val q = """
|
||||||
{
|
{
|
||||||
Viewer {
|
Viewer {
|
||||||
|
|
|
||||||
|
|
@ -1,677 +1,8 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.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.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
const val KITSU_MAX_SEARCH_LIMIT = 20
|
|
||||||
|
|
||||||
class KitsuApi: SyncAPI() {
|
|
||||||
override var name = "Kitsu"
|
|
||||||
override val idPrefix = "kitsu"
|
|
||||||
|
|
||||||
private val apiUrl = "https://kitsu.io/api/edge"
|
|
||||||
private val fallbackApiUrl = "https://kitsu.app/api/edge"
|
|
||||||
private val oauthUrl = "https://kitsu.io/api/oauth"
|
|
||||||
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
|
|
||||||
override val hasInApp = true
|
|
||||||
override val mainUrl = "https://kitsu.app"
|
|
||||||
override val icon = R.drawable.kitsu_icon
|
|
||||||
override val syncIdName = SyncIdName.Kitsu
|
|
||||||
override val createAccountUrl = mainUrl
|
|
||||||
|
|
||||||
override val supportedWatchTypes = setOf(
|
|
||||||
SyncWatchType.WATCHING,
|
|
||||||
SyncWatchType.COMPLETED,
|
|
||||||
SyncWatchType.PLANTOWATCH,
|
|
||||||
SyncWatchType.DROPPED,
|
|
||||||
SyncWatchType.ONHOLD,
|
|
||||||
SyncWatchType.NONE
|
|
||||||
)
|
|
||||||
|
|
||||||
override val inAppLoginRequirement = AuthLoginRequirement(
|
|
||||||
password = true,
|
|
||||||
email = true
|
|
||||||
)
|
|
||||||
|
|
||||||
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request: Request = chain.request()
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
val response = chain.proceed(request);
|
|
||||||
|
|
||||||
if (response.isSuccessful) return response
|
|
||||||
|
|
||||||
response.close()
|
|
||||||
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
|
|
||||||
val fallbackRequest: Request = request.newBuilder()
|
|
||||||
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return chain.proceed(fallbackRequest)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
|
|
||||||
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
|
|
||||||
|
|
||||||
override suspend fun login(form: AuthLoginResponse): AuthToken? {
|
|
||||||
val username = form.email ?: return null
|
|
||||||
val password = form.password ?: return null
|
|
||||||
|
|
||||||
val grantType = "password"
|
|
||||||
|
|
||||||
val token = app.post(
|
|
||||||
"$oauthUrl/token",
|
|
||||||
data = mapOf(
|
|
||||||
"grant_type" to grantType,
|
|
||||||
"username" to username,
|
|
||||||
"password" to password
|
|
||||||
),
|
|
||||||
interceptor = oauthFallbackInterceptor
|
|
||||||
).parsed<ResponseToken>()
|
|
||||||
|
|
||||||
return AuthToken(
|
|
||||||
accessTokenLifetime = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
data class KitsuResponse(
|
|
||||||
@field:JsonProperty(value = "data")
|
|
||||||
val data: KitsuNode,
|
|
||||||
)
|
|
||||||
|
|
||||||
val url =
|
|
||||||
"$apiUrl/anime/$id"
|
|
||||||
|
|
||||||
val anime = app.get(
|
|
||||||
url, headers = mapOf(
|
|
||||||
"Authorization" to "Bearer $auth"
|
|
||||||
),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
).parsed<KitsuResponse>().data.attributes
|
|
||||||
|
|
||||||
return SyncResult(
|
|
||||||
id = id,
|
|
||||||
totalEpisodes = anime.episodeCount,
|
|
||||||
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
|
|
||||||
publicScore = Score.from(anime.ratingTwenty, 20),
|
|
||||||
duration = anime.episodeLength,
|
|
||||||
synopsis = anime.synopsis,
|
|
||||||
airStatus = when(anime.status) {
|
|
||||||
"finished" -> ShowStatus.Completed
|
|
||||||
"current" -> ShowStatus.Ongoing
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
nextAiring = null,
|
|
||||||
studio = null,
|
|
||||||
genres = null,
|
|
||||||
trailers = null,
|
|
||||||
startDate = LocalDate.parse(anime.startDate).toEpochDay(),
|
|
||||||
endDate = LocalDate.parse(anime.endDate).toEpochDay(),
|
|
||||||
recommendations = null,
|
|
||||||
nextSeason =null,
|
|
||||||
prevSeason = null,
|
|
||||||
actors = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? {
|
|
||||||
val accessToken = auth?.token?.accessToken ?: return null
|
|
||||||
val userId = auth.user.id
|
|
||||||
|
|
||||||
val selectedFields = arrayOf("status","ratingTwenty", "progress")
|
|
||||||
|
|
||||||
val url =
|
|
||||||
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}"
|
|
||||||
|
|
||||||
val anime = app.get(
|
|
||||||
url, headers = mapOf(
|
|
||||||
"Authorization" to "Bearer $accessToken"
|
|
||||||
),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
|
|
||||||
|
|
||||||
if (anime == null) {
|
|
||||||
return SyncStatus(
|
|
||||||
score = null,
|
|
||||||
status = SyncWatchType.NONE,
|
|
||||||
isFavorite = null,
|
|
||||||
watchedEpisodes = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SyncStatus(
|
|
||||||
score = Score.from(anime.ratingTwenty, 20),
|
|
||||||
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
|
|
||||||
isFavorite = null,
|
|
||||||
watchedEpisodes = anime.progress,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
suspend fun getAnimeIdByTitle(title: String): String? {
|
|
||||||
|
|
||||||
val animeSelectedFields = arrayOf("titles","canonicalTitle")
|
|
||||||
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
|
|
||||||
|
|
||||||
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
|
|
||||||
|
|
||||||
return res.data.firstOrNull()?.id
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun urlToId(url: String): String? =
|
|
||||||
Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first()
|
|
||||||
|
|
||||||
override suspend fun updateStatus(
|
|
||||||
auth : AuthData?,
|
|
||||||
id: String,
|
|
||||||
newStatus: AbstractSyncStatus
|
|
||||||
): Boolean {
|
|
||||||
|
|
||||||
return setScoreRequest(
|
|
||||||
auth ?: return false,
|
|
||||||
id.toIntOrNull() ?: return false,
|
|
||||||
fromIntToAnimeStatus(newStatus.status),
|
|
||||||
newStatus.score?.toInt(20),
|
|
||||||
newStatus.watchedEpisodes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun setScoreRequest(
|
|
||||||
auth : AuthData,
|
|
||||||
id: Int,
|
|
||||||
status: KitsuStatusType? = null,
|
|
||||||
score: Int? = null,
|
|
||||||
numWatchedEpisodes: Int? = null,
|
|
||||||
): Boolean {
|
|
||||||
|
|
||||||
val libraryEntryId = getAnimeLibraryEntryId(auth, id)
|
|
||||||
|
|
||||||
// Exists entry for anime in library
|
|
||||||
if (libraryEntryId != null) {
|
|
||||||
|
|
||||||
// Delete anime from library
|
|
||||||
if (status == null || status == KitsuStatusType.None) {
|
|
||||||
|
|
||||||
val res = app.delete(
|
|
||||||
"$apiUrl/library-entries/$libraryEntryId",
|
|
||||||
headers = mapOf(
|
|
||||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
|
||||||
),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
return res.isSuccessful
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return setScoreRequest(
|
|
||||||
auth,
|
|
||||||
libraryEntryId,
|
|
||||||
kitsuStatusAsString[maxOf(0, status.value)],
|
|
||||||
score,
|
|
||||||
numWatchedEpisodes
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
val data = mapOf(
|
|
||||||
"data" to mapOf(
|
|
||||||
"type" to "libraryEntries",
|
|
||||||
"attributes" to mapOf(
|
|
||||||
"ratingTwenty" to score,
|
|
||||||
"progress" to numWatchedEpisodes,
|
|
||||||
"status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)],
|
|
||||||
),
|
|
||||||
"relationships" to mapOf(
|
|
||||||
"anime" to mapOf(
|
|
||||||
"data" to mapOf(
|
|
||||||
"type" to "anime",
|
|
||||||
"id" to id.toString()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"user" to mapOf(
|
|
||||||
"data" to mapOf(
|
|
||||||
"type" to "users",
|
|
||||||
"id" to auth.user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val res = app.post(
|
|
||||||
"$apiUrl/library-entries",
|
|
||||||
headers = mapOf(
|
|
||||||
"content-type" to "application/vnd.api+json",
|
|
||||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
|
||||||
),
|
|
||||||
requestBody = data.toJson().toRequestBody(),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
)
|
|
||||||
|
|
||||||
return res.isSuccessful
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private suspend fun setScoreRequest(
|
|
||||||
auth : AuthData,
|
|
||||||
id: Int,
|
|
||||||
status: String? = null,
|
|
||||||
score: Int? = null,
|
|
||||||
numWatchedEpisodes: Int? = null,
|
|
||||||
): Boolean {
|
|
||||||
val data = mapOf(
|
|
||||||
"data" to mapOf(
|
|
||||||
"type" to "libraryEntries",
|
|
||||||
"id" to id.toString(),
|
|
||||||
"attributes" to mapOf(
|
|
||||||
"ratingTwenty" to score,
|
|
||||||
"progress" to numWatchedEpisodes,
|
|
||||||
"status" to status
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val res = app.patch(
|
|
||||||
"$apiUrl/library-entries/$id",
|
|
||||||
headers = mapOf(
|
|
||||||
"content-type" to "application/vnd.api+json",
|
|
||||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
|
||||||
),
|
|
||||||
requestBody = data.toJson().toRequestBody(),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
return res.isSuccessful
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? {
|
|
||||||
|
|
||||||
val userId = auth.user.id
|
|
||||||
|
|
||||||
val res = app.get(
|
|
||||||
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id",
|
|
||||||
headers = mapOf(
|
|
||||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
|
||||||
),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
|
|
||||||
|
|
||||||
return res.id.toInt()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun library(auth : AuthData?): LibraryMetadata? {
|
|
||||||
val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy {
|
|
||||||
convertToStatus(it.attributes.status ?: "").stringRes
|
|
||||||
}?.mapValues { group ->
|
|
||||||
group.value.map { it.toLibraryItem() }
|
|
||||||
} ?: emptyMap()
|
|
||||||
|
|
||||||
// To fill empty lists when Kitsu does not return them
|
|
||||||
val baseMap =
|
|
||||||
KitsuStatusType.entries.filter { it.value >= 0 }.associate {
|
|
||||||
it.stringRes to emptyList<LibraryItem>()
|
|
||||||
}
|
|
||||||
|
|
||||||
return LibraryMetadata(
|
|
||||||
(baseMap + list).map { LibraryList(txt(it.key), it.value) },
|
|
||||||
setOf(
|
|
||||||
ListSorting.AlphabeticalA,
|
|
||||||
ListSorting.AlphabeticalZ,
|
|
||||||
ListSorting.UpdatedNew,
|
|
||||||
ListSorting.UpdatedOld,
|
|
||||||
ListSorting.ReleaseDateNew,
|
|
||||||
ListSorting.ReleaseDateOld,
|
|
||||||
ListSorting.RatingHigh,
|
|
||||||
ListSorting.RatingLow,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array<KitsuNode>? {
|
|
||||||
return if (requireLibraryRefresh) {
|
|
||||||
val list = getKitsuAnimeList(auth.token, auth.user.id)
|
|
||||||
setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
|
|
||||||
list
|
|
||||||
} else {
|
|
||||||
getKey<Array<KitsuNode>>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array<KitsuNode>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
|
|
||||||
|
|
||||||
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
|
|
||||||
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
|
|
||||||
val limit = 500
|
|
||||||
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
|
|
||||||
|
|
||||||
val fullList = mutableListOf<KitsuNode>()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
|
|
||||||
val data: KitsuResponse = getKitsuAnimeListSlice(token, url)
|
|
||||||
|
|
||||||
data.data.forEachIndexed { index, value ->
|
|
||||||
value.anime = data.included?.get(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
fullList.addAll(data.data)
|
|
||||||
|
|
||||||
url = data.links?.next ?: break
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return fullList.toTypedArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse {
|
|
||||||
val res = app.get(
|
|
||||||
url, headers = mapOf(
|
|
||||||
"Authorization" to "Bearer ${token.accessToken}",
|
|
||||||
),
|
|
||||||
interceptor = apiFallbackInterceptor
|
|
||||||
).parsed<KitsuResponse>()
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
data class ResponseToken(
|
|
||||||
@JsonProperty("token_type") val tokenType: String,
|
|
||||||
@JsonProperty("expires_in") val expiresIn: Int,
|
|
||||||
@JsonProperty("access_token") val accessToken: String,
|
|
||||||
@JsonProperty("refresh_token") val refreshToken: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuNode(
|
|
||||||
@JsonProperty("id") val id: String,
|
|
||||||
@JsonProperty("attributes") val attributes: KitsuNodeAttributes,
|
|
||||||
/* User list anime node */
|
|
||||||
@JsonProperty("relationships") val relationships: KitsuRelationships?,
|
|
||||||
var anime: KitsuAnimeData?
|
|
||||||
) {
|
|
||||||
fun toLibraryItem(): LibraryItem {
|
|
||||||
|
|
||||||
val animeItem = this.anime
|
|
||||||
|
|
||||||
val numEpisodes = animeItem?.attributes?.episodeCount
|
|
||||||
|
|
||||||
val startDate = animeItem?.attributes?.startDate
|
|
||||||
|
|
||||||
val posterImage = animeItem?.attributes?.posterImage
|
|
||||||
|
|
||||||
val canonicalTitle = animeItem?.attributes?.canonicalTitle
|
|
||||||
val titles = animeItem?.attributes?.titles
|
|
||||||
|
|
||||||
val animeId = animeItem?.id
|
|
||||||
|
|
||||||
val synopsis: String? = animeItem?.attributes?.synopsis
|
|
||||||
|
|
||||||
return LibraryItem(
|
|
||||||
canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(),
|
|
||||||
"https://kitsu.app/anime/${animeId}/",
|
|
||||||
this.id,
|
|
||||||
this.attributes.progress,
|
|
||||||
numEpisodes,
|
|
||||||
Score.from(this.attributes.ratingTwenty, 20),
|
|
||||||
parseDateLong(this.attributes.updatedAt),
|
|
||||||
"Kitsu",
|
|
||||||
TvType.Anime,
|
|
||||||
posterImage?.large ?: posterImage?.medium,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
plot = synopsis,
|
|
||||||
releaseDate = if (startDate == null) null else try {
|
|
||||||
Date.from(LocalDate.parse(startDate).atStartOfDay()
|
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
.toInstant())
|
|
||||||
} catch (_: RuntimeException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
data class KitsuAnimeAttributes(
|
|
||||||
@JsonProperty("titles") val titles: KitsuTitles?,
|
|
||||||
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
|
|
||||||
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
|
|
||||||
@JsonProperty("synopsis") val synopsis: String?,
|
|
||||||
@JsonProperty("startDate") val startDate: String?,
|
|
||||||
@JsonProperty("endDate") val endDate: String?,
|
|
||||||
@JsonProperty("episodeCount") val episodeCount: Int?,
|
|
||||||
@JsonProperty("episodeLength") val episodeLength: Int?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuAnimeData(
|
|
||||||
@JsonProperty("id") val id: String,
|
|
||||||
@JsonProperty("attributes") val attributes: KitsuAnimeAttributes,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
data class KitsuNodeAttributes(
|
|
||||||
/* General attributes */
|
|
||||||
@JsonProperty("titles") val titles: KitsuTitles?,
|
|
||||||
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
|
|
||||||
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
|
|
||||||
@JsonProperty("synopsis") val synopsis: String?,
|
|
||||||
@JsonProperty("startDate") val startDate: String?,
|
|
||||||
@JsonProperty("endDate") val endDate: String?,
|
|
||||||
@JsonProperty("episodeCount") val episodeCount: Int?,
|
|
||||||
@JsonProperty("episodeLength") val episodeLength: Int?,
|
|
||||||
/* User attributes */
|
|
||||||
@JsonProperty("name") val name: String?,
|
|
||||||
@JsonProperty("location") val location: String?,
|
|
||||||
@JsonProperty("createdAt") val createdAt: String?,
|
|
||||||
@JsonProperty("avatar") val avatar: KitsuUserAvatar?,
|
|
||||||
/* User list anime attributes */
|
|
||||||
@JsonProperty("progress") val progress: Int?,
|
|
||||||
@JsonProperty("ratingTwenty") val ratingTwenty: Int?,
|
|
||||||
@JsonProperty("updatedAt") val updatedAt: String?,
|
|
||||||
@JsonProperty("status") val status: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuRelationships(
|
|
||||||
@JsonProperty("anime") val anime: KitsuRelationshipsAnime?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuRelationshipsAnime(
|
|
||||||
@JsonProperty("links") val links: KitsuLinks?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuPosterImage(
|
|
||||||
@JsonProperty("large") val large: String?,
|
|
||||||
@JsonProperty("medium") val medium: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuTitles(
|
|
||||||
@JsonProperty("en_jp") val enJp: String?,
|
|
||||||
@JsonProperty("ja_jp") val jaJp: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuUserAvatar(
|
|
||||||
@JsonProperty("original") val original: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuLinks(
|
|
||||||
/* Pagination */
|
|
||||||
@JsonProperty("first") val first: String?,
|
|
||||||
@JsonProperty("next") val next: String?,
|
|
||||||
@JsonProperty("last") val last: String?,
|
|
||||||
/* Relationships */
|
|
||||||
@JsonProperty("related") val related: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class KitsuResponse(
|
|
||||||
@JsonProperty("links") val links: KitsuLinks?,
|
|
||||||
@JsonProperty("data") val data: List<KitsuNode>,
|
|
||||||
/* When requesting related info (User library entry -> anime) */
|
|
||||||
@JsonProperty("included") val included: List<KitsuAnimeData>?,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
|
|
||||||
private fun parseDateLong(string: String?): Long? {
|
|
||||||
return try {
|
|
||||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
|
|
||||||
string ?: return null
|
|
||||||
)?.time?.div(1000)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val kitsuStatusAsString =
|
|
||||||
arrayOf("current", "completed", "on_hold", "dropped", "planned")
|
|
||||||
private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType {
|
|
||||||
return when (inp) {
|
|
||||||
SyncWatchType.NONE -> KitsuStatusType.None
|
|
||||||
SyncWatchType.WATCHING -> KitsuStatusType.Watching
|
|
||||||
SyncWatchType.COMPLETED -> KitsuStatusType.Completed
|
|
||||||
SyncWatchType.ONHOLD -> KitsuStatusType.OnHold
|
|
||||||
SyncWatchType.DROPPED -> KitsuStatusType.Dropped
|
|
||||||
SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch
|
|
||||||
SyncWatchType.REWATCHING -> KitsuStatusType.Watching
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) {
|
|
||||||
Watching(0, R.string.type_watching),
|
|
||||||
Completed(1, R.string.type_completed),
|
|
||||||
OnHold(2, R.string.type_on_hold),
|
|
||||||
Dropped(3, R.string.type_dropped),
|
|
||||||
PlanToWatch(4, R.string.type_plan_to_watch),
|
|
||||||
None(-1, R.string.type_none)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun convertToStatus(string: String): KitsuStatusType {
|
|
||||||
return when (string) {
|
|
||||||
"current" -> KitsuStatusType.Watching
|
|
||||||
"completed" -> KitsuStatusType.Completed
|
|
||||||
"on_hold" -> KitsuStatusType.OnHold
|
|
||||||
"dropped" -> KitsuStatusType.Dropped
|
|
||||||
"planned" -> KitsuStatusType.PlanToWatch
|
|
||||||
else -> KitsuStatusType.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
|
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
|
||||||
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.BuildConfig
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.Score
|
import com.lagradost.cloudstream3.Score
|
||||||
import com.lagradost.cloudstream3.ShowStatus
|
import com.lagradost.cloudstream3.ShowStatus
|
||||||
|
|
@ -36,7 +34,7 @@ class MALApi : SyncAPI() {
|
||||||
override var name = "MAL"
|
override var name = "MAL"
|
||||||
override val idPrefix = "mal"
|
override val idPrefix = "mal"
|
||||||
|
|
||||||
private val key = BuildConfig.MAL_KEY
|
val key = "1714d6f2f4f7cc19644384f8c4629910"
|
||||||
private val apiUrl = "https://api.myanimelist.net"
|
private val apiUrl = "https://api.myanimelist.net"
|
||||||
override val hasOAuth2 = true
|
override val hasOAuth2 = true
|
||||||
override val redirectUrlIdentifier: String? = "mallogin"
|
override val redirectUrlIdentifier: String? = "mallogin"
|
||||||
|
|
@ -80,7 +78,7 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
).parsed<ResponseToken>()
|
).parsed<ResponseToken>()
|
||||||
return AuthToken(
|
return AuthToken(
|
||||||
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
|
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
|
||||||
refreshToken = token.refreshToken,
|
refreshToken = token.refreshToken,
|
||||||
accessToken = token.accessToken
|
accessToken = token.accessToken
|
||||||
)
|
)
|
||||||
|
|
@ -100,9 +98,9 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT"
|
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||||
val res = app.get(
|
val res = app.get(
|
||||||
url, headers = mapOf(
|
url, headers = mapOf(
|
||||||
"Authorization" to "Bearer $auth",
|
"Authorization" to "Bearer $auth",
|
||||||
|
|
@ -124,7 +122,7 @@ class MALApi : SyncAPI() {
|
||||||
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||||
|
|
||||||
override suspend fun updateStatus(
|
override suspend fun updateStatus(
|
||||||
auth: AuthData?,
|
auth : AuthData?,
|
||||||
id: String,
|
id: String,
|
||||||
newStatus: SyncAPI.AbstractSyncStatus
|
newStatus: SyncAPI.AbstractSyncStatus
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
|
@ -227,7 +225,7 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
val url =
|
val url =
|
||||||
|
|
@ -273,7 +271,7 @@ class MALApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
val auth = auth?.token?.accessToken ?: return null
|
val auth = auth?.token?.accessToken ?: return null
|
||||||
|
|
||||||
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
|
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
|
||||||
|
|
@ -368,7 +366,7 @@ class MALApi : SyncAPI() {
|
||||||
return AuthToken(
|
return AuthToken(
|
||||||
accessToken = res.accessToken,
|
accessToken = res.accessToken,
|
||||||
refreshToken = res.refreshToken,
|
refreshToken = res.refreshToken,
|
||||||
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong()
|
accessTokenLifetime = unixTime + res.expiresIn.toLong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,7 +477,7 @@ class MALApi : SyncAPI() {
|
||||||
@JsonProperty("start_time") val startTime: String?
|
@JsonProperty("start_time") val startTime: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun library(auth: AuthData?): LibraryMetadata? {
|
override suspend fun library(auth : AuthData?): LibraryMetadata? {
|
||||||
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
||||||
convertToStatus(it.listStatus?.status ?: "").stringRes
|
convertToStatus(it.listStatus?.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
|
|
@ -507,7 +505,7 @@ class MALApi : SyncAPI() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
|
private suspend fun getMalAnimeListSmart(auth : AuthData): Array<Data>? {
|
||||||
return if (requireLibraryRefresh) {
|
return if (requireLibraryRefresh) {
|
||||||
val list = getMalAnimeList(auth.token)
|
val list = getMalAnimeList(auth.token)
|
||||||
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
|
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
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.ErrorLoadingException
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||||
|
|
@ -14,12 +13,9 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils
|
import com.lagradost.cloudstream3.utils.AppUtils
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
|
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
|
|
||||||
|
|
||||||
class OpenSubtitlesApi : SubtitleAPI() {
|
class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
override val name = "OpenSubtitles"
|
override val name = "OpenSubtitles"
|
||||||
|
|
@ -45,17 +41,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canDoRequest(): Boolean {
|
private fun canDoRequest(): Boolean {
|
||||||
return unixTimeMS > currentCoolDown
|
return unixTimeMs > currentCoolDown
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throwIfCantDoRequest() {
|
private fun throwIfCantDoRequest() {
|
||||||
if (!canDoRequest()) {
|
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() {
|
private fun throwGotTooManyRequests() {
|
||||||
currentCoolDown = unixTimeMS + COOLDOWN_DURATION
|
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
|
||||||
throw ErrorLoadingException("Too many requests")
|
throw ErrorLoadingException("Too many requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,11 +87,29 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
accessToken = response.token
|
accessToken = response.token
|
||||||
?: throw ErrorLoadingException("Invalid password or username"),
|
?: throw ErrorLoadingException("Invalid password or username"),
|
||||||
/// JWT token is valid 24 hours after successfully authentication of user
|
/// 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()
|
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).
|
* Fetch subtitles using token authenticated on previous method (see authorize).
|
||||||
* Returns list of Subtitles which user can select to download (see load).
|
* Returns list of Subtitles which user can select to download (see load).
|
||||||
|
|
@ -105,7 +119,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
query: AbstractSubtitleEntities.SubtitleSearch
|
query: AbstractSubtitleEntities.SubtitleSearch
|
||||||
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
throwIfCantDoRequest()
|
throwIfCantDoRequest()
|
||||||
val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: ""
|
val fixedLang = fixLanguage(query.lang)
|
||||||
|
|
||||||
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
|
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
|
||||||
val queryText = query.query
|
val queryText = query.query
|
||||||
|
|
@ -118,8 +132,8 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
|
|
||||||
val searchQueryUrl = when (imdbId > 0) {
|
val searchQueryUrl = when (imdbId > 0) {
|
||||||
//Use imdb_id to search if its valid
|
//Use imdb_id to search if its valid
|
||||||
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
|
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||||
false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery"
|
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||||
}
|
}
|
||||||
|
|
||||||
val req = app.get(
|
val req = app.get(
|
||||||
|
|
@ -128,7 +142,6 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
Pair("Content-Type", "application/json")
|
Pair("Content-Type", "application/json")
|
||||||
) + headers,
|
) + headers,
|
||||||
)
|
)
|
||||||
Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
|
|
||||||
Log.i(TAG, "Search Req => ${req.text}")
|
Log.i(TAG, "Search Req => ${req.text}")
|
||||||
if (!req.isSuccessful) {
|
if (!req.isSuccessful) {
|
||||||
if (req.code == 429)
|
if (req.code == 429)
|
||||||
|
|
@ -149,7 +162,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
//Use any valid name/title in hierarchy
|
//Use any valid name/title in hierarchy
|
||||||
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
||||||
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
||||||
val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: ""
|
val lang = fixLanguageReverse(attr.language) ?: ""
|
||||||
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
||||||
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
||||||
val year = featureDetails?.year ?: query.year
|
val year = featureDetails?.year ?: query.year
|
||||||
|
|
@ -163,7 +176,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
|
||||||
AbstractSubtitleEntities.SubtitleEntity(
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
idPrefix = this.idPrefix,
|
idPrefix = this.idPrefix,
|
||||||
name = name,
|
name = name,
|
||||||
lang = langTagIETF,
|
lang = lang,
|
||||||
data = resultData,
|
data = resultData,
|
||||||
type = type,
|
type = type,
|
||||||
source = this.name,
|
source = this.name,
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,19 @@ import androidx.annotation.StringRes
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.BuildConfig
|
import com.lagradost.cloudstream3.BuildConfig
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
|
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.Score
|
import com.lagradost.cloudstream3.Score
|
||||||
import com.lagradost.cloudstream3.SimklSyncServices
|
import com.lagradost.cloudstream3.SimklSyncServices
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.mapper
|
||||||
import com.lagradost.cloudstream3.mvvm.debugPrint
|
import com.lagradost.cloudstream3.mvvm.debugPrint
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
||||||
|
|
@ -30,7 +30,6 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
@ -78,15 +77,15 @@ class SimklApi : SyncAPI() {
|
||||||
private class SimklCacheWrapper<T>(
|
private class SimklCacheWrapper<T>(
|
||||||
@JsonProperty("obj") val obj: T?,
|
@JsonProperty("obj") val obj: T?,
|
||||||
@JsonProperty("validUntil") val validUntil: Long,
|
@JsonProperty("validUntil") val validUntil: Long,
|
||||||
@JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime,
|
@JsonProperty("cacheTime") val cacheTime: Long = unixTime,
|
||||||
) {
|
) {
|
||||||
/** Returns true if cache is newer than cacheDays */
|
/** Returns true if cache is newer than cacheDays */
|
||||||
fun isFresh(): Boolean {
|
fun isFresh(): Boolean {
|
||||||
return validUntil > APIHolder.unixTime
|
return validUntil > unixTime
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remainingTime(): Duration {
|
fun remainingTime(): Duration {
|
||||||
val unixTime = APIHolder.unixTime
|
val unixTime = unixTime
|
||||||
return if (validUntil > unixTime) {
|
return if (validUntil > unixTime) {
|
||||||
(validUntil - unixTime).toDuration(DurationUnit.SECONDS)
|
(validUntil - unixTime).toDuration(DurationUnit.SECONDS)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -97,7 +96,7 @@ class SimklApi : SyncAPI() {
|
||||||
|
|
||||||
fun cleanOldCache() {
|
fun cleanOldCache() {
|
||||||
getKeys(SIMKL_CACHE_KEY)?.forEach {
|
getKeys(SIMKL_CACHE_KEY)?.forEach {
|
||||||
val isOld = CloudStreamApp.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
|
val isOld = AcraApplication.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
|
||||||
if (isOld) {
|
if (isOld) {
|
||||||
removeKey(it)
|
removeKey(it)
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +109,7 @@ class SimklApi : SyncAPI() {
|
||||||
SIMKL_CACHE_KEY,
|
SIMKL_CACHE_KEY,
|
||||||
path,
|
path,
|
||||||
// Storing as plain sting is required to make generics work.
|
// Storing as plain sting is required to make generics work.
|
||||||
SimklCacheWrapper(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson()
|
SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,8 +117,13 @@ class SimklApi : SyncAPI() {
|
||||||
* Gets cached object, if object is not fresh returns null and removes it from cache
|
* Gets cached object, if object is not fresh returns null and removes it from cache
|
||||||
*/
|
*/
|
||||||
inline fun <reified T : Any> getKey(path: String): T? {
|
inline fun <reified T : Any> getKey(path: String): T? {
|
||||||
|
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
|
||||||
|
val type = mapper.typeFactory.constructParametricType(
|
||||||
|
SimklCacheWrapper::class.java,
|
||||||
|
T::class.java
|
||||||
|
)
|
||||||
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
|
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
|
||||||
tryParseJson<SimklCacheWrapper<T>>(it)
|
mapper.readValue<SimklCacheWrapper<T>>(it, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (cache?.isFresh() == true) {
|
return if (cache?.isFresh() == true) {
|
||||||
|
|
@ -419,7 +423,7 @@ class SimklApi : SyncAPI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun execute(): Boolean {
|
suspend fun execute(): Boolean {
|
||||||
val time = getDateTime(APIHolder.unixTime)
|
val time = getDateTime(unixTime)
|
||||||
val headers = this.headers ?: emptyMap()
|
val headers = this.headers ?: emptyMap()
|
||||||
return if (this.status == SimklListStatusType.None.value) {
|
return if (this.status == SimklListStatusType.None.value) {
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -569,7 +573,7 @@ class SimklApi : SyncAPI() {
|
||||||
@JsonProperty("year") year: Int?,
|
@JsonProperty("year") year: Int?,
|
||||||
@JsonProperty("ids") ids: Ids?,
|
@JsonProperty("ids") ids: Ids?,
|
||||||
@JsonProperty("rating") val rating: Int,
|
@JsonProperty("rating") val rating: Int,
|
||||||
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime)
|
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime)
|
||||||
) : MediaObject(title, year, ids)
|
) : MediaObject(title, year, ids)
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
|
@ -578,7 +582,7 @@ class SimklApi : SyncAPI() {
|
||||||
@JsonProperty("year") year: Int?,
|
@JsonProperty("year") year: Int?,
|
||||||
@JsonProperty("ids") ids: Ids?,
|
@JsonProperty("ids") ids: Ids?,
|
||||||
@JsonProperty("to") val to: String,
|
@JsonProperty("to") val to: String,
|
||||||
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime)
|
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime)
|
||||||
) : MediaObject(title, year, ids)
|
) : MediaObject(title, year, ids)
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
|
@ -863,7 +867,7 @@ class SimklApi : SyncAPI() {
|
||||||
newStatus: AbstractSyncStatus
|
newStatus: AbstractSyncStatus
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val parsedId = readIdFromString(id)
|
val parsedId = readIdFromString(id)
|
||||||
lastScoreTime = APIHolder.unixTime
|
lastScoreTime = unixTime
|
||||||
val simklStatus = newStatus as? SimklSyncStatus
|
val simklStatus = newStatus as? SimklSyncStatus
|
||||||
|
|
||||||
val builder = SimklScoreBuilder.Builder()
|
val builder = SimklScoreBuilder.Builder()
|
||||||
|
|
@ -912,7 +916,7 @@ class SimklApi : SyncAPI() {
|
||||||
|
|
||||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
return app.get(
|
return app.get(
|
||||||
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query)
|
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
|
||||||
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
|
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class SubSourceApi : SubtitleAPI() {
|
||||||
|
|
||||||
//Only supports Imdb Id search for now
|
//Only supports Imdb Id search for now
|
||||||
if (query.imdbId == null) return null
|
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 type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
|
|
||||||
val searchRes = app.post(
|
val searchRes = app.post(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.R
|
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.AbstractSubtitleEntities
|
||||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||||
|
|
@ -11,9 +12,6 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
import com.lagradost.cloudstream3.syncproviders.AuthToken
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||||
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
class SubDlApi : SubtitleAPI() {
|
class SubDlApi : SubtitleAPI() {
|
||||||
override val name = "SubDL"
|
override val name = "SubDL"
|
||||||
|
|
@ -26,7 +24,7 @@ class SubDlApi : SubtitleAPI() {
|
||||||
override val createAccountUrl = "https://subdl.com/panel/register"
|
override val createAccountUrl = "https://subdl.com/panel/register"
|
||||||
|
|
||||||
companion object {
|
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 APIENDPOINT = "$APIURL/api/v1/subtitles"
|
||||||
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
|
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +65,6 @@ class SubDlApi : SubtitleAPI() {
|
||||||
val epNum = query.epNumber ?: 0
|
val epNum = query.epNumber ?: 0
|
||||||
val seasonNum = query.seasonNumber ?: 0
|
val seasonNum = query.seasonNumber ?: 0
|
||||||
val yearNum = query.year ?: 0
|
val yearNum = query.year ?: 0
|
||||||
val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang
|
|
||||||
|
|
||||||
val idQuery = when {
|
val idQuery = when {
|
||||||
query.imdbId != null -> "&imdb_id=${query.imdbId}"
|
query.imdbId != null -> "&imdb_id=${query.imdbId}"
|
||||||
|
|
@ -81,8 +78,8 @@ class SubDlApi : SubtitleAPI() {
|
||||||
|
|
||||||
val searchQueryUrl = when (idQuery) {
|
val searchQueryUrl = when (idQuery) {
|
||||||
//Use imdb/tmdb id to search if its valid
|
//Use imdb/tmdb id to search if its valid
|
||||||
null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery"
|
null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||||
else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery"
|
else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||||
}
|
}
|
||||||
|
|
||||||
val req = app.get(
|
val req = app.get(
|
||||||
|
|
@ -94,9 +91,7 @@ class SubDlApi : SubtitleAPI() {
|
||||||
|
|
||||||
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
|
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
|
||||||
|
|
||||||
val langTagIETF =
|
val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
|
||||||
langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?:
|
|
||||||
subtitle.lang
|
|
||||||
val resEpNum = subtitle.episode ?: query.epNumber
|
val resEpNum = subtitle.episode ?: query.epNumber
|
||||||
val resSeasonNum = subtitle.season ?: query.seasonNumber
|
val resSeasonNum = subtitle.season ?: query.seasonNumber
|
||||||
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
|
|
@ -104,7 +99,7 @@ class SubDlApi : SubtitleAPI() {
|
||||||
AbstractSubtitleEntities.SubtitleEntity(
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
idPrefix = this.idPrefix,
|
idPrefix = this.idPrefix,
|
||||||
name = subtitle.releaseName,
|
name = subtitle.releaseName,
|
||||||
lang = langTagIETF,
|
lang = lang,
|
||||||
data = "${DOWNLOADENDPOINT}${subtitle.url}",
|
data = "${DOWNLOADENDPOINT}${subtitle.url}",
|
||||||
type = type,
|
type = type,
|
||||||
source = this.name,
|
source = this.name,
|
||||||
|
|
@ -124,146 +119,68 @@ class SubDlApi : SubtitleAPI() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SubtitleOAuthEntity(
|
data class SubtitleOAuthEntity(
|
||||||
@JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String,
|
@JsonProperty("userEmail") var userEmail: String,
|
||||||
@JsonProperty("pass") @SerialName("pass") var pass: String,
|
@JsonProperty("pass") var pass: String,
|
||||||
@JsonProperty("name") @SerialName("name") var name: String? = null,
|
@JsonProperty("name") var name: String? = null,
|
||||||
@JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null,
|
@JsonProperty("accessToken") var accessToken: String? = null,
|
||||||
@JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null,
|
@JsonProperty("apiKey") var apiKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class OAuthTokenResponse(
|
data class OAuthTokenResponse(
|
||||||
@JsonProperty("token") @SerialName("token") val token: String,
|
@JsonProperty("token") val token: String,
|
||||||
@JsonProperty("userData") @SerialName("userData") val userData: UserData? = null,
|
@JsonProperty("userData") val userData: UserData? = null,
|
||||||
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
|
@JsonProperty("status") val status: Boolean? = null,
|
||||||
@JsonProperty("message") @SerialName("message") val message: String? = null,
|
@JsonProperty("message") val message: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UserData(
|
data class UserData(
|
||||||
@JsonProperty("email") @SerialName("email") val email: String,
|
@JsonProperty("email") val email: String,
|
||||||
@JsonProperty("name") @SerialName("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("country") @SerialName("country") val country: String,
|
@JsonProperty("country") val country: String,
|
||||||
@JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String,
|
@JsonProperty("scStepCode") val scStepCode: String,
|
||||||
@JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean,
|
@JsonProperty("scVerified") val scVerified: Boolean,
|
||||||
@JsonProperty("username") @SerialName("username") val username: String? = null,
|
@JsonProperty("username") val username: String? = null,
|
||||||
@JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String,
|
@JsonProperty("scUsername") val scUsername: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiKeyResponse(
|
data class ApiKeyResponse(
|
||||||
@JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false,
|
@JsonProperty("ok") val ok: Boolean? = false,
|
||||||
@JsonProperty("api_key") @SerialName("api_key") val apiKey: String,
|
@JsonProperty("api_key") val apiKey: String,
|
||||||
@JsonProperty("usage") @SerialName("usage") val usage: Usage? = null,
|
@JsonProperty("usage") val usage: Usage? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Usage(
|
data class Usage(
|
||||||
@JsonProperty("total") @SerialName("total") val total: Long? = 0,
|
@JsonProperty("total") val total: Long? = 0,
|
||||||
@JsonProperty("today") @SerialName("today") val today: Long? = 0,
|
@JsonProperty("today") val today: Long? = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiResponse(
|
data class ApiResponse(
|
||||||
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
|
@JsonProperty("status") val status: Boolean? = null,
|
||||||
@JsonProperty("results") @SerialName("results") val results: List<Result>? = null,
|
@JsonProperty("results") val results: List<Result>? = null,
|
||||||
@JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List<Subtitle>? = null,
|
@JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Result(
|
data class Result(
|
||||||
@JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null,
|
@JsonProperty("sd_id") val sdId: Int? = null,
|
||||||
@JsonProperty("type") @SerialName("type") val type: String? = null,
|
@JsonProperty("type") val type: String? = null,
|
||||||
@JsonProperty("name") @SerialName("name") val name: String? = null,
|
@JsonProperty("name") val name: String? = null,
|
||||||
@JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null,
|
@JsonProperty("imdb_id") val imdbId: String? = null,
|
||||||
@JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null,
|
@JsonProperty("tmdb_id") val tmdbId: Long? = null,
|
||||||
@JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null,
|
@JsonProperty("first_air_date") val firstAirDate: String? = null,
|
||||||
@JsonProperty("year") @SerialName("year") val year: Int? = null,
|
@JsonProperty("year") val year: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Subtitle(
|
data class Subtitle(
|
||||||
@JsonProperty("release_name") @SerialName("release_name") val releaseName: String,
|
@JsonProperty("release_name") val releaseName: String,
|
||||||
@JsonProperty("name") @SerialName("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code
|
@JsonProperty("lang") val lang: String,
|
||||||
@JsonProperty("author") @SerialName("author") val author: String? = null,
|
@JsonProperty("author") val author: String? = null,
|
||||||
@JsonProperty("url") @SerialName("url") val url: String? = null,
|
@JsonProperty("url") val url: String? = null,
|
||||||
@JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null,
|
@JsonProperty("subtitlePage") val subtitlePage: String? = null,
|
||||||
@JsonProperty("season") @SerialName("season") val season: Int? = null,
|
@JsonProperty("season") val season: Int? = null,
|
||||||
@JsonProperty("episode") @SerialName("episode") val episode: Int? = null,
|
@JsonProperty("episode") val episode: Int? = null,
|
||||||
@JsonProperty("language") @SerialName("language") val language: String? = null, // full language name
|
@JsonProperty("language") val language: String? = null,
|
||||||
@JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null,
|
@JsonProperty("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"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,14 @@ import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||||
import com.lagradost.cloudstream3.MainPageRequest
|
import com.lagradost.cloudstream3.MainPageRequest
|
||||||
import com.lagradost.cloudstream3.SearchResponseList
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
import com.lagradost.cloudstream3.SubtitleFile
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.fixUrl
|
import com.lagradost.cloudstream3.fixUrl
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
import com.lagradost.cloudstream3.newSearchResponseList
|
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -29,7 +28,7 @@ class APIRepository(val api: MainAPI) {
|
||||||
// 2 minute timeout to prevent bad extensions/extractors from hogging the resources
|
// 2 minute timeout to prevent bad extensions/extractors from hogging the resources
|
||||||
// No real provider should take longer, so we hard kill them.
|
// No real provider should take longer, so we hard kill them.
|
||||||
private const val DEFAULT_TIMEOUT = 120_000L
|
private const val DEFAULT_TIMEOUT = 120_000L
|
||||||
private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT
|
private const val MAX_TIMEOUT = 4*DEFAULT_TIMEOUT
|
||||||
private const val MIN_TIMEOUT = 5_000L
|
private const val MIN_TIMEOUT = 5_000L
|
||||||
|
|
||||||
var dubStatusActive = HashSet<DubStatus>()
|
var dubStatusActive = HashSet<DubStatus>()
|
||||||
|
|
@ -55,18 +54,20 @@ class APIRepository(val api: MainAPI) {
|
||||||
val hash: Pair<String, String>
|
val hash: Pair<String, String>
|
||||||
)
|
)
|
||||||
|
|
||||||
private val cache = atomicListOf<SavedLoadResponse>()
|
private val cache = threadSafeListOf<SavedLoadResponse>()
|
||||||
private var cacheIndex: Int = 0
|
private var cacheIndex: Int = 0
|
||||||
const val CACHE_SIZE = 20
|
const val CACHE_SIZE = 20
|
||||||
|
|
||||||
fun getTimeout(desired: Long?): Long {
|
fun getTimeout(desired : Long?) : Long {
|
||||||
return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT)
|
return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||||
if (forceReload) {
|
if (forceReload) {
|
||||||
cache.clear()
|
synchronized(cache) {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,25 +90,21 @@ class APIRepository(val api: MainAPI) {
|
||||||
val fixedUrl = api.fixUrl(url)
|
val fixedUrl = api.fixUrl(url)
|
||||||
val lookingForHash = Pair(api.name, fixedUrl)
|
val lookingForHash = Pair(api.name, fixedUrl)
|
||||||
|
|
||||||
val cached = cache.withLock {
|
synchronized(cache) {
|
||||||
var found: LoadResponse? = null
|
|
||||||
for (item in cache) {
|
for (item in cache) {
|
||||||
// 10 min save
|
// 10 min save
|
||||||
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
||||||
found = item.response
|
return@withTimeout item.response
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cached != null) return@withTimeout cached
|
|
||||||
api.load(fixedUrl)?.also { response ->
|
api.load(fixedUrl)?.also { response ->
|
||||||
// Remove all blank tags as early as possible
|
// Remove all blank tags as early as possible
|
||||||
response.tags = response.tags?.filter { it.isNotBlank() }
|
response.tags = response.tags?.filter { it.isNotBlank() }
|
||||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||||
|
|
||||||
cache.withLock {
|
synchronized(cache) {
|
||||||
if (cache.size > CACHE_SIZE) {
|
if (cache.size > CACHE_SIZE) {
|
||||||
cache[cacheIndex] = add // rolling cache
|
cache[cacheIndex] = add // rolling cache
|
||||||
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
||||||
|
|
@ -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())
|
if (query.isEmpty())
|
||||||
return Resource.Success(newSearchResponseList(emptyList()))
|
return Resource.Success(emptyList())
|
||||||
|
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
withTimeout(getTimeout(api.searchTimeoutMs)) {
|
withTimeout(getTimeout(api.searchTimeoutMs)) {
|
||||||
(api.search(query, page)
|
(api.search(query)
|
||||||
?: throw ErrorLoadingException())
|
?: throw ErrorLoadingException())
|
||||||
// .filter { typesActive.contains(it.type) }
|
// .filter { typesActive.contains(it.type) }
|
||||||
|
.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun quickSearch(query: String): Resource<SearchResponseList> {
|
suspend fun quickSearch(query: String): Resource<List<SearchResponse>> {
|
||||||
if (query.isEmpty())
|
if (query.isEmpty())
|
||||||
return Resource.Success(newSearchResponseList(emptyList()))
|
return Resource.Success(emptyList())
|
||||||
|
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
withTimeout(getTimeout(api.quickSearchTimeoutMs)) {
|
withTimeout(getTimeout(api.quickSearchTimeoutMs)) {
|
||||||
newSearchResponseList(
|
api.quickSearch(query) ?: throw ErrorLoadingException()
|
||||||
api.quickSearch(query) ?: throw ErrorLoadingException(),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,34 @@
|
||||||
package com.lagradost.cloudstream3.ui
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.core.view.children
|
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.AsyncDifferConfig
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
import androidx.recyclerview.widget.AsyncListDiffer
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import coil3.dispose
|
|
||||||
import java.util.WeakHashMap
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
||||||
open fun save(): T? = null
|
open fun save(): T? = null
|
||||||
open fun restore(state: T) = Unit
|
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.
|
// 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() {
|
||||||
* The reason for this complicated structure is that a pool should not be shared between contexts
|
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
||||||
* as it makes coil fuck up, and theming.
|
|
||||||
* */
|
|
||||||
fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit> =
|
|
||||||
WeakHashMap<Context, RecyclerView.RecycledViewPool>() to lambda
|
|
||||||
|
|
||||||
/** Sets the shared pool of the recyclerview */
|
|
||||||
fun RecyclerView.setRecycledViewPool(pool: Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>) {
|
|
||||||
val ctx = context ?: return
|
|
||||||
synchronized(pool.first) {
|
|
||||||
this.setRecycledViewPool(pool.first.getOrPut(ctx) {
|
|
||||||
RecyclerView.RecycledViewPool().apply(pool.second)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears the shared pool of views */
|
abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
|
||||||
fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>.clear() {
|
|
||||||
synchronized(this.first) {
|
|
||||||
for (pool in this.first.values) {
|
|
||||||
pool?.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
||||||
|
|
@ -70,14 +49,13 @@ fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.Recyc
|
||||||
abstract class BaseAdapter<
|
abstract class BaseAdapter<
|
||||||
T : Any,
|
T : Any,
|
||||||
S : Any>(
|
S : Any>(
|
||||||
|
fragment: Fragment,
|
||||||
val id: Int = 0,
|
val id: Int = 0,
|
||||||
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
||||||
) : RecyclerView.Adapter<ViewHolderState<S>>() {
|
) : RecyclerView.Adapter<ViewHolderState<S>>() {
|
||||||
open val footers: Int = 0
|
open val footers: Int = 0
|
||||||
open val headers: Int = 0
|
open val headers: Int = 0
|
||||||
|
|
||||||
val immutableCurrentList: List<T> get() = mDiffer.currentList
|
|
||||||
|
|
||||||
fun getItem(position: Int): T {
|
fun getItem(position: Int): T {
|
||||||
return mDiffer.currentList[position]
|
return mDiffer.currentList[position]
|
||||||
}
|
}
|
||||||
|
|
@ -107,33 +85,9 @@ abstract class BaseAdapter<
|
||||||
AsyncDifferConfig.Builder(diffCallback).build()
|
AsyncDifferConfig.Builder(diffCallback).build()
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
open fun submitList(list: List<T>?) {
|
||||||
* 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) {
|
|
||||||
// deep copy at least the top list, because otherwise adapter can go crazy
|
// deep copy at least the top list, because otherwise adapter can go crazy
|
||||||
if (list.isNullOrEmpty()) {
|
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
|
||||||
mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList()
|
|
||||||
} else {
|
|
||||||
mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
|
|
@ -147,25 +101,16 @@ abstract class BaseAdapter<
|
||||||
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
|
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
|
||||||
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
|
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
|
||||||
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
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 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 onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
open fun onCreateCustomHeader(
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int
|
|
||||||
) = onCreateHeader(parent)
|
|
||||||
|
|
||||||
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {}
|
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
|
||||||
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {}
|
holder.onViewAttachedToWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
|
||||||
|
holder.onViewDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun save(recyclerView: RecyclerView) {
|
fun save(recyclerView: RecyclerView) {
|
||||||
|
|
@ -176,20 +121,21 @@ abstract class BaseAdapter<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearState() {
|
fun clear() {
|
||||||
layoutManagerStates[id]?.clear()
|
stateViewModel.layoutManagerStates[id]?.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun getState(holder: ViewHolderState<S>): S? =
|
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>) {
|
private fun setState(holder: ViewHolderState<S>) {
|
||||||
if (id == 0) return
|
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()
|
map[holder.absoluteAdapterPosition] = holder.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,40 +158,30 @@ abstract class BaseAdapter<
|
||||||
super.onDetachedFromRecyclerView(recyclerView)
|
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 {
|
final override fun getItemViewType(position: Int): Int {
|
||||||
if (position < headers) {
|
if (position < headers) {
|
||||||
return HEADER or customHeaderViewType()
|
return HEADER
|
||||||
}
|
}
|
||||||
val realPosition = position - headers
|
if (position - headers >= mDiffer.currentList.size) {
|
||||||
if (realPosition >= mDiffer.currentList.size) {
|
return FOOTER
|
||||||
return FOOTER or customFooterViewType()
|
|
||||||
}
|
}
|
||||||
return CONTENT or customContentViewType(getItem(realPosition))
|
|
||||||
|
return CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val stateViewModel: StateViewModel by fragment.viewModels()
|
||||||
|
|
||||||
final override fun onViewRecycled(holder: ViewHolderState<S>) {
|
final override fun onViewRecycled(holder: ViewHolderState<S>) {
|
||||||
setState(holder)
|
setState(holder)
|
||||||
onClearView(holder)
|
holder.onViewRecycled()
|
||||||
super.onViewRecycled(holder)
|
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> {
|
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
|
||||||
return when (viewType and TYPE_MASK) {
|
return when (viewType) {
|
||||||
CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK)
|
CONTENT -> onCreateContent(parent)
|
||||||
HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK)
|
HEADER -> onCreateHeader(parent)
|
||||||
FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK)
|
FOOTER -> onCreateFooter(parent)
|
||||||
else -> throw NotImplementedError()
|
else -> throw NotImplementedError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,7 +196,7 @@ abstract class BaseAdapter<
|
||||||
super.onBindViewHolder(holder, position, payloads)
|
super.onBindViewHolder(holder, position, payloads)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when (getItemViewType(position) and TYPE_MASK) {
|
when (getItemViewType(position)) {
|
||||||
CONTENT -> {
|
CONTENT -> {
|
||||||
val realPosition = position - headers
|
val realPosition = position - headers
|
||||||
val item = getItem(realPosition)
|
val item = getItem(realPosition)
|
||||||
|
|
@ -278,7 +214,7 @@ abstract class BaseAdapter<
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
|
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
|
||||||
when (getItemViewType(position) and TYPE_MASK) {
|
when (getItemViewType(position)) {
|
||||||
CONTENT -> {
|
CONTENT -> {
|
||||||
val realPosition = position - headers
|
val realPosition = position - headers
|
||||||
val item = getItem(realPosition)
|
val item = getItem(realPosition)
|
||||||
|
|
@ -300,20 +236,9 @@ abstract class BaseAdapter<
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
private const val HEADER: Int = 1
|
||||||
fun clearImage(image: ImageView?) {
|
private const val FOOTER: Int = 2
|
||||||
image?.dispose()
|
private const val CONTENT: Int = 0
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,5 +248,5 @@ class BaseDiffCallback<T : Any>(
|
||||||
) : DiffUtil.ItemCallback<T>() {
|
) : DiffUtil.ItemCallback<T>() {
|
||||||
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
|
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
|
||||||
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(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,6 +12,9 @@ import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||||
import com.google.android.gms.cast.MediaLoadOptions
|
import com.google.android.gms.cast.MediaLoadOptions
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
import com.google.android.gms.cast.MediaSeekOptions
|
import com.google.android.gms.cast.MediaSeekOptions
|
||||||
|
|
@ -102,6 +105,9 @@ data class MetadataHolder(
|
||||||
|
|
||||||
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
||||||
UIController() {
|
UIController() {
|
||||||
|
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
||||||
view.setOnClickListener {
|
view.setOnClickListener {
|
||||||
|
|
@ -239,12 +245,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
.setPlayPosition(startAt)
|
.setPlayPosition(startAt)
|
||||||
.setAutoplay(true)
|
.setAutoplay(true)
|
||||||
.build()
|
.build()
|
||||||
awaitLinks(
|
awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
|
||||||
remoteMediaClient?.load(
|
|
||||||
mediaItem,
|
|
||||||
mediaLoadOptions
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
loadMirror(index + 1)
|
loadMirror(index + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,13 +299,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
val currentDuration = remoteMediaClient?.streamDuration
|
val currentDuration = remoteMediaClient?.streamDuration
|
||||||
val currentPosition = remoteMediaClient?.approximateStreamPosition
|
val currentPosition = remoteMediaClient?.approximateStreamPosition
|
||||||
if (currentDuration != null && currentPosition != null)
|
if (currentDuration != null && currentPosition != null)
|
||||||
DataStoreHelper.setViewPosAndResume(
|
DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
|
||||||
epData.id,
|
|
||||||
currentPosition,
|
|
||||||
currentDuration,
|
|
||||||
epData,
|
|
||||||
meta.episodes.getOrNull(index + 1)
|
|
||||||
)
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
logError(t)
|
logError(t)
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +315,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
val isSuccessful = safeApiCall {
|
val isSuccessful = safeApiCall {
|
||||||
generator.generateLinks(
|
generator.generateLinks(
|
||||||
clearCache = false,
|
clearCache = false,
|
||||||
sourceTypes = LOADTYPE_CHROMECAST,
|
allowedTypes = LOADTYPE_CHROMECAST,
|
||||||
callback = {
|
callback = {
|
||||||
it.first?.let { link ->
|
it.first?.let { link ->
|
||||||
currentLinks.add(link)
|
currentLinks.add(link)
|
||||||
|
|
@ -328,9 +323,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
}, subtitleCallback = {
|
}, subtitleCallback = {
|
||||||
currentSubs.add(it)
|
currentSubs.add(it)
|
||||||
},
|
},
|
||||||
offset = 0,
|
isCasting = true)
|
||||||
isCasting = true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortedLinks = sortUrls(currentLinks)
|
val sortedLinks = sortUrls(currentLinks)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.content.withStyledAttributes
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) {
|
val attrsArray = intArrayOf(android.R.attr.columnWidth)
|
||||||
columnWidth = getDimensionPixelSize(0, -1)
|
val array = context.obtainStyledAttributes(attrs, attrsArray)
|
||||||
}
|
columnWidth = array.getDimensionPixelSize(0, -1)
|
||||||
|
array.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutManager = manager
|
layoutManager = manager
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,17 @@ import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
import android.animation.ObjectAnimator
|
import android.animation.ObjectAnimator
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.view.animation.AccelerateInterpolator
|
import android.view.animation.AccelerateInterpolator
|
||||||
import android.view.animation.LinearInterpolator
|
import android.view.animation.LinearInterpolator
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding
|
import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding
|
||||||
|
|
@ -22,9 +26,10 @@ import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
class EasterEggMonkeFragment : Fragment() {
|
||||||
BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate)
|
|
||||||
) {
|
private var _binding: FragmentEasterEggMonkeBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
// planet of monks
|
// planet of monks
|
||||||
private val monkeys: List<Int> = listOf(
|
private val monkeys: List<Int> = listOf(
|
||||||
|
|
@ -46,20 +51,27 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
private val activeMonkeys = mutableListOf<ImageView>()
|
private val activeMonkeys = mutableListOf<ImageView>()
|
||||||
private var spawningJob: Job? = null
|
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()
|
activity?.hideSystemUI()
|
||||||
spawningJob = lifecycleScope.launch {
|
spawningJob = lifecycleScope.launch {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
spawnMonkey(binding)
|
spawnMonkey()
|
||||||
delay(500)
|
delay(500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) {
|
private fun spawnMonkey() {
|
||||||
val newMonkey = ImageView(context ?: return).apply {
|
val newMonkey = ImageView(context ?: return).apply {
|
||||||
setImageResource(monkeys.random())
|
setImageResource(monkeys.random())
|
||||||
isVisible = true
|
isVisible = true
|
||||||
|
|
@ -90,12 +102,12 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@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(
|
val floatUpAnimator = ObjectAnimator.ofFloat(
|
||||||
monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat()
|
monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat()
|
||||||
).apply {
|
).apply {
|
||||||
|
|
@ -105,8 +117,11 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
|
|
||||||
floatUpAnimator.addListener(object : AnimatorListenerAdapter() {
|
floatUpAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
binding.frame.removeView(monkey)
|
// necessary check because binding becomes null but monkes are still moving until onDestroy()
|
||||||
activeMonkeys.remove(monkey)
|
if (_binding != null) {
|
||||||
|
binding.frame.removeView(monkey)
|
||||||
|
activeMonkeys.remove(monkey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -114,11 +129,7 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
monkey.tag = floatUpAnimator
|
monkey.tag = floatUpAnimator
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleTouch(
|
private fun handleTouch(view: View, event: MotionEvent): Boolean {
|
||||||
view: View,
|
|
||||||
event: MotionEvent,
|
|
||||||
binding: FragmentEasterEggMonkeBinding
|
|
||||||
): Boolean {
|
|
||||||
val monkey = view as ImageView
|
val monkey = view as ImageView
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
|
@ -132,17 +143,17 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
monkey.y = event.rawY - monkey.height / 2
|
monkey.y = event.rawY - monkey.height / 2
|
||||||
|
|
||||||
// Check if monkey touches the screen edge
|
// Check if monkey touches the screen edge
|
||||||
if (isTouchingEdge(monkey, binding)) {
|
if (isTouchingEdge(monkey)) {
|
||||||
removeMonkey(monkey, binding)
|
removeMonkey(monkey)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
if (isTouchingEdge(monkey, binding)) {
|
if (isTouchingEdge(monkey)) {
|
||||||
removeMonkey(monkey, binding)
|
removeMonkey(monkey)
|
||||||
} else {
|
} else {
|
||||||
startFloatingAnimation(monkey, binding)
|
startFloatingAnimation(monkey)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -150,12 +161,12 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
return false
|
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 ||
|
return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width ||
|
||||||
monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height
|
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
|
// Fade out and remove the monkey
|
||||||
ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply {
|
ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply {
|
||||||
duration = 300
|
duration = 300
|
||||||
|
|
@ -173,5 +184,6 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
activity?.showSystemUI()
|
activity?.showSystemUI()
|
||||||
spawningJob?.cancel()
|
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.LinearLayout
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import androidx.core.content.withStyledAttributes
|
|
||||||
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
|
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
|
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
|
||||||
class MyMiniControllerFragment : MiniControllerFragment() {
|
class MyMiniControllerFragment : MiniControllerFragment() {
|
||||||
|
|
@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() {
|
||||||
|
|
||||||
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
|
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
|
||||||
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
|
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
|
||||||
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)
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
package com.lagradost.cloudstream3.ui
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
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.network.WebViewResolver
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
|
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 {
|
val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
|
||||||
findNavController().popBackStack()
|
findNavController().popBackStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.webView.webViewClient = object : WebViewClient() {
|
binding?.webView?.webViewClient = object : WebViewClient() {
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
override fun shouldOverrideUrlLoading(
|
override fun shouldOverrideUrlLoading(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?
|
request: WebResourceRequest?
|
||||||
|
|
@ -40,17 +46,28 @@ class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
|
||||||
return super.shouldOverrideUrlLoading(view, request)
|
return super.shouldOverrideUrlLoading(view, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding?.webView?.apply {
|
||||||
binding.webView.apply {
|
|
||||||
WebViewResolver.webViewUserAgent = settings.userAgentString
|
WebViewResolver.webViewUserAgent = settings.userAgentString
|
||||||
|
|
||||||
addJavascriptInterface(RepoApi(activity), "RepoApi")
|
addJavascriptInterface(RepoApi(activity), "RepoApi")
|
||||||
settings.javaScriptEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.userAgentString = USER_AGENT
|
settings.userAgentString = USER_AGENT
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
|
// WebView.setWebContentsDebuggingEnabled(true)
|
||||||
|
|
||||||
loadUrl(url)
|
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 {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.ui.account
|
package com.lagradost.cloudstream3.ui.account
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
import coil3.transform.RoundedCornersTransformation
|
import coil3.transform.RoundedCornersTransformation
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
|
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
|
||||||
import com.lagradost.cloudstream3.databinding.AccountListItemBinding
|
import com.lagradost.cloudstream3.databinding.AccountListItemBinding
|
||||||
import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
|
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.account.AccountHelper.showAccountEditDialog
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
|
@ -20,174 +19,137 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
|
|
||||||
class AccountAdapter(
|
class AccountAdapter(
|
||||||
|
private val accounts: List<DataStoreHelper.Account>,
|
||||||
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
|
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
|
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
|
private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
|
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
|
||||||
) : NoStateAdapter<DataStoreHelper.Account>() {
|
) : RecyclerView.Adapter<AccountAdapter.AccountViewHolder>() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val VIEW_TYPE_SELECT_ACCOUNT = 0
|
const val VIEW_TYPE_SELECT_ACCOUNT = 0
|
||||||
|
const val VIEW_TYPE_ADD_ACCOUNT = 1
|
||||||
const val VIEW_TYPE_EDIT_ACCOUNT = 2
|
const val VIEW_TYPE_EDIT_ACCOUNT = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner class AccountViewHolder(private val binding: ViewBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
override val footers: Int = 1
|
fun bind(account: DataStoreHelper.Account?) {
|
||||||
var viewType = VIEW_TYPE_SELECT_ACCOUNT
|
when (binding) {
|
||||||
|
is AccountListItemBinding -> binding.apply {
|
||||||
|
if (account == null) return@apply
|
||||||
|
|
||||||
override fun customContentViewType(item: DataStoreHelper.Account): Int {
|
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
||||||
return viewType
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindContent(
|
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
holder: ViewHolderState<Any>,
|
|
||||||
item: DataStoreHelper.Account,
|
|
||||||
position: Int
|
|
||||||
) {
|
|
||||||
when (val binding = holder.view) {
|
|
||||||
is AccountListItemBinding -> binding.apply {
|
|
||||||
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
|
||||||
|
|
||||||
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex
|
accountName.text = account.name
|
||||||
|
accountImage.loadImage(account.image)
|
||||||
|
lockIcon.isVisible = account.lockPin != null
|
||||||
|
outline.isVisible = !isTv && isLastUsedAccount
|
||||||
|
|
||||||
accountName.text = item.name
|
if (isTv) {
|
||||||
accountImage.loadImage(item.image)
|
// For emulator but this is fine on TV also
|
||||||
lockIcon.isVisible = item.lockPin != null
|
root.isFocusableInTouchMode = true
|
||||||
outline.isVisible = !isTv && isLastUsedAccount
|
if (isLastUsedAccount) {
|
||||||
|
root.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
if (isTv) {
|
root.foreground = ContextCompat.getDrawable(
|
||||||
// For emulator but this is fine on TV also
|
root.context,
|
||||||
root.isFocusableInTouchMode = true
|
R.drawable.outline_drawable
|
||||||
if (isLastUsedAccount) {
|
)
|
||||||
root.requestFocus()
|
} else {
|
||||||
|
root.setOnLongClickListener {
|
||||||
|
showAccountEditDialog(
|
||||||
|
context = root.context,
|
||||||
|
account = account,
|
||||||
|
isNewAccount = false,
|
||||||
|
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
||||||
|
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
|
||||||
|
)
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
root.setOnClickListener {
|
||||||
|
accountSelectCallback.invoke(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AccountListItemEditBinding -> binding.apply {
|
||||||
|
if (account == null) return@apply
|
||||||
|
|
||||||
|
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
||||||
|
|
||||||
|
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
|
||||||
|
accountName.text = account.name
|
||||||
|
accountImage.loadImage(account.image) {
|
||||||
|
RoundedCornersTransformation(10f)
|
||||||
|
}
|
||||||
|
lockIcon.isVisible = account.lockPin != null
|
||||||
|
outline.isVisible = !isTv && isLastUsedAccount
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// For emulator but this is fine on TV also
|
||||||
|
root.isFocusableInTouchMode = true
|
||||||
|
if (isLastUsedAccount) {
|
||||||
|
root.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
root.foreground = ContextCompat.getDrawable(
|
root.foreground = ContextCompat.getDrawable(
|
||||||
root.context,
|
root.context,
|
||||||
R.drawable.outline_drawable
|
R.drawable.outline_drawable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
root.setOnLongClickListener {
|
root.setOnClickListener {
|
||||||
showAccountEditDialog(
|
showAccountEditDialog(
|
||||||
context = root.context,
|
context = root.context,
|
||||||
account = item,
|
account = account,
|
||||||
isNewAccount = false,
|
isNewAccount = false,
|
||||||
accountEditCallback = { account ->
|
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
||||||
accountEditCallback.invoke(
|
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
|
||||||
account
|
|
||||||
)
|
|
||||||
},
|
|
||||||
accountDeleteCallback = { account ->
|
|
||||||
accountDeleteCallback.invoke(
|
|
||||||
account
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
root.setOnClickListener {
|
is AccountListItemAddBinding -> binding.apply {
|
||||||
accountSelectCallback.invoke(item)
|
root.setOnClickListener {
|
||||||
}
|
val remainingImages =
|
||||||
}
|
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
|
||||||
|
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
|
||||||
|
|
||||||
is AccountListItemEditBinding -> binding.apply {
|
val image =
|
||||||
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
|
||||||
|
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
|
||||||
|
|
||||||
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex
|
val accountName = root.context.getString(R.string.account)
|
||||||
|
|
||||||
accountName.text = item.name
|
showAccountEditDialog(
|
||||||
accountImage.loadImage(item.image) {
|
|
||||||
RoundedCornersTransformation(10f)
|
|
||||||
}
|
|
||||||
lockIcon.isVisible = item.lockPin != null
|
|
||||||
outline.isVisible = !isTv && isLastUsedAccount
|
|
||||||
|
|
||||||
if (isTv) {
|
|
||||||
// For emulator but this is fine on TV also
|
|
||||||
root.isFocusableInTouchMode = true
|
|
||||||
if (isLastUsedAccount) {
|
|
||||||
root.requestFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
root.foreground = ContextCompat.getDrawable(
|
|
||||||
root.context,
|
root.context,
|
||||||
R.drawable.outline_drawable
|
DataStoreHelper.Account(
|
||||||
|
keyIndex = keyIndex,
|
||||||
|
name = "$accountName $keyIndex",
|
||||||
|
customImage = null,
|
||||||
|
defaultImageIndex = image
|
||||||
|
),
|
||||||
|
isNewAccount = true,
|
||||||
|
accountEditCallback = { account -> accountCreateCallback.invoke(account) },
|
||||||
|
accountDeleteCallback = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
root.setOnClickListener {
|
|
||||||
showAccountEditDialog(
|
|
||||||
context = root.context,
|
|
||||||
account = item,
|
|
||||||
isNewAccount = false,
|
|
||||||
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
|
||||||
accountDeleteCallback = { account ->
|
|
||||||
accountDeleteCallback.invoke(
|
|
||||||
account
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindFooter(holder: ViewHolderState<Any>) {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
|
||||||
val binding = holder.view as? AccountListItemAddBinding ?: return
|
AccountViewHolder(
|
||||||
binding.apply {
|
binding = when (viewType) {
|
||||||
root.setOnClickListener {
|
|
||||||
val accounts = this@AccountAdapter.immutableCurrentList
|
|
||||||
|
|
||||||
val remainingImages =
|
|
||||||
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
|
|
||||||
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
val image =
|
|
||||||
DataStoreHelper.profileImages.indexOf(
|
|
||||||
remainingImages.randomOrNull()
|
|
||||||
?: DataStoreHelper.profileImages.random()
|
|
||||||
)
|
|
||||||
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
|
|
||||||
|
|
||||||
val accountName = root.context.getString(R.string.account)
|
|
||||||
|
|
||||||
showAccountEditDialog(
|
|
||||||
root.context,
|
|
||||||
DataStoreHelper.Account(
|
|
||||||
keyIndex = keyIndex,
|
|
||||||
name = "$accountName $keyIndex",
|
|
||||||
customImage = null,
|
|
||||||
defaultImageIndex = image
|
|
||||||
),
|
|
||||||
isNewAccount = true,
|
|
||||||
accountEditCallback = { account -> accountCreateCallback.invoke(account) },
|
|
||||||
accountDeleteCallback = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
VIEW_TYPE_SELECT_ACCOUNT -> {
|
VIEW_TYPE_SELECT_ACCOUNT -> {
|
||||||
AccountListItemBinding.inflate(
|
AccountListItemBinding.inflate(
|
||||||
LayoutInflater.from(parent.context),
|
LayoutInflater.from(parent.context),
|
||||||
|
|
@ -195,7 +157,13 @@ class AccountAdapter(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
VIEW_TYPE_ADD_ACCOUNT -> {
|
||||||
|
AccountListItemAddBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
VIEW_TYPE_EDIT_ACCOUNT -> {
|
VIEW_TYPE_EDIT_ACCOUNT -> {
|
||||||
AccountListItemEditBinding.inflate(
|
AccountListItemEditBinding.inflate(
|
||||||
LayoutInflater.from(parent.context),
|
LayoutInflater.from(parent.context),
|
||||||
|
|
@ -203,9 +171,28 @@ class AccountAdapter(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Invalid view type")
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ import coil3.ImageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.allowHardware
|
import coil3.request.allowHardware
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
|
@ -392,6 +392,7 @@ object AccountHelper {
|
||||||
|
|
||||||
activity.observe(viewModel.accounts) { liveAccounts ->
|
activity.observe(viewModel.accounts) { liveAccounts ->
|
||||||
recyclerView.adapter = AccountAdapter(
|
recyclerView.adapter = AccountAdapter(
|
||||||
|
liveAccounts,
|
||||||
accountSelectCallback = { account ->
|
accountSelectCallback = { account ->
|
||||||
viewModel.handleAccountSelect(account, activity)
|
viewModel.handleAccountSelect(account, activity)
|
||||||
builder.dismissSafe()
|
builder.dismissSafe()
|
||||||
|
|
@ -399,9 +400,7 @@ object AccountHelper {
|
||||||
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
|
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
|
||||||
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
|
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
|
||||||
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
|
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
|
||||||
).apply {
|
)
|
||||||
submitList(liveAccounts)
|
|
||||||
}
|
|
||||||
|
|
||||||
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
|
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
|
||||||
// Scroll to current account (which is focused by default)
|
// 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.accounts
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.openActivity
|
import com.lagradost.cloudstream3.utils.UIHelper.openActivity
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
|
||||||
|
|
||||||
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
|
|
||||||
companion object {
|
|
||||||
var hasLoggedIn: Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
val accountViewModel: AccountViewModel by viewModels()
|
val accountViewModel: AccountViewModel by viewModels()
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
loadThemes(this)
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
|
||||||
|
|
||||||
// Are we editing and coming from MainActivity?
|
// Are we editing and coming from MainActivity?
|
||||||
val isEditingFromMainActivity = intent.getBooleanExtra(
|
val isEditingFromMainActivity = intent.getBooleanExtra(
|
||||||
|
|
@ -54,22 +52,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sometimes we start this activity when we have already logged in
|
|
||||||
// For example when using cloudstreamsearch://
|
|
||||||
// In those cases we want to just go to the main activity instantly
|
|
||||||
if (hasLoggedIn && !isEditingFromMainActivity) {
|
|
||||||
navigateToMainActivity()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadThemes(this)
|
|
||||||
|
|
||||||
enableEdgeToEdgeCompat()
|
|
||||||
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
val skipStartup = settingsManager.getBoolean(
|
val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
|
||||||
getString(R.string.skip_startup_account_select_key), false
|
|
||||||
) || accounts.count() <= 1
|
) || accounts.count() <= 1
|
||||||
|
|
||||||
fun askBiometricAuth() {
|
fun askBiometricAuth() {
|
||||||
|
|
@ -105,12 +89,10 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
accountViewModel.handleAccountSelect(currentAccount, this, true)
|
accountViewModel.handleAccountSelect(currentAccount, this, true)
|
||||||
} else {
|
} else {
|
||||||
if (accounts.count() > 1) {
|
if (accounts.count() > 1) {
|
||||||
showToast(
|
showToast(this, getString(
|
||||||
this, getString(
|
R.string.logged_account,
|
||||||
R.string.logged_account,
|
currentAccount?.name
|
||||||
currentAccount?.name
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToMainActivity()
|
navigateToMainActivity()
|
||||||
|
|
@ -123,12 +105,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
|
|
||||||
val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
|
val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
fixSystemBarsPadding(binding.root, padTop = false)
|
|
||||||
|
|
||||||
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
|
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
|
||||||
|
|
||||||
observe(accountViewModel.accounts) { liveAccounts ->
|
observe(accountViewModel.accounts) { liveAccounts ->
|
||||||
val adapter = AccountAdapter(
|
val adapter = AccountAdapter(
|
||||||
|
liveAccounts,
|
||||||
// Handle the selected account
|
// Handle the selected account
|
||||||
accountSelectCallback = {
|
accountSelectCallback = {
|
||||||
accountViewModel.handleAccountSelect(it, this)
|
accountViewModel.handleAccountSelect(it, this)
|
||||||
|
|
@ -136,6 +118,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) },
|
accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) },
|
||||||
accountEditCallback = {
|
accountEditCallback = {
|
||||||
accountViewModel.handleAccountUpdate(it, this)
|
accountViewModel.handleAccountUpdate(it, this)
|
||||||
|
|
||||||
// We came from MainActivity, return there
|
// We came from MainActivity, return there
|
||||||
// and switch to the edited account
|
// and switch to the edited account
|
||||||
if (isEditingFromMainActivity) {
|
if (isEditingFromMainActivity) {
|
||||||
|
|
@ -143,10 +126,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
navigateToMainActivity()
|
navigateToMainActivity()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) }
|
accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) }
|
||||||
).apply {
|
)
|
||||||
submitList(liveAccounts)
|
|
||||||
}
|
|
||||||
|
|
||||||
recyclerView.adapter = adapter
|
recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
|
@ -201,16 +182,13 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||||
askBiometricAuth()
|
askBiometricAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeIntentLaunch")
|
|
||||||
private fun navigateToMainActivity() {
|
private fun navigateToMainActivity() {
|
||||||
hasLoggedIn = true
|
openActivity(MainActivity::class.java)
|
||||||
// We want to propagate any intent we get here to MainActivity since this is just an intermediary
|
|
||||||
openActivity(MainActivity::class.java, baseIntent = intent)
|
|
||||||
finish() // Finish the account selection activity
|
finish() // Finish the account selection activity
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAuthenticationSuccess() {
|
override fun onAuthenticationSuccess() {
|
||||||
Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity")
|
Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAuthenticationError() {
|
override fun onAuthenticationError() {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import android.content.Context
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
|
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -8,18 +7,19 @@ import android.widget.CheckBox
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
|
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
|
||||||
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
|
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
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.ui.download.button.DownloadStatusTell
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
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.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
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_PLAY_FILE = 0
|
||||||
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
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_PAUSE_DOWNLOAD = 3
|
||||||
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
||||||
const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
||||||
const val DOWNLOAD_ACTION_CANCEL_PENDING = 6
|
|
||||||
|
|
||||||
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
|
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
|
||||||
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
||||||
|
|
@ -35,22 +34,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
||||||
sealed class VisualDownloadCached {
|
sealed class VisualDownloadCached {
|
||||||
abstract val currentBytes: Long
|
abstract val currentBytes: Long
|
||||||
abstract val totalBytes: Long
|
abstract val totalBytes: Long
|
||||||
abstract val data: DownloadObjects.DownloadCached
|
abstract val data: VideoDownloadHelper.DownloadCached
|
||||||
abstract var isSelected: Boolean
|
abstract var isSelected: Boolean
|
||||||
|
|
||||||
data class Child(
|
data class Child(
|
||||||
override val currentBytes: Long,
|
override val currentBytes: Long,
|
||||||
override val totalBytes: Long,
|
override val totalBytes: Long,
|
||||||
override val data: DownloadObjects.DownloadEpisodeCached,
|
override val data: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
override var isSelected: Boolean,
|
override var isSelected: Boolean,
|
||||||
) : VisualDownloadCached()
|
) : VisualDownloadCached()
|
||||||
|
|
||||||
data class Header(
|
data class Header(
|
||||||
override val currentBytes: Long,
|
override val currentBytes: Long,
|
||||||
override val totalBytes: Long,
|
override val totalBytes: Long,
|
||||||
override val data: DownloadObjects.DownloadHeaderCached,
|
override val data: VideoDownloadHelper.DownloadHeaderCached,
|
||||||
override var isSelected: Boolean,
|
override var isSelected: Boolean,
|
||||||
val child: DownloadObjects.DownloadEpisodeCached?,
|
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
||||||
val currentOngoingDownloads: Int,
|
val currentOngoingDownloads: Int,
|
||||||
val totalDownloads: Int,
|
val totalDownloads: Int,
|
||||||
) : VisualDownloadCached()
|
) : VisualDownloadCached()
|
||||||
|
|
@ -58,19 +57,19 @@ sealed class VisualDownloadCached {
|
||||||
|
|
||||||
data class DownloadClickEvent(
|
data class DownloadClickEvent(
|
||||||
val action: Int,
|
val action: Int,
|
||||||
val data: DownloadObjects.DownloadEpisodeCached
|
val data: VideoDownloadHelper.DownloadEpisodeCached
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DownloadHeaderClickEvent(
|
data class DownloadHeaderClickEvent(
|
||||||
val action: Int,
|
val action: Int,
|
||||||
val data: DownloadObjects.DownloadHeaderCached
|
val data: VideoDownloadHelper.DownloadHeaderCached
|
||||||
)
|
)
|
||||||
|
|
||||||
class DownloadAdapter(
|
class DownloadAdapter(
|
||||||
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
|
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
|
||||||
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
|
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
|
||||||
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
|
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
|
||||||
) : NoStateAdapter<VisualDownloadCached>(DiffCallback()) {
|
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
|
||||||
|
|
||||||
private var isMultiDeleteState: Boolean = false
|
private var isMultiDeleteState: Boolean = false
|
||||||
|
|
||||||
|
|
@ -79,224 +78,112 @@ class DownloadAdapter(
|
||||||
private const val VIEW_TYPE_CHILD = 1
|
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?) {
|
||||||
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
|
when (binding) {
|
||||||
|
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
|
||||||
|
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val data = card.data
|
private fun bindHeader(card: VisualDownloadCached.Header?) {
|
||||||
binding.apply {
|
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
|
||||||
episodeHolder.apply {
|
|
||||||
if (isMultiDeleteState) {
|
val data = card.data
|
||||||
setOnClickListener {
|
binding.apply {
|
||||||
toggleIsChecked(deleteCheckbox, data.id)
|
episodeHolder.apply {
|
||||||
|
if (isMultiDeleteState) {
|
||||||
|
setOnClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnLongClickListener {
|
setOnLongClickListener {
|
||||||
toggleIsChecked(deleteCheckbox, data.id)
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setOnLongClickListener {
|
|
||||||
onItemSelectionChanged.invoke(data.id, true)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
downloadHeaderPoster.apply {
|
downloadHeaderPoster.apply {
|
||||||
loadImage(data.poster)
|
loadImage(data.poster)
|
||||||
if (isMultiDeleteState) {
|
if (isMultiDeleteState) {
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
toggleIsChecked(deleteCheckbox, data.id)
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
onHeaderClickEvent.invoke(
|
onHeaderClickEvent.invoke(
|
||||||
DownloadHeaderClickEvent(
|
DownloadHeaderClickEvent(
|
||||||
DOWNLOAD_ACTION_LOAD_RESULT,
|
DOWNLOAD_ACTION_LOAD_RESULT,
|
||||||
data
|
data
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnLongClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
downloadHeaderTitle.text = data.name
|
||||||
|
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
|
||||||
|
|
||||||
setOnLongClickListener {
|
if (card.child != null) {
|
||||||
toggleIsChecked(deleteCheckbox, data.id)
|
handleChildDownload(card, formattedSize)
|
||||||
true
|
} else handleParentDownload(card, formattedSize)
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadHeaderTitle.text = data.name
|
|
||||||
val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes)
|
|
||||||
|
|
||||||
if (card.child != null) {
|
if (isMultiDeleteState) {
|
||||||
handleChildDownload(card, formattedSize)
|
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
} else handleParentDownload(card, formattedSize)
|
onItemSelectionChanged.invoke(data.id, isChecked)
|
||||||
|
}
|
||||||
|
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
||||||
|
|
||||||
if (isMultiDeleteState) {
|
deleteCheckbox.apply {
|
||||||
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
isVisible = isMultiDeleteState
|
||||||
onItemSelectionChanged.invoke(data.id, isChecked)
|
isChecked = card.isSelected
|
||||||
}
|
|
||||||
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
|
||||||
|
|
||||||
deleteCheckbox.apply {
|
|
||||||
isVisible = isMultiDeleteState
|
|
||||||
isChecked = card.isSelected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DownloadHeaderEpisodeBinding.handleChildDownload(
|
|
||||||
card: VisualDownloadCached.Header,
|
|
||||||
formattedSize: String
|
|
||||||
) {
|
|
||||||
card.child ?: return
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadButton.resetView()
|
private fun DownloadHeaderEpisodeBinding.handleChildDownload(
|
||||||
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
card: VisualDownloadCached.Header,
|
||||||
if (status == DownloadStatusTell.IsDone) {
|
formattedSize: String
|
||||||
// We do this here instead if we are finished downloading
|
) {
|
||||||
// so that we can use the value from the view model
|
card.child ?: return
|
||||||
// rather than extra unneeded disk operations and to prevent a
|
downloadHeaderGotoChild.isVisible = false
|
||||||
// delay in updating download icon state.
|
|
||||||
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
|
||||||
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
|
|
||||||
// We will let the view model handle this
|
|
||||||
downloadButton.doSetProgress = false
|
|
||||||
downloadButton.progressBar.progressDrawable =
|
|
||||||
downloadButton.getDrawableFromStatus(status)
|
|
||||||
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
|
||||||
downloadHeaderInfo.text = formattedSize
|
|
||||||
} else {
|
|
||||||
// We need to make sure we restore the correct progress
|
|
||||||
// when we refresh data in the adapter.
|
|
||||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
|
||||||
ContextCompat.getDrawable(downloadButton.context, it)
|
|
||||||
}
|
|
||||||
downloadButton.statusView.setImageDrawable(drawable)
|
|
||||||
downloadButton.progressBar.progressDrawable =
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
downloadButton.context,
|
|
||||||
downloadButton.progressDrawable
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadHeaderInfo.isVisible = true
|
val posDur = getViewPos(card.data.id)
|
||||||
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
|
downloadHeaderEpisodeProgress.apply {
|
||||||
downloadButton.isVisible = !isMultiDeleteState
|
|
||||||
|
|
||||||
if (!isMultiDeleteState) {
|
|
||||||
episodeHolder.setOnClickListener {
|
|
||||||
onItemClickEvent.invoke(
|
|
||||||
DownloadClickEvent(
|
|
||||||
DOWNLOAD_ACTION_PLAY_FILE,
|
|
||||||
card.child
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DownloadHeaderEpisodeBinding.handleParentDownload(
|
|
||||||
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,
|
|
||||||
downloadHeaderInfo.context.resources.getQuantityString(
|
|
||||||
R.plurals.episodes,
|
|
||||||
card.totalDownloads
|
|
||||||
),
|
|
||||||
formattedSize
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
downloadHeaderInfo.text = null
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isMultiDeleteState) {
|
|
||||||
episodeHolder.setOnClickListener {
|
|
||||||
onHeaderClickEvent.invoke(
|
|
||||||
DownloadHeaderClickEvent(
|
|
||||||
DOWNLOAD_ACTION_GO_TO_CHILD,
|
|
||||||
card.data
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) {
|
|
||||||
if (binding !is DownloadChildEpisodeBinding || card == null) return
|
|
||||||
|
|
||||||
val data = card.data
|
|
||||||
binding.apply {
|
|
||||||
val posDur = getViewPos(data.id)
|
|
||||||
downloadChildEpisodeProgress.apply {
|
|
||||||
isVisible = posDur != null
|
isVisible = posDur != null
|
||||||
posDur?.let {
|
posDur?.let {
|
||||||
val max = (it.duration / 1000).toInt()
|
val visualPos = it.fixVisual()
|
||||||
val progress = (it.position / 1000).toInt()
|
max = (visualPos.duration / 1000).toInt()
|
||||||
|
progress = (visualPos.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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadButton.resetView()
|
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
||||||
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
|
||||||
if (status == DownloadStatusTell.IsDone) {
|
if (status == DownloadStatusTell.IsDone) {
|
||||||
// We do this here instead if we are finished downloading
|
// We do this here instead if we are finished downloading
|
||||||
// so that we can use the value from the view model
|
// so that we can use the value from the view model
|
||||||
// rather than extra unneeded disk operations and to prevent a
|
// rather than extra unneeded disk operations and to prevent a
|
||||||
// delay in updating download icon state.
|
// delay in updating download icon state.
|
||||||
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||||
downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
|
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
|
||||||
// We will let the view model handle this
|
// We will let the view model handle this
|
||||||
downloadButton.doSetProgress = false
|
downloadButton.doSetProgress = false
|
||||||
downloadButton.progressBar.progressDrawable =
|
downloadButton.progressBar.progressDrawable =
|
||||||
downloadButton.getDrawableFromStatus(status)
|
downloadButton.getDrawableFromStatus(status)
|
||||||
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||||
downloadChildEpisodeTextExtra.text =
|
downloadHeaderInfo.text = formattedSize
|
||||||
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
|
|
||||||
} else {
|
} else {
|
||||||
// We need to make sure we restore the correct progress
|
// We need to make sure we restore the correct progress
|
||||||
// when we refresh data in the adapter.
|
// when we refresh data in the adapter.
|
||||||
|
downloadButton.resetView()
|
||||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||||
ContextCompat.getDrawable(downloadButton.context, it)
|
ContextCompat.getDrawable(downloadButton.context, it)
|
||||||
}
|
}
|
||||||
|
|
@ -308,105 +195,199 @@ class DownloadAdapter(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadButton.setDefaultClickListener(
|
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
|
||||||
data,
|
|
||||||
downloadChildEpisodeTextExtra,
|
|
||||||
onItemClickEvent
|
|
||||||
)
|
|
||||||
downloadButton.isVisible = !isMultiDeleteState
|
downloadButton.isVisible = !isMultiDeleteState
|
||||||
|
|
||||||
downloadChildEpisodeText.apply {
|
if (!isMultiDeleteState) {
|
||||||
text = context.getNameFull(data.name, data.episode, data.season)
|
episodeHolder.setOnClickListener {
|
||||||
isSelected = true // Needed for text repeating
|
onItemClickEvent.invoke(
|
||||||
|
DownloadClickEvent(
|
||||||
|
DOWNLOAD_ACTION_PLAY_FILE,
|
||||||
|
card.child
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DownloadHeaderEpisodeBinding.handleParentDownload(
|
||||||
|
card: VisualDownloadCached.Header,
|
||||||
|
formattedSize: String
|
||||||
|
) {
|
||||||
|
downloadButton.isVisible = false
|
||||||
|
downloadHeaderEpisodeProgress.isVisible = false
|
||||||
|
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloadHeaderInfo.text =
|
||||||
|
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
|
||||||
|
card.totalDownloads,
|
||||||
|
downloadHeaderInfo.context.resources.getQuantityString(
|
||||||
|
R.plurals.episodes,
|
||||||
|
card.totalDownloads
|
||||||
|
),
|
||||||
|
formattedSize
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
downloadHeaderInfo.text = null
|
||||||
|
logError(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadChildEpisodeHolder.setOnClickListener {
|
if (!isMultiDeleteState) {
|
||||||
onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
|
episodeHolder.setOnClickListener {
|
||||||
|
onHeaderClickEvent.invoke(
|
||||||
|
DownloadHeaderClickEvent(
|
||||||
|
DOWNLOAD_ACTION_GO_TO_CHILD,
|
||||||
|
card.data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
downloadChildEpisodeHolder.apply {
|
private fun bindChild(card: VisualDownloadCached.Child?) {
|
||||||
when {
|
if (binding !is DownloadChildEpisodeBinding || card == null) return
|
||||||
isMultiDeleteState -> {
|
|
||||||
setOnClickListener {
|
val data = card.data
|
||||||
toggleIsChecked(deleteCheckbox, data.id)
|
binding.apply {
|
||||||
}
|
val posDur = getViewPos(data.id)
|
||||||
setOnLongClickListener {
|
downloadChildEpisodeProgress.apply {
|
||||||
toggleIsChecked(deleteCheckbox, data.id)
|
isVisible = posDur != null
|
||||||
true
|
posDur?.let {
|
||||||
}
|
val visualPos = it.fixVisual()
|
||||||
|
max = (visualPos.duration / 1000).toInt()
|
||||||
|
progress = (visualPos.position / 1000).toInt()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
||||||
setOnClickListener {
|
if (status == DownloadStatusTell.IsDone) {
|
||||||
onItemClickEvent.invoke(
|
// We do this here instead if we are finished downloading
|
||||||
DownloadClickEvent(
|
// so that we can use the value from the view model
|
||||||
DOWNLOAD_ACTION_PLAY_FILE,
|
// rather than extra unneeded disk operations and to prevent a
|
||||||
data
|
// delay in updating download icon state.
|
||||||
|
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||||
|
downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
|
||||||
|
// We will let the view model handle this
|
||||||
|
downloadButton.doSetProgress = false
|
||||||
|
downloadButton.progressBar.progressDrawable =
|
||||||
|
downloadButton.getDrawableFromStatus(status)
|
||||||
|
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||||
|
downloadChildEpisodeTextExtra.text =
|
||||||
|
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
downloadButton.statusView.setImageDrawable(drawable)
|
||||||
|
downloadButton.progressBar.progressDrawable =
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
downloadButton.context,
|
||||||
|
downloadButton.progressDrawable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadButton.setDefaultClickListener(
|
||||||
|
data,
|
||||||
|
downloadChildEpisodeTextExtra,
|
||||||
|
onItemClickEvent
|
||||||
|
)
|
||||||
|
downloadButton.isVisible = !isMultiDeleteState
|
||||||
|
|
||||||
|
downloadChildEpisodeText.apply {
|
||||||
|
text = context.getNameFull(data.name, data.episode, data.season)
|
||||||
|
isSelected = true // Needed for text repeating
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadChildEpisodeHolder.setOnClickListener {
|
||||||
|
onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadChildEpisodeHolder.apply {
|
||||||
|
when {
|
||||||
|
isMultiDeleteState -> {
|
||||||
|
setOnClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
setOnClickListener {
|
||||||
|
onItemClickEvent.invoke(
|
||||||
|
DownloadClickEvent(
|
||||||
|
DOWNLOAD_ACTION_PLAY_FILE,
|
||||||
|
data
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setOnLongClickListener {
|
|
||||||
onItemSelectionChanged.invoke(data.id, true)
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMultiDeleteState) {
|
setOnLongClickListener {
|
||||||
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
onItemSelectionChanged.invoke(data.id, isChecked)
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
|
||||||
|
|
||||||
deleteCheckbox.apply {
|
if (isMultiDeleteState) {
|
||||||
isVisible = isMultiDeleteState
|
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
isChecked = card.isSelected
|
onItemSelectionChanged.invoke(data.id, isChecked)
|
||||||
|
}
|
||||||
|
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
||||||
|
|
||||||
|
deleteCheckbox.apply {
|
||||||
|
isVisible = isMultiDeleteState
|
||||||
|
isChecked = card.isSelected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState<Any> {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
|
||||||
val inflater = LayoutInflater.from(parent.context)
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
val binding = when (viewType) {
|
val binding = when (viewType) {
|
||||||
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
|
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
|
||||||
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
|
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
|
||||||
else -> throw IllegalArgumentException("Invalid view type")
|
else -> throw IllegalArgumentException("Invalid view type")
|
||||||
}
|
}
|
||||||
return ViewHolderState(binding)
|
return DownloadViewHolder(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindContent(
|
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
|
||||||
holder: ViewHolderState<Any>,
|
holder.bind(getItem(position))
|
||||||
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 customContentViewType(item: VisualDownloadCached): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
return when (item) {
|
return when (getItem(position)) {
|
||||||
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
|
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
|
||||||
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
|
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
|
||||||
|
else -> throw IllegalArgumentException("Invalid data type at position $position")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
fun setIsMultiDeleteState(value: Boolean) {
|
fun setIsMultiDeleteState(value: Boolean) {
|
||||||
if (isMultiDeleteState == value) return
|
if (isMultiDeleteState == value) return
|
||||||
isMultiDeleteState = value
|
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) {
|
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import android.content.DialogInterface
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
|
||||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
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.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
|
|
||||||
object DownloadButtonSetup {
|
object DownloadButtonSetup {
|
||||||
|
|
@ -83,7 +82,7 @@ object DownloadButtonSetup {
|
||||||
} else {
|
} else {
|
||||||
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
|
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
|
||||||
if (pkg != null) {
|
if (pkg != null) {
|
||||||
DownloadQueueManager.addToQueue(pkg.toWrapper())
|
VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
|
||||||
} else {
|
} else {
|
||||||
VideoDownloadManager.downloadEvent.invoke(
|
VideoDownloadManager.downloadEvent.invoke(
|
||||||
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
|
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
|
||||||
|
|
@ -96,7 +95,7 @@ object DownloadButtonSetup {
|
||||||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
val length =
|
val length =
|
||||||
VideoDownloadManager.getDownloadFileInfo(
|
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
|
||||||
act,
|
act,
|
||||||
click.data.id
|
click.data.id
|
||||||
)?.fileLength
|
)?.fileLength
|
||||||
|
|
@ -111,31 +110,24 @@ object DownloadButtonSetup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_CANCEL_PENDING -> {
|
|
||||||
DownloadQueueManager.cancelDownload(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
DOWNLOAD_ACTION_PLAY_FILE -> {
|
DOWNLOAD_ACTION_PLAY_FILE -> {
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
val parent = getKey<DownloadObjects.DownloadHeaderCached>(
|
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
click.data.parentId.toString()
|
click.data.parentId.toString()
|
||||||
) ?: return
|
) ?: return
|
||||||
|
|
||||||
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
|
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
?.mapNotNull {
|
?.mapNotNull {
|
||||||
getKey<DownloadObjects.DownloadEpisodeCached>(it)
|
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
|
||||||
}
|
}
|
||||||
?.filter { it.parentId == click.data.parentId }
|
?.filter { it.parentId == click.data.parentId }
|
||||||
|
|
||||||
val items = mutableListOf<ExtractorUri>()
|
val items = mutableListOf<ExtractorUri>()
|
||||||
val allRelevantEpisodes =
|
val allRelevantEpisodes = episodes?.sortedWith(compareBy<VideoDownloadHelper.DownloadEpisodeCached> { it.season ?: 0 }.thenBy { it.episode })
|
||||||
episodes?.sortedWith(compareBy<DownloadObjects.DownloadEpisodeCached> {
|
|
||||||
it.season ?: 0
|
|
||||||
}.thenBy { it.episode })
|
|
||||||
|
|
||||||
allRelevantEpisodes?.forEach {
|
allRelevantEpisodes?.forEach {
|
||||||
val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>(
|
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
|
||||||
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
||||||
it.id.toString()
|
it.id.toString()
|
||||||
) ?: return@forEach
|
) ?: return@forEach
|
||||||
|
|
@ -149,7 +141,7 @@ object DownloadButtonSetup {
|
||||||
uri = Uri.EMPTY,
|
uri = Uri.EMPTY,
|
||||||
id = it.id,
|
id = it.id,
|
||||||
parentId = it.parentId,
|
parentId = it.parentId,
|
||||||
name = it.name ?: act.getString(R.string.downloaded_file),
|
name = act.getString(R.string.downloaded_file),
|
||||||
season = it.season,
|
season = it.season,
|
||||||
episode = it.episode,
|
episode = it.episode,
|
||||||
headerName = parent.name,
|
headerName = parent.name,
|
||||||
|
|
@ -162,8 +154,7 @@ object DownloadButtonSetup {
|
||||||
}
|
}
|
||||||
act.navigate(
|
act.navigate(
|
||||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||||
DownloadFileGenerator(items),
|
DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) }
|
||||||
items.indexOfFirst { it.id == click.data.id }
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,32 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.view.isGone
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isVisible
|
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.R
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
|
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
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.DownloadButtonSetup.handleDownloadClick
|
||||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||||
|
|
||||||
class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
class DownloadChildFragment : Fragment() {
|
||||||
BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate)
|
private lateinit var downloadsViewModel: DownloadViewModel
|
||||||
) {
|
private var binding: FragmentChildDownloadsBinding? = null
|
||||||
|
|
||||||
private val downloadViewModel: DownloadViewModel by activityViewModels()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun newInstance(headerName: String, folder: String): Bundle {
|
fun newInstance(headerName: String, folder: String): Bundle {
|
||||||
|
|
@ -42,104 +39,99 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
activity?.detachBackPressedCallback("Downloads")
|
activity?.detachBackPressedCallback("Downloads")
|
||||||
downloadViewModel.clearChildren()
|
binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fixLayout(view: View) {
|
override fun onCreateView(
|
||||||
fixSystemBarsPadding(
|
inflater: LayoutInflater,
|
||||||
view,
|
container: ViewGroup?,
|
||||||
padBottom = isLandscape(),
|
savedInstanceState: Bundle?
|
||||||
padLeft = isLayout(TV or EMULATOR)
|
): 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 folder = arguments?.getString("folder")
|
||||||
val name = arguments?.getString("name")
|
val name = arguments?.getString("name")
|
||||||
if (folder == null) {
|
if (folder == null) {
|
||||||
dispatchBackPressed()
|
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context?.let { downloadViewModel.updateChildList(it, folder) }
|
binding?.downloadChildToolbar?.apply {
|
||||||
|
|
||||||
binding.downloadChildToolbar.apply {
|
|
||||||
title = name
|
title = name
|
||||||
if (isLayout(PHONE or EMULATOR)) {
|
if (isLayout(PHONE or EMULATOR)) {
|
||||||
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
||||||
setNavigationOnClickListener {
|
setNavigationOnClickListener {
|
||||||
dispatchBackPressed()
|
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAppBarNoScrollFlagsOnTV()
|
setAppBarNoScrollFlagsOnTV()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
|
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||||
|
|
||||||
observe(downloadViewModel.childCards) { cards ->
|
observe(downloadsViewModel.childCards) {
|
||||||
when (cards) {
|
if (it.isEmpty()) {
|
||||||
is Resource.Success -> {
|
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
if (cards.value.isEmpty()) {
|
return@observe
|
||||||
dispatchBackPressed()
|
|
||||||
}
|
|
||||||
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||||
}
|
}
|
||||||
|
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||||
observe(downloadViewModel.selectedBytes) {
|
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||||
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
|
|
||||||
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||||
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
|
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||||
binding.downloadChildToolbar.isGone = isMultiDeleteState
|
if (!isMultiDeleteState) {
|
||||||
|
|
||||||
if (selection == null) {
|
|
||||||
activity?.detachBackPressedCallback("Downloads")
|
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()
|
val allSelected = downloadsViewModel.isAllSelected()
|
||||||
binding.selectItemsText.isVisible = selection.isEmpty()
|
|
||||||
|
|
||||||
val allSelected = downloadViewModel.isAllChildrenSelected()
|
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
binding.btnToggleAll.setText(R.string.deselect_all)
|
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||||
} else binding.btnToggleAll.setText(R.string.select_all)
|
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||||
}
|
}
|
||||||
|
|
||||||
val adapter = DownloadAdapter(
|
val adapter = DownloadAdapter(
|
||||||
|
|
@ -147,18 +139,18 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
||||||
{ click ->
|
{ click ->
|
||||||
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||||
context?.let { ctx ->
|
context?.let { ctx ->
|
||||||
downloadViewModel.handleSingleDelete(ctx, click.data.id)
|
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||||
}
|
}
|
||||||
} else handleDownloadClick(click)
|
} else handleDownloadClick(click)
|
||||||
},
|
},
|
||||||
{ itemId, isChecked ->
|
{ itemId, isChecked ->
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
downloadViewModel.addSelected(itemId)
|
downloadsViewModel.addSelected(itemId)
|
||||||
} else downloadViewModel.removeSelected(itemId)
|
} else downloadsViewModel.removeSelected(itemId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
binding.downloadChildList.apply {
|
binding?.downloadChildList?.apply {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
setItemViewCacheSize(20)
|
setItemViewCacheSize(20)
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
|
|
@ -168,6 +160,43 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
|
||||||
nextDown = FOCUS_SELF,
|
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) {
|
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,13 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
|
@ -17,28 +22,23 @@ import androidx.annotation.StringRes
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.widget.doOnTextChanged
|
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.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
|
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
|
||||||
import com.lagradost.cloudstream3.databinding.StreamInputBinding
|
import com.lagradost.cloudstream3.databinding.StreamInputBinding
|
||||||
import com.lagradost.cloudstream3.isEpisodeBased
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
|
||||||
import com.lagradost.cloudstream3.mvvm.safe
|
import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
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.DownloadButtonSetup.handleDownloadClick
|
||||||
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel
|
|
||||||
import com.lagradost.cloudstream3.ui.player.BasicLink
|
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||||
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
||||||
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
||||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
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.DOWNLOAD_EPISODE_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||||
|
|
@ -54,12 +54,9 @@ import java.net.URI
|
||||||
|
|
||||||
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
|
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
|
||||||
|
|
||||||
class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
class DownloadFragment : Fragment() {
|
||||||
BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate)
|
private lateinit var downloadsViewModel: DownloadViewModel
|
||||||
) {
|
private var binding: FragmentDownloadsBinding? = null
|
||||||
|
|
||||||
private val downloadViewModel: DownloadViewModel by activityViewModels()
|
|
||||||
private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
|
|
||||||
|
|
||||||
private fun View.setLayoutWidth(weight: Long) {
|
private fun View.setLayoutWidth(weight: Long) {
|
||||||
val param = LinearLayout.LayoutParams(
|
val param = LinearLayout.LayoutParams(
|
||||||
|
|
@ -72,135 +69,120 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
activity?.detachBackPressedCallback("Downloads")
|
activity?.detachBackPressedCallback("Downloads")
|
||||||
|
binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fixLayout(view: View) {
|
override fun onCreateView(
|
||||||
fixSystemBarsPadding(
|
inflater: LayoutInflater,
|
||||||
view,
|
container: ViewGroup?,
|
||||||
padBottom = isLandscape(),
|
savedInstanceState: Bundle?
|
||||||
padLeft = isLayout(TV or EMULATOR)
|
): 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()
|
hideKeyboard()
|
||||||
binding.downloadAppbar.setAppBarNoScrollFlagsOnTV()
|
binding?.downloadAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||||
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
|
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||||
|
|
||||||
observe(downloadViewModel.headerCards) { cards ->
|
/**
|
||||||
when (cards) {
|
* We never want to retain multi-delete state
|
||||||
is Resource.Success -> {
|
* when navigating to downloads. Setting this state
|
||||||
(binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value)
|
* immediately can sometimes result in the observer
|
||||||
binding.textNoDownloads.isVisible = cards.value.isEmpty()
|
* not being notified in time to update the UI.
|
||||||
binding.downloadLoading.isVisible = false
|
*
|
||||||
binding.downloadList.isVisible = true
|
* 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
|
||||||
is Resource.Loading -> {
|
* observer to properly receive and handle the state change.
|
||||||
binding.downloadList.isVisible = false
|
*/
|
||||||
binding.downloadLoading.isVisible = true
|
Handler(Looper.getMainLooper()).post {
|
||||||
}
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
|
|
||||||
is Resource.Failure -> {
|
|
||||||
binding.downloadList.isVisible = true
|
|
||||||
binding.downloadLoading.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(downloadViewModel.availableBytes) {
|
/**
|
||||||
|
* 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()
|
||||||
|
|
||||||
|
observe(downloadsViewModel.headerCards) {
|
||||||
|
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||||
|
binding?.downloadLoading?.isVisible = false
|
||||||
|
binding?.textNoDownloads?.isVisible = it.isEmpty()
|
||||||
|
}
|
||||||
|
observe(downloadsViewModel.availableBytes) {
|
||||||
updateStorageInfo(
|
updateStorageInfo(
|
||||||
binding.root.context,
|
view.context,
|
||||||
it,
|
it,
|
||||||
R.string.free_storage,
|
R.string.free_storage,
|
||||||
binding.downloadFreeTxt,
|
binding?.downloadFreeTxt,
|
||||||
binding.downloadFree
|
binding?.downloadFree
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
observe(downloadViewModel.usedBytes) {
|
observe(downloadsViewModel.usedBytes) {
|
||||||
updateStorageInfo(
|
updateStorageInfo(
|
||||||
binding.root.context,
|
view.context,
|
||||||
it,
|
it,
|
||||||
R.string.used_storage,
|
R.string.used_storage,
|
||||||
binding.downloadUsedTxt,
|
binding?.downloadUsedTxt,
|
||||||
binding.downloadUsed
|
binding?.downloadUsed
|
||||||
)
|
)
|
||||||
|
|
||||||
val hasBytes = it > 0
|
val hasBytes = it > 0
|
||||||
if (hasBytes) {
|
if(hasBytes) {
|
||||||
binding.downloadLoadingBytes.stopShimmer()
|
binding?.downloadLoadingBytes?.stopShimmer()
|
||||||
} else binding.downloadLoadingBytes.startShimmer()
|
} else {
|
||||||
|
binding?.downloadLoadingBytes?.startShimmer()
|
||||||
|
}
|
||||||
|
|
||||||
binding.downloadBytesBar.isVisible = hasBytes
|
binding?.downloadBytesBar?.isVisible = hasBytes
|
||||||
binding.downloadLoadingBytes.isGone = hasBytes
|
binding?.downloadLoadingBytes?.isGone = hasBytes
|
||||||
}
|
}
|
||||||
observe(downloadViewModel.downloadBytes) {
|
observe(downloadsViewModel.downloadBytes) {
|
||||||
updateStorageInfo(
|
updateStorageInfo(
|
||||||
binding.root.context,
|
view.context,
|
||||||
it,
|
it,
|
||||||
R.string.app_storage,
|
R.string.app_storage,
|
||||||
binding.downloadAppTxt,
|
binding?.downloadAppTxt,
|
||||||
binding.downloadApp
|
binding?.downloadApp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
observe(downloadQueueViewModel.childCards) { cards ->
|
observe(downloadsViewModel.selectedBytes) {
|
||||||
val size = cards.currentDownloads.size + cards.queue.size
|
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||||
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.isMultiDeleteState) { isMultiDeleteState ->
|
||||||
observe(downloadViewModel.selectedBytes) {
|
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||||
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
|
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||||
}
|
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||||
|
if (!isMultiDeleteState) {
|
||||||
binding.apply {
|
activity?.detachBackPressedCallback("Downloads")
|
||||||
btnDelete.setOnClickListener { view ->
|
downloadsViewModel.clearSelectedItems()
|
||||||
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
|
// Prevent race condition and make sure
|
||||||
}
|
// we don't display it early
|
||||||
|
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
|
||||||
btnCancel.setOnClickListener {
|
binding?.downloadAppbar?.isVisible = true
|
||||||
downloadViewModel.cancelSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
btnToggleAll.setOnClickListener {
|
|
||||||
val allSelected = downloadViewModel.isAllHeadersSelected()
|
|
||||||
if (allSelected) {
|
|
||||||
downloadViewModel.clearSelectedItems()
|
|
||||||
} else {
|
|
||||||
downloadViewModel.selectAllHeaders()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
observe(downloadsViewModel.selectedItemIds) {
|
||||||
|
handleSelectedChange(it)
|
||||||
|
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||||
|
|
||||||
observeNullable(downloadViewModel.selectedItemIds) { selection ->
|
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||||
val isMultiDeleteState = selection != null
|
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||||
val adapter = binding.downloadList.adapter as? DownloadAdapter
|
|
||||||
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
|
||||||
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
|
|
||||||
binding.downloadAppbar.isGone = isMultiDeleteState
|
|
||||||
|
|
||||||
if (selection == null) {
|
val allSelected = downloadsViewModel.isAllSelected()
|
||||||
activity?.detachBackPressedCallback("Downloads")
|
|
||||||
return@observeNullable
|
|
||||||
}
|
|
||||||
activity?.attachBackPressedCallback("Downloads") {
|
|
||||||
downloadViewModel.cancelSelection()
|
|
||||||
}
|
|
||||||
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
|
|
||||||
|
|
||||||
binding.btnDelete.isVisible = selection.isNotEmpty()
|
|
||||||
binding.selectItemsText.isVisible = selection.isEmpty()
|
|
||||||
|
|
||||||
val allSelected = downloadViewModel.isAllHeadersSelected()
|
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
binding.btnToggleAll.setText(R.string.deselect_all)
|
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||||
} else binding.btnToggleAll.setText(R.string.select_all)
|
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||||
}
|
}
|
||||||
|
|
||||||
val adapter = DownloadAdapter(
|
val adapter = DownloadAdapter(
|
||||||
|
|
@ -208,29 +190,29 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
{ click ->
|
{ click ->
|
||||||
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||||
context?.let { ctx ->
|
context?.let { ctx ->
|
||||||
downloadViewModel.handleSingleDelete(ctx, click.data.id)
|
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||||
}
|
}
|
||||||
} else handleDownloadClick(click)
|
} else handleDownloadClick(click)
|
||||||
},
|
},
|
||||||
{ itemId, isChecked ->
|
{ itemId, isChecked ->
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
downloadViewModel.addSelected(itemId)
|
downloadsViewModel.addSelected(itemId)
|
||||||
} else downloadViewModel.removeSelected(itemId)
|
} else downloadsViewModel.removeSelected(itemId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
binding.downloadList.apply {
|
binding?.downloadList?.apply {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
setItemViewCacheSize(20)
|
setItemViewCacheSize(20)
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
setLinearListLayout(
|
setLinearListLayout(
|
||||||
isHorizontal = false,
|
isHorizontal = false,
|
||||||
nextRight = FOCUS_SELF,
|
nextRight = FOCUS_SELF,
|
||||||
nextDown = R.id.download_queue_button,
|
nextDown = FOCUS_SELF,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.apply {
|
binding?.apply {
|
||||||
openLocalVideoButton.apply {
|
openLocalVideoButton.apply {
|
||||||
isGone = isLayout(TV)
|
isGone = isLayout(TV)
|
||||||
setOnClickListener { openLocalVideo() }
|
setOnClickListener { openLocalVideo() }
|
||||||
|
|
@ -240,10 +222,6 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
setOnClickListener { showStreamInputDialog(it.context) }
|
setOnClickListener { showStreamInputDialog(it.context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueueButton.setOnClickListener {
|
|
||||||
activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
|
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
|
||||||
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
|
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
|
||||||
|
|
||||||
|
|
@ -252,12 +230,13 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||||
handleScroll(scrollY - oldScrollY)
|
handleScroll(scrollY - oldScrollY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context?.let { downloadViewModel.updateHeaderList(it) }
|
context?.let { downloadsViewModel.updateHeaderList(it) }
|
||||||
|
fixPaddingStatusbar(binding?.downloadRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleItemClick(click: DownloadHeaderClickEvent) {
|
private fun handleItemClick(click: DownloadHeaderClickEvent) {
|
||||||
|
|
@ -279,6 +258,40 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||||
|
if (selected.isNotEmpty()) {
|
||||||
|
binding?.downloadDeleteAppbar?.isVisible = true
|
||||||
|
binding?.downloadAppbar?.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) {
|
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||||
val formattedSize = formatShortFileSize(context, selectedBytes)
|
val formattedSize = formatShortFileSize(context, selectedBytes)
|
||||||
binding?.btnDelete?.text =
|
binding?.btnDelete?.text =
|
||||||
|
|
@ -349,8 +362,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
listOf(BasicLink(url)),
|
listOf(BasicLink(url)),
|
||||||
extract = true,
|
extract = true,
|
||||||
refererUrl = referer,
|
refererUrl = referer,
|
||||||
id = url.hashCode()
|
)
|
||||||
), 0
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dialog.dismissSafe(activity)
|
dialog.dismissSafe(activity)
|
||||||
|
|
@ -381,7 +393,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
||||||
ActivityResultContracts.StartActivityForResult()
|
ActivityResultContracts.StartActivityForResult()
|
||||||
) { result ->
|
) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
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)
|
playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,119 +5,91 @@ import android.content.DialogInterface
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.lagradost.api.Log
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.isEpisodeBased
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
|
||||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.services.DownloadQueueService
|
|
||||||
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.ConsistentLiveData
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP
|
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
||||||
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.getDownloadFileInfo
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class DownloadViewModel : ViewModel() {
|
class DownloadViewModel : ViewModel() {
|
||||||
companion object {
|
|
||||||
const val TAG = "DownloadViewModel"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _headerCards =
|
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
|
||||||
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
|
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
|
||||||
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
|
|
||||||
|
|
||||||
private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading())
|
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
|
||||||
val childCards: LiveData<Resource<List<VisualDownloadCached.Child>>> = _childCards
|
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
|
||||||
|
|
||||||
private val _usedBytes = ConsistentLiveData<Long>()
|
private val _usedBytes = MutableLiveData<Long>()
|
||||||
val usedBytes: LiveData<Long> = _usedBytes
|
val usedBytes: LiveData<Long> = _usedBytes
|
||||||
|
|
||||||
private val _availableBytes = ConsistentLiveData<Long>()
|
private val _availableBytes = MutableLiveData<Long>()
|
||||||
val availableBytes: LiveData<Long> = _availableBytes
|
val availableBytes: LiveData<Long> = _availableBytes
|
||||||
|
|
||||||
private val _downloadBytes = ConsistentLiveData<Long>()
|
private val _downloadBytes = MutableLiveData<Long>()
|
||||||
val downloadBytes: LiveData<Long> = _downloadBytes
|
val downloadBytes: LiveData<Long> = _downloadBytes
|
||||||
|
|
||||||
private val _selectedBytes = ConsistentLiveData<Long>(0)
|
private val _selectedBytes = MutableLiveData<Long>(0)
|
||||||
val selectedBytes: LiveData<Long> = _selectedBytes
|
val selectedBytes: LiveData<Long> = _selectedBytes
|
||||||
|
|
||||||
private val _selectedItemIds = ConsistentLiveData<Set<Int>?>(null)
|
private val _isMultiDeleteState = MutableLiveData(false)
|
||||||
val selectedItemIds: LiveData<Set<Int>?> = _selectedItemIds
|
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
|
||||||
|
|
||||||
|
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
|
||||||
|
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
|
||||||
|
|
||||||
fun cancelSelection() {
|
private var previousVisual: List<VisualDownloadCached>? = null
|
||||||
updateSelectedItems { null }
|
|
||||||
|
fun setIsMultiDeleteState(value: Boolean) {
|
||||||
|
_isMultiDeleteState.postValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSelected(itemId: Int) {
|
fun addSelected(itemId: Int) {
|
||||||
updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) }
|
updateSelectedItems { it.add(itemId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeSelected(itemId: Int) {
|
fun removeSelected(itemId: Int) {
|
||||||
updateSelectedItems { it?.minus(itemId) ?: emptySet() }
|
updateSelectedItems { it.remove(itemId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectAllHeaders() {
|
fun selectAllItems() {
|
||||||
updateSelectedItems {
|
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||||
_headerCards.success.orEmpty()
|
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
|
||||||
.map { item -> item.data.id }.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectAllChildren() {
|
|
||||||
updateSelectedItems {
|
|
||||||
_childCards.success.orEmpty()
|
|
||||||
.map { item -> item.data.id }.toSet()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearSelectedItems() {
|
fun clearSelectedItems() {
|
||||||
// We need this to be done immediately
|
// We need this to be done immediately
|
||||||
// so we can't use postValue
|
// 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 currentSelected = selectedItemIds.value ?: return false
|
||||||
val children = _childCards.success.orEmpty()
|
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||||
return currentSelected.size == children.size && children.all { it.data.id in currentSelected }
|
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAllHeadersSelected(): Boolean {
|
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
|
||||||
val currentSelected = selectedItemIds.value ?: return false
|
val currentSelected = selectedItemIds.value ?: mutableSetOf()
|
||||||
val headers = _headerCards.success.orEmpty()
|
action(currentSelected)
|
||||||
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)
|
|
||||||
_selectedItemIds.postValue(currentSelected)
|
_selectedItemIds.postValue(currentSelected)
|
||||||
postHeaders()
|
|
||||||
postChildren()
|
|
||||||
updateSelectedBytes()
|
updateSelectedBytes()
|
||||||
|
updateSelectedCards()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSelectedBytes() = viewModelScope.launchSafe {
|
private fun updateSelectedBytes() = viewModelScope.launchSafe {
|
||||||
|
|
@ -126,173 +98,61 @@ class DownloadViewModel : ViewModel() {
|
||||||
_selectedBytes.postValue(totalSelectedBytes)
|
_selectedBytes.postValue(totalSelectedBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateSelectedCards() = viewModelScope.launchSafe {
|
||||||
|
val currentSelected = selectedItemIds.value ?: return@launchSafe
|
||||||
|
|
||||||
fun removeRedundantEpisodeKeys(context: Context, keys: List<Pair<Int, Int>>) {
|
headerCards.value?.let { headers ->
|
||||||
val settingsManager = context.getSharedPrefs()
|
headers.forEach { header ->
|
||||||
ioSafe {
|
header.isSelected = header.data.id in currentSelected
|
||||||
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.postValue(headers)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun removeRedundantHeaderKeys(
|
childCards.value?.let { children ->
|
||||||
context: Context,
|
children.forEach { child ->
|
||||||
cached: List<DownloadObjects.DownloadHeaderCached>,
|
child.isSelected = child.data.id in currentSelected
|
||||||
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.postValue(children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
||||||
// Do not push loading as it interrupts the UI
|
val visual = withContext(Dispatchers.IO) {
|
||||||
//_headerCards.postValue(Resource.Loading())
|
|
||||||
|
|
||||||
val visual = ioWork {
|
|
||||||
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
.mapNotNull { context.getKey<DownloadObjects.DownloadEpisodeCached>(it) }
|
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
|
||||||
.distinctBy { it.id } // Remove duplicates
|
.distinctBy { it.id } // Remove duplicates
|
||||||
|
|
||||||
val isCurrentlyDownloading =
|
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
|
||||||
DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty()
|
|
||||||
|
|
||||||
val downloadStats =
|
|
||||||
calculateDownloadStats(context, children)
|
calculateDownloadStats(context, children)
|
||||||
|
|
||||||
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
||||||
.mapNotNull { context.getKey<DownloadObjects.DownloadHeaderCached>(it) }
|
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
|
||||||
|
|
||||||
// Download stats and header keys may change when downloading.
|
|
||||||
// To prevent the downloader and key removal from colliding, simply do not prune keys when downloading.
|
|
||||||
if (!isCurrentlyDownloading) {
|
|
||||||
removeRedundantHeaderKeys(
|
|
||||||
context,
|
|
||||||
cached,
|
|
||||||
downloadStats.totalBytesUsedByChild,
|
|
||||||
downloadStats.totalDownloads
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required
|
|
||||||
removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads)
|
|
||||||
|
|
||||||
createVisualDownloadList(
|
createVisualDownloadList(
|
||||||
context,
|
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
|
||||||
cached,
|
|
||||||
downloadStats.totalBytesUsedByChild,
|
|
||||||
downloadStats.currentBytesUsedByChild,
|
|
||||||
downloadStats.totalDownloads
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStorageStats(visual)
|
if (visual != previousVisual) {
|
||||||
postHeaders(visual)
|
previousVisual = visual
|
||||||
|
updateStorageStats(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(
|
private fun calculateDownloadStats(
|
||||||
context: Context,
|
context: Context,
|
||||||
children: List<DownloadObjects.DownloadEpisodeCached>
|
children: List<VideoDownloadHelper.DownloadEpisodeCached>
|
||||||
): DownloadStats {
|
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
|
||||||
// parentId : bytes
|
// parentId : bytes
|
||||||
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||||
// parentId : bytes
|
// parentId : bytes
|
||||||
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||||
// parentId : downloadsCount
|
// parentId : downloadsCount
|
||||||
val totalDownloads = mutableMapOf<Int, Int>()
|
val totalDownloads = mutableMapOf<Int, Int>()
|
||||||
val redundantDownloads = mutableListOf<Pair<Int, Int>>()
|
|
||||||
|
|
||||||
children.forEach { child ->
|
children.forEach { child ->
|
||||||
val childFile = getDownloadFileInfo(context, child.id)
|
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
|
||||||
|
|
||||||
if (childFile == null) {
|
|
||||||
// It may not be a redundant child if something is currently downloading.
|
|
||||||
// DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader
|
|
||||||
// leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE
|
|
||||||
if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) {
|
|
||||||
redundantDownloads.add(child.parentId to child.id)
|
|
||||||
}
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
if (childFile.fileLength <= 1) return@forEach
|
if (childFile.fileLength <= 1) return@forEach
|
||||||
|
|
||||||
val len = childFile.totalBytes
|
val len = childFile.totalBytes
|
||||||
|
|
@ -302,17 +162,12 @@ class DownloadViewModel : ViewModel() {
|
||||||
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
||||||
totalDownloads.merge(child.parentId, 1, Int::plus)
|
totalDownloads.merge(child.parentId, 1, Int::plus)
|
||||||
}
|
}
|
||||||
return DownloadStats(
|
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
|
||||||
totalBytesUsedByChild,
|
|
||||||
currentBytesUsedByChild,
|
|
||||||
totalDownloads,
|
|
||||||
redundantDownloads
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createVisualDownloadList(
|
private fun createVisualDownloadList(
|
||||||
context: Context,
|
context: Context,
|
||||||
cached: List<DownloadObjects.DownloadHeaderCached>,
|
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
|
||||||
totalBytesUsedByChild: Map<Int, Long>,
|
totalBytesUsedByChild: Map<Int, Long>,
|
||||||
currentBytesUsedByChild: Map<Int, Long>,
|
currentBytesUsedByChild: Map<Int, Long>,
|
||||||
totalDownloads: Map<Int, Int>
|
totalDownloads: Map<Int, Int>
|
||||||
|
|
@ -321,17 +176,13 @@ class DownloadViewModel : ViewModel() {
|
||||||
val downloads = totalDownloads[it.id] ?: 0
|
val downloads = totalDownloads[it.id] ?: 0
|
||||||
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
||||||
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
||||||
|
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
|
||||||
if (bytes <= 0 || downloads <= 0) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||||
val movieEpisode =
|
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||||
if (it.type.isEpisodeBased()) null else context.getKey<DownloadObjects.DownloadEpisodeCached>(
|
DOWNLOAD_EPISODE_CACHE,
|
||||||
DOWNLOAD_EPISODE_CACHE,
|
getFolderName(it.id.toString(), it.id.toString())
|
||||||
getFolderName(it.id.toString(), it.id.toString())
|
)
|
||||||
)
|
|
||||||
|
|
||||||
VisualDownloadCached.Header(
|
VisualDownloadCached.Header(
|
||||||
currentBytes = currentBytes,
|
currentBytes = currentBytes,
|
||||||
|
|
@ -357,14 +208,12 @@ class DownloadViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
|
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
|
||||||
_childCards.postValue(Resource.Loading()) // always push loading
|
|
||||||
|
|
||||||
val visual = withContext(Dispatchers.IO) {
|
val visual = withContext(Dispatchers.IO) {
|
||||||
context.getKeys(folder).mapNotNull { key ->
|
context.getKeys(folder).mapNotNull { key ->
|
||||||
context.getKey<DownloadObjects.DownloadEpisodeCached>(key)
|
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
||||||
}.mapNotNull {
|
}.mapNotNull {
|
||||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
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(
|
VisualDownloadCached.Child(
|
||||||
currentBytes = info.fileLength,
|
currentBytes = info.fileLength,
|
||||||
totalBytes = info.totalBytes,
|
totalBytes = info.totalBytes,
|
||||||
|
|
@ -372,21 +221,24 @@ class DownloadViewModel : ViewModel() {
|
||||||
data = it,
|
data = it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.sortedWith(
|
}.sortedWith(compareBy(
|
||||||
compareBy(
|
// Sort by season first, and then by episode number,
|
||||||
// Sort by season first, and then by episode number,
|
// to ensure sorting is consistent.
|
||||||
// to ensure sorting is consistent.
|
{ it.data.season ?: 0 },
|
||||||
{ it.data.season ?: 0 },
|
{ it.data.episode }
|
||||||
{ it.data.episode }
|
))
|
||||||
))
|
|
||||||
|
|
||||||
postChildren(visual)
|
if (previousVisual != visual) {
|
||||||
|
previousVisual = visual
|
||||||
|
_childCards.postValue(visual)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
|
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
|
||||||
_selectedItemIds.postValue(null)
|
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||||
postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove })
|
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||||
postChildren(_childCards.success?.filter { it.data.id !in idsToRemove })
|
_headerCards.postValue(updatedHeaders)
|
||||||
|
_childCards.postValue(updatedChildren)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
|
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
|
||||||
|
|
@ -440,7 +292,7 @@ class DownloadViewModel : ViewModel() {
|
||||||
if (item.data.type.isEpisodeBased()) {
|
if (item.data.type.isEpisodeBased()) {
|
||||||
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
.mapNotNull {
|
.mapNotNull {
|
||||||
context.getKey<DownloadObjects.DownloadEpisodeCached>(
|
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -464,7 +316,7 @@ class DownloadViewModel : ViewModel() {
|
||||||
|
|
||||||
is VisualDownloadCached.Child -> {
|
is VisualDownloadCached.Child -> {
|
||||||
ids.add(item.data.id)
|
ids.add(item.data.id)
|
||||||
val parent = context.getKey<DownloadObjects.DownloadHeaderCached>(
|
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
item.data.parentId.toString()
|
item.data.parentId.toString()
|
||||||
)
|
)
|
||||||
|
|
@ -493,16 +345,16 @@ class DownloadViewModel : ViewModel() {
|
||||||
.joinToString(separator = "\n") { "• $it" }
|
.joinToString(separator = "\n") { "• $it" }
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
|
|
||||||
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
|
|
||||||
}
|
|
||||||
|
|
||||||
data.ids.count() == 1 -> {
|
data.ids.count() == 1 -> {
|
||||||
context.getString(R.string.delete_message).format(
|
context.getString(R.string.delete_message).format(
|
||||||
data.names.firstOrNull()
|
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() -> {
|
data.parentName != null && data.names.isNotEmpty() -> {
|
||||||
context.getString(R.string.delete_message_series_episodes)
|
context.getString(R.string.delete_message_series_episodes)
|
||||||
.format(data.parentName, formattedNames)
|
.format(data.parentName, formattedNames)
|
||||||
|
|
@ -531,6 +383,7 @@ class DownloadViewModel : ViewModel() {
|
||||||
when (which) {
|
when (which) {
|
||||||
DialogInterface.BUTTON_POSITIVE -> {
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
viewModelScope.launchSafe {
|
viewModelScope.launchSafe {
|
||||||
|
setIsMultiDeleteState(false)
|
||||||
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
|
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
|
||||||
// We always remove parent because if we are deleting from here
|
// We always remove parent because if we are deleting from here
|
||||||
// and we have it as non-empty, it was triggered on
|
// and we have it as non-empty, it was triggered on
|
||||||
|
|
@ -561,8 +414,8 @@ class DownloadViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
|
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
|
||||||
val headers = _headerCards.success.orEmpty()
|
val headers = headerCards.value.orEmpty()
|
||||||
val children = _childCards.success.orEmpty()
|
val children = childCards.value.orEmpty()
|
||||||
|
|
||||||
return selectedItemIds.value?.mapNotNull { id ->
|
return selectedItemIds.value?.mapNotNull { id ->
|
||||||
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
|
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
|
||||||
|
|
@ -570,11 +423,10 @@ class DownloadViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
|
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() {
|
return (headers + children).filter { it.data.id == itemId }
|
||||||
_childCards.postValue(Resource.Loading())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class DeleteData(
|
private data class DeleteData(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
|
|
||||||
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
|
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
|
||||||
|
|
||||||
|
|
@ -62,7 +62,6 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
|
|
||||||
open fun resetViewData() {
|
open fun resetViewData() {
|
||||||
// lastRequest = null
|
// lastRequest = null
|
||||||
progressText = null
|
|
||||||
isZeroBytes = true
|
isZeroBytes = true
|
||||||
doSetProgress = true
|
doSetProgress = true
|
||||||
persistentId = null
|
persistentId = null
|
||||||
|
|
@ -76,10 +75,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
currentMetaData.id = id
|
currentMetaData.id = id
|
||||||
|
|
||||||
if (!doSetProgress) return
|
if (!doSetProgress) return
|
||||||
val appContext = context.applicationContext
|
|
||||||
|
|
||||||
ioSafe {
|
ioSafe {
|
||||||
val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
|
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
|
||||||
|
|
||||||
mainWork {
|
mainWork {
|
||||||
if (savedData != null) {
|
if (savedData != null) {
|
||||||
val downloadedBytes = savedData.fileLength
|
val downloadedBytes = savedData.fileLength
|
||||||
|
|
@ -87,7 +86,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
|
|
||||||
setProgress(downloadedBytes, totalBytes)
|
setProgress(downloadedBytes, totalBytes)
|
||||||
applyMetaData(id, downloadedBytes, totalBytes)
|
applyMetaData(id, downloadedBytes, totalBytes)
|
||||||
}
|
} else run { resetView() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import androidx.core.view.isVisible
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
|
|
||||||
class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
PieFetchButton(context, attributeSet) {
|
PieFetchButton(context, attributeSet) {
|
||||||
|
|
@ -18,7 +18,6 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
super.onAttachedToWindow()
|
super.onAttachedToWindow()
|
||||||
progressText = findViewById(R.id.result_movie_download_text_precentage)
|
progressText = findViewById(R.id.result_movie_download_text_precentage)
|
||||||
mainText = findViewById(R.id.result_movie_download_text)
|
mainText = findViewById(R.id.result_movie_download_text)
|
||||||
setStatus(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStatus(status: DownloadStatusTell?) {
|
override fun setStatus(status: DownloadStatusTell?) {
|
||||||
|
|
@ -36,7 +35,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDefaultClickListener(
|
override fun setDefaultClickListener(
|
||||||
card: DownloadObjects.DownloadEpisodeCached,
|
card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
textView: TextView?,
|
textView: TextView?,
|
||||||
callback: (DownloadClickEvent) -> Unit
|
callback: (DownloadClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,11 @@ import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.withStyledAttributes
|
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
|
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
|
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||||
|
|
@ -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.DOWNLOAD_ACTION_RESUME_DOWNLOAD
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
|
|
||||||
|
|
||||||
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
BaseFetchButton(context, attributeSet) {
|
BaseFetchButton(context, attributeSet) {
|
||||||
|
|
@ -67,7 +63,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
open fun onInflate() {}
|
open fun onInflate() {}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) {
|
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
|
||||||
try {
|
try {
|
||||||
inflate(
|
inflate(
|
||||||
overrideLayout ?: getResourceId(
|
overrideLayout ?: getResourceId(
|
||||||
|
|
@ -76,7 +72,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
recycle() // Manually call recycle first to avoid memory leaks
|
|
||||||
Log.e(
|
Log.e(
|
||||||
"PieFetchButton", "Error inflating PieFetchButton, " +
|
"PieFetchButton", "Error inflating PieFetchButton, " +
|
||||||
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
|
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
|
||||||
|
|
@ -84,6 +79,11 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
progressBar = findViewById(R.id.progress_downloaded)
|
||||||
|
progressBarBackground = findViewById(R.id.progress_downloaded_background)
|
||||||
|
statusView = findViewById(R.id.image_download_status)
|
||||||
|
|
||||||
animateWaiting = getBoolean(
|
animateWaiting = getBoolean(
|
||||||
R.styleable.PieFetchButton_download_animate_waiting,
|
R.styleable.PieFetchButton_download_animate_waiting,
|
||||||
true
|
true
|
||||||
|
|
@ -92,13 +92,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
R.styleable.PieFetchButton_download_hide_when_icon,
|
R.styleable.PieFetchButton_download_hide_when_icon,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
waitingAnimation = getResourceId(
|
waitingAnimation = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_waiting_animation,
|
R.styleable.PieFetchButton_download_waiting_animation,
|
||||||
R.anim.rotate_around_center_point
|
R.anim.rotate_around_center_point
|
||||||
)
|
)
|
||||||
|
|
||||||
activeOutline = getResourceId(
|
activeOutline = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
|
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
|
||||||
)
|
)
|
||||||
|
|
||||||
nonActiveOutline = getResourceId(
|
nonActiveOutline = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_outline_non_active,
|
R.styleable.PieFetchButton_download_outline_non_active,
|
||||||
R.drawable.circle_shape_dotted
|
R.drawable.circle_shape_dotted
|
||||||
|
|
@ -126,29 +129,19 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
)
|
)
|
||||||
|
|
||||||
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
|
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
|
||||||
|
|
||||||
progressDrawable = getResourceId(
|
progressDrawable = getResourceId(
|
||||||
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
|
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
|
||||||
|
|
||||||
|
recycle()
|
||||||
}
|
}
|
||||||
|
resetView()
|
||||||
progressBar = findViewById(R.id.progress_downloaded)
|
|
||||||
progressBarBackground = findViewById(R.id.progress_downloaded_background)
|
|
||||||
statusView = findViewById(R.id.image_download_status)
|
|
||||||
|
|
||||||
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
|
|
||||||
|
|
||||||
// resetView()
|
|
||||||
onInflate()
|
onInflate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
|
||||||
super.onAttachedToWindow()
|
|
||||||
// Re-run all animations when the view gets visible.
|
|
||||||
// Otherwise views may run without animations after recycled
|
|
||||||
setStatusInternal(currentStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentStatus: DownloadStatusTell? = null
|
private var currentStatus: DownloadStatusTell? = null
|
||||||
/*private fun getActivity(): Activity? {
|
/*private fun getActivity(): Activity? {
|
||||||
var context = context
|
var context = context
|
||||||
|
|
@ -169,31 +162,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
protected fun setDefaultClickListener(
|
protected fun setDefaultClickListener(
|
||||||
view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached,
|
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
callback: (DownloadClickEvent) -> Unit
|
callback: (DownloadClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
this.progressText = textView
|
this.progressText = textView
|
||||||
this.setPersistentId(card.id)
|
this.setPersistentId(card.id)
|
||||||
view.setOnClickListener {
|
view.setOnClickListener {
|
||||||
if (isZeroBytes) {
|
if (isZeroBytes) {
|
||||||
val localQueue = queue.value
|
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
|
||||||
val localInstances = downloadInstances.value
|
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
|
||||||
val id = card.id
|
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
|
||||||
|
|
||||||
// If the download is already in queue or active downloads, provide an option to cancel it
|
|
||||||
if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) {
|
|
||||||
it.popupMenuNoIcons(
|
|
||||||
arrayListOf(
|
|
||||||
Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
callback(DownloadClickEvent(itemId, card))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Otherwise just start a download instantly
|
|
||||||
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
|
|
||||||
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val list = arrayListOf(
|
val list = arrayListOf(
|
||||||
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
||||||
|
|
@ -234,7 +212,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun setDefaultClickListener(
|
open fun setDefaultClickListener(
|
||||||
card: DownloadObjects.DownloadEpisodeCached,
|
card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
textView: TextView?,
|
textView: TextView?,
|
||||||
callback: (DownloadClickEvent) -> Unit
|
callback: (DownloadClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -304,8 +282,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
override fun setStatus(status: DownloadStatusTell?) {
|
override fun setStatus(status: DownloadStatusTell?) {
|
||||||
currentStatus = status
|
currentStatus = status
|
||||||
|
|
||||||
// Runs on the main thread, but also instant if it already is.
|
// Runs on the main thread, but also instant if it already is
|
||||||
if (Looper.getMainLooper().isCurrentThread) {
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
try {
|
try {
|
||||||
setStatusInternal(status)
|
setStatusInternal(status)
|
||||||
} catch (t: Throwable) {
|
} 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
|
package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.lagradost.cloudstream3.R
|
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.HomeResultGridBinding
|
||||||
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
|
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
|
||||||
import com.lagradost.cloudstream3.ui.BaseAdapter
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
import com.lagradost.cloudstream3.ui.newSharedPool
|
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||||
|
|
@ -44,11 +41,13 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(vi
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResumeItemAdapter(
|
class ResumeItemAdapter(
|
||||||
|
fragment: Fragment,
|
||||||
nextFocusUp: Int? = null,
|
nextFocusUp: Int? = null,
|
||||||
nextFocusDown: Int? = null,
|
nextFocusDown: Int? = null,
|
||||||
clickCallback: (SearchClickCallback) -> Unit,
|
clickCallback: (SearchClickCallback) -> Unit,
|
||||||
private val removeCallback: (View) -> Unit,
|
private val removeCallback: (View) -> Unit,
|
||||||
) : HomeChildItemAdapter(
|
) : HomeChildItemAdapter(
|
||||||
|
fragment = fragment,
|
||||||
id = "resumeAdapter".hashCode(),
|
id = "resumeAdapter".hashCode(),
|
||||||
nextFocusUp = nextFocusUp,
|
nextFocusUp = nextFocusUp,
|
||||||
nextFocusDown = nextFocusDown,
|
nextFocusDown = nextFocusDown,
|
||||||
|
|
@ -68,32 +67,20 @@ class ResumeItemAdapter(
|
||||||
return HomeScrollViewHolderState(binding)
|
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>) {
|
override fun onBindFooter(holder: ViewHolderState<Boolean>) {
|
||||||
this.applyBinding(holder, false)
|
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 {
|
holder.itemView.apply {
|
||||||
if (isLayout(TV)) {
|
if (isLayout(TV)) {
|
||||||
isFocusableInTouchMode = true
|
isFocusableInTouchMode = true
|
||||||
isFocusable = true
|
isFocusable = true
|
||||||
}
|
}
|
||||||
nextFocusUp?.let {
|
|
||||||
nextFocusUpId = it
|
if (nextFocusUp != null) {
|
||||||
|
nextFocusUpId = nextFocusUp
|
||||||
}
|
}
|
||||||
nextFocusDown?.let {
|
|
||||||
nextFocusDownId = it
|
if (nextFocusDown != null) {
|
||||||
|
nextFocusDownId = nextFocusDown
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnClickListener { v ->
|
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(
|
open class HomeChildItemAdapter(
|
||||||
|
fragment: Fragment,
|
||||||
id: Int,
|
id: Int,
|
||||||
var nextFocusUp: Int? = null,
|
protected val nextFocusUp: Int? = null,
|
||||||
var nextFocusDown: Int? = null,
|
protected val nextFocusDown: Int? = null,
|
||||||
var clickCallback: (SearchClickCallback) -> Unit,
|
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||||
) :
|
) :
|
||||||
BaseAdapter<SearchResponse, Boolean>(
|
BaseAdapter<SearchResponse, Boolean>(fragment, id) {
|
||||||
id, diffCallback = BaseDiffCallback(
|
|
||||||
itemSame = { a, b ->
|
|
||||||
a.url == b.url && a.name == b.name
|
|
||||||
},
|
|
||||||
contentSame = { a, b ->
|
|
||||||
a == b
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
var hasNext: Boolean = false
|
|
||||||
var isHorizontal: Boolean = false
|
var isHorizontal: Boolean = false
|
||||||
set(value) {
|
var hasNext: Boolean = false
|
||||||
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
|
|
||||||
|
|
||||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
|
||||||
val expanded = parent.context.isBottomLayout()
|
val expanded = parent.context.isBottomLayout()
|
||||||
|
|
@ -158,43 +112,52 @@ open class HomeChildItemAdapter(
|
||||||
return HomeScrollViewHolderState(binding)
|
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)
|
|
||||||
?.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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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
|
||||||
|
val min = (114.toPx.toFloat() * mul).toInt()
|
||||||
|
val max = (180.toPx.toFloat() * mul).toInt()
|
||||||
|
|
||||||
when (val binding = holder.view) {
|
when (val binding = holder.view) {
|
||||||
is HomeResultGridBinding -> {
|
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 -> {
|
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
|
if (isFirstItem) { // to fix tv
|
||||||
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
|
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
|
||||||
|
|
|
||||||
|
|
@ -5,36 +5,26 @@ import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.AbsListView
|
import android.widget.*
|
||||||
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 androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isInvisible
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.chip.Chip
|
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.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.AllLanguagesName
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
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.FragmentHomeBinding
|
||||||
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
|
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
|
||||||
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
|
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
|
||||||
|
|
@ -45,18 +35,13 @@ import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
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.AccountHelper.showAccountSelectLinear
|
||||||
import com.lagradost.cloudstream3.ui.account.AccountViewModel
|
import com.lagradost.cloudstream3.utils.txt
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
import com.lagradost.cloudstream3.ui.search.*
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE
|
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
|
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||||
|
|
@ -66,30 +51,22 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
|
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
|
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper
|
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.EmptyEvent
|
import com.lagradost.cloudstream3.utils.Event
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
|
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.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
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>(
|
class HomeFragment : Fragment() {
|
||||||
BindingCreator.Bind(FragmentHomeBinding::bind)
|
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
// Used for configuration changed events to fix any popups that are not attached to a fragment
|
val configEvent = Event<Int>()
|
||||||
val configEvent = EmptyEvent()
|
|
||||||
var currentSpan = 1
|
var currentSpan = 1
|
||||||
|
val listHomepageItems = mutableListOf<SearchResponse>()
|
||||||
|
|
||||||
private val errorProfilePics = listOf(
|
private val errorProfilePics = listOf(
|
||||||
R.drawable.monke_benene,
|
R.drawable.monke_benene,
|
||||||
|
|
@ -118,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
|
// 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(
|
fun Activity.loadHomepageList(
|
||||||
expand: HomeViewModel.ExpandableHomepageList,
|
expand: HomeViewModel.ExpandableHomepageList,
|
||||||
deleteCallback: (() -> Unit)? = null,
|
deleteCallback: (() -> Unit)? = null,
|
||||||
|
|
@ -200,17 +176,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
|
|
||||||
|
|
||||||
// Span settings
|
// Span settings
|
||||||
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages)
|
binding.homeExpandedRecycler.spanCount = currentSpan
|
||||||
binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool)
|
|
||||||
binding.homeExpandedRecycler.adapter =
|
binding.homeExpandedRecycler.adapter =
|
||||||
SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback ->
|
SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
|
||||||
handleSearchClickCallback(callback)
|
handleSearchClickCallback(callback)
|
||||||
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
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.ownHide() // we hide here because we want to resume it later
|
||||||
//bottomSheetDialogBuilder.dismissSafe(this)
|
//bottomSheetDialogBuilder.dismissSafe(this)
|
||||||
}
|
}
|
||||||
}.apply {
|
}.apply {
|
||||||
submitList(item.list)
|
|
||||||
hasNext = expand.hasNext
|
hasNext = expand.hasNext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,7 +209,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
expandCallback?.invoke(name)?.let { newExpand ->
|
expandCallback?.invoke(name)?.let { newExpand ->
|
||||||
(recyclerView.adapter as? SearchAdapter?)?.apply {
|
(recyclerView.adapter as? SearchAdapter?)?.apply {
|
||||||
hasNext = newExpand.hasNext
|
hasNext = newExpand.hasNext
|
||||||
submitList(newExpand.list.list)
|
updateList(newExpand.list.list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,12 +217,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
val spanListener = Runnable {
|
val spanListener = { span: Int ->
|
||||||
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages)
|
binding.homeExpandedRecycler.spanCount = span
|
||||||
// We want to rebind everything to update the UI, however we also want to avoid
|
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||||
// any animations ect, this is the easiest way to do this, and the most correct
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
binding.homeExpandedRecycler.adapter?.notifyDataSetChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
configEvent += spanListener
|
configEvent += spanListener
|
||||||
|
|
@ -317,7 +289,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
val pairList = getPairList(header)
|
val pairList = getPairList(header)
|
||||||
for ((button, types) in pairList) {
|
for ((button, types) in pairList) {
|
||||||
button?.isChecked =
|
button?.isChecked =
|
||||||
button.isVisible && selectedTypes.any { types.contains(it) }
|
button?.isVisible == true && selectedTypes.any { types.contains(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,23 +383,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
|
|
||||||
val listView = dialog.findViewById<ListView>(R.id.listview1)
|
val listView = dialog.findViewById<ListView>(R.id.listview1)
|
||||||
|
|
||||||
val arrayAdapter = object : ArrayAdapter<String>(
|
val arrayAdapter = object : ArrayAdapter<String>(this, R.layout.sort_bottom_single_provider_choice,
|
||||||
this, R.layout.sort_bottom_single_provider_choice,
|
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
) {
|
) {
|
||||||
override fun getView(
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
position: Int,
|
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.sort_bottom_single_provider_choice, parent, false)
|
||||||
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 titleText = view.findViewById<TextView>(R.id.text1)
|
||||||
val pinIcon = view.findViewById<ImageView>(R.id.pinicon)
|
val pinIcon = view.findViewById<ImageView>(R.id.pinicon)
|
||||||
val name = getItem(position)
|
val name = getItem(position)
|
||||||
titleText?.text = name
|
titleText?.text = name
|
||||||
val isPinned =
|
val isPinned = pinnedphashset.contains(currentValidApis[position].name ?: "")
|
||||||
pinnedphashset.contains(currentValidApis[position].name)
|
|
||||||
pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE
|
pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +404,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
if (currentValidApis.isNotEmpty()) {
|
if (currentValidApis.isNotEmpty()) {
|
||||||
currentApiName = currentValidApis[i].name
|
currentApiName = currentValidApis[i].name
|
||||||
//to switch to apply simply remove this
|
//to switch to apply simply remove this
|
||||||
currentApiName.let(callback)
|
currentApiName?.let(callback)
|
||||||
dialog.dismissSafe()
|
dialog.dismissSafe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -450,11 +415,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
pinnedphashset = pinnedp.toHashSet()
|
pinnedphashset = pinnedp.toHashSet()
|
||||||
arrayAdapter.clear()
|
arrayAdapter.clear()
|
||||||
val sortedApis = validAPIs
|
val sortedApis = validAPIs
|
||||||
.filter {
|
.filter {it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(preSelectedTypes::contains)) }
|
||||||
it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(
|
|
||||||
preSelectedTypes::contains
|
|
||||||
))
|
|
||||||
}
|
|
||||||
.sortedBy { it.name.lowercase() }
|
.sortedBy { it.name.lowercase() }
|
||||||
|
|
||||||
val sortedApiMap = LinkedHashMap<String, MainAPI>().apply {
|
val sortedApiMap = LinkedHashMap<String, MainAPI>().apply {
|
||||||
|
|
@ -482,12 +443,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
}
|
}
|
||||||
// pin provider on hold
|
// pin provider on hold
|
||||||
listView?.setOnItemLongClickListener { _, _, i, _ ->
|
listView?.setOnItemLongClickListener { _, _, i, _ ->
|
||||||
if (currentValidApis.isNotEmpty() && i > 1) {
|
if (currentValidApis.isNotEmpty() && i>1) {
|
||||||
val pinnedp = DataStoreHelper.pinnedProviders.toMutableList()
|
val pinnedp = DataStoreHelper.pinnedProviders.toMutableList()
|
||||||
val thisapi = currentValidApis[i].name
|
val thisapi = currentValidApis[i].name
|
||||||
if (pinnedp.contains(thisapi)) {
|
if(pinnedp.contains(thisapi)){
|
||||||
pinnedp.remove(thisapi)
|
pinnedp.remove(thisapi)
|
||||||
} else {
|
}else{
|
||||||
pinnedp.add(thisapi)
|
pinnedp.add(thisapi)
|
||||||
}
|
}
|
||||||
DataStoreHelper.pinnedProviders = pinnedp.toTypedArray()
|
DataStoreHelper.pinnedProviders = pinnedp.toTypedArray()
|
||||||
|
|
@ -511,71 +472,47 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
private val accountViewModel: AccountViewModel by activityViewModels()
|
|
||||||
|
|
||||||
fun addMovies(cards: List<SearchResponse>) {
|
var binding: FragmentHomeBinding? = null
|
||||||
val ctx = context ?: run {
|
|
||||||
Log.e(TAG, "Context is null, aborting addMovies")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View? {
|
||||||
|
//homeViewModel =
|
||||||
|
// ViewModelProvider(this).get(HomeViewModel::class.java)
|
||||||
|
|
||||||
bottomSheetDialog?.ownShow()
|
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() {
|
override fun onDestroyView() {
|
||||||
(activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress")
|
|
||||||
bottomSheetDialog?.ownHide()
|
bottomSheetDialog?.ownHide()
|
||||||
|
binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fixGrid() {
|
||||||
|
activity?.getSpanCount()?.let {
|
||||||
|
currentSpan = it
|
||||||
|
}
|
||||||
|
configEvent.invoke(currentSpan)
|
||||||
|
}
|
||||||
|
|
||||||
private val apiChangeClickListener = View.OnClickListener { view ->
|
private val apiChangeClickListener = View.OnClickListener { view ->
|
||||||
view.context.selectHomepage(currentApiName) { api ->
|
view.context.selectHomepage(currentApiName) { api ->
|
||||||
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
||||||
|
|
@ -589,129 +526,55 @@ 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 currentApiName: String? = null
|
||||||
private var toggleRandomButton = false
|
private var toggleRandomButton = false
|
||||||
|
|
||||||
private var bottomSheetDialog: BottomSheetDialog? = null
|
private var bottomSheetDialog: BottomSheetDialog? = null
|
||||||
private var homeMasterAdapter: HomeParentItemAdapterPreview? = 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")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onBindingCreated(binding: FragmentHomeBinding) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
context?.let { HomeChildItemAdapter.updatePosterSize(it) }
|
super.onViewCreated(view, savedInstanceState)
|
||||||
(activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") {
|
fixGrid()
|
||||||
handleTvBackPress(this)
|
|
||||||
}
|
binding?.apply {
|
||||||
binding.apply {
|
|
||||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||||
homeApiFab.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)
|
homeChangeApi.setOnClickListener(apiChangeClickListener)
|
||||||
homeSwitchAccount.setOnClickListener {
|
homeSwitchAccount.setOnClickListener {
|
||||||
activity?.showAccountSelectLinear()
|
activity?.showAccountSelectLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
homeRandom.setOnClickListener {
|
||||||
|
if (listHomepageItems.isNotEmpty()) {
|
||||||
|
activity.loadSearchResult(listHomepageItems.random())
|
||||||
|
}
|
||||||
|
}
|
||||||
homeMasterAdapter = HomeParentItemAdapterPreview(
|
homeMasterAdapter = HomeParentItemAdapterPreview(
|
||||||
homeViewModel, accountViewModel
|
fragment = this@HomeFragment,
|
||||||
|
homeViewModel,
|
||||||
)
|
)
|
||||||
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
|
||||||
homeMasterRecycler.adapter = homeMasterAdapter
|
homeMasterRecycler.adapter = homeMasterAdapter
|
||||||
|
//fixPaddingStatusbar(homeLoadingStatusbar)
|
||||||
|
|
||||||
homeApiFab.isVisible = isLayout(PHONE)
|
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)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
homePreviewSearchButton.setOnClickListener { _ ->
|
|
||||||
// Open blank screen.
|
|
||||||
homeViewModel.queryTextSubmit("")
|
|
||||||
}
|
|
||||||
|
|
||||||
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (isLayout(PHONE)) {
|
if (dy > 0) { //check for scroll down
|
||||||
// Fab is only relevant to Phone
|
homeApiFab.shrink() // hide
|
||||||
if (dy > 0) { //check for scroll down
|
homeRandom.shrink()
|
||||||
homeApiFab.shrink() // hide
|
} else if (dy < -5) {
|
||||||
homeRandom.shrink()
|
if (isLayout(PHONE)) {
|
||||||
} else if (dy < -5) {
|
homeApiFab.extend() // show
|
||||||
if (isLayout(PHONE)) {
|
homeRandom.extend()
|
||||||
homeApiFab.extend() // show
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
super.onScrolled(recyclerView, dx, dy)
|
super.onScrolled(recyclerView, dx, dy)
|
||||||
|
|
@ -720,6 +583,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//Load value for toggling Random button. Hide at startup
|
//Load value for toggling Random button. Hide at startup
|
||||||
context?.let {
|
context?.let {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
||||||
|
|
@ -727,56 +591,46 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
settingsManager.getBoolean(
|
settingsManager.getBoolean(
|
||||||
getString(R.string.random_button_key),
|
getString(R.string.random_button_key),
|
||||||
false
|
false
|
||||||
)
|
) && isLayout(PHONE)
|
||||||
binding.homeRandom.visibility = View.GONE
|
binding?.homeRandom?.visibility = View.GONE
|
||||||
binding.homeRandomButtonTv.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(homeViewModel.apiName) { apiName ->
|
observe(homeViewModel.apiName) { apiName ->
|
||||||
currentApiName = apiName
|
currentApiName = apiName
|
||||||
binding.apply {
|
binding?.homeApiFab?.text = apiName
|
||||||
homeApiFab.text = apiName
|
binding?.homeChangeApi?.text = apiName
|
||||||
homeChangeApi.text = apiName
|
|
||||||
homePreviewReloadProvider.isGone = (apiName == noneApi.name)
|
|
||||||
homePreviewSearchButton.isGone = (apiName == noneApi.name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(homeViewModel.page) { data ->
|
observe(homeViewModel.page) { data ->
|
||||||
binding.apply {
|
binding?.apply {
|
||||||
when (data) {
|
when (data) {
|
||||||
is Resource.Success -> {
|
is Resource.Success -> {
|
||||||
|
homeLoadingShimmer.stopShimmer()
|
||||||
|
|
||||||
val d = data.value
|
val d = data.value
|
||||||
|
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||||
|
listHomepageItems.clear()
|
||||||
|
|
||||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
|
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
|
||||||
it.copy(
|
it.copy(
|
||||||
list = it.list.copy(list = it.list.list.toMutableList())
|
list = it.list.copy(list = it.list.list.toMutableList())
|
||||||
)
|
)
|
||||||
})
|
}.toMutableList())
|
||||||
|
|
||||||
saveHomepageToTV(d)
|
|
||||||
|
|
||||||
homeLoading.isVisible = false
|
homeLoading.isVisible = false
|
||||||
homeLoadingError.isVisible = false
|
homeLoadingError.isVisible = false
|
||||||
homeMasterRecycler.isVisible = true
|
homeMasterRecycler.isVisible = true
|
||||||
homeLoadingShimmer.stopShimmer()
|
|
||||||
//home_loaded?.isVisible = true
|
//home_loaded?.isVisible = true
|
||||||
if (toggleRandomButton) {
|
if (toggleRandomButton) {
|
||||||
val distinct = d.values
|
//Flatten list
|
||||||
.flatMap { it.list.list }
|
d.values.forEach { dlist ->
|
||||||
.distinctBy { it.url }
|
mutableListOfResponse.addAll(dlist.list.list)
|
||||||
val hasItems = distinct.isNotEmpty()
|
|
||||||
val isPhone = isLayout(PHONE)
|
|
||||||
val randomClickListener = View.OnClickListener {
|
|
||||||
distinct.randomOrNull()?.let { activity.loadSearchResult(it) }
|
|
||||||
}
|
}
|
||||||
|
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
|
||||||
|
|
||||||
homeRandom.isVisible = isPhone && hasItems
|
homeRandom.isVisible = listHomepageItems.isNotEmpty()
|
||||||
homeRandom.setOnClickListener(randomClickListener)
|
|
||||||
homeRandomButtonTv.isVisible = !isPhone && hasItems
|
|
||||||
homeRandomButtonTv.setOnClickListener(randomClickListener)
|
|
||||||
} else {
|
} else {
|
||||||
homeRandom.isGone = true
|
homeRandom.isGone = true
|
||||||
homeRandomButtonTv.isGone = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -794,7 +648,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
val i = Intent(Intent.ACTION_VIEW)
|
val i = Intent(Intent.ACTION_VIEW)
|
||||||
i.data = validAPIs[itemId].mainUrl.toUri()
|
i.data = Uri.parse(validAPIs[itemId].mainUrl)
|
||||||
startActivity(i)
|
startActivity(i)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
|
@ -804,7 +658,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
|
|
||||||
homeLoading.isVisible = false
|
homeLoading.isVisible = false
|
||||||
homeLoadingError.isVisible = true
|
homeLoadingError.isVisible = true
|
||||||
homeMasterRecycler.isInvisible = true
|
homeMasterRecycler.isVisible = false
|
||||||
|
|
||||||
// Based on https://github.com/recloudstream/cloudstream/pull/1438
|
// Based on https://github.com/recloudstream/cloudstream/pull/1438
|
||||||
val hasNoNetworkConnection = context?.isNetworkAvailable() == false
|
val hasNoNetworkConnection = context?.isNetworkAvailable() == false
|
||||||
|
|
@ -826,28 +680,24 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
||||||
homeReloadConnectionGoToDownloads.setOnClickListener {
|
homeReloadConnectionGoToDownloads.setOnClickListener {
|
||||||
activity.navigate(R.id.navigation_downloads)
|
activity.navigate(R.id.navigation_downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
|
|
||||||
submitList(null)
|
|
||||||
clearState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is Resource.Loading -> {
|
is Resource.Loading -> {
|
||||||
|
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
|
||||||
homeLoadingShimmer.startShimmer()
|
homeLoadingShimmer.startShimmer()
|
||||||
homeLoading.isVisible = true
|
homeLoading.isVisible = true
|
||||||
homeLoadingError.isVisible = false
|
homeLoadingError.isVisible = false
|
||||||
homeMasterRecycler.isInvisible = true
|
homeMasterRecycler.isVisible = false
|
||||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
|
|
||||||
submitList(null)
|
|
||||||
clearState()
|
|
||||||
}
|
|
||||||
//home_loaded?.isVisible = false
|
//home_loaded?.isVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//context?.fixPaddingStatusbarView(home_statusbar)
|
||||||
|
//context?.fixPaddingStatusbar(home_padding)
|
||||||
|
|
||||||
observeNullable(homeViewModel.popup) { item ->
|
observeNullable(homeViewModel.popup) { item ->
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
bottomSheetDialog?.dismissSafe()
|
bottomSheetDialog?.dismissSafe()
|
||||||
|
|
@ -892,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.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.lagradost.cloudstream3.HomePageList
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
||||||
|
|
@ -15,11 +17,9 @@ import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.BaseAdapter
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
import com.lagradost.cloudstream3.ui.newSharedPool
|
|
||||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
|
@ -34,11 +34,13 @@ class LoadClickCallback(
|
||||||
)
|
)
|
||||||
|
|
||||||
open class ParentItemAdapter(
|
open class ParentItemAdapter(
|
||||||
|
open val fragment: Fragment,
|
||||||
id: Int,
|
id: Int,
|
||||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||||
private val expandCallback: ((String) -> Unit)? = null,
|
private val expandCallback: ((String) -> Unit)? = null,
|
||||||
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
|
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
|
||||||
|
fragment,
|
||||||
id,
|
id,
|
||||||
diffCallback = BaseDiffCallback(
|
diffCallback = BaseDiffCallback(
|
||||||
itemSame = { a, b -> a.list.name == b.list.name },
|
itemSame = { a, b -> a.list.name == b.list.name },
|
||||||
|
|
@ -46,11 +48,6 @@ open class ParentItemAdapter(
|
||||||
a.list.list == b.list.list
|
a.list.list == b.list.list
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
companion object {
|
|
||||||
val sharedPool =
|
|
||||||
newSharedPool { setMaxRecycledViews(CONTENT, 4) }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
|
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
|
||||||
override fun save(): Bundle = Bundle().apply {
|
override fun save(): Bundle = Bundle().apply {
|
||||||
val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview
|
val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview
|
||||||
|
|
@ -63,16 +60,13 @@ open class ParentItemAdapter(
|
||||||
|
|
||||||
override fun restore(state: Bundle) {
|
override fun restore(state: Bundle) {
|
||||||
(binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
|
(binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
|
||||||
state.getSafeParcelable<Parcelable>("value")
|
state.getSafeParcelable<Parcelable>("value")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun submitList(
|
override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
|
||||||
list: Collection<HomeViewModel.ExpandableHomepageList>?,
|
super.submitList(list?.sortedBy { it.list.list.isEmpty() })
|
||||||
commitCallback: Runnable?
|
|
||||||
) {
|
|
||||||
super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpdateContent(
|
override fun onUpdateContent(
|
||||||
|
|
@ -96,30 +90,17 @@ open class ParentItemAdapter(
|
||||||
if (binding !is HomepageParentBinding) return
|
if (binding !is HomepageParentBinding) return
|
||||||
val info = item.list
|
val info = item.list
|
||||||
binding.apply {
|
binding.apply {
|
||||||
val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter
|
homeChildRecyclerview.adapter = HomeChildItemAdapter(
|
||||||
if (currentAdapter == null) {
|
fragment = fragment,
|
||||||
homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
|
id = id + position + 100,
|
||||||
homeChildRecyclerview.adapter = HomeChildItemAdapter(
|
clickCallback = clickCallback,
|
||||||
id = id + position + 100,
|
nextFocusUp = homeChildRecyclerview.nextFocusUpId,
|
||||||
clickCallback = clickCallback,
|
nextFocusDown = homeChildRecyclerview.nextFocusDownId,
|
||||||
nextFocusUp = homeChildRecyclerview.nextFocusUpId,
|
).apply {
|
||||||
nextFocusDown = homeChildRecyclerview.nextFocusDownId,
|
isHorizontal = info.isHorizontalImages
|
||||||
).apply {
|
hasNext = item.hasNext
|
||||||
isHorizontal = info.isHorizontalImages
|
submitList(item.list.list)
|
||||||
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(
|
homeChildRecyclerview.setLinearListLayout(
|
||||||
isHorizontal = true,
|
isHorizontal = true,
|
||||||
nextLeft = startFocus,
|
nextLeft = startFocus,
|
||||||
|
|
@ -185,6 +166,11 @@ open class ParentItemAdapter(
|
||||||
|
|
||||||
return ParentItemHolder(binding)
|
return ParentItemHolder(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateList(newList: List<HomePageList>) {
|
||||||
|
submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
|
||||||
|
.toMutableList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
package com.lagradost.cloudstream3.ui.home
|
package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
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.Chip
|
||||||
import com.google.android.material.chip.ChipGroup
|
import com.google.android.material.chip.ChipGroup
|
||||||
import com.google.android.material.navigation.NavigationBarItemView
|
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.activity
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.HomePageList
|
import com.lagradost.cloudstream3.HomePageList
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
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.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
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.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.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
||||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
||||||
import com.lagradost.cloudstream3.ui.result.getId
|
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.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
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.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
||||||
import androidx.core.graphics.toColorInt
|
|
||||||
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
|
||||||
|
|
||||||
class HomeParentItemAdapterPreview(
|
class HomeParentItemAdapterPreview(
|
||||||
|
override val fragment: Fragment,
|
||||||
private val viewModel: HomeViewModel,
|
private val viewModel: HomeViewModel,
|
||||||
private val accountViewModel: AccountViewModel
|
|
||||||
) : ParentItemAdapter(
|
) : ParentItemAdapter(
|
||||||
id = "HomeParentItemAdapterPreview".hashCode(),
|
fragment, id = "HomeParentItemAdapterPreview".hashCode(),
|
||||||
clickCallback = {
|
clickCallback = {
|
||||||
viewModel.click(it)
|
viewModel.click(it)
|
||||||
}, moreInfoClickCallback = {
|
}, moreInfoClickCallback = {
|
||||||
|
|
@ -104,33 +97,15 @@ class HomeParentItemAdapterPreview(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return HeaderViewHolder(binding, viewModel, accountViewModel)
|
return HeaderViewHolder(binding, viewModel, fragment = fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
|
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
|
||||||
(holder as? HeaderViewHolder)?.bind()
|
(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(
|
private class HeaderViewHolder(
|
||||||
val binding: ViewBinding,
|
val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment,
|
||||||
val viewModel: HomeViewModel,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
) :
|
) :
|
||||||
ViewHolderState<Bundle>(binding) {
|
ViewHolderState<Bundle>(binding) {
|
||||||
|
|
||||||
|
|
@ -156,13 +131,9 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val previewAdapter = HomeScrollAdapter { view, position, item ->
|
val previewAdapter = HomeScrollAdapter(fragment = fragment)
|
||||||
viewModel.click(
|
|
||||||
LoadClickCallback(0, view, position, item)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val resumeAdapter = ResumeItemAdapter(
|
private val resumeAdapter = ResumeItemAdapter(
|
||||||
|
fragment,
|
||||||
nextFocusUp = itemView.nextFocusUpId,
|
nextFocusUp = itemView.nextFocusUpId,
|
||||||
nextFocusDown = itemView.nextFocusDownId,
|
nextFocusDown = itemView.nextFocusDownId,
|
||||||
removeCallback = { v ->
|
removeCallback = { v ->
|
||||||
|
|
@ -245,6 +216,7 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
private val bookmarkAdapter = HomeChildItemAdapter(
|
private val bookmarkAdapter = HomeChildItemAdapter(
|
||||||
|
fragment,
|
||||||
id = "bookmarkAdapter".hashCode(),
|
id = "bookmarkAdapter".hashCode(),
|
||||||
nextFocusUp = itemView.nextFocusUpId,
|
nextFocusUp = itemView.nextFocusUpId,
|
||||||
nextFocusDown = itemView.nextFocusDownId
|
nextFocusDown = itemView.nextFocusDownId
|
||||||
|
|
@ -321,14 +293,9 @@ class HomeParentItemAdapterPreview(
|
||||||
private val bookmarkRecyclerView: RecyclerView =
|
private val bookmarkRecyclerView: RecyclerView =
|
||||||
itemView.findViewById(R.id.home_bookmarked_child_recyclerview)
|
itemView.findViewById(R.id.home_bookmarked_child_recyclerview)
|
||||||
|
|
||||||
private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic)
|
private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account)
|
||||||
private val headProfilePicCard: View? =
|
private val alternativeHomeAccount: View? =
|
||||||
itemView.findViewById(R.id.home_head_profile_padding)
|
itemView.findViewById(R.id.alternative_switch_account)
|
||||||
|
|
||||||
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 topPadding: View? = itemView.findViewById(R.id.home_padding)
|
private val topPadding: View? = itemView.findViewById(R.id.home_padding)
|
||||||
|
|
||||||
|
|
@ -339,73 +306,38 @@ class HomeParentItemAdapterPreview(
|
||||||
|
|
||||||
fun onSelect(item: LoadResponse, position: Int) {
|
fun onSelect(item: LoadResponse, position: Int) {
|
||||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
||||||
homePreviewDescription.isGone = item.plot.isNullOrBlank()
|
homePreviewDescription.isGone =
|
||||||
homePreviewDescription.text = item.plot?.html() ?: ""
|
item.plot.isNullOrBlank()
|
||||||
|
homePreviewDescription.text =
|
||||||
|
item.plot ?: ""
|
||||||
|
|
||||||
val scoreText = item.score?.toStringNull(0.1, 10, 1, false)
|
homePreviewText.text = item.name
|
||||||
|
|
||||||
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()
|
|
||||||
populateChips(
|
populateChips(
|
||||||
homePreviewTags,
|
homePreviewTags,
|
||||||
item.tags?.take(6) ?: emptyList(),
|
item.tags?.take(6) ?: emptyList(),
|
||||||
R.style.ChipFilledSemiTransparent,
|
R.style.ChipFilledSemiTransparent
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
bindLogo(
|
|
||||||
url = item.logoUrl,
|
|
||||||
headers = item.posterHeaders,
|
|
||||||
titleView = homePreviewText,
|
|
||||||
logoView = homeBackgroundPosterWatermarkBadgeHolder
|
|
||||||
)
|
)
|
||||||
|
|
||||||
homePreviewTags.isGone =
|
homePreviewTags.isGone =
|
||||||
item.tags.isNullOrEmpty()
|
item.tags.isNullOrEmpty()
|
||||||
|
|
||||||
|
homePreviewPlayBtt.setOnClickListener { view ->
|
||||||
|
viewModel.click(
|
||||||
|
LoadClickCallback(
|
||||||
|
START_ACTION_RESUME_LATEST,
|
||||||
|
view,
|
||||||
|
position,
|
||||||
|
item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
homePreviewInfoBtt.setOnClickListener { view ->
|
homePreviewInfoBtt.setOnClickListener { view ->
|
||||||
viewModel.click(
|
viewModel.click(
|
||||||
LoadClickCallback(0, view, position, item)
|
LoadClickCallback(0, view, position, item)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
(binding as? FragmentHomeHeadBinding)?.apply {
|
(binding as? FragmentHomeHeadBinding)?.apply {
|
||||||
//homePreviewImage.setImage(item.posterUrl, item.posterHeaders)
|
//homePreviewImage.setImage(item.posterUrl, item.posterHeaders)
|
||||||
|
|
@ -490,7 +422,7 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onViewDetachedFromWindow() {
|
override fun onViewDetachedFromWindow() {
|
||||||
previewViewpager.unregisterOnPageChangeCallback(previewCallback)
|
previewViewpager.unregisterOnPageChangeCallback(previewCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,14 +443,12 @@ class HomeParentItemAdapterPreview(
|
||||||
|
|
||||||
previewViewpager.adapter = previewAdapter
|
previewViewpager.adapter = previewAdapter
|
||||||
resumeRecyclerView.adapter = resumeAdapter
|
resumeRecyclerView.adapter = resumeAdapter
|
||||||
bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
|
|
||||||
bookmarkRecyclerView.adapter = bookmarkAdapter
|
bookmarkRecyclerView.adapter = bookmarkAdapter
|
||||||
|
|
||||||
resumeRecyclerView.setLinearListLayout(
|
resumeRecyclerView.setLinearListLayout(
|
||||||
nextLeft = R.id.nav_rail_view,
|
nextLeft = R.id.nav_rail_view,
|
||||||
nextRight = FOCUS_SELF
|
nextRight = FOCUS_SELF
|
||||||
)
|
)
|
||||||
|
|
||||||
bookmarkRecyclerView.setLinearListLayout(
|
bookmarkRecyclerView.setLinearListLayout(
|
||||||
nextLeft = R.id.nav_rail_view,
|
nextLeft = R.id.nav_rail_view,
|
||||||
nextRight = FOCUS_SELF
|
nextRight = FOCUS_SELF
|
||||||
|
|
@ -539,80 +469,36 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
homeAccount?.isGone = isLayout(TV or EMULATOR)
|
||||||
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
|
||||||
|
|
||||||
(headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
|
homeAccount?.setOnClickListener {
|
||||||
headProfilePic?.loadImage(currentAccount?.image)
|
|
||||||
alternateHeadProfilePic?.loadImage(currentAccount?.image)
|
|
||||||
}
|
|
||||||
|
|
||||||
headProfilePicCard?.setOnClickListener {
|
|
||||||
activity?.showAccountSelectLinear()
|
activity?.showAccountSelectLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAccountEditBox(context: Context): Boolean {
|
alternativeHomeAccount?.setOnClickListener {
|
||||||
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 {
|
|
||||||
activity?.showAccountSelectLinear()
|
activity?.showAccountSelectLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
||||||
/*homePreviewChangeApi.setOnClickListener { view ->
|
homePreviewChangeApi.setOnClickListener { view ->
|
||||||
view.context.selectHomepage(viewModel.repo?.name) { api ->
|
view.context.selectHomepage(viewModel.repo?.name) { api ->
|
||||||
viewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
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 { _ ->
|
homePreviewSearchButton.setOnClickListener { _ ->
|
||||||
// Open blank screen.
|
// Open blank screen.
|
||||||
viewModel.queryTextSubmit("")
|
viewModel.queryTextSubmit("")
|
||||||
}*/
|
}
|
||||||
|
|
||||||
// A workaround to the focus problem of always centering the view on focus
|
// This makes the hidden next buttons only available when on the info button
|
||||||
// as that causes higher android versions to stretch the ui when switching between shows
|
// Otherwise you might be able to go to the next item without being at the info button
|
||||||
var lastFocusTimeoutMs = 0L
|
homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus ->
|
||||||
homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus ->
|
homePreviewHiddenNextFocus.isFocusable = hasFocus
|
||||||
val lastFocusMs = lastFocusTimeoutMs
|
}
|
||||||
// Always reset timer, as we only want to update
|
|
||||||
// it if we have not interacted in half a second
|
homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus ->
|
||||||
lastFocusTimeoutMs = System.currentTimeMillis()
|
homePreviewHiddenPrevFocus.isFocusable = hasFocus
|
||||||
if (!hasFocus) return@setOnFocusChangeListener
|
|
||||||
if (lastFocusMs + 500L < System.currentTimeMillis()) {
|
|
||||||
MainActivity.centerView(view)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus ->
|
homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus ->
|
||||||
|
|
@ -630,8 +516,7 @@ class HomeParentItemAdapterPreview(
|
||||||
)?.requestFocus()
|
)?.requestFocus()
|
||||||
} else {
|
} else {
|
||||||
previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true)
|
previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true)
|
||||||
binding.homePreviewInfoBtt.requestFocus()
|
binding.homePreviewPlayBtt.requestFocus()
|
||||||
//binding.homePreviewPlayBtt.requestFocus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -658,7 +543,9 @@ class HomeParentItemAdapterPreview(
|
||||||
params.height = 0
|
params.height = 0
|
||||||
layoutParams = params
|
layoutParams = params
|
||||||
}
|
}
|
||||||
} else fixPaddingStatusbarView(homeNonePadding)
|
} else {
|
||||||
|
fixPaddingStatusbarView(homeNonePadding)
|
||||||
|
}
|
||||||
|
|
||||||
when (preview) {
|
when (preview) {
|
||||||
is Resource.Success -> {
|
is Resource.Success -> {
|
||||||
|
|
@ -682,15 +569,6 @@ class HomeParentItemAdapterPreview(
|
||||||
previewViewpager.isVisible = true
|
previewViewpager.isVisible = true
|
||||||
previewViewpagerText.isVisible = true
|
previewViewpagerText.isVisible = true
|
||||||
alternativeAccountPadding?.isVisible = false
|
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 -> {
|
else -> {
|
||||||
|
|
@ -699,9 +577,6 @@ class HomeParentItemAdapterPreview(
|
||||||
previewViewpager.isVisible = false
|
previewViewpager.isVisible = false
|
||||||
previewViewpagerText.isVisible = false
|
previewViewpagerText.isVisible = false
|
||||||
alternativeAccountPadding?.isVisible = true
|
alternativeAccountPadding?.isVisible = true
|
||||||
(binding as? FragmentHomeHeadTvBinding)?.apply {
|
|
||||||
homePreviewInfoBtt.isVisible = false
|
|
||||||
}
|
|
||||||
//previewHeader.isVisible = false
|
//previewHeader.isVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -770,19 +645,18 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onViewAttachedToWindow() {
|
override fun onViewAttachedToWindow() {
|
||||||
previewViewpager.registerOnPageChangeCallback(previewCallback)
|
previewViewpager.registerOnPageChangeCallback(previewCallback)
|
||||||
|
|
||||||
previewViewpager.apply {
|
binding.root.findViewTreeLifecycleOwner()?.apply {
|
||||||
observe(viewModel.preview) {
|
observe(viewModel.preview) {
|
||||||
updatePreview(it)
|
updatePreview(it)
|
||||||
}
|
}
|
||||||
/*if (binding is FragmentHomeHeadTvBinding) {
|
if (binding is FragmentHomeHeadTvBinding) {
|
||||||
observe(viewModel.apiName) { name ->
|
observe(viewModel.apiName) { name ->
|
||||||
binding.homePreviewChangeApi.text = name
|
binding.homePreviewChangeApi.text = name
|
||||||
binding.homePreviewReloadProvider.isGone = (name == noneApi.name)
|
|
||||||
}
|
}
|
||||||
}*/
|
}
|
||||||
observe(viewModel.resumeWatching) {
|
observe(viewModel.resumeWatching) {
|
||||||
updateResume(it)
|
updateResume(it)
|
||||||
}
|
}
|
||||||
|
|
@ -798,7 +672,7 @@ class HomeParentItemAdapterPreview(
|
||||||
}
|
}
|
||||||
toggleListHolder?.isGone = visible.isEmpty()
|
toggleListHolder?.isGone = visible.isEmpty()
|
||||||
}
|
}
|
||||||
}
|
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
package com.lagradost.cloudstream3.ui.home
|
package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
|
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
|
||||||
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
|
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
|
||||||
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
|
||||||
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||||
import com.lagradost.cloudstream3.ui.ViewHolderState
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
|
||||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||||
|
|
||||||
class HomeScrollAdapter(
|
class HomeScrollAdapter(
|
||||||
val callback: ((View, Int, LoadResponse) -> Unit)
|
fragment: Fragment
|
||||||
) : NoStateAdapter<LoadResponse>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
|
) : NoStateAdapter<LoadResponse>(fragment) {
|
||||||
a.uniqueUrl == b.uniqueUrl && a.name == b.name
|
|
||||||
})) {
|
|
||||||
var hasMoreItems: Boolean = false
|
var hasMoreItems: Boolean = false
|
||||||
|
|
||||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
||||||
|
|
@ -35,26 +31,19 @@ class HomeScrollAdapter(
|
||||||
return ViewHolderState(binding)
|
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(
|
override fun onBindContent(
|
||||||
holder: ViewHolderState<Any>,
|
holder: ViewHolderState<Any>,
|
||||||
item: LoadResponse,
|
item: LoadResponse,
|
||||||
position: Int,
|
position: Int,
|
||||||
) {
|
) {
|
||||||
val binding = holder.view
|
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) {
|
when (binding) {
|
||||||
is HomeScrollViewBinding -> {
|
is HomeScrollViewBinding -> {
|
||||||
|
|
@ -64,21 +53,10 @@ class HomeScrollAdapter(
|
||||||
isGone = item.tags.isNullOrEmpty()
|
isGone = item.tags.isNullOrEmpty()
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
}
|
}
|
||||||
binding.homeScrollPreviewTitle.text = item.name.html()
|
binding.homeScrollPreviewTitle.text = item.name
|
||||||
|
|
||||||
bindLogo(
|
|
||||||
url = item.logoUrl,
|
|
||||||
headers = item.posterHeaders,
|
|
||||||
titleView = binding.homeScrollPreviewTitle,
|
|
||||||
logoView = binding.homePreviewLogo
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeScrollViewTvBinding -> {
|
is HomeScrollViewTvBinding -> {
|
||||||
binding.homeScrollPreview.isFocusable = false
|
|
||||||
binding.homeScrollPreview.setOnClickListener { view ->
|
|
||||||
callback.invoke(view ?: return@setOnClickListener, position, item)
|
|
||||||
}
|
|
||||||
binding.homeScrollPreview.loadImage(posterUrl)
|
binding.homeScrollPreview.loadImage(posterUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.lagradost.cloudstream3.APIHolder.apis
|
import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.HomePageList
|
import com.lagradost.cloudstream3.HomePageList
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.amap
|
import com.lagradost.cloudstream3.amap
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
|
|
@ -40,7 +40,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
||||||
|
|
@ -50,12 +49,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import kotlin.collections.set
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel : ViewModel() {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -67,26 +67,11 @@ class HomeViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
val resumeWatchingResult = withContext(Dispatchers.IO) {
|
||||||
resumeWatching?.mapNotNull { resume ->
|
resumeWatching?.mapNotNull { resume ->
|
||||||
val headerCache = getKey<DownloadObjects.DownloadHeaderCached>(
|
|
||||||
|
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
resume.parentId.toString()
|
resume.parentId.toString()
|
||||||
)
|
) ?: return@mapNotNull null
|
||||||
|
|
||||||
val data = if (headerCache == null) {
|
|
||||||
// We store resume watching data in download header cache
|
|
||||||
// Because downloads automatically pruned outdated download headers we
|
|
||||||
// removed resume watching data. We should restore the data for affected users.
|
|
||||||
val oldData = getKey<DownloadObjects.DownloadHeaderCached>(
|
|
||||||
DOWNLOAD_HEADER_CACHE_BACKUP,
|
|
||||||
resume.parentId.toString()
|
|
||||||
) ?: return@mapNotNull null
|
|
||||||
|
|
||||||
// Restore data
|
|
||||||
setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData)
|
|
||||||
oldData
|
|
||||||
} else {
|
|
||||||
headerCache
|
|
||||||
}
|
|
||||||
|
|
||||||
val watchPos = getViewPos(resume.episodeId)
|
val watchPos = getViewPos(resume.episodeId)
|
||||||
|
|
||||||
|
|
@ -133,7 +118,7 @@ class HomeViewModel : ViewModel() {
|
||||||
private var currentShuffledList: List<SearchResponse> = listOf()
|
private var currentShuffledList: List<SearchResponse> = listOf()
|
||||||
|
|
||||||
private fun autoloadRepo(): APIRepository {
|
private fun autoloadRepo(): APIRepository {
|
||||||
return APIRepository(apis.withLock { apis.first { it.hasMainPage } })
|
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _availableWatchStatusTypes =
|
private val _availableWatchStatusTypes =
|
||||||
|
|
@ -535,12 +520,12 @@ class HomeViewModel : ViewModel() {
|
||||||
} else if (api == null) {
|
} else if (api == null) {
|
||||||
// API is not found aka not loaded or removed, post the loading
|
// API is not found aka not loaded or removed, post the loading
|
||||||
// progress if waiting for plugins, otherwise nothing
|
// progress if waiting for plugins, otherwise nothing
|
||||||
if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) {
|
if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
|
||||||
loadAndCancel(noneApi)
|
loadAndCancel(noneApi)
|
||||||
} else {
|
} else {
|
||||||
_page.postValue(Resource.Loading())
|
_page.postValue(Resource.Loading())
|
||||||
if (preferredApiName != null)
|
if (preferredApiName != null)
|
||||||
_apiName.postValue(preferredApiName)
|
_apiName.postValue(preferredApiName!!)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if the api is found, then set it to it and save key
|
// if the api is found, then set it to it and save key
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,22 @@ import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
|
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
|
||||||
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||||
import android.view.animation.AlphaAnimation
|
import android.view.animation.AlphaAnimation
|
||||||
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.view.allViews
|
import androidx.core.view.allViews
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
@ -24,33 +30,35 @@ import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||||
import com.lagradost.cloudstream3.utils.txt
|
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_LOAD
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
|
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.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.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||||
import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
|
import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
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 com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
@ -76,10 +84,10 @@ data class ProviderLibraryData(
|
||||||
val apiName: String
|
val apiName: String
|
||||||
)
|
)
|
||||||
|
|
||||||
class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
class LibraryFragment : Fragment() {
|
||||||
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
|
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
|
||||||
fun newInstance() = LibraryFragment()
|
fun newInstance() = LibraryFragment()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,10 +98,35 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
|
|
||||||
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
||||||
|
|
||||||
|
var binding: FragmentLibraryBinding? = null
|
||||||
private var toggleRandomButton = false
|
private var toggleRandomButton = false
|
||||||
|
|
||||||
override fun pickLayout(): Int? =
|
override fun onCreateView(
|
||||||
if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv
|
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) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
binding?.viewpager?.currentItem?.let { currentItem ->
|
binding?.viewpager?.currentItem?.let { currentItem ->
|
||||||
|
|
@ -102,52 +135,48 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateRandomVisibility(binding: FragmentLibraryBinding) {
|
private fun updateRandom() {
|
||||||
if (!toggleRandomButton) {
|
|
||||||
binding.libraryRandom.isGone = true
|
|
||||||
binding.libraryRandomButtonTv.isGone = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val position = libraryViewModel.currentPage.value ?: 0
|
val position = libraryViewModel.currentPage.value ?: 0
|
||||||
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
|
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
|
||||||
val hasItems = pages[position].items.isNotEmpty()
|
if (toggleRandomButton) {
|
||||||
val isPhone = isLayout(PHONE)
|
listLibraryItems.clear()
|
||||||
|
listLibraryItems.addAll(pages[position].items)
|
||||||
binding.libraryRandom.isVisible = isPhone && hasItems
|
binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty()
|
||||||
binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems
|
} else {
|
||||||
}
|
binding?.libraryRandom?.isGone = true
|
||||||
|
}
|
||||||
override fun fixLayout(view: View) {
|
|
||||||
fixSystemBarsPadding(
|
|
||||||
view,
|
|
||||||
padBottom = isLandscape(),
|
|
||||||
padLeft = !isLayout(PHONE)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ResourceType", "CutPasteId")
|
@SuppressLint("ResourceType", "CutPasteId")
|
||||||
override fun onBindingCreated(
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
binding: FragmentLibraryBinding,
|
super.onViewCreated(view, savedInstanceState)
|
||||||
savedInstanceState: Bundle?
|
fixPaddingStatusbar(binding?.searchStatusBarPadding)
|
||||||
) {
|
|
||||||
binding.sortFab.setOnClickListener(sortChangeClickListener)
|
|
||||||
binding.librarySort.setOnClickListener(sortChangeClickListener)
|
|
||||||
|
|
||||||
binding.libraryRoot.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)
|
binding?.sortFab?.setOnClickListener(sortChangeClickListener)
|
||||||
?.apply {
|
binding?.librarySort?.setOnClickListener(sortChangeClickListener)
|
||||||
tag = "tv_no_focus_tag"
|
|
||||||
// Expand the Appbar when search bar is focused, fixing scroll up issue
|
binding?.libraryRoot?.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)?.apply {
|
||||||
setOnFocusChangeListener { _, _ ->
|
tag = "tv_no_focus_tag"
|
||||||
binding.searchBar.setExpanded(true)
|
//Expand the Appbar when search bar is focused, fixing scroll up issue
|
||||||
}
|
setOnFocusChangeListener { _, _ ->
|
||||||
|
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 searchCallback = Runnable {
|
||||||
val newText = binding.mainSearch.query.toString()
|
val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable
|
||||||
libraryViewModel.sort(ListSorting.Query, newText)
|
libraryViewModel.sort(ListSorting.Query, newText)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
libraryViewModel.sort(ListSorting.Query, query)
|
libraryViewModel.sort(ListSorting.Query, query)
|
||||||
return true
|
return true
|
||||||
|
|
@ -163,11 +192,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.mainSearch.removeCallbacks(searchCallback)
|
binding?.mainSearch?.removeCallbacks(searchCallback)
|
||||||
|
|
||||||
// Delay the execution of the search operation by 1 second (adjust as needed)
|
// Delay the execution of the search operation by 1 second (adjust as needed)
|
||||||
// this prevents running search when the user is typing
|
// this prevents running search when the user is typing
|
||||||
binding.mainSearch.postDelayed(searchCallback, 1000)
|
binding?.mainSearch?.postDelayed(searchCallback, 1000)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -175,12 +204,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
|
|
||||||
libraryViewModel.reloadPages(false)
|
libraryViewModel.reloadPages(false)
|
||||||
|
|
||||||
binding.listSelector.setOnClickListener {
|
binding?.listSelector?.setOnClickListener {
|
||||||
val items = libraryViewModel.availableApiNames
|
val items = libraryViewModel.availableApiNames
|
||||||
val currentItem = libraryViewModel.currentApiName.value
|
val currentItem = libraryViewModel.currentApiName.value
|
||||||
|
|
||||||
activity?.showBottomDialog(
|
activity?.showBottomDialog(items,
|
||||||
items,
|
|
||||||
items.indexOf(currentItem),
|
items.indexOf(currentItem),
|
||||||
txt(R.string.select_library).asString(it.context),
|
txt(R.string.select_library).asString(it.context),
|
||||||
false,
|
false,
|
||||||
|
|
@ -197,9 +225,17 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
settingsManager.getBoolean(
|
settingsManager.getBoolean(
|
||||||
getString(R.string.random_button_key),
|
getString(R.string.random_button_key),
|
||||||
false
|
false
|
||||||
)
|
) && isLayout(PHONE)
|
||||||
binding.libraryRandom.visibility = View.GONE
|
binding?.libraryRandom?.visibility = View.GONE
|
||||||
binding.libraryRandomButtonTv.visibility = View.GONE
|
}
|
||||||
|
|
||||||
|
binding?.libraryRandom?.setOnClickListener {
|
||||||
|
if (listLibraryItems.isNotEmpty()) {
|
||||||
|
val listLibraryItem = listLibraryItems.random()
|
||||||
|
libraryViewModel.currentSyncApi?.syncIdName?.let {
|
||||||
|
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -210,13 +246,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
syncId: SyncIdName,
|
syncId: SyncIdName,
|
||||||
apiName: String? = null,
|
apiName: String? = null,
|
||||||
) {
|
) {
|
||||||
val availableProviders = allProviders.filter {
|
val availableProviders = synchronized(allProviders) {
|
||||||
it.supportedSyncNames.contains(syncId)
|
allProviders.filter {
|
||||||
}.map { it.name } +
|
it.supportedSyncNames.contains(syncId)
|
||||||
// Add the api if it exists
|
}.map { it.name } +
|
||||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
// Add the api if it exists
|
||||||
?: emptyList())
|
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
||||||
|
?: emptyList())
|
||||||
|
}
|
||||||
val baseOptions = listOf(
|
val baseOptions = listOf(
|
||||||
LibraryOpenerType.Default,
|
LibraryOpenerType.Default,
|
||||||
LibraryOpenerType.None,
|
LibraryOpenerType.None,
|
||||||
|
|
@ -268,21 +305,22 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.providerSelector.setOnClickListener {
|
binding?.providerSelector?.setOnClickListener {
|
||||||
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
||||||
activity?.showPluginSelectionDialog(syncName.name, syncName)
|
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 ->
|
{ isScrollingDown: Boolean ->
|
||||||
if (isScrollingDown) {
|
if (isScrollingDown) {
|
||||||
binding.sortFab.shrink()
|
binding?.sortFab?.shrink()
|
||||||
binding.libraryRandom.shrink()
|
binding?.libraryRandom?.shrink()
|
||||||
} else {
|
} else {
|
||||||
binding.sortFab.extend()
|
binding?.sortFab?.extend()
|
||||||
binding.libraryRandom.extend()
|
binding?.libraryRandom?.extend()
|
||||||
}
|
}
|
||||||
}) callback@{ searchClickCallback ->
|
}) callback@{ searchClickCallback ->
|
||||||
// To prevent future accidents
|
// To prevent future accidents
|
||||||
|
|
@ -315,15 +353,15 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.apply {
|
binding?.apply {
|
||||||
viewpager.offscreenPageLimit = 2
|
viewpager.offscreenPageLimit = 2
|
||||||
viewpager.reduceDragSensitivity()
|
viewpager.reduceDragSensitivity()
|
||||||
searchBar.setExpanded(true)
|
searchBar.setExpanded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val startLoading = Runnable {
|
val startLoading = Runnable {
|
||||||
binding.apply {
|
binding?.apply {
|
||||||
gridview.numColumns = root.context.getSpanCount()
|
gridview.numColumns = context?.getSpanCount() ?: 3
|
||||||
gridview.adapter =
|
gridview.adapter =
|
||||||
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
||||||
libraryLoadingOverlay.isVisible = true
|
libraryLoadingOverlay.isVisible = true
|
||||||
|
|
@ -333,7 +371,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
val stopLoading = Runnable {
|
val stopLoading = Runnable {
|
||||||
binding.apply {
|
binding?.apply {
|
||||||
gridview.adapter = null
|
gridview.adapter = null
|
||||||
libraryLoadingOverlay.isVisible = false
|
libraryLoadingOverlay.isVisible = false
|
||||||
libraryLoadingShimmer.stopShimmer()
|
libraryLoadingShimmer.stopShimmer()
|
||||||
|
|
@ -349,7 +387,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
val pages = resource.value
|
val pages = resource.value
|
||||||
val showNotice = pages.all { it.items.isEmpty() }
|
val showNotice = pages.all { it.items.isEmpty() }
|
||||||
|
|
||||||
binding.apply {
|
binding?.apply {
|
||||||
emptyListTextview.isVisible = showNotice
|
emptyListTextview.isVisible = showNotice
|
||||||
if (showNotice) {
|
if (showNotice) {
|
||||||
if (libraryViewModel.availableApiNames.size > 1) {
|
if (libraryViewModel.availableApiNames.size > 1) {
|
||||||
|
|
@ -377,23 +415,10 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
)*/
|
)*/
|
||||||
|
|
||||||
libraryViewModel.currentPage.value?.let { page ->
|
libraryViewModel.currentPage.value?.let { page ->
|
||||||
binding.viewpager.setCurrentItem(page, false)
|
binding?.viewpager?.setCurrentItem(page, false)
|
||||||
binding.searchBar.setExpanded(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up random button click listener
|
updateRandom()
|
||||||
if (toggleRandomButton) {
|
|
||||||
val randomClickListener = View.OnClickListener {
|
|
||||||
val position = libraryViewModel.currentPage.value ?: 0
|
|
||||||
val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener
|
|
||||||
pages[position].items.randomOrNull()?.let { item ->
|
|
||||||
loadLibraryItem(syncIdName, item.syncId, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
libraryRandom.setOnClickListener(randomClickListener)
|
|
||||||
libraryRandomButtonTv.setOnClickListener(randomClickListener)
|
|
||||||
}
|
|
||||||
updateRandomVisibility(binding)
|
|
||||||
|
|
||||||
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
||||||
// Without this there would be a flashing effect:
|
// Without this there would be a flashing effect:
|
||||||
|
|
@ -434,20 +459,21 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
tab.view.nextFocusDownId = R.id.search_result_root
|
tab.view.nextFocusDownId = R.id.search_result_root
|
||||||
|
|
||||||
tab.view.setOnClickListener {
|
tab.view.setOnClickListener {
|
||||||
val currentItem = binding.viewpager.currentItem
|
val currentItem =
|
||||||
|
binding?.viewpager?.currentItem ?: return@setOnClickListener
|
||||||
val distance = abs(position - currentItem)
|
val distance = abs(position - currentItem)
|
||||||
hideViewpager(distance)
|
hideViewpager(distance)
|
||||||
}
|
}
|
||||||
//Expand the appBar on tab focus
|
//Expand the appBar on tab focus
|
||||||
tab.view.setOnFocusChangeListener { _, _ ->
|
tab.view.setOnFocusChangeListener { _, _ ->
|
||||||
binding.searchBar.setExpanded(true)
|
binding?.searchBar?.setExpanded(true)
|
||||||
}
|
}
|
||||||
}.attach()
|
}.attach()
|
||||||
|
|
||||||
binding.libraryTabLayout.addOnTabSelectedListener(object :
|
binding?.libraryTabLayout?.addOnTabSelectedListener(object :
|
||||||
TabLayout.OnTabSelectedListener {
|
TabLayout.OnTabSelectedListener {
|
||||||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||||
binding.libraryTabLayout.selectedTabPosition.let { page ->
|
binding?.libraryTabLayout?.selectedTabPosition?.let { page ->
|
||||||
libraryViewModel.switchPage(page)
|
libraryViewModel.switchPage(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,11 +498,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(libraryViewModel.currentPage) { position ->
|
observe(libraryViewModel.currentPage) { position ->
|
||||||
updateRandomVisibility(binding)
|
updateRandom()
|
||||||
val all = binding.viewpager.allViews.toList()
|
val all = binding?.viewpager?.allViews?.toList()
|
||||||
.filterIsInstance<AutofitRecyclerView>()
|
?.filterIsInstance<AutofitRecyclerView>()
|
||||||
|
|
||||||
all.forEach { view ->
|
all?.forEach { view ->
|
||||||
view.isVisible = view.tag == position
|
view.isVisible = view.tag == position
|
||||||
view.isFocusable = view.tag == position
|
view.isFocusable = view.tag == position
|
||||||
|
|
||||||
|
|
@ -486,6 +512,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*binding?.viewpager?.registerOnPageChangeCallback(object :
|
||||||
|
ViewPager2.OnPageChangeCallback() {
|
||||||
|
override fun onPageSelected(position: Int) {
|
||||||
|
|
||||||
|
super.onPageSelected(position)
|
||||||
|
}
|
||||||
|
})*/
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadLibraryItem(
|
private fun loadLibraryItem(
|
||||||
|
|
@ -544,10 +578,10 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
binding?.viewpager?.adapter?.notifyDataSetChanged()
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
val adapter = binding?.viewpager?.adapter ?: return
|
|
||||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val sortChangeClickListener = View.OnClickListener { view ->
|
private val sortChangeClickListener = View.OnClickListener { view ->
|
||||||
|
|
@ -555,8 +589,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
||||||
txt(it.stringRes).asString(view.context)
|
txt(it.stringRes).asString(view.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
activity?.showBottomDialog(
|
activity?.showBottomDialog(methods,
|
||||||
methods,
|
|
||||||
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
|
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
|
||||||
txt(R.string.sort_by).asString(view.context),
|
txt(R.string.sort_by).asString(view.context),
|
||||||
false,
|
false,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import androidx.annotation.StringRes
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,31 @@
|
||||||
package com.lagradost.cloudstream3.ui.library
|
package com.lagradost.cloudstream3.ui.library
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
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.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
||||||
class PageAdapter(
|
class PageAdapter(
|
||||||
|
override val items: MutableList<SyncAPI.LibraryItem>,
|
||||||
private val resView: AutofitRecyclerView,
|
private val resView: AutofitRecyclerView,
|
||||||
val clickCallback: (SearchClickCallback) -> Unit
|
val clickCallback: (SearchClickCallback) -> Unit
|
||||||
) :
|
) :
|
||||||
NoStateAdapter<SyncAPI.LibraryItem>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
|
AppContextUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
|
||||||
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()
|
|
||||||
|
|
||||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
return ViewHolderState(
|
return LibraryItemViewHolder(
|
||||||
SearchResultGridExpandedBinding.inflate(
|
SearchResultGridExpandedBinding.inflate(
|
||||||
LayoutInflater.from(parent.context),
|
LayoutInflater.from(parent.context),
|
||||||
parent,
|
parent,
|
||||||
|
|
@ -37,45 +34,86 @@ class PageAdapter(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClearView(holder: ViewHolderState<Any>) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (val binding = holder.view) {
|
when (holder) {
|
||||||
is SearchResultGridExpandedBinding -> {
|
is LibraryItemViewHolder -> {
|
||||||
clearImage(binding.imageView)
|
holder.bind(items[position], position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindContent(
|
private fun isDark(color: Int): Boolean {
|
||||||
holder: ViewHolderState<Any>,
|
return ColorUtils.calculateLuminance(color) < 0.5
|
||||||
item: SyncAPI.LibraryItem,
|
}
|
||||||
position: Int
|
|
||||||
) {
|
|
||||||
val binding = holder.view as? SearchResultGridExpandedBinding ?: return
|
|
||||||
|
|
||||||
/** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */
|
fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int {
|
||||||
SearchResultBuilder.bind(
|
return if (isDark(color)) {
|
||||||
this@PageAdapter.clickCallback,
|
ColorUtils.blendARGB(color, Color.WHITE, ratio)
|
||||||
item,
|
} else {
|
||||||
position,
|
ColorUtils.blendARGB(color, Color.BLACK, ratio)
|
||||||
holder.itemView,
|
|
||||||
)
|
|
||||||
|
|
||||||
// See searchAdaptor for this, it basically fixes the height
|
|
||||||
val params = 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
|
inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) :
|
||||||
binding.watchProgress.isVisible = showProgress
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
if (showProgress) {
|
|
||||||
binding.watchProgress.max = item.episodesTotal
|
private val compactView = false//itemView.context.getGridIsCompact()
|
||||||
binding.watchProgress.progress = item.episodesCompleted
|
private val coverHeight: Int =
|
||||||
|
if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt()
|
||||||
|
|
||||||
|
fun bind(item: SyncAPI.LibraryItem, position: Int) {
|
||||||
|
/** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */
|
||||||
|
|
||||||
|
SearchResultBuilder.bind(
|
||||||
|
this@PageAdapter.clickCallback,
|
||||||
|
item,
|
||||||
|
position,
|
||||||
|
itemView,
|
||||||
|
/*colorCallback = { palette ->
|
||||||
|
AcraApplication.context?.let { ctx ->
|
||||||
|
val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg)
|
||||||
|
var bg = palette.getDarkVibrantColor(defColor)
|
||||||
|
if (bg == defColor) {
|
||||||
|
bg = palette.getDarkMutedColor(defColor)
|
||||||
|
}
|
||||||
|
if (bg == defColor) {
|
||||||
|
bg = palette.getVibrantColor(defColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fg =
|
||||||
|
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
|
||||||
|
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
|
||||||
|
if (!compactView) {
|
||||||
|
binding.imageView.apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
coverHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.imageText.text = item.name
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.imageText.text = item.name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -40,19 +40,19 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewpagerAdapter(
|
class ViewpagerAdapter(
|
||||||
|
fragment: Fragment,
|
||||||
val scrollCallback: (isScrollingDown: Boolean) -> Unit,
|
val scrollCallback: (isScrollingDown: Boolean) -> Unit,
|
||||||
val clickCallback: (SearchClickCallback) -> Unit
|
val clickCallback: (SearchClickCallback) -> Unit
|
||||||
) : BaseAdapter<SyncAPI.Page, Bundle>(
|
) : BaseAdapter<SyncAPI.Page, Bundle>(fragment,
|
||||||
id = "ViewpagerAdapter".hashCode(),
|
id = "ViewpagerAdapter".hashCode(),
|
||||||
diffCallback = BaseDiffCallback(
|
diffCallback = BaseDiffCallback(
|
||||||
itemSame = { a, b ->
|
itemSame = { a, b ->
|
||||||
a.title == b.title
|
a.title == b.title
|
||||||
},
|
},
|
||||||
contentSame = { a, b ->
|
contentSame = { a, b ->
|
||||||
a.items == b.items && a.title == b.title
|
a.items == b.items && a.title == b.title
|
||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
|
|
||||||
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Bundle> {
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Bundle> {
|
||||||
return ViewpagerAdapterViewHolderState(
|
return ViewpagerAdapterViewHolderState(
|
||||||
LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
|
@ -66,8 +66,7 @@ class ViewpagerAdapter(
|
||||||
) {
|
) {
|
||||||
val binding = holder.view
|
val binding = holder.view
|
||||||
if (binding !is LibraryViewpagerPageBinding) return
|
if (binding !is LibraryViewpagerPageBinding) return
|
||||||
(binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items)
|
(binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items)
|
||||||
binding.pageRecyclerview.scrollToPosition(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindContent(holder: ViewHolderState<Bundle>, item: SyncAPI.Page, position: Int) {
|
override fun onBindContent(holder: ViewHolderState<Bundle>, item: SyncAPI.Page, position: Int) {
|
||||||
|
|
@ -76,21 +75,21 @@ class ViewpagerAdapter(
|
||||||
|
|
||||||
binding.pageRecyclerview.tag = position
|
binding.pageRecyclerview.tag = position
|
||||||
binding.pageRecyclerview.apply {
|
binding.pageRecyclerview.apply {
|
||||||
spanCount = binding.root.context.getSpanCount()
|
spanCount =
|
||||||
|
binding.root.context.getSpanCount() ?: 3
|
||||||
if (adapter == null) { // || rebind
|
if (adapter == null) { // || rebind
|
||||||
// Only add the items after it has been attached since the items rely on ItemWidth
|
// Only add the items after it has been attached since the items rely on ItemWidth
|
||||||
// Which is only determined after the recyclerview is attached.
|
// Which is only determined after the recyclerview is attached.
|
||||||
// If this fails then item height becomes 0 when there is only one item
|
// If this fails then item height becomes 0 when there is only one item
|
||||||
doOnAttach {
|
doOnAttach {
|
||||||
adapter = PageAdapter(
|
adapter = PageAdapter(
|
||||||
|
item.items.toMutableList(),
|
||||||
this,
|
this,
|
||||||
clickCallback
|
clickCallback
|
||||||
).apply {
|
)
|
||||||
submitList(item.items)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(adapter as? PageAdapter)?.submitList(item.items)
|
(adapter as? PageAdapter)?.updateList(item.items)
|
||||||
// scrollToPosition(0)
|
// scrollToPosition(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +100,7 @@ class ViewpagerAdapter(
|
||||||
//Expand the top Appbar based on scroll direction up/down, simulate phone behavior
|
//Expand the top Appbar based on scroll direction up/down, simulate phone behavior
|
||||||
if (isLayout(TV or EMULATOR)) {
|
if (isLayout(TV or EMULATOR)) {
|
||||||
binding.root.rootView.findViewById<AppBarLayout>(R.id.search_bar)
|
binding.root.rootView.findViewById<AppBarLayout>(R.id.search_bar)
|
||||||
?.apply {
|
.apply {
|
||||||
if (diff <= 0)
|
if (diff <= 0)
|
||||||
setExpanded(true)
|
setExpanded(true)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,61 @@
|
||||||
package com.lagradost.cloudstream3.ui.player
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
|
import android.graphics.drawable.AnimatedVectorDrawable
|
||||||
|
import android.media.metrics.PlaybackErrorEvent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.annotation.OptIn
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.LayoutRes
|
||||||
import androidx.annotation.StringRes
|
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.session.MediaSession
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.DefaultTimeBar
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.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.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) {
|
enum class PlayerResize(@StringRes val nameRes: Int) {
|
||||||
Fit(R.string.resize_fit),
|
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
|
// when the player should sync the progress of "watched", TODO MAKE SETTING
|
||||||
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
|
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
abstract class AbstractPlayerFragment(
|
||||||
abstract class AbstractPlayerFragment<T : ViewBinding>(
|
var player: IPlayer = CS3IPlayer()
|
||||||
bindingCreator: BindingCreator<T>
|
) : Fragment() {
|
||||||
) : BaseFragment<T>(bindingCreator), PlayerView.Callbacks {
|
var resizeMode: Int = 0
|
||||||
|
var subView: SubtitleView? = null
|
||||||
|
var isBuffering = true
|
||||||
|
protected open var hasPipModeSupport = true
|
||||||
|
|
||||||
// Stored pre-initialization so subclasses can set them before onBindingCreated.
|
var playerPausePlayHolderHolder: FrameLayout? = null
|
||||||
private var _player: IPlayer = CS3IPlayer()
|
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. */
|
@LayoutRes
|
||||||
protected var playerHostView: PlayerView? = null
|
protected open var layout: Int = R.layout.fragment_player
|
||||||
|
|
||||||
var player: IPlayer
|
open fun nextEpisode() {
|
||||||
get() = playerHostView?.player ?: _player
|
throw NotImplementedError()
|
||||||
set(value) {
|
}
|
||||||
_player = value
|
|
||||||
playerHostView?.player = value
|
open fun prevEpisode() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun playerPositionChanged(position: Long, duration: Long) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun playerStatusChanged() {}
|
||||||
|
|
||||||
|
open fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun subtitlesChanged() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun onTracksInfoChanged() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun exitedPipMode() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keepScreenOn(on: Boolean) {
|
||||||
|
if (on) {
|
||||||
|
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
} else {
|
||||||
|
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateIsPlaying(
|
||||||
|
wasPlaying: CSPlayerLoading,
|
||||||
|
isPlaying: CSPlayerLoading
|
||||||
|
) {
|
||||||
|
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
||||||
|
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawable is AnimatedVectorDrawable) {
|
||||||
|
drawable.start()
|
||||||
|
startedAnimation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawable is AnimatedVectorDrawableCompat) {
|
||||||
|
drawable.start()
|
||||||
|
startedAnimation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// somehow the phone is wacked
|
||||||
|
if (!startedAnimation) {
|
||||||
|
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val subView: SubtitleView? get() = playerHostView?.subView
|
canEnterPipMode = isPlayingRightNow && hasPipModeSupport
|
||||||
val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
activity?.let { act ->
|
||||||
/** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
|
PlayerPipHelper.updatePIPModeActions(
|
||||||
val playerView: androidx.media3.ui.PlayerView?
|
act,
|
||||||
get() = playerHostView?.exoPlayerView
|
isPlayingRightNow,
|
||||||
|
player.getAspectRatio()
|
||||||
var currentPlayerStatus: CSPlayerLoading
|
)
|
||||||
get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering
|
}
|
||||||
set(value) { playerHostView?.currentPlayerStatus = value }
|
}
|
||||||
|
|
||||||
protected var mMediaSession: MediaSession?
|
|
||||||
get() = playerHostView?.mMediaSession
|
|
||||||
set(value) { playerHostView?.mMediaSession = value }
|
|
||||||
|
|
||||||
// No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as
|
|
||||||
// open so subclasses can override only what they need. The ones below throw
|
|
||||||
// to make it obvious when an implementation is missing.
|
|
||||||
|
|
||||||
override fun nextEpisode() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prevEpisode() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playerPositionChanged(position: Long, duration: Long) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playerDimensionsLoaded(width: Int, height: Int) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun subtitlesChanged() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTracksInfoChanged() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun exitedPipMode() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hasNextMirror(): Boolean {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun nextMirror() {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delegates to [PlayerView.playerError] by default; override to customize. */
|
|
||||||
override fun playerError(exception: Throwable) {
|
|
||||||
playerHostView?.playerError(exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Player fragments don't need system-bar padding adjustment by default. */
|
|
||||||
override fun fixLayout(view: View) = Unit
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
|
|
||||||
val ctx = context ?: return
|
|
||||||
playerHostView = PlayerView(ctx)
|
|
||||||
playerHostView?.player = _player
|
|
||||||
playerHostView?.callbacks = this
|
|
||||||
playerHostView?.bindViews(binding.root)
|
|
||||||
playerHostView?.initialize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var pipReceiver: BroadcastReceiver? = null
|
||||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||||
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() {
|
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()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
fun nextResize() {
|
||||||
playerHostView?.releaseKeyEventListener()
|
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
|
||||||
super.onPause()
|
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() {
|
override fun onStop() {
|
||||||
playerHostView?.onStop()
|
player.onStop()
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
context?.let { ctx ->
|
context?.let { ctx ->
|
||||||
playerHostView?.onResume(ctx)
|
player.onResume(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onResume()
|
super.onResume()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nextResize() {
|
override fun onCreateView(
|
||||||
playerHostView?.nextResize()
|
inflater: LayoutInflater,
|
||||||
}
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
open fun resize(resize: PlayerResize, showToast: Boolean) {
|
): View? {
|
||||||
playerHostView?.resize(resize, showToast)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,296 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (C) 2016 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes.
|
|
||||||
*/
|
|
||||||
package com.lagradost.cloudstream3.ui.player
|
|
||||||
|
|
||||||
import android.text.Html
|
|
||||||
import android.text.Spanned
|
|
||||||
import android.text.TextUtils
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.Format
|
|
||||||
import androidx.media3.common.Format.CueReplacementBehavior
|
|
||||||
import androidx.media3.common.text.Cue
|
|
||||||
import androidx.media3.common.text.Cue.AnchorType
|
|
||||||
import androidx.media3.common.util.Consumer
|
|
||||||
import androidx.media3.common.util.Log
|
|
||||||
import androidx.media3.common.util.ParsableByteArray
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.extractor.text.CuesWithTiming
|
|
||||||
import androidx.media3.extractor.text.SubtitleParser
|
|
||||||
import androidx.media3.extractor.text.SubtitleParser.OutputOptions
|
|
||||||
import com.google.common.base.Preconditions.checkNotNull
|
|
||||||
import com.google.common.collect.ImmutableList
|
|
||||||
import java.nio.charset.Charset
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import java.util.regex.Matcher
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
|
|
||||||
/** A [SubtitleParser] for SubRip. */
|
|
||||||
@UnstableApi
|
|
||||||
class CustomSubripParser : SubtitleParser {
|
|
||||||
private val textBuilder: StringBuilder = StringBuilder()
|
|
||||||
private val tags: ArrayList<String> = ArrayList()
|
|
||||||
private val parsableByteArray: ParsableByteArray = ParsableByteArray()
|
|
||||||
|
|
||||||
override fun getCueReplacementBehavior(): @CueReplacementBehavior Int {
|
|
||||||
return CUE_REPLACEMENT_BEHAVIOR
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun parse(
|
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
length: Int,
|
|
||||||
outputOptions: OutputOptions,
|
|
||||||
output: Consumer<CuesWithTiming>
|
|
||||||
) {
|
|
||||||
parsableByteArray.reset(data, /* limit= */offset + length)
|
|
||||||
parsableByteArray.setPosition(offset)
|
|
||||||
val charset = detectUtfCharset(parsableByteArray)
|
|
||||||
|
|
||||||
val cuesWithTimingBeforeRequestedStartTimeUs: MutableList<CuesWithTiming>? =
|
|
||||||
if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues)
|
|
||||||
ArrayList<CuesWithTiming>()
|
|
||||||
else
|
|
||||||
null
|
|
||||||
var currentLine: String?
|
|
||||||
while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) {
|
|
||||||
if (currentLine!!.isEmpty()) {
|
|
||||||
// Skip blank lines.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and check the index line.
|
|
||||||
try {
|
|
||||||
currentLine.toInt()
|
|
||||||
} catch (_: NumberFormatException) {
|
|
||||||
Log.w(TAG, "Skipping invalid index: $currentLine")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse the timing line.
|
|
||||||
currentLine = parsableByteArray.readLine(charset)
|
|
||||||
if (currentLine == null) {
|
|
||||||
Log.w(TAG, "Unexpected end")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
val startTimeUs: Long
|
|
||||||
val endTimeUs: Long
|
|
||||||
val matcher = SUBRIP_TIMING_LINE.matcher(currentLine)
|
|
||||||
if (matcher.matches()) {
|
|
||||||
startTimeUs = parseTimecode(matcher, /* groupOffset= */1)
|
|
||||||
endTimeUs = parseTimecode(matcher, /* groupOffset= */6)
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Skipping invalid timing: $currentLine")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse the text and tags.
|
|
||||||
textBuilder.setLength(0)
|
|
||||||
tags.clear()
|
|
||||||
currentLine = parsableByteArray.readLine(charset)
|
|
||||||
while (!TextUtils.isEmpty(currentLine)) {
|
|
||||||
if (textBuilder.isNotEmpty()) {
|
|
||||||
textBuilder.append("<br>")
|
|
||||||
}
|
|
||||||
textBuilder.append(processLine(currentLine!!, tags))
|
|
||||||
currentLine = parsableByteArray.readLine(charset)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val text = Html.fromHtml(textBuilder.toString())
|
|
||||||
|
|
||||||
var alignmentTag: String? = null
|
|
||||||
for (i in tags.indices) {
|
|
||||||
val tag = tags[i]
|
|
||||||
if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) {
|
|
||||||
alignmentTag = tag
|
|
||||||
// Subsequent alignment tags should be ignored.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) {
|
|
||||||
output.accept(
|
|
||||||
CuesWithTiming(
|
|
||||||
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
|
|
||||||
startTimeUs, /* durationUs= */
|
|
||||||
endTimeUs - startTimeUs
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else cuesWithTimingBeforeRequestedStartTimeUs?.add(
|
|
||||||
CuesWithTiming(
|
|
||||||
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
|
|
||||||
startTimeUs, /* durationUs= */
|
|
||||||
endTimeUs - startTimeUs
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (cuesWithTimingBeforeRequestedStartTimeUs != null) {
|
|
||||||
for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) {
|
|
||||||
output.accept(cuesWithTiming)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if
|
|
||||||
* no BOM is found.
|
|
||||||
*/
|
|
||||||
private fun detectUtfCharset(data: ParsableByteArray): Charset {
|
|
||||||
val charset = data.readUtfCharsetFromBom()
|
|
||||||
return charset ?: StandardCharsets.UTF_8
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trims and removes tags from the given line. The removed tags are added to `tags`.
|
|
||||||
*
|
|
||||||
* @param line The line to process.
|
|
||||||
* @param tags A list to which removed tags will be added.
|
|
||||||
* @return The processed line.
|
|
||||||
*/
|
|
||||||
private fun processLine(line: String, tags: ArrayList<String>): String {
|
|
||||||
var line = line
|
|
||||||
line = line.trim { it <= ' ' }
|
|
||||||
|
|
||||||
var removedCharacterCount = 0
|
|
||||||
val processedLine = StringBuilder(line)
|
|
||||||
val matcher = SUBRIP_TAG_PATTERN.matcher(line)
|
|
||||||
while (matcher.find()) {
|
|
||||||
val tag = matcher.group()
|
|
||||||
tags.add(tag)
|
|
||||||
val start = matcher.start() - removedCharacterCount
|
|
||||||
val tagLength = tag.length
|
|
||||||
processedLine.replace(start, /* end= */start + tagLength, /* str= */"")
|
|
||||||
removedCharacterCount += tagLength
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedLine.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a [Cue] based on the given text and alignment tag.
|
|
||||||
*
|
|
||||||
* @param text The text.
|
|
||||||
* @param alignmentTag The alignment tag, or `null` if no alignment tag is available.
|
|
||||||
* @return Built cue
|
|
||||||
*/
|
|
||||||
private fun buildCue(text: Spanned, alignmentTag: String?): Cue {
|
|
||||||
val cue = Cue.Builder().setText(text)
|
|
||||||
if (alignmentTag == null) {
|
|
||||||
return cue.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal alignment.
|
|
||||||
when (alignmentTag) {
|
|
||||||
ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START)
|
|
||||||
ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END)
|
|
||||||
ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
|
||||||
else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical alignment.
|
|
||||||
when (alignmentTag) {
|
|
||||||
ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END)
|
|
||||||
ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START)
|
|
||||||
ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
|
||||||
else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor()))
|
|
||||||
.setLine(
|
|
||||||
getFractionalPositionForAnchorType(cue.getLineAnchor()),
|
|
||||||
Cue.LINE_TYPE_FRACTION
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this
|
|
||||||
* implementation.
|
|
||||||
*/
|
|
||||||
const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int =
|
|
||||||
Format.CUE_REPLACEMENT_BEHAVIOR_MERGE
|
|
||||||
|
|
||||||
// Fractional positions for use when alignment tags are present.
|
|
||||||
private const val START_FRACTION = 0.08f
|
|
||||||
private const val END_FRACTION = 1 - START_FRACTION
|
|
||||||
private const val MID_FRACTION = 0.5f
|
|
||||||
|
|
||||||
private const val TAG = "SubripParser"
|
|
||||||
|
|
||||||
// The google devs are useless, this entire class is just to override this
|
|
||||||
private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?"
|
|
||||||
private val SUBRIP_TIMING_LINE: Pattern =
|
|
||||||
Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*")
|
|
||||||
|
|
||||||
// NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183].
|
|
||||||
private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}")
|
|
||||||
private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}"
|
|
||||||
|
|
||||||
// Alignment tags for SSA V4+.
|
|
||||||
private const val ALIGN_BOTTOM_LEFT = "{\\an1}"
|
|
||||||
private const val ALIGN_BOTTOM_MID = "{\\an2}"
|
|
||||||
private const val ALIGN_BOTTOM_RIGHT = "{\\an3}"
|
|
||||||
private const val ALIGN_MID_LEFT = "{\\an4}"
|
|
||||||
private const val ALIGN_MID_MID = "{\\an5}"
|
|
||||||
private const val ALIGN_MID_RIGHT = "{\\an6}"
|
|
||||||
private const val ALIGN_TOP_LEFT = "{\\an7}"
|
|
||||||
private const val ALIGN_TOP_MID = "{\\an8}"
|
|
||||||
private const val ALIGN_TOP_RIGHT = "{\\an9}"
|
|
||||||
|
|
||||||
private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long {
|
|
||||||
val hours = matcher.group(groupOffset + 1)
|
|
||||||
var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0
|
|
||||||
timestampMs += checkNotNull(matcher.group(groupOffset + 2))
|
|
||||||
.toLong() * 60 * 1000
|
|
||||||
timestampMs += checkNotNull(matcher.group(groupOffset + 3))
|
|
||||||
.toLong() * 1000
|
|
||||||
val millis = matcher.group(groupOffset + 4)
|
|
||||||
|
|
||||||
timestampMs += when (millis?.length) {
|
|
||||||
null -> 0L
|
|
||||||
1 -> millis.toLong() * 100L
|
|
||||||
2 -> millis.toLong() * 10L
|
|
||||||
3 -> millis.toLong() * 1L
|
|
||||||
else -> millis.substring(0, 3).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
return timestampMs * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(b/289983417): Make package-private again, once it is no longer needed in
|
|
||||||
// DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed)
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE)
|
|
||||||
fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float {
|
|
||||||
return when (anchorType) {
|
|
||||||
Cue.ANCHOR_TYPE_START -> START_FRACTION
|
|
||||||
Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION
|
|
||||||
Cue.ANCHOR_TYPE_END -> END_FRACTION
|
|
||||||
Cue.TYPE_UNSET -> // Should never happen.
|
|
||||||
throw IllegalArgumentException()
|
|
||||||
|
|
||||||
else ->
|
|
||||||
throw IllegalArgumentException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,6 +18,7 @@ import androidx.media3.extractor.text.SubtitleParser
|
||||||
import androidx.media3.extractor.text.dvb.DvbParser
|
import androidx.media3.extractor.text.dvb.DvbParser
|
||||||
import androidx.media3.extractor.text.pgs.PgsParser
|
import androidx.media3.extractor.text.pgs.PgsParser
|
||||||
import androidx.media3.extractor.text.ssa.SsaParser
|
import androidx.media3.extractor.text.ssa.SsaParser
|
||||||
|
import androidx.media3.extractor.text.subrip.SubripParser
|
||||||
import androidx.media3.extractor.text.ttml.TtmlParser
|
import androidx.media3.extractor.text.ttml.TtmlParser
|
||||||
import androidx.media3.extractor.text.tx3g.Tx3gParser
|
import androidx.media3.extractor.text.tx3g.Tx3gParser
|
||||||
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
|
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
|
||||||
|
|
@ -34,8 +35,8 @@ import java.nio.charset.Charset
|
||||||
/**
|
/**
|
||||||
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
|
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
|
||||||
* enough to identify the subtitle format.
|
* enough to identify the subtitle format.
|
||||||
*/
|
**/
|
||||||
@OptIn(UnstableApi::class)
|
@UnstableApi
|
||||||
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
companion object {
|
companion object {
|
||||||
fun updateForcedEncoding(context: Context) {
|
fun updateForcedEncoding(context: Context) {
|
||||||
|
|
@ -52,15 +53,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val DEFAULT_MARGIN: Float = 0.05f
|
private const val DEFAULT_MARGIN: Float = 0.05f
|
||||||
const val SSA_ALIGNMENT_BOTTOM_LEFT = 1
|
private const val SSA_ALIGNMENT_BOTTOM_LEFT = 1
|
||||||
const val SSA_ALIGNMENT_BOTTOM_CENTER = 2
|
private const val SSA_ALIGNMENT_BOTTOM_CENTER = 2
|
||||||
const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3
|
private const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3
|
||||||
const val SSA_ALIGNMENT_MIDDLE_LEFT = 4
|
private const val SSA_ALIGNMENT_MIDDLE_LEFT = 4
|
||||||
const val SSA_ALIGNMENT_MIDDLE_CENTER = 5
|
private const val SSA_ALIGNMENT_MIDDLE_CENTER = 5
|
||||||
const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6
|
private const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6
|
||||||
const val SSA_ALIGNMENT_TOP_LEFT = 7
|
private const val SSA_ALIGNMENT_TOP_LEFT = 7
|
||||||
const val SSA_ALIGNMENT_TOP_CENTER = 8
|
private const val SSA_ALIGNMENT_TOP_CENTER = 8
|
||||||
const val SSA_ALIGNMENT_TOP_RIGHT = 9
|
private const val SSA_ALIGNMENT_TOP_RIGHT = 9
|
||||||
|
|
||||||
/** Subtitle offset in milliseconds */
|
/** Subtitle offset in milliseconds */
|
||||||
var subtitleOffset: Long = 0
|
var subtitleOffset: Long = 0
|
||||||
|
|
@ -147,7 +148,37 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
// exoplayer can already parse this, however for eg webvtt it fails
|
// exoplayer can already parse this, however for eg webvtt it fails
|
||||||
locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment ->
|
locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment ->
|
||||||
// toLineAnchor
|
// toLineAnchor
|
||||||
this.setSubtitleAlignment(alignment)
|
when (alignment) {
|
||||||
|
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END
|
||||||
|
SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE
|
||||||
|
SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START
|
||||||
|
else -> null
|
||||||
|
}?.let { anchor ->
|
||||||
|
setLineAnchor(anchor)
|
||||||
|
setLine(
|
||||||
|
computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// toPositionAnchor
|
||||||
|
when (alignment) {
|
||||||
|
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START
|
||||||
|
SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE
|
||||||
|
SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END
|
||||||
|
else -> null
|
||||||
|
}?.let { anchor ->
|
||||||
|
setPositionAnchor(anchor)
|
||||||
|
setPosition(computeDefaultLineOrPosition(anchor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toTextAlignment
|
||||||
|
when (alignment) {
|
||||||
|
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL
|
||||||
|
SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER
|
||||||
|
SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE
|
||||||
|
else -> null
|
||||||
|
}?.let { anchor ->
|
||||||
|
setTextAlignment(anchor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove all matches, so we do not display \anx
|
// remove all matches, so we do not display \anx
|
||||||
|
|
@ -155,42 +186,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
setText(trimmed)
|
setText(trimmed)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder {
|
|
||||||
if (alignment == null) return this
|
|
||||||
when (alignment) {
|
|
||||||
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END
|
|
||||||
SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE
|
|
||||||
SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START
|
|
||||||
else -> null
|
|
||||||
}?.let { anchor ->
|
|
||||||
setLineAnchor(anchor)
|
|
||||||
setLine(
|
|
||||||
computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// toPositionAnchor
|
|
||||||
when (alignment) {
|
|
||||||
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START
|
|
||||||
SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE
|
|
||||||
SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END
|
|
||||||
else -> null
|
|
||||||
}?.let { anchor ->
|
|
||||||
setPositionAnchor(anchor)
|
|
||||||
setPosition(computeDefaultLineOrPosition(anchor))
|
|
||||||
}
|
|
||||||
|
|
||||||
// toTextAlignment
|
|
||||||
when (alignment) {
|
|
||||||
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL
|
|
||||||
SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER
|
|
||||||
SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE
|
|
||||||
else -> null
|
|
||||||
}?.let { anchor ->
|
|
||||||
setTextAlignment(anchor)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var realDecoder: SubtitleParser? = null
|
private var realDecoder: SubtitleParser? = null
|
||||||
|
|
@ -250,14 +245,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
ignoreCase = true
|
ignoreCase = true
|
||||||
)) -> SsaParser(fallbackFormat?.initializationData)
|
)) -> SsaParser(fallbackFormat?.initializationData)
|
||||||
|
|
||||||
trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser()
|
trimmedText.startsWith("1", ignoreCase = true) -> SubripParser()
|
||||||
fallbackFormat != null -> {
|
fallbackFormat != null -> {
|
||||||
when (fallbackFormat.sampleMimeType) {
|
when (val mimeType = fallbackFormat.sampleMimeType) {
|
||||||
MimeTypes.TEXT_VTT -> WebvttParser()
|
MimeTypes.TEXT_VTT -> WebvttParser()
|
||||||
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
|
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
|
||||||
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
|
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
|
||||||
MimeTypes.APPLICATION_TTML -> TtmlParser()
|
MimeTypes.APPLICATION_TTML -> TtmlParser()
|
||||||
MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser()
|
MimeTypes.APPLICATION_SUBRIP -> SubripParser()
|
||||||
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
|
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
|
||||||
// These decoders are not converted to parsers yet
|
// These decoders are not converted to parsers yet
|
||||||
// TODO
|
// TODO
|
||||||
|
|
@ -391,7 +386,7 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
|
||||||
/**
|
/**
|
||||||
* Decoders created here persists across reset()
|
* Decoders created here persists across reset()
|
||||||
* Do not save state in the decoder which you want to reset (e.g subtitle offset)
|
* Do not save state in the decoder which you want to reset (e.g subtitle offset)
|
||||||
*/
|
**/
|
||||||
override fun createDecoder(format: Format): SubtitleDecoder {
|
override fun createDecoder(format: Format): SubtitleDecoder {
|
||||||
val parser = CustomDecoder(format)
|
val parser = CustomDecoder(format)
|
||||||
// Allow garbage collection if player releases the decoder
|
// Allow garbage collection if player releases the decoder
|
||||||
|
|
@ -403,8 +398,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */
|
||||||
class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) :
|
class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) :
|
||||||
SimpleSubtitleDecoder(name) {
|
SimpleSubtitleDecoder(name) {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,60 @@
|
||||||
package com.lagradost.cloudstream3.ui.player
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
|
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
|
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
|
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
|
||||||
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
||||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
class DownloadFileGenerator(
|
class DownloadFileGenerator(
|
||||||
episodes: List<ExtractorUri>
|
private val episodes: List<ExtractorUri>,
|
||||||
) : VideoGenerator<ExtractorUri>(episodes) {
|
private var currentIndex: Int = 0
|
||||||
|
) : IGenerator {
|
||||||
override val hasCache = false
|
override val hasCache = false
|
||||||
override val canSkipLoading = false
|
override val canSkipLoading = false
|
||||||
|
|
||||||
override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id
|
override fun hasNext(): Boolean {
|
||||||
|
return currentIndex < episodes.size - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasPrev(): Boolean {
|
||||||
|
return currentIndex > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun next() {
|
||||||
|
if (hasNext())
|
||||||
|
currentIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun prev() {
|
||||||
|
if (hasPrev())
|
||||||
|
currentIndex--
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun goto(index: Int) {
|
||||||
|
// clamps value
|
||||||
|
currentIndex = min(episodes.size - 1, max(0, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentId(): Int? {
|
||||||
|
return episodes[currentIndex].id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrent(offset: Int): Any? {
|
||||||
|
return episodes.getOrNull(currentIndex + offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(): List<Any>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
|
|
@ -29,14 +64,14 @@ class DownloadFileGenerator(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
isCasting: Boolean
|
isCasting: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val meta = videos.getOrNull(offset) ?: return false
|
val meta = episodes[currentIndex + offset]
|
||||||
|
|
||||||
if (meta.uri == Uri.EMPTY) {
|
if (meta.uri == Uri.EMPTY) {
|
||||||
// We do this here so that we only load it when
|
// We do this here so that we only load it when
|
||||||
// we actually need it as it can be more expensive.
|
// we actually need it as it can be more expensive.
|
||||||
val info = meta.id?.let { id ->
|
val info = meta.id?.let { id ->
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
getDownloadFileInfo(act, id)
|
getDownloadFileInfoAndUpdateSettings(act, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,19 +90,17 @@ class DownloadFileGenerator(
|
||||||
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
|
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
|
||||||
if (isMatchingSubtitle(name, display, cleanDisplay)) {
|
if (isMatchingSubtitle(name, display, cleanDisplay)) {
|
||||||
val cleanName = cleanDisplayName(name)
|
val cleanName = cleanDisplayName(name)
|
||||||
val lastNum = Regex(" ([0-9]+)$")
|
val realName = cleanName.removePrefix(cleanDisplay)
|
||||||
val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: ""
|
|
||||||
val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim()
|
|
||||||
|
|
||||||
subtitleCallback(
|
subtitleCallback(
|
||||||
SubtitleData(
|
SubtitleData(
|
||||||
originalName.ifBlank { ctx.getString(R.string.default_subtitles) },
|
realName.ifBlank { ctx.getString(R.string.default_subtitles) },
|
||||||
nameSuffix,
|
"",
|
||||||
uri.toString(),
|
uri.toString(),
|
||||||
SubtitleOrigin.DOWNLOADED_FILE,
|
SubtitleOrigin.DOWNLOADED_FILE,
|
||||||
name.toSubtitleMimeType(),
|
name.toSubtitleMimeType(),
|
||||||
emptyMap(),
|
emptyMap(),
|
||||||
fromLanguageToTagIETF(originalName, true)
|
null
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,9 @@ import com.lagradost.cloudstream3.mvvm.safe
|
||||||
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
|
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
|
||||||
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
||||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
|
||||||
|
|
||||||
class DownloadedPlayerActivity : AppCompatActivity() {
|
class DownloadedPlayerActivity : AppCompatActivity() {
|
||||||
companion object {
|
private val dTAG = "DownloadedPlayerAct"
|
||||||
const val TAG = "DownloadedPlayerActivity"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
||||||
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
|
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
|
||||||
|
|
@ -29,79 +26,48 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
||||||
CommonActivity.onUserLeaveHint(this)
|
CommonActivity.onUserLeaveHint(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
// Ignore same intent so the player doesnt totally
|
|
||||||
// reload if you are playing the same thing.
|
|
||||||
if (isSameIntent(intent)) return
|
|
||||||
setIntent(intent)
|
|
||||||
Log.i(TAG, "onNewIntent")
|
|
||||||
handleIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isSameIntent(newIntent: Intent): Boolean {
|
|
||||||
val old = intent ?: return false
|
|
||||||
// Compare URIs first
|
|
||||||
val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri
|
|
||||||
val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri
|
|
||||||
if (oldUri != null && oldUri == newUri) return true
|
|
||||||
// Fall back to comparing EXTRA_TEXT links
|
|
||||||
val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) }
|
|
||||||
val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) }
|
|
||||||
return oldText != null && oldText == newText
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
CommonActivity.loadThemes(this)
|
CommonActivity.loadThemes(this)
|
||||||
CommonActivity.init(this)
|
CommonActivity.init(this)
|
||||||
enableEdgeToEdgeCompat()
|
|
||||||
setContentView(R.layout.empty_layout)
|
setContentView(R.layout.empty_layout)
|
||||||
Log.i(TAG, "onCreate")
|
Log.i(dTAG, "onCreate")
|
||||||
handleIntent(intent)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use moveTaskToBack instead of finish() so there is always exactly one task
|
|
||||||
* entry in recents, always reflecting the current file.
|
|
||||||
*
|
|
||||||
* finish() destroys the Activity but may leave the task in recents. Each new file
|
|
||||||
* open can create a new task entry, so recents accumulates stale entries for old
|
|
||||||
* files. The user then taps a stale entry and gets the wrong file.
|
|
||||||
*
|
|
||||||
* moveTaskToBack keeps the Activity alive in the background. There is only ever
|
|
||||||
* one task entry in recents. New files opened from the file manager arrive via
|
|
||||||
* onNewIntent on the live instance, updating the player immediately. The single
|
|
||||||
* recents entry always reflects the current state, ensuring we load the
|
|
||||||
* correct file.
|
|
||||||
*/
|
|
||||||
attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleIntent(intent: Intent) {
|
|
||||||
val data = intent.data
|
val data = intent.data
|
||||||
|
|
||||||
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
|
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) {
|
||||||
intent.action == Intent.ACTION_SEND ||
|
val extraText = safe { // I dont trust android
|
||||||
intent.action == Intent.ACTION_OPEN_DOCUMENT ||
|
intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
intent.action == Intent.ACTION_VIEW
|
}
|
||||||
) {
|
|
||||||
val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
|
|
||||||
val cd = intent.clipData
|
val cd = intent.clipData
|
||||||
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
|
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
|
||||||
val url = item?.text?.toString()
|
val url = item?.text?.toString()
|
||||||
when {
|
|
||||||
item?.uri != null -> playUri(this, item.uri)
|
// idk what I am doing, just hope any of these work
|
||||||
url != null -> playLink(this, url)
|
if (item?.uri != null)
|
||||||
data != null -> playUri(this, data)
|
playUri(this, item.uri)
|
||||||
extraText != null -> playLink(this, extraText)
|
else if (url != null)
|
||||||
else -> finishAndRemoveTask()
|
playLink(this, url)
|
||||||
|
else if (data != null)
|
||||||
|
playUri(this, data)
|
||||||
|
else if (extraText != null)
|
||||||
|
playLink(this, extraText)
|
||||||
|
else {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else if (data?.scheme == "content") {
|
} else if (data?.scheme == "content") {
|
||||||
playUri(this, data)
|
playUri(this, data)
|
||||||
} else finishAndRemoveTask()
|
} else {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,36 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||||
class ExtractorLinkGenerator(
|
class ExtractorLinkGenerator(
|
||||||
private val links: List<ExtractorLink>,
|
private val links: List<ExtractorLink>,
|
||||||
private val subtitles: List<SubtitleData>,
|
private val subtitles: List<SubtitleData>,
|
||||||
) : NoVideoGenerator(null) {
|
) : IGenerator {
|
||||||
|
override val hasCache = false
|
||||||
|
override val canSkipLoading = true
|
||||||
|
|
||||||
|
override fun getCurrentId(): Int? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasNext(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(): List<Any>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasPrev(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrent(offset: Int): Any? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun goto(index: Int) {}
|
||||||
|
|
||||||
|
override fun next() {}
|
||||||
|
|
||||||
|
override fun prev() {}
|
||||||
|
|
||||||
override suspend fun generateLinks(
|
override suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.ui.player
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Looper
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.Renderer
|
|
||||||
import androidx.media3.exoplayer.text.TextOutput
|
|
||||||
import androidx.media3.exoplayer.text.TextRenderer
|
|
||||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
|
||||||
|
|
||||||
@UnstableApi
|
|
||||||
class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) {
|
|
||||||
/** Somehow the nextlib authors decided that we need a text renderer that causes
|
|
||||||
* "ERROR_CODE_FAILED_RUNTIME_CHECK".
|
|
||||||
*
|
|
||||||
* Core issue: https://github.com/anilbeesetti/nextlib/pull/158
|
|
||||||
* Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718
|
|
||||||
* */
|
|
||||||
override fun buildTextRenderers(
|
|
||||||
context: Context,
|
|
||||||
output: TextOutput,
|
|
||||||
outputLooper: Looper,
|
|
||||||
extensionRendererMode: Int,
|
|
||||||
out: ArrayList<Renderer>
|
|
||||||
) {
|
|
||||||
out.add(TextRenderer(output, outputLooper))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -25,27 +25,27 @@ val LOADTYPE_CHROMECAST = setOf(
|
||||||
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
|
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
|
||||||
|
|
||||||
|
|
||||||
abstract class NoVideoGenerator(val id : Int?) : VideoGenerator<Nothing>(emptyList()) {
|
interface IGenerator {
|
||||||
override val hasCache = false
|
val hasCache: Boolean
|
||||||
override val canSkipLoading = false
|
val canSkipLoading: Boolean
|
||||||
override fun getId(index: Int): Int? = id
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class VideoGenerator<T : Any>(val videos: List<T>) {
|
fun hasNext(): Boolean
|
||||||
abstract val hasCache: Boolean
|
fun hasPrev(): Boolean
|
||||||
abstract val canSkipLoading: Boolean
|
fun next()
|
||||||
abstract fun getId(index : Int) : Int?
|
fun prev()
|
||||||
|
fun goto(index: Int)
|
||||||
|
|
||||||
fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex
|
fun getCurrentId(): Int? // this is used to save data or read data about this id
|
||||||
fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0
|
fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
|
||||||
|
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
|
||||||
|
|
||||||
@Throws
|
/* not safe, must use try catch */
|
||||||
abstract suspend fun generateLinks(
|
suspend fun generateLinks(
|
||||||
clearCache: Boolean,
|
clearCache: Boolean,
|
||||||
sourceTypes: Set<ExtractorLinkType>,
|
sourceTypes: Set<ExtractorLinkType>,
|
||||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||||
subtitleCallback: (SubtitleData) -> Unit,
|
subtitleCallback: (SubtitleData) -> Unit,
|
||||||
offset: Int,
|
offset: Int = 0,
|
||||||
isCasting: Boolean
|
isCasting: Boolean = false
|
||||||
): Boolean
|
): Boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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