Compare commits

...

258 commits

Author SHA1 Message Date
KingLucius
3c6bf2984e
SyncApi Search query fix (#2932) 2026-06-19 00:06:46 +02:00
Luna712
6f458fc9b5
Remove unused classgraph dependency (#2924) 2026-06-17 23:41:02 +00:00
Bnyro
b4100dbfca
feat(extractors): add flyfile.app extractor (#2925) 2026-06-17 23:40:30 +00:00
Luna712
943bc551e9
[skip ci] HlsPlaylistParser: use base64DecodeArray (#2929) 2026-06-17 23:34:25 +00:00
Luna712
c045bfdc0d
[skip ci] MainAPI: remove @OptIn(ExperimentalEncodingApi::class) (#2930)
It is stable since Kotlin 2.2.0
2026-06-17 23:03:28 +00:00
firelight
2c03a3d976
fix gradient (#2912) 2026-06-13 02:59:02 +02:00
firelight
3417fe0160
Feat: OnlyPlayer (#2905) 2026-06-13 02:58:43 +02:00
firelight
55450a02fa
Merge pull request #2904 from recloudstream/mpvrx
Feat: MpvRx
2026-06-13 02:56:45 +02:00
Osten
6f9646e52f
Fix one last issue in JSON parsing :I 2026-06-11 15:06:23 +02:00
Luna712
b222911e29
Fix parseJson inline on stable once again! (#2908) 2026-06-11 12:29:29 +02:00
firelight
5667f52648
Translated using Weblate (German) (#2876)
Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (German)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Macedonian)

Currently translated at 100.0% (729 of 729 strings)





Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mk/
Translation: Cloudstream/App

Co-authored-by: Deleted User <pandoroo@users.noreply.hosted.weblate.org>
Co-authored-by: WertZuz <97708601+wertzuz@users.noreply.github.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
2026-06-11 02:28:26 +02:00
Hosted Weblate
b2a02a174f
Merge remote-tracking branch 'origin/master' 2026-06-11 02:26:56 +02:00
Luna712
18a857723b
Replace java.net.uri in library (#2839) 2026-06-11 00:26:49 +00:00
Hosted Weblate
292d3f1442
Merge remote-tracking branch 'origin/master' 2026-06-11 02:24:56 +02:00
Luna712
8012c58069
Set explicitNulls = false for kotlinx serialization (#2897)
This is more inline with Jackson's behavior otherwise for nullable types with non default values, and they don't exist, gives a Missingfield exception whereas it worked on Jackson. We may need coerceInputValues = true also but I am unsure of that right now.
2026-06-11 00:24:50 +00:00
Hosted Weblate
4f8a79669c
Merge remote-tracking branch 'origin/master' 2026-06-11 02:09:06 +02:00
Luna712
2181243dd1
Bump NewPipeExtractor to fix trailers and other YouTube videos (#2906) 2026-06-11 00:08:58 +00:00
Hosted Weblate
eae18bb50d
Merge remote-tracking branch 'origin/master' 2026-06-09 21:03:54 +00:00
Luna712
f7cbf25b30
Replace EnumSet for dubStatus (#2845) 2026-06-09 23:03:48 +02:00
Hosted Weblate
fd579fcc18
Translated using Weblate (German)
Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (German)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Macedonian)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: Deleted User <pandoroo@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: WertZuz <97708601+wertzuz@users.noreply.github.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mk/
Translation: Cloudstream/App
2026-06-08 22:22:51 +02:00
Luna712
79cc3fb501
Fix kotlinx serialization in parseJson (#2902)
It doing runCatching on the Result itself, that results in the serializer always returning null. Just do what we do everywhere else here also, getContextual doesn't throw anything.
2026-06-08 20:22:34 +00:00
KingLucius
d78b991d66
Trakt meta provider logo support (#2894) 2026-06-07 23:59:42 +02:00
firelight
70053ebbae
[skip ci] Fix serialization testing (#2896) 2026-06-06 19:48:37 +00:00
Luna712
a4a4c31f8d
Replace toByteArray() in some places in library (#2866) 2026-06-03 16:09:30 +02:00
Osten
3844c896f1
Fixed Json again 2026-06-02 14:14:38 +02:00
Osten
11f77fbe11
Fixed parseJson inline problem 2026-06-01 18:11:54 +02:00
Luna712
62662cb064
Bump a few libs (#2840) 2026-06-01 01:24:31 +02:00
Luna712
8e0c664b1e
Add TRAKT_CLIENT_ID to Archive build as well (#2861) 2026-06-01 01:10:03 +02:00
fgmitesh
4836e2b371
fix: treat KEYCODE_ENTER as select for TV remotes (LG Magic Remote) (#2853)
* Improve key event handling in FullScreenPlayer

Refactor key event handling for play/pause and chapter skipping.

* Handle KEYCODE_ENTER for SearchView focus
2026-06-01 01:07:22 +02:00
firelight
0e16f429af
Update TRAKT_CLIENT_ID (#2860) 2026-06-01 01:04:36 +02:00
KingLucius
5de7f207f2
feat(MetaProviders): Fix Trakt (#2858) 2026-06-01 00:53:47 +02:00
Luna712
e1aacce93d
ExtractorAPI: support Kotlin Uuid (#2855) 2026-06-01 00:53:37 +02:00
Luna712
8e1b41ea61
Fix one instance of String(byteArray) I previously missed (#2859) 2026-06-01 00:49:23 +02:00
Luna712
b7f5826a19
Add expect/actual for YoutubeExtractor (#2844)
NewPipeExtractor won't work in non-JVM platforms.
2026-05-31 01:48:06 +00:00
Luna712
8e7569df53
Fix T::class in parseJson causing type erasure in some cases (#2852) 2026-05-31 01:47:46 +00:00
Luna712
0728dd06a1
[skip ci] Replace using Char constructor in a couple extractors (#2856) 2026-05-31 01:09:43 +00:00
Luna712
041d21a486
Emergency patch (#2851) 2026-05-29 19:30:55 +02:00
Luna712
a124450ddc
Migrate Java date utils in library to kotlinx-datetime (#2798) 2026-05-29 10:58:08 +00:00
Luna712
028a794ea5
Add support for using kotlinx-serialization rather than Jackson (#2791) 2026-05-28 21:16:31 +00:00
PiterDev
c1b6fc2eeb
Fix Updated/ReleaseDate/Rating sorting for Kitsu SyncProvider (#2780) 2026-05-28 10:46:49 +00:00
Luna712
647c274944
[skip ci] Move versionCode and versionName to version catalog 2026-05-27 21:05:18 +00:00
firelight
22be73619e
Translated using Weblate (Norwegian Nynorsk) (#2784)
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ms/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ar_SA/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/pt/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Co-authored-by: Dark <darkbeamer.official@gmail.com>
Co-authored-by: Douglas de Santana Ramos <ramos.ti@live.com>
Co-authored-by: Gabriel <bloxgabriel18@gmail.com>
Co-authored-by: Johannes Bø <johannes.bo@gmail.com>
Co-authored-by: Maarten De Jong <maarten.de.jong2003@gmail.com>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Saleh ALHarbi <alfraidigamerofficial@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
2026-05-27 22:57:43 +02:00
Hosted Weblate
a3c100e75b
Merge remote-tracking branch 'origin/master' 2026-05-27 20:57:16 +00:00
Luna712
d24f8bca0f
Don't use any external library for Levenshtein ratio matching (#2802) 2026-05-27 20:57:09 +00:00
Hosted Weblate
4c3c463a19
Merge remote-tracking branch 'origin/master' 2026-05-26 19:22:13 +02:00
Luna712
007c0ff9bc
Fix some bugs in DownloadedPlayerActivity (#2758)
* Fix some bugs in DownloadedPlayerActivity

* Remove savedInstanceState check and use better long comment format
2026-05-26 19:22:06 +02:00
Hosted Weblate
c8bc999d22
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 26.4% (193 of 729 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (480 of 729 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Russian)

Currently translated at 100.0% (729 of 729 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Dutch)

Currently translated at 90.8% (662 of 729 strings)

Translated using Weblate (Arabic (Saudi Arabia))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Co-authored-by: Dark <darkbeamer.official@gmail.com>
Co-authored-by: Douglas de Santana Ramos <ramos.ti@live.com>
Co-authored-by: Gabriel <bloxgabriel18@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Johannes Bø <johannes.bo@gmail.com>
Co-authored-by: Maarten De Jong <maarten.de.jong2003@gmail.com>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Saleh ALHarbi <alfraidigamerofficial@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ms/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ar_SA/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/pt/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2026-05-26 17:00:51 +00:00
Luna712
b353cf2017
Remove back-compat constructor for AnimeSearchResponse (#2815)
This is in preparation to remove the use of EnumSet for `dubStatus`. Those that use the builder, use `addDubStatus` which means we can easily do this without breaking bytecode compatibility, but first we need to remove the back-compat constructor, as once we do that it wouldn't work anymore anyway.
2026-05-26 19:00:36 +02:00
IndusAryan
70ed1c753d
(fix): implement hardware check for image loading (#2765) 2026-05-24 22:59:43 +00:00
Luna712
00e943ebc4
Replace synchronized usage in library with kotlinx-atomicfu (#2808) 2026-05-24 22:31:27 +00:00
Luna712
0afb23eb2e
Bump material to 1.14.0 stable (#2752) 2026-05-24 22:16:53 +00:00
Luna712
0b642bb47f
[skip ci] Use StringUtils.decodeUri in a couple places (#2831)
Gives us just one place to update the API when we get there
2026-05-23 22:44:31 +00:00
Luna712
c6c70d5751
[skip ci] Remove an unused import (#2830) 2026-05-23 22:43:32 +00:00
Luna712
c1b49d0dcb
[skip ci] Replace charset string conversions with Kotlin native equivalents (#2807) 2026-05-23 20:34:13 +00:00
Luna712
85cc10c2e0
[skip ci] Replace usage of String.format in library (#2819) 2026-05-23 20:20:50 +00:00
Luna712
dd016341c0
[skip ci] Replace ArrayList in extractor (#2826) 2026-05-23 20:11:46 +00:00
Luna712
ac0a0d2941
[skip ci] Replace Integer.parseInt with Kotlin-native equivalent (#2827) 2026-05-23 20:10:21 +00:00
Osten
4ab97e4605
Delete issue workflow to prevent security issues 2026-05-23 18:02:53 +02:00
Luna712
f894b8f7ec
SubtitleHelper: replace usage of java.lang.Character (#2817) 2026-05-22 22:53:46 +00:00
Luna712
72386cb98c
[skip ci] HlsPlaylistParser: don't use java.lang.StringBuilder directly (#2811)
Just using StringBuilder will allow it to use kotlin.text.StringBuilder from Kotlin instead, which it already does in some places, making using java.lang.StringBuilder in here very inconsistent with other parts of the same class.
2026-05-21 22:01:46 +00:00
Luna712
419b902ead
[skip ci] Use this::class rather than javaClass in MainAPI (#2809) 2026-05-21 21:45:04 +00:00
Luna712
638d749945
[skip ci] Remove usage of junit within the app itself (#2820)
When I was testing compose, I realized that this causes issues where junit was wiring up instrumentation within the app, which overrode and conflicted with compose resource context. We don't need it as a dependency for just TestingUtils, so this refsctors it to just use AssertionError directly.
2026-05-21 21:29:33 +00:00
Luna712
0f41ca2641
[skip ci] Don't pass locale to titlecase in String.capitalize (#2810)
That is the default anyway.
2026-05-21 21:17:03 +00:00
Luna712
a6000fbe04
[skip ci] AppDebug: use kotlin.concurrent.Volatile (#2818)
By default this uses kotlin.jvm.Volatile, which we should be using kotlin.concurrent.Volatile instead.
2026-05-21 21:09:13 +00:00
Abodabodd
862e2590d2
Update StreamWishExtractor.kt (#2770) 2026-05-19 16:45:58 +00:00
fire-light43
9bc5027ea7
shared buffer to decrease alloc (#2787) 2026-05-19 01:19:16 +02:00
Luna712
7e406cb5eb
CryptoJS: replace array copies with Kotlin stdlib equivalents (#2799)
* Remove use of java.util.Arrays

* Remove unused import

* Replace more
2026-05-19 01:13:57 +02:00
Luna712
a24dc2600e
JsHunter/JsUnpacker: use Kotlin-native regex (#2803) 2026-05-19 01:06:07 +02:00
Luna712
89cc63673b
Remove okhttp3.HttpUrl version of loadImage (#2790)
It is currently unused and at some point we will want to move coil to use ktor and fully phase out the dependency on okhttp so we don't an unnecessary extra dependency on it.
2026-05-19 01:00:10 +02:00
Luna712
ab85737637
[skip ci] TraktProvider: use text rather than toString for app.get (#2804)
toString is just an alias to text at the moment, but isn't really clear, and isn't really what it is meant for.
2026-05-18 22:35:44 +00:00
Alvin
9a53e267ac
fix: only return subtitle if not null (#2794) 2026-05-18 01:29:59 +02:00
Asheesh Sahu
03eb6ccd45
fix: implement memory-based throttle to prevent OOM during downloads (#2786) 2026-05-12 15:58:48 +00:00
Asheesh Sahu
7425d283cd
fix: external player visibility on Android 11+ (#2785) 2026-05-12 15:30:17 +00:00
fire-light43
6eb833130d
Fix rare bug in the download queue 2026-05-11 15:59:00 +00:00
recloudstream[bot]
3d1852ba04 chore(locales): fix locale issues 2026-05-11 13:02:22 +00:00
fire-light43
59ae579b7c
Merge pull request #2754 from recloudstream/weblate
Translations update from Hosted Weblate
2026-05-11 13:02:02 +00:00
Hosted Weblate
aa6e702b59
Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (729 of 729 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (726 of 729 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (French)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Albanian)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Arabic)

Currently translated at 99.5% (726 of 729 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Albanian)

Currently translated at 77.2% (563 of 729 strings)

Translated using Weblate (Italian)

Currently translated at 99.8% (728 of 729 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (729 of 729 strings)

Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Czech)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 25.2% (183 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.6% (172 of 726 strings)

Translated using Weblate (Assamese)

Currently translated at 86.9% (631 of 726 strings)

Translated using Weblate (Assamese)

Currently translated at 86.9% (631 of 726 strings)

Translated using Weblate (Assamese)

Currently translated at 86.9% (631 of 726 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 45.0% (327 of 726 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 45.0% (327 of 726 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 45.0% (327 of 726 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 45.0% (327 of 726 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 45.0% (327 of 726 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.0% (719 of 726 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.0% (719 of 726 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.0% (719 of 726 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.0% (719 of 726 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.0% (719 of 726 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.0% (719 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Persian)

Currently translated at 46.5% (338 of 726 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (283 of 726 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (283 of 726 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (283 of 726 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (283 of 726 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (283 of 726 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (283 of 726 strings)

Translated using Weblate (Catalan)

Currently translated at 45.0% (327 of 726 strings)

Translated using Weblate (Dutch)

Currently translated at 88.9% (646 of 726 strings)

Translated using Weblate (Dutch)

Currently translated at 88.9% (646 of 726 strings)

Translated using Weblate (Dutch)

Currently translated at 88.9% (646 of 726 strings)

Translated using Weblate (Dutch)

Currently translated at 88.9% (646 of 726 strings)

Translated using Weblate (Dutch)

Currently translated at 88.9% (646 of 726 strings)

Translated using Weblate (Dutch)

Currently translated at 88.9% (646 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Amharic)

Currently translated at 14.4% (105 of 726 strings)

Translated using Weblate (Albanian)

Currently translated at 73.1% (531 of 726 strings)

Translated using Weblate (Albanian)

Currently translated at 73.1% (531 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Kannada)

Currently translated at 17.4% (127 of 726 strings)

Translated using Weblate (Nepali)

Currently translated at 17.0% (124 of 726 strings)

Translated using Weblate (Nepali)

Currently translated at 17.0% (124 of 726 strings)

Translated using Weblate (Nepali)

Currently translated at 17.0% (124 of 726 strings)

Translated using Weblate (Nepali)

Currently translated at 17.0% (124 of 726 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 19.8% (144 of 726 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 19.8% (144 of 726 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 65.7% (477 of 726 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 65.7% (477 of 726 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 65.7% (477 of 726 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 65.7% (477 of 726 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 65.7% (477 of 726 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 65.7% (477 of 726 strings)

Translated using Weblate (Romanian)

Currently translated at 84.8% (616 of 726 strings)

Translated using Weblate (Romanian)

Currently translated at 84.8% (616 of 726 strings)

Translated using Weblate (Romanian)

Currently translated at 84.8% (616 of 726 strings)

Translated using Weblate (Romanian)

Currently translated at 84.8% (616 of 726 strings)

Translated using Weblate (Romanian)

Currently translated at 84.8% (616 of 726 strings)

Translated using Weblate (Romanian)

Currently translated at 84.8% (616 of 726 strings)

Translated using Weblate (Odia)

Currently translated at 21.4% (156 of 726 strings)

Translated using Weblate (Odia)

Currently translated at 21.4% (156 of 726 strings)

Translated using Weblate (Odia)

Currently translated at 21.4% (156 of 726 strings)

Translated using Weblate (Odia)

Currently translated at 21.4% (156 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Croatian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Croatian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Croatian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Maltese)

Currently translated at 16.6% (121 of 726 strings)

Translated using Weblate (Maltese)

Currently translated at 16.6% (121 of 726 strings)

Translated using Weblate (Maltese)

Currently translated at 16.6% (121 of 726 strings)

Translated using Weblate (Greek)

Currently translated at 90.4% (657 of 726 strings)

Translated using Weblate (Greek)

Currently translated at 90.4% (657 of 726 strings)

Translated using Weblate (Greek)

Currently translated at 90.4% (657 of 726 strings)

Translated using Weblate (Greek)

Currently translated at 90.4% (657 of 726 strings)

Translated using Weblate (Greek)

Currently translated at 90.4% (657 of 726 strings)

Translated using Weblate (Greek)

Currently translated at 90.4% (657 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.5% (527 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.9% (595 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Slovak)

Currently translated at 62.5% (454 of 726 strings)

Translated using Weblate (Afrikaans)

Currently translated at 16.2% (118 of 726 strings)

Translated using Weblate (Afrikaans)

Currently translated at 16.2% (118 of 726 strings)

Translated using Weblate (Afrikaans)

Currently translated at 16.2% (118 of 726 strings)

Translated using Weblate (Afrikaans)

Currently translated at 16.2% (118 of 726 strings)

Translated using Weblate (Afrikaans)

Currently translated at 16.2% (118 of 726 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Bengali)

Currently translated at 48.2% (350 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Macedonian)

Currently translated at 96.4% (700 of 726 strings)

Translated using Weblate (Macedonian)

Currently translated at 96.4% (700 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Malay)

Currently translated at 65.8% (478 of 726 strings)

Translated using Weblate (Lithuanian)

Currently translated at 29.7% (216 of 726 strings)

Translated using Weblate (Lithuanian)

Currently translated at 29.7% (216 of 726 strings)

Translated using Weblate (Lithuanian)

Currently translated at 29.7% (216 of 726 strings)

Translated using Weblate (Lithuanian)

Currently translated at 29.7% (216 of 726 strings)

Translated using Weblate (Lithuanian)

Currently translated at 29.7% (216 of 726 strings)

Translated using Weblate (Arabic (Egyptian))

Currently translated at 0.0% (0 of 726 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 726 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 726 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 726 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Tigrinya)

Currently translated at 0.4% (3 of 726 strings)

Translated using Weblate (Tigrinya)

Currently translated at 0.4% (3 of 726 strings)

Translated using Weblate (Tigrinya)

Currently translated at 0.4% (3 of 726 strings)

Translated using Weblate (Tigrinya)

Currently translated at 0.4% (3 of 726 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Tamil)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Tamil)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Kurdish (Central))

Currently translated at 11.2% (82 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Tagalog)

Currently translated at 32.5% (236 of 726 strings)

Translated using Weblate (Burmese)

Currently translated at 69.1% (502 of 726 strings)

Translated using Weblate (Burmese)

Currently translated at 69.1% (502 of 726 strings)

Translated using Weblate (Burmese)

Currently translated at 69.1% (502 of 726 strings)

Translated using Weblate (Burmese)

Currently translated at 69.1% (502 of 726 strings)

Translated using Weblate (Burmese)

Currently translated at 69.1% (502 of 726 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Urdu)

Currently translated at 81.8% (594 of 726 strings)

Translated using Weblate (Urdu)

Currently translated at 81.8% (594 of 726 strings)

Translated using Weblate (Urdu)

Currently translated at 81.8% (594 of 726 strings)

Translated using Weblate (Urdu)

Currently translated at 81.8% (594 of 726 strings)

Translated using Weblate (Urdu)

Currently translated at 81.8% (594 of 726 strings)

Translated using Weblate (Urdu)

Currently translated at 81.8% (594 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (German)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 96.9% (704 of 726 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 96.9% (704 of 726 strings)

Translated using Weblate (Latvian)

Currently translated at 80.8% (587 of 726 strings)

Translated using Weblate (Latvian)

Currently translated at 80.8% (587 of 726 strings)

Translated using Weblate (Latvian)

Currently translated at 80.8% (587 of 726 strings)

Translated using Weblate (Latvian)

Currently translated at 80.8% (587 of 726 strings)

Translated using Weblate (Latvian)

Currently translated at 80.8% (587 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Somali)

Currently translated at 60.4% (439 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Malayalam)

Currently translated at 31.2% (227 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (qt (generated) (qt))

Currently translated at 85.3% (620 of 726 strings)

Translated using Weblate (qt (generated) (qt))

Currently translated at 85.3% (620 of 726 strings)

Translated using Weblate (qt (generated) (qt))

Currently translated at 85.3% (620 of 726 strings)

Translated using Weblate (qt (generated) (qt))

Currently translated at 85.3% (620 of 726 strings)

Translated using Weblate (qt (generated) (qt))

Currently translated at 85.3% (620 of 726 strings)

Translated using Weblate (qt (generated) (qt))

Currently translated at 85.3% (620 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Co-authored-by: 007 <juri.malaj@gmail.com>
Co-authored-by: Aayush Shah <shahaayush999@gmail.com>
Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
Co-authored-by: Ahmed Abd El-Fattah <a.aelfattah5@gmail.com>
Co-authored-by: Aitor Salaberria <amento@ni.eus>
Co-authored-by: Akhlak Ur Rahman <akhlak.pro.red@gmail.com>
Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Co-authored-by: Alexander Svärd <genc.demiri@hotmail.com>
Co-authored-by: Alexandru <negrualexandru52@gmail.com>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Anarchydr <patrikpik879@gmail.com>
Co-authored-by: Andre Costa <andrecaeu@gmail.com>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Apostol Penkov <apostol.penkov@gmail.com>
Co-authored-by: Argo Carpathians <chrisarabagas@gmail.com>
Co-authored-by: Azgar <azgaresncf@gmail.com>
Co-authored-by: Balmunk <reilria77@gmail.com>
Co-authored-by: Bananenbrot <keram2810@outlook.de>
Co-authored-by: Beabfekad Zikie <beabfekadz@gmail.com>
Co-authored-by: Bhupesh Yadav <thebhupeshyadav@gmail.com>
Co-authored-by: Bitpaint <bitpaintclub@gmail.com>
Co-authored-by: BluTiger <beqirstafa@gmail.com>
Co-authored-by: ButterflyOfFire <boffire@users.noreply.hosted.weblate.org>
Co-authored-by: Bálint László <blaszlobors@gmail.com>
Co-authored-by: Carlos Luiz <ecarlos-luiz@hotmail.com>
Co-authored-by: Chaya Endot <cheyenne.xyn@gmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: DarkOrbFX <darkorbfx@gmail.com>
Co-authored-by: Davide Marcoli <davide.marcoli13@gmail.com>
Co-authored-by: Deleted User <Skrripy@users.noreply.hosted.weblate.org>
Co-authored-by: Deleted User <inavleb@users.noreply.hosted.weblate.org>
Co-authored-by: Deleted User <jsdelvani@users.noreply.hosted.weblate.org>
Co-authored-by: Deleted User <noreply+48943@weblate.org>
Co-authored-by: Deleted User <noreply+53776@weblate.org>
Co-authored-by: Deleted User <noreply+68493@weblate.org>
Co-authored-by: Doctorredits_here <182783629+doctorreditshere@users.noreply.github.com>
Co-authored-by: Emmanuel HEMERIT <emmanuel@hemer.it>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: FastAct <alex.rijckaert@gmail.com>
Co-authored-by: Felicity <kjev666+weblate@protonmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Gabriel <bloxgabriel18@gmail.com>
Co-authored-by: Gabriel Cnudde <gabriel.cnudde59@gmail.com>
Co-authored-by: Giuseppe Terrana <terranagiuseppe03@gmail.com>
Co-authored-by: Gnkalk <github.fngyb@slmail.me>
Co-authored-by: H Tamás <hovanszki@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Imprevisible <imprevisible@duck.com>
Co-authored-by: Itsmechinmoy <167056923+itsmechinmoy@users.noreply.github.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: JL Pilgram <twich_89@hotmail.it>
Co-authored-by: Jean-Michel <arsene_lupin_57@hotmail.fr>
Co-authored-by: Jen Xie <aulaul825879@gmail.com>
Co-authored-by: Joana Trashlieva <j.trashlieva@gmail.com>
Co-authored-by: John Titor <utkin2007@gmail.com>
Co-authored-by: Joshua Joseph <joshuasaju2@gmail.com>
Co-authored-by: Julia Sugawara <jm.sugawara@gmail.com>
Co-authored-by: Kardi Demha <kardi.demha@gmail.com>
Co-authored-by: Konstantinos Tranoudis <kontranpro@gmail.com>
Co-authored-by: Kraptor123 <kraptor121@gmail.com>
Co-authored-by: LagradOst <46196380+Blatzar@users.noreply.github.com>
Co-authored-by: Leon de Klerk <deklerkleon5@gmail.com>
Co-authored-by: Levi Klippel <leviklippel@gmail.com>
Co-authored-by: LiJu09 <lisojuraj@gmail.com>
Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: Ludovic Pagès <nanucq@proton.me>
Co-authored-by: Luna712 <142361265+Luna712@users.noreply.github.com>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Marian Turba <puki247@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Michael John Scerri <michaeljscerri@gmail.com>
Co-authored-by: Mika <akimivanov43@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Muhammad Fahad Khan <itxmfahadkhan@gmail.com>
Co-authored-by: Muhammet <zumruduanka0013@gmail.com>
Co-authored-by: Murat Han <murathancw@gmail.com>
Co-authored-by: Márkó <gost1336@gmail.com>
Co-authored-by: Naga <yz2000.pro@gmail.com>
Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Nicoara Alex <alex.nicoara@yahoo.com>
Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Co-authored-by: PaneradFisk <andre.bergvall@icloud.com>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Pixelcode <pixelcode@dismail.de>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Qareen Skoll <qareen101@protonmail.com>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Radoslav Vasilev Vasilev <fifata@gmail.com>
Co-authored-by: Ranforingus <ranforingus@hotmail.com>
Co-authored-by: Red Star Over Earth <rs0vere@outlook.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: SBS1313 <simonsaade005@gmail.com>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Co-authored-by: Samuel Gadiel <samuelgadiel@gmail.com>
Co-authored-by: Sandyran <sandyran@protonmail.com>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: Sdarfeesh <Sdarfeesh@protonmail.com>
Co-authored-by: SehrGuterCode <philemonpfeiffer@gmail.com>
Co-authored-by: Shafici Isxariifshe <mega12xhaphiee@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Sintayew Gashaw <sintayewgashaw4@gmail.com>
Co-authored-by: SleepyOwl <artem726artem@gmail.com>
Co-authored-by: Sufyan Zahoor Jutt <sufyanpahore@gmail.com>
Co-authored-by: Synertry <jonny.somrak@gmail.com>
Co-authored-by: T1z3n <info@njbraun.de>
Co-authored-by: TZVS <akyasan@tuta.io>
Co-authored-by: The Initiator <eithansten@gmail.com>
Co-authored-by: Tiago Lucas <tiago.slucas@gmail.com>
Co-authored-by: Tomas Andersson <jotakswe@users.noreply.hosted.weblate.org>
Co-authored-by: TubaApollo <86665265+TubaApollo@users.noreply.github.com>
Co-authored-by: Vrwi <jurgisbums@gmail.com>
Co-authored-by: Walter H. <walter75@gmail.com>
Co-authored-by: Wei-Cheng Yeh (IID) <iid@ccns.ncku.edu.tw>
Co-authored-by: almost gamer <almost.gamer01+language@gmail.com>
Co-authored-by: arcopnt <arcopnt@posteo.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: edgolron <edgolron@tutanota.com>
Co-authored-by: eightyy8 <oliver.kha@pm.me>
Co-authored-by: enly sure <enlysure@gmail.com>
Co-authored-by: esfzzddfse <esfzzddfse@proton.me>
Co-authored-by: george kitsoukakis <norhorn@gmail.com>
Co-authored-by: helpless_helper <dis.hd.eif.y.bk.ao.up@gmail.com>
Co-authored-by: hou1234 <gjqmgjsl3@gmail.com>
Co-authored-by: htet <htetoh2006@outlook.com>
Co-authored-by: hugoalh <hugoalh@users.noreply.hosted.weblate.org>
Co-authored-by: jinu147 <nesqea20@gmail.com>
Co-authored-by: kaajjo <claymanoff@gmail.com>
Co-authored-by: kerklangsi <arifilah.01@gmail.com>
Co-authored-by: khalidbelk <khalid.belkassmi-el-hafi@epitech.eu>
Co-authored-by: l <thisuserooo@gmail.com>
Co-authored-by: liva <livinja@proton.me>
Co-authored-by: mojtaba piri <Mpcuteangel@gmail.com>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: programutox <programutox@disroot.org>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: therry47 <soulietherry@gmail.com>
Co-authored-by: tuan041 <tuananh163025ttt@gmail.com>
Co-authored-by: william piti <loolyowo@gmail.com>
Co-authored-by: Ícaro Rodrigo Ferreira Da Fonseca Bezerra <icaro.bezerra@sptech.school>
Co-authored-by: Ömer Faruk Sancak <keyiflerolsun@gmail.com>
Co-authored-by: ΣΤΑΥΡΟΣ ΔΑΛΙΑΚΟΠΟΥΛΟΣ <stavros.daliakopoulos@gmail.com>
Co-authored-by: Влад Николаев <vladnic1990@gmail.com>
Co-authored-by: Сергей (MrSabin) <sabin.21011986@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: 电棍老板 <qwertyuiop9296@outlook.com>
Co-authored-by: 구병우 <dodamby@ajou.ac.kr>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/af/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/am/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/apc/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ars/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/arz/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/as/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/az/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bg/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ca/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ckb/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/el/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/eo/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/es/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fa/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/gl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/he/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/id/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/kn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ml/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ms/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/my/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ne/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/or/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/qt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ro/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/so/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sq/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ti/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ur/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/
Translation: Cloudstream/App
2026-05-11 13:12:30 +02:00
firelight
0d16a636e2
Fix: playerVideoTitleRez visibility (#2767) 2026-05-06 20:13:29 +02:00
firelight
bfc926814c
Fixobserveorder (#2766) 2026-05-06 20:13:13 +02:00
firelight
a45f1d9ab1
Refactor: Player, Generator and ViewModel (#2764) 2026-05-06 02:29:47 +02:00
Luna712
948a2c1725
Always go to next source in player and add thread safety (#2733) 2026-05-04 21:57:27 +02:00
Luna712
4e24aa5db1
Minor fixes to player (#2756) 2026-05-04 18:43:01 +02:00
Osten
7476d24db3
Added exhaustive keyCode check, fixed #2757 2026-05-04 14:40:13 +02:00
firelight
c82fec0862
Refactor: Move all key logic into the player, and added toggleEpisodesOverlay keybind (#2745) 2026-05-03 22:46:09 +02:00
Luna712
e36e9e8d24
Use Looper.getMainLooper().isCurrentThread for simplicity (#2753) 2026-05-03 18:06:09 +02:00
Luna712
e64136db8a
Use runOnMainThread for simplicity (#2749) 2026-05-02 20:15:34 +02:00
Luna712
104ab26790
Use ioWorkSafe for updateFillers for simplification (#2743) 2026-05-01 18:53:30 +02:00
Osten
2400e6ab45
fixed observe, aka #2567 (#2736) 2026-04-30 17:23:49 +02:00
firelight
4cc76ee6c5
Fix(TV): Color on "Skip Chapter" button (#2731) 2026-04-30 17:23:08 +02:00
Luna712
8523a4bd90
Fix DownloadedPlayerActivity not loading new files when activity is already running (#2738) 2026-04-30 17:22:32 +02:00
Luna712
58f45c7bda
Remove unnecessary ?: 0 (#2734) 2026-04-30 00:46:12 +02:00
firelight
d30d71f39e
Fix(TV): Metadata hide episode selector #2715 (#2730) 2026-04-28 23:35:37 +02:00
Luna712
4610d6aae7
Revert "Improve subtitle selection UX: Move "No Subtitles" option to bottom (…" (#2720)
This reverts commit ee6a9af217.
2026-04-28 23:22:30 +02:00
firelight
9257d31090
Fix: Cancel popup dialog if dismissed (#2726) 2026-04-28 23:21:45 +02:00
firelight
2755385fa6
Feat: Ask for path before download from UI (#2699) 2026-04-28 23:21:32 +02:00
Luna712
18d9f5c317
Major rework to player (#2689) 2026-04-28 23:20:43 +02:00
Luna712
53345a804f
Enable abortOnError in lint (#2681) 2026-04-25 22:32:09 +00:00
Luna712
71306d4e98
[skip ci] Delete empty files (#2723) 2026-04-25 22:21:36 +00:00
firelight
6d79b0e5d0
Fix: Remove default headers from downloads, like player (#2722) 2026-04-25 01:17:32 +02:00
firelight
3bdda7d380
Feat: Anime Skip (#2710) 2026-04-25 00:29:37 +02:00
Luna712
659f639acd
Add new TVType for generic Video (#2712) 2026-04-25 00:26:07 +02:00
Luna712
1d750340a0
Fix some typos in singular (#2721) 2026-04-25 00:22:17 +02:00
Bnyro
d4899536d3
refactor(extractors): simplify and combine jwplayer extraction (#2398) 2026-04-22 00:45:04 +00:00
firelight
18ee71664f
Feat: Offline filler database (#2704) 2026-04-20 23:24:37 +00:00
Luna712
f7494f20e1
Support resuming fragmented MP4s (#2690) 2026-04-19 21:42:46 +00:00
Luna712
590a94e318
Fix typo in credits (#2703) 2026-04-19 21:40:12 +00:00
Luna712
2264b90396
Bump nicehttp (#2697) 2026-04-19 21:39:22 +00:00
Luna712
0ed6fd8fef
Bump jsoup and zipline libs (#2517) 2026-04-19 21:39:12 +00:00
Luna712
e3e995b222
Add lint ignore (#2669)
We only care about the source language with this, not translations which would mostly be false positives.
2026-04-19 17:01:50 +00:00
Luna712
7c1554a479
AGP 9! (#2604) 2026-04-19 17:00:47 +00:00
firelight
7926e60fb0
Add plugin hash validation (#2644) 2026-04-19 13:29:37 +00:00
Luna712
68a1d0856c
Fix STATE_IDLE issues in player (#2691) 2026-04-19 13:09:19 +00:00
hrisabhy
ee6a9af217
Improve subtitle selection UX: Move "No Subtitles" option to bottom (#2523) 2026-04-19 12:59:44 +00:00
Luna712
c1eef1de1d
Add new URL for Voe (#2701) 2026-04-19 12:41:05 +00:00
firelight
f175beb51b
Fix concurrent plugin loading (#2700) 2026-04-19 12:05:24 +00:00
Luna712
e55794c200
Bump buildkonfig lib (#2643) 2026-04-18 13:52:14 +00:00
Luna712
c67ba2b485
Add explicit permission checks for notifications in downloader (#2667) 2026-04-18 13:45:55 +00:00
Luna712
6336837903
Revert media3 to 1.9.3 (#2693) 2026-04-18 13:40:05 +00:00
Luna712
636d2507f7
Add missing OptIn (#2668)
This an error level opt in introduced in media3 1.10.0.
2026-04-16 23:28:16 +00:00
Luna712
cd03392364
Remove setup-android action from Dokka action (#2666)
It shouldn't be necessary with setup-gradle.
2026-04-16 23:26:52 +00:00
Luna712
c31c5764ea
Bump nextlibMedia3 (#2658) 2026-04-14 22:22:38 +00:00
Luna712
7925e714e7
Fix editing accounts from MainActivity (#2663) 2026-04-14 21:39:31 +00:00
Luna712
8d416fa2fc
Remove commented android.enableJetifier from gradle.properties (#2662)
It is now deprecated anyway. We will never use it now, so we can just fully remove it.
2026-04-14 20:48:23 +00:00
Luna712
0bb9322276
Don't explicitly enable WebContentsDebugging (#2657)
"this is enabled automatically if the app is declared as `android:debuggable="true"` in its manifest; otherwise, the default is false." - which we set on CloudStream Debug but not release flavors.

"Enabling web contents debugging allows the state of any WebView in the app to be inspected and modified by the user via adb. This is a security liability and should not be enabled in production builds of apps unless this is an explicitly intended use of the app."
2026-04-14 20:47:38 +00:00
Luna712
cfce80e93e
Bump DGP and KGP libs (#2582)
Final compatibility with AGP 9
2026-04-13 22:27:27 +00:00
firelight
fb54d02979
Fix SSL issues (#2655) 2026-04-13 22:21:52 +00:00
Luna712
788189c80c
Bump github-script action (#2642) 2026-04-13 21:52:57 +00:00
Luna712
1b0fdb57a8
Add permissions to workflows (#2654)
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions
2026-04-13 21:50:57 +00:00
firelight
2eb63dc334
Change default installer to legacy (#2653)
Switching the default to the more reliable legacy installer until we fix the new installer.
2026-04-13 21:47:12 +00:00
Luna712
bd7db6c20a
Upgrade media3 to 1.10.0 (#2608) 2026-04-12 22:46:11 +02:00
Phisher98
14d56de61e
Adding a subtle shadow and minor adjustments to make the description stand out more on a white background. (#2648) 2026-04-12 22:40:05 +02:00
CranberrySoup
adf2ed6df3
Fix livestreams (#2627) 2026-04-12 22:36:31 +02:00
Luna712
b89f36c9bc
Bump material to 1.14.0-beta01 (#2636) 2026-04-09 22:01:20 +02:00
Luna712
0f1cb3a773
Add strictly for coil lib (#2635) 2026-04-09 21:59:53 +02:00
Phisher98
c304e8556e
Minor Fix IntroDbSkip (#2634) 2026-04-09 21:59:31 +02:00
firelight
a7f5f9a35a
Feat: TheIntroDBSkip + Bugfix (#2631) 2026-04-09 21:58:50 +02:00
Luna712
d7b030e7ef
Update gradle to 9.4.1 (#2610) 2026-04-09 17:19:57 +02:00
Luna712
fe0829ff64
Bump material (#2609) 2026-04-09 17:11:17 +02:00
Luna712
bb4e5da5c9
Bump androidx libraries (#2607) 2026-04-09 17:07:10 +02:00
Luna712
c9a24e198c
Add true configuration cache support for git commit hash (#2285)
Co-authored-by: firelight <147925818+fire-light42@users.noreply.github.com>
2026-04-09 10:32:41 +00:00
Luna712
ca96aa6891
Bump actions (#2588)
* Keep gradle/actions/setup-gradle@v5 for now
2026-04-09 10:16:24 +00:00
Luna712
8bdc1a83d7
Use InternalAPI rather than permanent deprecations in PluginManager (#2615) 2026-04-09 10:12:53 +00:00
firelight
be69ec938e
Merge pull request #2633 from recloudstream/weblate
Translations update from Hosted Weblate
2026-04-09 10:11:32 +00:00
firelight
04b22ba4df
Small backup fix 2026-04-09 10:11:15 +00:00
Hosted Weblate
e22a596d0c
Translated using Weblate (Arabic)
Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Albanian)

Currently translated at 68.0% (494 of 726 strings)

Co-authored-by: 007 <juri.malaj@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: hollow04 <ichigo0404@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sq/
Translation: Cloudstream/App
2026-04-09 07:09:57 +00:00
Onur Civanoğlu
f6920fb05d
feat: Force landscape orientation and pillarbox portrait videos on TV and emulator devices. (#2560) 2026-04-09 01:22:57 +02:00
CranberrySoup
f28924f704
Fix intent launches (#2554) 2026-04-09 01:17:21 +02:00
Luna712
b510942027
Bump newpipeextractor to v0.26.0 (#2624) 2026-04-09 01:11:40 +02:00
firelight
1d03b05a7c
Refactor: New SkipAPI for SkipStamp (#2601) 2026-04-09 01:04:39 +02:00
Luna712
f51885fb6e
Fix MotionEvent gestures getting stuck in player (#2629) 2026-04-09 01:00:53 +02:00
firelight
31165a87c1
Merge pull request #2625 from Luna712/short-commit
Use short commit hashes for libs
2026-04-08 22:40:58 +00:00
firelight
9f792f5b1a
Merge pull request #2616 from recloudstream/weblate
Translations update from Hosted Weblate
2026-04-08 22:40:01 +00:00
Hosted Weblate
418cf08ad4
Translated using Weblate (Albanian)
Currently translated at 57.0% (414 of 726 strings)

Translated using Weblate (Albanian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Added translation using Weblate (Albanian)

Translated using Weblate (Hindi)

Currently translated at 56.6% (411 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Co-authored-by: 007 <juri.malaj@gmail.com>
Co-authored-by: ByAyzen <Ayzenxyz@proton.me>
Co-authored-by: Hariom Jha <hariom.jha5499@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: hou1234 <gjqmgjsl3@gmail.com>
Co-authored-by: muhaco <cemusa10@gmail.com>
Co-authored-by: xinshoutw <me@xinshou.tw>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sq/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/sq/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2026-04-08 17:10:18 +00:00
Luna712
d495cbe32d
Use short commit hashes for libs 2026-04-06 19:01:57 -06:00
Bnyro
08b1d97152
feat(extractors): add playmogo (doodstream mirror) (#2620) 2026-04-06 21:45:29 +00:00
Luna712
62e6895d8e
Replace deprecated viewBinding { enable = true } in Gradle (#2623) 2026-04-06 21:39:27 +00:00
Luna712
ae9a374a83
Don't keep screen on when ended (#2619) 2026-04-06 21:36:49 +00:00
Luna712
154cd7500b
Remove @prerelease annotations and enable some deprecations (#2614) 2026-04-06 21:32:58 +00:00
firelight
76e30d2e75
Merge pull request #2591 from recloudstream/weblate
Translations update from Hosted Weblate
2026-04-04 18:32:47 +00:00
Hosted Weblate
b2cd9612ea
Merge remote-tracking branch 'origin/master' 2026-04-04 18:27:01 +00:00
Luna712
562a5d8192
Fix CustomSubripParser to match assertion usage upstream (#2612)
Also suppresses a deprecation to fix build warnings. Fixes all other warnings in assertions by changing them because that is what it uses upstream also.
2026-04-04 18:26:55 +00:00
Hosted Weblate
bb295ded09
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (723 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (French)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Latvian)

Currently translated at 80.8% (587 of 726 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (726 of 726 strings)

Co-authored-by: Ardev Prisec <prisecardev@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: blueocean2308 <bluewhale2308@gmail.com>
Co-authored-by: hou1234 <gjqmgjsl3@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@mail.lv>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/id/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translation: Cloudstream/App
2026-04-04 18:26:35 +00:00
Luna712
ba9413e972
Change param name in interface to match everywhere else (#2611) 2026-04-04 18:26:17 +00:00
Phisher98
db154a8cd2
Adding IntroDB (#2599) 2026-04-03 16:36:42 +02:00
Nguyen Van Nam
736c6374a6
Fix: thread-safe HashMap for image bitmap cache 2026-03-30 21:23:02 +00:00
firelight
9fe7662f95
Merge pull request #2573 from recloudstream/weblate
Translations update from Hosted Weblate
2026-03-30 23:21:45 +02:00
Hosted Weblate
d23fb0ac4c
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (725 of 726 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (726 of 726 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (725 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Korean)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 62.6% (454 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.7% (172 of 725 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Latvian)

Currently translated at 81.2% (589 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Esperanto)

Currently translated at 17.5% (127 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.8% (724 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 82.0% (595 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (722 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Belarusian)

Currently translated at 99.5% (722 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 77.3% (561 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 89.1% (646 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (German)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 75.4% (547 of 725 strings)

Co-authored-by: Ardev Prisec <prisecardev@gmail.com>
Co-authored-by: Aron Folkerts <aronfolkerts@gmail.com>
Co-authored-by: Daniel Konstantinov <bgshadow2010@gmail.com>
Co-authored-by: David Hermann <theumis@users.noreply.hosted.weblate.org>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jen Xie <aulaul825879@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: Wacky Wars <wackywars21@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: clearstripe <sakurasan000510@gmail.com>
Co-authored-by: hollow <ichigo0404@hotmail.com>
Co-authored-by: hou1234 <gjqmgjsl3@gmail.com>
Co-authored-by: jpkaster 77 <jpkaster81@gmail.com>
Co-authored-by: programutox <programutox@disroot.org>
Co-authored-by: tomas293 <tomaskopodstreleny@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@mail.lv>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bg/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/eo/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ar/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2026-03-30 15:10:01 +00:00
firelight
c26f236202
Fix: Minor UX bugs with #2461 2026-03-30 00:29:27 +02:00
recloudstream[bot]
673569a747 chore(locales): fix locale issues 2026-03-29 22:12:16 +00:00
Phisher98
7a2222b252
Adding Metdata on Player (Initial Draft) (TV) (#2461) 2026-03-30 00:11:58 +02:00
PiterDev
76a2feb79c
Add fallback url for kitsu sync (#2552) 2026-03-29 20:33:36 +02:00
Nguyen Van Nam
81c7d90a5f
Fix: Subtitle deletion matches on substring extension, can delete non-subtitle files (#2584) 2026-03-29 18:15:34 +00:00
Osten
235863f9d2
Bump to 4.7.0 2026-03-29 14:06:17 +02:00
Osten
07eb9973f8
Increased DOWNLOAD_PARTIAL_MIN_SIZE to 50MB 2026-03-29 13:54:51 +02:00
Osten
a23c136d81
Fixed partial downloads + resume bugs 2026-03-26 01:13:13 +01:00
Osten
89400be5e5
Remove google dependenciesInfo + bumb nicehttp 2026-03-25 18:31:57 +01:00
Phisher98
d06afa32fd
Tracks naming fix and minor UI improvements (#2480) 2026-03-23 22:14:48 +00:00
Luna712
f674b427ac
Use assemblePrereleaseRelease for building prerelease (#2365) 2026-03-23 21:48:48 +00:00
Luna712
f5b46949ec
Add support for configuration cache with keystore (#2328) 2026-03-22 23:16:39 +00:00
firelight
dce70ac229
Merge pull request #2565 from recloudstream/weblate
Translations update from Hosted Weblate
2026-03-23 00:16:30 +01:00
Osten
45699b72a8
Added .close to m3u8 hslLazy 2026-03-22 13:41:12 +01:00
Osten
a74a0840d6
Fixed BackPressedCallbackHelper activity leak 2026-03-22 13:04:22 +01:00
Hosted Weblate
73e19212cc
Merge remote-tracking branch 'origin/master' 2026-03-22 08:34:50 +01:00
Osten
ee1e90e0f4
Emergency fix for OOM and leaks 2026-03-22 08:34:43 +01:00
Hosted Weblate
51bd1c4a6c
Translated using Weblate (Slovak)
Currently translated at 62.6% (454 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 23.7% (172 of 725 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Latvian)

Currently translated at 81.2% (589 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Esperanto)

Currently translated at 17.5% (127 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.8% (724 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 82.0% (595 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (722 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Belarusian)

Currently translated at 99.5% (722 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 77.3% (561 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 89.1% (646 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (German)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 75.4% (547 of 725 strings)

Co-authored-by: Aron Folkerts <aronfolkerts@gmail.com>
Co-authored-by: Daniel Konstantinov <bgshadow2010@gmail.com>
Co-authored-by: David Hermann <theumis@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jen Xie <aulaul825879@gmail.com>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: Wacky Wars <wackywars21@gmail.com>
Co-authored-by: clearstripe <sakurasan000510@gmail.com>
Co-authored-by: jpkaster 77 <jpkaster81@gmail.com>
Co-authored-by: programutox <programutox@disroot.org>
Co-authored-by: tomas293 <tomaskopodstreleny@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@mail.lv>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bg/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/eo/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/
Translation: Cloudstream/App
2026-03-20 09:09:57 +00:00
Luna712
19efb1ffc3
Fix BuildConfig import (#2566) 2026-03-13 23:21:12 +00:00
Luna712
86cca03dd7
Use a new method to pass if debug to fix debug logging in library (#2330)
* Use a new method to pass if debug to fix debug logging in library
2026-03-13 22:39:34 +00:00
Nivin
ef07f761d7
Prefer player default live position for HLS/DASH (#2547) 2026-03-13 22:17:53 +00:00
Bnyro
904dda0c60
feat(extractors): add new extractor for vidsonic.net (#2557) 2026-03-13 22:03:27 +00:00
Bnyro
8d3846d2a3
feat(extractors): add support for vidara.to (#2556)
* feat(extractors): add support for vidara.to

* Allow soft subtitle failure in Streamup
2026-03-13 21:59:59 +00:00
Osten
ccc0a45065
Fixed shared pool, closes #2082 (#2553) 2026-03-13 21:54:11 +00:00
firelight
06907bed05
Translated using Weblate (Vietnamese) (#2530)
Co-authored-by: Aron Folkerts <aronfolkerts@gmail.com>
Co-authored-by: David Hermann <theumis@users.noreply.hosted.weblate.org>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: Wacky Wars <wackywars21@gmail.com>
Co-authored-by: clearstripe <sakurasan000510@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
2026-03-13 22:54:03 +01:00
Bnyro
b0d3731faa
feat(extractors): add vide0 doodstream mirror (#2558) 2026-03-11 14:19:57 +00:00
Hosted Weblate
618f9cde65
Translated using Weblate (Vietnamese)
Currently translated at 99.8% (724 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 82.0% (595 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (722 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Belarusian)

Currently translated at 99.5% (722 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 21.2% (154 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 77.3% (561 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 89.1% (646 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (German)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 75.4% (547 of 725 strings)

Co-authored-by: Aron Folkerts <aronfolkerts@gmail.com>
Co-authored-by: David Hermann <theumis@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: Wacky Wars <wackywars21@gmail.com>
Co-authored-by: clearstripe <sakurasan000510@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translation: Cloudstream/App
2026-03-09 19:09:55 +01:00
Luna712
1fb6ce310d
Bump material (#2519) 2026-02-28 23:34:24 +01:00
Luna712
2ca21051d5
Bump androidx libraries (#2518) 2026-02-28 23:29:54 +01:00
firelight
a8f6ef0ea5
Fix: Nextlib textrenderer from #2510 2026-02-28 23:23:24 +01:00
Luna712
809b66af81
Revert "Revert "Update media3 to 1.9.2"" (#2510) 2026-02-28 23:02:49 +01:00
CranberrySoup
ecc3e506f9
Backup fix (#2542)
* Fix subtitle selection

* Move logic to getLanguageDataFromName

* Update BackupUtils.kt
2026-02-28 22:59:10 +01:00
saimuel
ef9e49d955
Remove dead Extractors (#2511) 2026-02-28 22:58:33 +01:00
DieGon7771
a65828e2b0
Fix trailer zoom not resetting after fullscreen exit (#2512) 2026-02-28 22:57:01 +01:00
Saurabh Kaperwan
9f2067bbff
fix gofile extractor (#2525)
* fix gofile extractor

* minor fix
2026-02-28 22:54:34 +01:00
Phisher98
514a808218
Removing default headers that caused some streams to return 2004. (#2533) 2026-02-28 22:25:44 +01:00
Phisher98
8e71baeb84
Make video info slightly dimmer (#2508) 2026-02-20 23:10:41 +00:00
firelight
543d1b4478
Translated using Weblate (#2482)
Co-authored-by: Broo Mohamed <broo91398@gmail.com>
Co-authored-by: Camila Sciocca <hellocamilatranslation@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Daniel Wiik <d.wiik@outlook.com>
Co-authored-by: Deepak C <deepakchatgpt19@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Kerim Demirkaynak <aschannel111@gmail.com>
Co-authored-by: Luiz Felipe Sudorio dos Santos <luizfelipe@post.com>
Co-authored-by: MD Sakibur Rahman <msr.official01@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oliver <oliver.puff@gmx.de>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: ShowhyT <showhy@proton.me>
Co-authored-by: Takeru Mikenu <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Timo Panda <pandoroo@users.noreply.hosted.weblate.org>
Co-authored-by: XC3 <darkofficial110@gmail.com>
Co-authored-by: kokolo <ivansto314@gmail.com>
Co-authored-by: lamrichiasmaa <lamrichiasma@gmail.com>
Co-authored-by: lizamimiku-wq <lizamimiku@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: tjy122 <tning0134@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
2026-02-20 23:08:25 +00:00
Saurabh Kaperwan
6e423ba24e
Add multiple extractors new domains (#2516) 2026-02-20 23:02:55 +00:00
Hosted Weblate
46f9c95376
Translated using Weblate (French)
Currently translated at 100.0% (725 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (German)

Currently translated at 98.8% (717 of 725 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (English)

Currently translated at 99.8% (724 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Indonesian)

Currently translated at 98.6% (715 of 725 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (724 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (725 of 725 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Croatian)

Currently translated at 99.3% (718 of 723 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (723 of 723 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (723 of 723 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (723 of 723 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (723 of 723 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (722 of 723 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (723 of 723 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (723 of 723 strings)

Merge remote-tracking branch 'origin/master'

Added translation using Weblate (Arabic (Egyptian))

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Russian)

Currently translated at 99.5% (712 of 715 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (712 of 715 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (714 of 715 strings)

Translated using Weblate (Belarusian)

Currently translated at 90.4% (647 of 715 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Belarusian)

Currently translated at 85.5% (612 of 715 strings)

Translated using Weblate (Bengali)

Currently translated at 48.9% (350 of 715 strings)

Translated using Weblate (German)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Belarusian)

Currently translated at 65.5% (469 of 715 strings)

Translated using Weblate (French)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Croatian)

Currently translated at 99.8% (714 of 715 strings)

Translated using Weblate (Belarusian)

Currently translated at 58.0% (415 of 715 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (715 of 715 strings)

Co-authored-by: Broo Mohamed <broo91398@gmail.com>
Co-authored-by: Camila Sciocca <hellocamilatranslation@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Daniel Wiik <d.wiik@outlook.com>
Co-authored-by: Deepak C <deepakchatgpt19@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Kerim Demirkaynak <aschannel111@gmail.com>
Co-authored-by: Luiz Felipe Sudorio dos Santos <luizfelipe@post.com>
Co-authored-by: MD Sakibur Rahman <msr.official01@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oliver <oliver.puff@gmx.de>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: Sasha Glazko <lenify@users.noreply.hosted.weblate.org>
Co-authored-by: ShowhyT <showhy@proton.me>
Co-authored-by: Takeru Mikenu <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Timo Panda <pandoroo@users.noreply.hosted.weblate.org>
Co-authored-by: XC3 <darkofficial110@gmail.com>
Co-authored-by: kokolo <ivansto314@gmail.com>
Co-authored-by: lamrichiasmaa <lamrichiasma@gmail.com>
Co-authored-by: lizamimiku-wq <lizamimiku@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: tjy122 <tning0134@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/en/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/es/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/id/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translation: Cloudstream/App
2026-02-19 10:09:57 +01:00
Mioki
1d1a7fb6fe
Improved TV Back Button UX (extension content -> extension selection Bttn -> home nav Bttn -> exit dialog) (#2468) 2026-02-18 17:58:48 +01:00
saimuel
eaf2b7bd0d
Add new ByseSX mainUrl (#2506) 2026-02-18 17:41:21 +01:00
firelight
6806a4e2e6
Revert "Update media3 to 1.9.2 (#2342)" (#2509)
This reverts commit ea4ef5c2f3.
2026-02-18 17:31:01 +01:00
firelight
8baee7ee78
Fix(UI): Minor color issue and padding on top chip bar 2026-02-18 00:10:36 +00:00
Luna712
ea4ef5c2f3
Update media3 to 1.9.2 (#2342) 2026-02-18 00:52:58 +01:00
Luna712
ad2168c5bc
Upgrade to minSdk 23 (#2078) 2026-02-18 00:31:01 +01:00
Luna712
76728d858f
Use withStyledAttributes (#2305) 2026-02-17 22:55:30 +01:00
Nivin
88d42613d3
Replace Google suggest API with TheMovieDB multi-search endpoint (#2500) 2026-02-17 21:53:07 +01:00
Mohd Kaif Shaikh
c862d119fb
add Videa.hu extractor (#2491)
* add Videa.hu extractor

* replace import android.util.Base64

* Refactor Videa extractor for improved URL handling
2026-02-17 21:48:29 +01:00
firelight
443c1c81c9
Refactor: Minor code cleanup for #2501 2026-02-17 20:45:39 +00:00
Swapnil Kuwar
8796a73f06
Search result provider pining feature added. (#2501) 2026-02-17 21:44:22 +01:00
Saurabh Kaperwan
960658df61
Remove hubcloud download link (#2502)
Sometimes it is making a get request to a video file
2026-02-17 21:28:03 +01:00
CranberrySoup
3da9b2ec7b
Better priority (#2496) 2026-02-13 19:59:29 +01:00
CranberrySoup
b58e0b893f
Fix resume watching (#2498) 2026-02-13 19:42:38 +01:00
firelight
f6339e44e1
Fix: YT Live + Subrip 2026-02-13 17:40:26 +00:00
Mohd Kaif Shaikh
32f1a2e6c3
Youtube Extractor Fix with NewPipe (#2489) 2026-02-13 18:39:29 +01:00
CranberrySoup
cf084ac2eb
Download rework (#2037) 2026-02-11 19:30:48 +01:00
Mioki
2c62f3fa46
Merge pull request #2478 from okibcn/0K_UqloadFixPR 2026-02-11 01:53:15 +01:00
firelight
8fcce6b5fd
Merge pull request #2488 from phisher98/Gdmirrorbot-improvement
GDMirrorbot Improvement
2026-02-11 00:43:58 +00:00
4530c00a71 Improvement 2026-02-09 19:28:54 +05:30
firelight
2766ac86a1
Merge pull request #2457 from recloudstream/weblate
Translations update from Hosted Weblate
2026-02-06 01:18:35 +00:00
Hosted Weblate
838989beaa
Merge remote-tracking branch 'origin/master' 2026-02-06 02:18:12 +01:00
Luna712
b370b5b9e7
Update gradle to 9.3.1 (#2477) 2026-02-06 01:18:05 +00:00
Hosted Weblate
30b5a4e649
Translated using Weblate (Belarusian)
Currently translated at 46.7% (334 of 715 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (German)

Currently translated at 99.1% (709 of 715 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.8% (714 of 715 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (714 of 715 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (715 of 715 strings)

Translated using Weblate (Belarusian)

Currently translated at 40.1% (286 of 712 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (712 of 712 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (712 of 712 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (712 of 712 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (712 of 712 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (712 of 712 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (709 of 710 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (709 of 710 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Spanish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (710 of 710 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (German)

Currently translated at 100.0% (709 of 709 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Russian)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Russian)

Currently translated at 99.5% (706 of 709 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (French)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (709 of 709 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Polish)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (709 of 709 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (707 of 709 strings)

Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Ardev Prisec <prisecardev@gmail.com>
Co-authored-by: Artem <artemkozhin80@gmail.com>
Co-authored-by: BruttoDiego <bruttodiego6@gmail.com>
Co-authored-by: Bryan Tank <perso@bryantank.fr>
Co-authored-by: Christopher Allen <nsn90255@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Haru Ijima <haruijimakun@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Kraptor123 <kraptor121@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mioki <okibcn@gmail.com>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: ShowhyT <showhy@proton.me>
Co-authored-by: Takeru Mikenu <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Theumis <me@david-hermann.de>
Co-authored-by: sam <cambridgeaccsamuel@gmail.com>
Co-authored-by: Дейв Рандом (MVboss1190) <m.v.boss123443211190@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/es/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translation: Cloudstream/App
2026-02-06 01:05:09 +01:00
Pawloland
a45593283d
Readd extra brightness feature as optional setting (#2469) 2026-02-03 20:19:25 +01:00
firelight
60244d86a4
Fix: Fixed stupid media3 parsing #2476 2026-02-03 19:07:56 +00:00
Phisher98
c25a9dc56b
Adding videoinfo optional in video Show player information (#2464)
* Adding videoinfo optional in video player resolution section
2026-02-02 18:28:56 +00:00
Luna712
6acc3d8f65
Run lint in pull requests (#2358) 2026-02-02 17:39:22 +00:00
Luna712
47b568c289
Update gradle to 9.3.0 (#2439)
* Update gradle to 9.3.0

* Update gradle-wrapper.properties
2026-02-02 17:34:40 +00:00
Mioki
045fc2770f
Fix track selection when same lang/id is used in 2+ audio tracks. Codec & channel configuration in track selection. Solves issue #2427 (#2447) 2026-02-01 18:13:48 +01:00
DieGon7771
bef80875b1
Add toggle for showing/hiding cast panel (#2466) 2026-02-01 17:58:19 +01:00
Mioki
c44d07b4e5
Vidmoly Fix and adds variants (#2470) 2026-02-01 17:49:48 +01:00
firelight
4e2bfd3d43
Fix: Logic bug in fixTitle, Closes #2465 2026-01-29 16:38:58 +00:00
firelight
06456bc548
Fix:(UI) Collapse sync to a single button, closes #2460 and closes #2458 2026-01-28 23:18:43 +00:00
firelight
af1e0757f4
Feat: Zoom (#2456)
* Feat: Zoom
2026-01-28 23:45:32 +01:00
firelight
4271b8104e
Fix(UI): Made outline consistent 2026-01-28 19:37:55 +00:00
CranberrySoup
5e039a80ba
Fix subtitle selection (#2449)
* Fix subtitle selection
2026-01-28 18:05:19 +01:00
firelight
c618e4e505
Fix: Minor fixes to #2454 2026-01-27 17:12:49 +00:00
Phisher98
cbad2cfdaf
Adding VideoInfo on Player (#2454)
Co-authored-by: Bnyro <bnyro@tutanota.com>
2026-01-27 18:06:27 +01:00
firelight
290283dc15
Chore: nicehttp -> 0.4.16 2026-01-26 22:17:31 +00:00
firelight
7ecb7785c2
Fix(UI): Move voice actor view behind actor view for better visibility 2026-01-26 22:11:12 +00:00
firelight
f82fe7b0ce
Bump to 4.6.2 2026-01-26 19:01:16 +00:00
Nivin
4b28140f8b
Add search suggestions to search UI (#2294) 2026-01-26 19:59:31 +01:00
Bnyro
6f1e4a959f
feat(extractors): add streamix extractor (streamup mirror) (#2455) 2026-01-26 19:49:37 +01:00
DieGon7771
0c25630f0b
Update VotingApi.kt (#2451) 2026-01-25 21:00:04 +01:00
firelight
f6f3e3ff73
Fix: Added backwards for subtitle+audio interceptor, Closes #2442 2026-01-25 19:53:39 +00:00
Osten
c1a2ae8704
Fixed #2448 and hdr by removing brightness filter 2026-01-24 20:06:54 +01:00
Yashas
fda9f0f8c0
feat: Add random play button to TV interface (#2430) 2026-01-24 15:15:21 +01:00
PiterDev
58ca69c284
Kitsu added as sync provider (#2440) 2026-01-24 15:04:37 +01:00
CranberrySoup
663c8a93cb
fix chapter skipping (#2444) 2026-01-24 14:44:10 +01:00
Osten
c28ee05bde
Added more software decoding options 2026-01-23 20:48:30 +01:00
381 changed files with 19885 additions and 10958 deletions

View file

@ -9,6 +9,9 @@ on:
- '**/wcokey.txt' - '**/wcokey.txt'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
concurrency: concurrency:
group: "Archive-build" group: "Archive-build"
cancel-in-progress: true cancel-in-progress: true
@ -61,13 +64,15 @@ jobs:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run Gradle - name: Run Gradle
run: ./gradlew assemblePrerelease run: ./gradlew assemblePrereleaseRelease
env: env:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:

View file

@ -6,6 +6,9 @@ on:
paths-ignore: paths-ignore:
- '*.md' - '*.md'
permissions:
contents: read
concurrency: concurrency:
group: "dokka" group: "dokka"
cancel-in-progress: true cancel-in-progress: true
@ -51,9 +54,6 @@ jobs:
with: with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Generate Dokka - name: Generate Dokka
run: | run: |
cd $GITHUB_WORKSPACE/src/ cd $GITHUB_WORKSPACE/src/

View file

@ -1,94 +0,0 @@
name: Issue automatic actions
on:
issues:
types: [opened]
jobs:
issue-moderator:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
- name: Similarity analysis
id: similarity
uses: actions-cool/issues-similarity-analysis@v1
with:
token: ${{ steps.generate_token.outputs.token }}
filter-threshold: 0.60
title-excludes: ''
comment-title: |
### Your issue looks similar to these issues:
Please close if duplicate.
comment-body: '${index}. ${similarity} #${number}'
- name: Label if possible duplicate
if: steps.similarity.outputs.similar-issues-found =='true'
uses: actions/github-script@v8
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["possible duplicate"]
})
- uses: actions/checkout@v6
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
github-token: ${{ steps.generate_token.outputs.token }}
issue-close-message: |
@${issue.user.login}: hello! :wave:
This issue is being automatically closed because it does not follow the issue template."
closed-issues-label: "invalid"
- name: Check if issue mentions a provider
id: provider_check
env:
GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
run: |
wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
pip3 install httpx
RES="$(python3 ./check_issue.py)"
echo "name=${RES}" >> $GITHUB_OUTPUT
- name: Comment if issue mentions a provider
if: steps.provider_check.outputs.name != 'none'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
token: ${{ steps.generate_token.outputs.token }}
body: |
Hello ${{ github.event.issue.user.login }}.
Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
Found provider name: `${{ steps.provider_check.outputs.name }}`
- name: Label if mentions provider
if: steps.provider_check.outputs.name != 'none'
uses: actions/github-script@v8
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["possible provider issue"]
})
- name: Add eyes reaction to all issues
uses: actions-cool/emoji-helper@v1.0.0
with:
type: 'issue'
token: ${{ steps.generate_token.outputs.token }}
emoji: 'eyes'

View file

@ -12,6 +12,9 @@ concurrency:
group: "pre-release" group: "pre-release"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -52,13 +55,14 @@ jobs:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run Gradle - name: Run Gradle
run: ./gradlew assemblePrerelease build androidSourcesJar makeJar run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
env: env:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
- name: Create pre-release - name: Create pre-release

View file

@ -2,6 +2,9 @@ name: Artifact Build
on: [pull_request] on: [pull_request]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -24,10 +27,10 @@ jobs:
cache-read-only: false cache-read-only: false
- name: Run Gradle - name: Run Gradle
run: ./gradlew assemblePrereleaseDebug run: ./gradlew assemblePrereleaseDebug lint check
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: pull-request-build name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk" path: "app/build/outputs/apk/prerelease/debug/*.apk"

View file

@ -11,6 +11,9 @@ concurrency:
group: "locale" group: "locale"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
create: create:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -8,47 +8,89 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.dokka) alias(libs.plugins.dokka)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization)
} }
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
fun getGitCommitHash(): String { abstract class GenerateGitHashTask : DefaultTask() {
return try {
val headFile = file("${project.rootDir}/.git/HEAD")
// Read the commit hash from .git/HEAD @get:InputFile
if (headFile.exists()) { @get:PathSensitive(PathSensitivity.RELATIVE)
val headContent = headFile.readText().trim() abstract val headFile: RegularFileProperty
if (headContent.startsWith("ref:")) {
val refPath = headContent.substring(5) // e.g., refs/heads/main @get:InputDirectory
val commitFile = file("${project.rootDir}/.git/$refPath") @get:PathSensitive(PathSensitivity.RELATIVE)
if (commitFile.exists()) commitFile.readText().trim() else "" abstract val headsDir: DirectoryProperty
} else headContent // If it's a detached HEAD (commit hash directly)
} else { @get:OutputDirectory
"" // If .git/HEAD doesn't exist abstract val outputDir: DirectoryProperty
}.take(7) // Return the short commit hash
} catch (_: Throwable) { @TaskAction
"" // Just return an empty string if any exception occurs fun generate() {
val head = headFile.get().asFile
val hash = try {
if (head.exists()) {
// Read the commit hash from .git/HEAD
val headContent = head.readText().trim()
if (headContent.startsWith("ref:")) {
val refPath = headContent.substring(5) // e.g., refs/heads/main
val commitFile = File(head.parentFile, refPath)
if (commitFile.exists()) commitFile.readText().trim() else ""
} else headContent // If it's a detached HEAD (commit hash directly)
} else "" // If .git/HEAD doesn't exist
} catch (_: Throwable) {
"" // Just set to an empty string if any exception occurs
}.take(7) // Get the short commit hash
val outFile = outputDir.file("git-hash.txt").get().asFile
outFile.parentFile.mkdirs()
outFile.writeText(hash)
} }
} }
val generateGitHash = tasks.register<GenerateGitHashTask>("generateGitHash") {
val gitDir = layout.projectDirectory.dir("../.git")
headFile.set(gitDir.file("HEAD"))
headsDir.set(gitDir.dir("refs/heads"))
outputDir.set(layout.buildDirectory.dir("generated/git"))
}
android { android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }
viewBinding { // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
enable = true dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(
generateGitHash,
GenerateGitHashTask::outputDir
)
}
} }
signingConfigs { signingConfigs {
if (prereleaseStoreFile != null) { // We just use SIGNING_KEY_ALIAS here since it won't change
// so won't kill the configuration cache.
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
create("prerelease") { create("prerelease") {
storeFile = file(prereleaseStoreFile) val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
storeFile = prereleaseStoreFile?.let { file(it) }
storePassword = System.getenv("SIGNING_STORE_PASSWORD") storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD") keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@ -62,10 +104,8 @@ android {
applicationId = "com.lagradost.cloudstream3" applicationId = "com.lagradost.cloudstream3"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 67 versionCode = libs.versions.versionCode.get().toInt()
versionName = "4.6.1" versionName = libs.versions.versionName.get()
resValue("string", "commit_hash", getGitCommitHash())
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
@ -135,21 +175,28 @@ android {
} }
java { java {
// Use Java 17 toolchain even if a higher JDK runs the build. // Use Java 17 toolchain even if a higher JDK runs the build.
// We still use Java 8 for now which higher JDKs have deprecated. // We still use Java 8 for now which higher JDKs have deprecated.
toolchain { toolchain {
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
} }
} }
lint { lint {
abortOnError = false
checkReleaseBuilds = false checkReleaseBuilds = false
} }
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
resValues = true viewBinding = true
}
packaging {
jniLibs {
// Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
// Note: This may increase app startup time slightly.
useLegacyPackaging = true
}
} }
namespace = "com.lagradost.cloudstream3" namespace = "com.lagradost.cloudstream3"
@ -160,17 +207,22 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.json) testImplementation(libs.json)
androidTestImplementation(libs.core) androidTestImplementation(libs.core)
implementation(libs.junit.ktx)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.instancio.core)
androidTestImplementation(libs.junit.ktx)
androidTestImplementation(libs.kotlin.test)
// Android Core & Lifecycle // Android Core & Lifecycle
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.activity.ktx) implementation(libs.activity.ktx)
implementation(libs.annotation)
implementation(libs.appcompat) implementation(libs.appcompat)
implementation(libs.fragment.ktx) implementation(libs.fragment.ktx)
implementation(libs.bundles.lifecycle) implementation(libs.bundles.lifecycle)
implementation(libs.bundles.navigation) implementation(libs.bundles.navigation)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.serialization.json) // JSON Parser
// Design & UI // Design & UI
implementation(libs.preference.ktx) implementation(libs.preference.ktx)
@ -187,6 +239,9 @@ dependencies {
// FFmpeg Decoding // FFmpeg Decoding
implementation(libs.bundles.nextlib) implementation(libs.bundles.nextlib)
// Anime-db for filler
implementation(libs.anime.db)
// PlayBack // PlayBack
implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.colorpicker) // Subtitle Color Picker
implementation(libs.newpipeextractor) // For Trailers implementation(libs.newpipeextractor) // For Trailers
@ -204,13 +259,15 @@ dependencies {
// Extensions & Other Libs // Extensions & Other Libs
implementation(libs.jsoup) // HTML Parser implementation(libs.jsoup) // HTML Parser
implementation(libs.rhino) // Run JavaScript implementation(libs.rhino) // Run JavaScript
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
implementation(libs.safefile) // To Prevent the URI File Fu*kery implementation(libs.safefile) // To Prevent the URI File Fu*kery
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.jackson.module.kotlin) // JSON Parser
implementation(libs.zipline) implementation(libs.zipline)
// Deprecated; will be removed once extensions have time to migrate from using it
implementation("me.xdrop:fuzzywuzzy:1.4.0")
// Torrent Support // Torrent Support
implementation(libs.torrentserver) implementation(libs.torrentserver)
@ -218,18 +275,7 @@ dependencies {
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
implementation(libs.nicehttp) // HTTP Lib implementation(libs.nicehttp) // HTTP Lib
implementation(project(":library") { implementation(project(":library"))
// There does not seem to be a good way of getting the android flavor.
val isDebug = gradle.startParameter.taskRequests.any { task ->
task.args.any { arg ->
arg.contains("debug", true)
}
}
this.extra.set("isDebug", isDebug)
})
// Extra brightness video filters
implementation(libs.gpuv)
} }
tasks.register<Jar>("androidSourcesJar") { tasks.register<Jar>("androidSourcesJar") {
@ -266,16 +312,22 @@ tasks.withType<KotlinJvmCompile> {
compilerOptions { compilerOptions {
jvmTarget.set(javaTarget) jvmTarget.set(javaTarget)
jvmDefault.set(JvmDefaultMode.ENABLE) jvmDefault.set(JvmDefaultMode.ENABLE)
optIn.add("com.lagradost.cloudstream3.Prerelease")
freeCompilerArgs.add("-Xannotation-default-target=param-property") freeCompilerArgs.add("-Xannotation-default-target=param-property")
optIn.addAll(
"com.lagradost.cloudstream3.InternalAPI",
"com.lagradost.cloudstream3.Prerelease",
"kotlin.uuid.ExperimentalUuidApi",
)
} }
} }
dokka { dokka {
moduleName = "App" moduleName = "App"
dokkaSourceSets { dokkaSourceSets {
main { configureEach {
suppress = name != "prereleaseDebug"
analysisPlatform = KotlinPlatform.JVM analysisPlatform = KotlinPlatform.JVM
displayName = "JVM"
documentedVisibilities( documentedVisibilities(
VisibilityModifier.Public, VisibilityModifier.Public,
VisibilityModifier.Protected VisibilityModifier.Protected

View file

@ -5,4 +5,9 @@
<!-- We don't care about MissingTranslation since it's handled by weblate. --> <!-- We don't care about MissingTranslation since it's handled by weblate. -->
<issue id="MissingTranslation" severity="ignore" /> <issue id="MissingTranslation" severity="ignore" />
<!-- We only care about the source language here. -->
<issue id="StringFormatInvalid">
<ignore path="**/res/values-*/**" />
</issue>
</lint> </lint>

View file

@ -0,0 +1,134 @@
package com.lagradost.cloudstream3
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import dalvik.system.DexFile
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.serializer
import kotlinx.serialization.serializerOrNull
import org.instancio.Instancio
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.reflect.KClass
import kotlin.reflect.jvm.jvmName
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@RunWith(AndroidJUnit4::class)
class SerializationClassTester {
// Same as app, or using app reference
val jacksonMapper = mapper
val kotlinxMapper = json
@Test
fun isIdenticalSerialization() {
val serializableClasses = findSerializableClasses("com.lagradost")
println("Number of serializable classes: ${serializableClasses.size}")
serializableClasses.forEach { kClass ->
val instance = Instancio.create(kClass.java)
val jacksonJson = jacksonMapper.writeValueAsString(instance)
val kotlinxJson = serializeWithKotlinx(kClass, instance)
assertEquals(
jacksonJson,
kotlinxJson,
"""
Serialization mismatch for:
${kClass.qualifiedName}
Jackson:
$jacksonJson
Kotlinx:
$kotlinxJson
""".trimIndent()
)
println("Identical serialization for: ${kClass.jvmName}")
}
}
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
@Test
fun isIdenticalDeserialization() {
val serializableClasses = findSerializableClasses("com.lagradost")
println("Number of serializable classes: ${serializableClasses.size}")
serializableClasses.forEach { kClass ->
val instance = Instancio.create(kClass.java)
// Convert to JSON to get example JSON object
// We prefer jackson here because the app may have many jackson JSON strings in local storage
val originalJson = jacksonMapper.writeValueAsString(instance)
// Create an object from the JSON using kotlinx
val serializer =
kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
// Create an object from the JSON using jackson
val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
// Deep inspect both object using the mapper toJson function.
// This deep equality check can be performed using other methods, but this just works.
val jacksonJson = mapperDecoded.toJson()
val kotlinxJson = kotlinxDecoded.toJson()
assertEquals(
jacksonJson,
kotlinxJson,
"""
Serialization mismatch for:
${kClass.qualifiedName}
Jackson:
$jacksonJson
Kotlinx:
$kotlinxJson
""".trimIndent()
)
println("Identical deserialization for: ${kClass.jvmName}")
}
}
// DEX files are the best solution to read all our classes dynamically.
// classgraph could be used instead, but it only gives results on the JVM, not Android.
@Suppress("DEPRECATION")
private fun findSerializableClasses(packageName: String): List<KClass<*>> {
val context = InstrumentationRegistry
.getInstrumentation()
.targetContext
val dexFile = DexFile(context.packageCodePath)
return dexFile.entries()
.toList()
.filter { it.startsWith(packageName) }
.mapNotNull {
runCatching { Class.forName(it).kotlin }.getOrNull()
}.filter { kClass ->
// Not possible to use .hasAnnotation() on newer Android versions.
kClass.java.annotations.any {
it is Serializable
}
}
}
@OptIn(InternalSerializationApi::class)
@Suppress("UNCHECKED_CAST")
private fun serializeWithKotlinx(
kClass: KClass<*>,
value: Any
): String {
val serializer = kClass.serializer() as KSerializer<Any>
return kotlinxMapper.encodeToString(serializer, value)
}
}

View file

@ -0,0 +1,157 @@
package com.lagradost.cloudstream3.utils.serializers
import android.net.Uri
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KeepGeneratedSerializer
import kotlinx.serialization.Serializable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = NonEmptyData.Serializer::class)
data class NonEmptyData(
val title: String = "",
val tags: List<String> = emptyList(),
val meta: Map<String, String> = emptyMap(),
val name: String = "hello",
) {
object Serializer : NonEmptySerializer<NonEmptyData>(NonEmptyData.generatedSerializer())
}
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = WriteOnlyData.Serializer::class)
data class WriteOnlyData(
val fieldA: String = "",
val fieldB: String = "",
) {
object Serializer : WriteOnlySerializer<WriteOnlyData>(
WriteOnlyData.generatedSerializer(),
setOf("fieldB"),
)
}
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = MultiWriteOnly.Serializer::class)
data class MultiWriteOnly(
val fieldA: String = "",
val fieldB: String = "",
val fieldC: String = "",
) {
object Serializer : WriteOnlySerializer<MultiWriteOnly>(
MultiWriteOnly.generatedSerializer(),
setOf("fieldB", "fieldC"),
)
}
@Serializable
data class UriData(
@Serializable(with = UriSerializer::class)
val uri: Uri = Uri.EMPTY,
)
class SerializerTest {
@Test
fun nonEmptySerializerOmitsEmptyStrings() {
val data = NonEmptyData(title = "", name = "hello")
val result = data.toJson()
assertFalse(result.contains("title"))
assertTrue(result.contains("name"))
}
@Test
fun nonEmptySerializerOmitsEmptyLists() {
val data = NonEmptyData(tags = emptyList(), name = "hello")
val result = data.toJson()
assertFalse(result.contains("tags"))
}
@Test
fun nonEmptySerializerOmitsEmptyMaps() {
val data = NonEmptyData(meta = emptyMap(), name = "hello")
val result = data.toJson()
assertFalse(result.contains("meta"))
}
@Test
fun nonEmptySerializerKeepsNonEmptyFields() {
val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
val result = data.toJson()
assertTrue(result.contains("title"))
assertTrue(result.contains("tags"))
assertTrue(result.contains("meta"))
}
@Test
fun nonEmptySerializerDoesNotAffectDeserialization() {
val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
val result = parseJson<NonEmptyData>(input)
assertEquals("hello", result.title)
assertEquals(listOf("a"), result.tags)
assertEquals(mapOf("k" to "v"), result.meta)
assertEquals("world", result.name)
}
@Test
fun writeOnlySerializerOmitsFieldOnSerialize() {
val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
val result = data.toJson()
assertTrue(result.contains("fieldA"))
assertFalse(result.contains("fieldB"))
}
@Test
fun writeOnlySerializerDeserializesNormally() {
val input = """{"fieldA":"hello","fieldB":"secret"}"""
val result = parseJson<WriteOnlyData>(input)
assertEquals("hello", result.fieldA)
assertEquals("secret", result.fieldB)
}
@Test
fun writeOnlySerializerDeserializesMissingAsDefault() {
val input = """{"fieldA":"hello"}"""
val result = parseJson<WriteOnlyData>(input)
assertEquals("hello", result.fieldA)
assertEquals("", result.fieldB)
}
@Test
fun writeOnlySerializerHandlesMultipleKeys() {
val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
val result = data.toJson()
assertTrue(result.contains("fieldA"))
assertFalse(result.contains("fieldB"))
assertFalse(result.contains("fieldC"))
}
@Test
fun uriSerializerSerializesUriToString() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val result = data.toJson()
assertTrue(result.contains("https://example.com/path?query=1"))
}
@Test
fun uriSerializerDeserializesStringToUri() {
val input = """{"uri":"https://example.com/path?query=1"}"""
val result = parseJson<UriData>(input)
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
}
@Test
fun uriSerializerRoundtripsCorrectly() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val encoded = data.toJson()
val decoded = parseJson<UriData>(encoded)
assertEquals(data.uri, decoded.uri)
}
}

View file

@ -22,6 +22,47 @@
android:name="android.permission.QUERY_ALL_PACKAGES" android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<queries>
<!--
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
-->
<!-- For external video players -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/x-mpegURL" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="magnet" />
</intent>
<!-- Common players supported in actions/temp -->
<package android:name="org.videolan.vlc" />
<package android:name="org.videolan.vlc.debug" />
<package android:name="is.xyz.mpv" />
<package android:name="is.xyz.mpv.ytdl" />
<package android:name="app.marlboroadvance.mpvex" />
<package android:name="live.mehiz.mpvkt" />
<package android:name="live.mehiz.mpvkt.preview" />
<package android:name="com.brouken.player" />
<package android:name="dev.anilbeesetti.nextplayer" />
<package android:name="com.instantbits.cast.webvideo" />
<package android:name="com.gianlu.aria2android" />
<!-- Torrent clients -->
<package android:name="org.proninyaroslav.libretorrent" />
<package android:name="com.biglybt.android.client" />
</queries>
<!-- Fixes android tv fuckery --> <!-- Fixes android tv fuckery -->
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
@ -108,14 +149,31 @@
android:launchMode="singleTask" android:launchMode="singleTask"
is a bit experimental, it makes loading repositories from browser still stay on the same page is a bit experimental, it makes loading repositories from browser still stay on the same page
no idea about side effects no idea about side effects
Not exported to prevent bypassing the AccountSelectActivity
--> -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="true" android:exported="false"
android:launchMode="singleTask" android:launchMode="singleTask"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="true"> android:supportsPictureInPicture="true" />
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- cloudstreamplayer://encodedUrl?name=Dune --> <!-- cloudstreamplayer://encodedUrl?name=Dune -->
<intent-filter> <intent-filter>
@ -173,7 +231,7 @@
<data android:scheme="cloudstreamcontinuewatching" /> <data android:scheme="cloudstreamcontinuewatching" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -186,21 +244,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<receiver <receiver
android:name=".receivers.VideoDownloadRestartReceiver" android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false" android:enabled="false"
@ -216,6 +259,12 @@
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" /> android:exported="false" />
<service
android:name=".services.DownloadQueueService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 --> <!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View file

@ -1,103 +1,78 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
import android.content.Context
import com.lagradost.api.setContext
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import java.lang.ref.WeakReference
/** /**
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins. * Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
* Use CloudStreamApp instead. * Use CloudStreamApp instead.
*/ */
// Deprecate after next stable @Deprecated(
/*@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
class AcraApplication { class AcraApplication {
// All methods here can be changed to be a wrapper around CloudStream app
// without a seperate deprecation after next stable. All methods should
// also be deprecated at that time.
companion object { companion object {
// This can be removed without deprecation after next stable @Deprecated(
private var _context: WeakReference<Context>? = null
/*@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
var context val context get() = CloudStreamApp.context
get() = _context?.get()
internal set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
fun removeKeys(folder: String): Int? { fun removeKeys(folder: String): Int? =
return context?.removeKeys(folder) CloudStreamApp.removeKeys(folder)
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
fun <T> setKey(path: String, value: T) { fun <T> setKey(path: String, value: T) =
context?.setKey(path, value) CloudStreamApp.setKey(path, value)
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
fun <T> setKey(folder: String, path: String, value: T) { fun <T> setKey(folder: String, path: String, value: T) =
context?.setKey(folder, path, value) CloudStreamApp.setKey(folder, path, value)
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? { inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
return context?.getKey(path, defVal) CloudStreamApp.getKey(path, defVal)
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
inline fun <reified T : Any> getKey(path: String): T? { inline fun <reified T : Any> getKey(path: String): T? =
return context?.getKey(path) CloudStreamApp.getKey(path)
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
inline fun <reified T : Any> getKey(folder: String, path: String): T? { inline fun <reified T : Any> getKey(folder: String, path: String): T? =
return context?.getKey(folder, path) CloudStreamApp.getKey(folder, path)
}
/*@Deprecated( @Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead", message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
level = DeprecationLevel.WARNING level = DeprecationLevel.WARNING
)*/ )
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? { inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
return context?.getKey(folder, path, defVal) CloudStreamApp.getKey(folder, path, defVal)
}
} }
} }

View file

@ -13,6 +13,7 @@ import coil3.ImageLoader
import coil3.PlatformContext import coil3.PlatformContext
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import com.lagradost.api.setContext import com.lagradost.api.setContext
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.mvvm.safeAsync
import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager
@ -20,6 +21,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.AppDebug
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
@ -65,7 +67,6 @@ class ExceptionHandler(
} }
} }
@Prerelease
class CloudStreamApp : Application(), SingletonImageLoader.Factory { class CloudStreamApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() { override fun onCreate() {
@ -81,13 +82,13 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory {
exceptionHandler = it exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it) Thread.setDefaultUncaughtExceptionHandler(it)
} }
AppDebug.isDebug = BuildConfig.DEBUG
} }
override fun attachBaseContext(base: Context?) { override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base) super.attachBaseContext(base)
context = base context = base
// This can be removed without deprecation after next stable
AcraApplication.context = context
} }
override fun newImageLoader(context: PlatformContext): ImageLoader { override fun newImageLoader(context: PlatformContext): ImageLoader {

View file

@ -9,6 +9,8 @@ import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
@ -39,7 +41,6 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
import com.lagradost.cloudstream3.ui.player.Torrent import com.lagradost.cloudstream3.ui.player.Torrent
import com.lagradost.cloudstream3.ui.result.ActorAdaptor import com.lagradost.cloudstream3.ui.result.ActorAdaptor
@ -115,7 +116,6 @@ object CommonActivity {
val onColorSelectedEvent = Event<Pair<Int, Int>>() val onColorSelectedEvent = Event<Pair<Int, Int>>()
val onDialogDismissedEvent = Event<Int>() val onDialogDismissedEvent = Event<Int>()
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var appliedTheme: Int = 0 var appliedTheme: Int = 0
var appliedColor: Int = 0 var appliedColor: Int = 0
@ -191,6 +191,16 @@ object CommonActivity {
currentToast = toast currentToast = toast
toast.show() toast.show()
val handler = Handler(Looper.getMainLooper())
val ref = WeakReference(toast)
/* Clean up activity leak */
handler.postDelayed({
if (ref.get() == currentToast) {
currentToast = null
}
}, 10_000)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
@ -234,19 +244,8 @@ object CommonActivity {
fun init(act: Activity) { fun init(act: Activity) {
setActivityInstance(act) setActivityInstance(act)
ioSafe { Torrent.deleteAllFiles() } ioSafe { Torrent.deleteAllFiles() }
// Clear all pools to apply the correct theme
for (pool in arrayOf(
PluginAdapter.sharedPool, HomeChildItemAdapter.sharedPool,
ParentItemAdapter.sharedPool, ActorAdaptor.sharedPool, EpisodeAdapter.sharedPool,
SearchAdapter.sharedPool, ImageAdapter.sharedPool
)) {
pool.clear()
}
val componentActivity = activity as? ComponentActivity ?: return val componentActivity = activity as? ComponentActivity ?: return
componentActivity.updateLocale() componentActivity.updateLocale()
componentActivity.updateTv() componentActivity.updateTv()
AccountManager.initMainAPI() AccountManager.initMainAPI()
@ -533,87 +532,7 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
// 149 keycode_numpad 5
val playerEvent = when (keyCode) {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward
}
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack
}
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
PlayerEventType.NextEpisode
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
PlayerEventType.PrevEpisode
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause
}
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play
}
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock
}
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide
}
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute
}
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors
}
// OpenSubtitles shortcut
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline
}
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed
}
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize
}
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp
}
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
PlayerEventType.SkipCurrentChapter
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
PlayerEventType.PlayPauseToggle
}
else -> return null
}
val listener = playerEventListener
if (listener != null) {
listener.invoke(playerEvent)
return true
}
return null return null
//when (keyCode) {
// KeyEvent.KEYCODE_DPAD_CENTER -> {
// println("DPAD PRESSED")
// }
//}
} }
/** overrides focus and custom key events */ /** overrides focus and custom key events */
@ -660,8 +579,10 @@ object CommonActivity {
// TODO: Figure out why removing the check for SearchAutoComplete seems // TODO: Figure out why removing the check for SearchAutoComplete seems
// to break focus on TV as it shouldn't need to be used. // to break focus on TV as it shouldn't need to be used.
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) { ) {
showInputMethod(act.currentFocus?.findFocus()) showInputMethod(act.currentFocus?.findFocus())
@ -682,4 +603,4 @@ object CommonActivity {
} }
return null return null
} }
} }

View file

@ -29,7 +29,6 @@ import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.children import androidx.core.view.children
@ -106,7 +105,6 @@ import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
import com.lagradost.cloudstream3.ui.home.HomeFragment
import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.HomeViewModel
import com.lagradost.cloudstream3.ui.library.LibraryViewModel import com.lagradost.cloudstream3.ui.library.LibraryViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.BasicLink
@ -190,11 +188,9 @@ import java.nio.charset.Charset
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.system.exitProcess import kotlin.system.exitProcess
import androidx.core.net.toUri import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import androidx.tvprovider.media.tv.Channel import kotlinx.coroutines.Job
import androidx.tvprovider.media.tv.TvContractCompat import kotlinx.coroutines.cancel
import android.content.ComponentName
import android.content.ContentUris
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object { companion object {
@ -204,6 +200,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
const val ANIMATED_OUTLINE: Boolean = false const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null var lastError: String? = null
/** Update lastError variable based on error file, to check if app crashed.
* Can be called multiple times without changing the lastError variable changing.
**/
fun setLastError(context: Context) {
if (lastError != null) return
val errorFile = context.filesDir.resolve("last_error")
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
} else {
lastError = null
}
}
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
@ -265,7 +276,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* @return true if the str has launched an app task (be it successful or not) * @return true if the str has launched an app task (be it successful or not)
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login. * @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
* */ * */
@Suppress("DEPRECATION_ERROR")
fun handleAppIntentUrl( fun handleAppIntentUrl(
activity: FragmentActivity?, activity: FragmentActivity?,
str: String?, str: String?,
@ -352,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
LinkGenerator( LinkGenerator(
listOf(BasicLink(url, name)), listOf(BasicLink(url, name)),
extract = true, extract = true,
) id = url.hashCode()
), 0
) )
) )
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
@ -397,13 +408,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
return true return true
} }
synchronized(apis) { val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
for (api in apis) { if (matchedApi != null) {
if (str.startsWith(api.mainUrl)) { loadResult(str, matchedApi.name, "")
loadResult(str, api.name, "") return true
return true
}
}
} }
} }
} }
@ -432,6 +440,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
var lastPopup: SearchResponse? = null var lastPopup: SearchResponse? = null
var lastPopupJob: Job? = null
fun loadPopup(result: SearchResponse, load: Boolean = true) { fun loadPopup(result: SearchResponse, load: Boolean = true) {
lastPopup = result lastPopup = result
val syncName = syncViewModel.syncName(result.apiName) val syncName = syncViewModel.syncName(result.apiName)
@ -447,7 +456,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
syncViewModel.clear() syncViewModel.clear()
} }
if (load) { lastPopupJob?.cancel()
lastPopupJob = if (load) {
viewModel.load( viewModel.load(
this, result.url, result.apiName, false, if (getApiDubstatusSettings() this, result.url, result.apiName, false, if (getApiDubstatusSettings()
.contains(DubStatus.Dubbed) .contains(DubStatus.Dubbed)
@ -494,6 +504,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
R.id.navigation_downloads, R.id.navigation_downloads,
R.id.navigation_settings, R.id.navigation_settings,
R.id.navigation_download_child, R.id.navigation_download_child,
R.id.navigation_download_queue,
R.id.navigation_subtitles, R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles, R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player, R.id.navigation_settings_player,
@ -546,9 +557,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
navView.isVisible = isNavVisible && !isLandscape() navView.isVisible = isNavVisible && !isLandscape()
navHostFragment.apply { navHostFragment.apply {
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { layoutParams =
marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
} marginStart =
if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
}
} }
/** /**
@ -557,7 +570,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* highlight the wrong one in UI. * highlight the wrong one in UI.
*/ */
when (destination.id) { when (destination.id) {
in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> { in listOf(
R.id.navigation_downloads,
R.id.navigation_download_child,
R.id.navigation_download_queue
) -> {
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
navView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true
} }
@ -789,12 +806,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
} }
private val pluginsLock = Mutex() private val pluginsLock = Mutex()
private fun onAllPluginsLoaded(success: Boolean = false) { private fun onAllPluginsLoaded(success: Boolean = false) {
ioSafe { ioSafe {
pluginsLock.withLock { pluginsLock.withLock {
synchronized(allProviders) { allProviders.withLock {
// Load cloned sites after plugins have been loaded since clones depend on plugins. // Load cloned sites after plugins have been loaded since clones depend on plugins.
try { try {
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list -> getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
@ -840,6 +856,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
private fun hidePreviewPopupDialog() { private fun hidePreviewPopupDialog() {
bottomPreviewPopup.dismissSafe(this) bottomPreviewPopup.dismissSafe(this)
lastPopupJob?.cancel()
lastPopupJob = null
bottomPreviewPopup = null bottomPreviewPopup = null
bottomPreviewBinding = null bottomPreviewBinding = null
} }
@ -1159,18 +1177,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
} }
@Suppress("DEPRECATION_ERROR")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
app.initClient(this) app.initClient(this, ignoreSSL = false)
@OptIn(UnsafeSSL::class)
insecureApp.initClient(this, ignoreSSL = true)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val errorFile = filesDir.resolve("last_error") setLastError(this)
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
} else {
lastError = null
}
val settingsForProvider = SettingsJson() val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult = settingsForProvider.enableAdult =
@ -1639,9 +1653,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
ioSafe { ioSafe {
initAll() initAll()
// No duplicates (which can happen by registerMainAPI) // No duplicates (which can happen by registerMainAPI)
apis = synchronized(allProviders) { apis = allProviders.distinctBy { it }
allProviders.distinctBy { it }
}
} }
// val navView: BottomNavigationView = findViewById(R.id.nav_view) // val navView: BottomNavigationView = findViewById(R.id.nav_view)
@ -1949,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
var providersAndroidManifestString = "Current androidmanifest should be:\n" var providersAndroidManifestString = "Current androidmanifest should be:\n"
synchronized(allProviders) { allProviders.withLock {
for (api in allProviders) { for (api in allProviders) {
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${ providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
api.mainUrl.removePrefix( api.mainUrl.removePrefix(
@ -2032,6 +2044,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
updateLocale() updateLocale()
runDefault() runDefault()
} }
// Start the download queue
DownloadQueueManager.init(this)
} }
/** Biometric stuff **/ /** Biometric stuff **/
@ -2054,4 +2069,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
false false
} }
} }
} }

View file

@ -20,8 +20,10 @@ import com.lagradost.cloudstream3.actions.temp.MpvExPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
import com.lagradost.cloudstream3.actions.temp.MpvPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
@ -32,8 +34,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -43,7 +45,7 @@ import java.util.concurrent.FutureTask
import kotlin.reflect.jvm.jvmName import kotlin.reflect.jvm.jvmName
object VideoClickActionHolder { object VideoClickActionHolder {
val allVideoClickActions = threadSafeListOf( val allVideoClickActions = atomicListOf(
// Default // Default
PlayInBrowserAction(), PlayInBrowserAction(),
CopyClipboardAction(), CopyClipboardAction(),
@ -64,6 +66,8 @@ object VideoClickActionHolder {
MpvYTDLPackage(), MpvYTDLPackage(),
MpvKtPackage(), MpvKtPackage(),
MpvKtPreviewPackage(), MpvKtPreviewPackage(),
OnlyPlayer(),
MpvRxPackage(),
// Always Ask option // Always Ask option
AlwaysAskAction(), AlwaysAskAction(),
// added by plugins // added by plugins

View file

@ -0,0 +1,75 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/Riteshp2001/mpvRx
*
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56
* */
class MpvRxPackage : OpenInAppAction(
appName = txt("mpvRx"),
packageName = "app.gyrolet.mpvrx",
intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity"
) {
override val oneSource = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.apply {
putExtra("title", video.name)
val link = result.links[index!!]
val headers = link.headers
setData(link.url.toUri())
if (headers.isNotEmpty()) {
// PlayerActivity expects a flat array: [key1, value1, key2, value2, ...]
val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray()
intent.putExtra("headers", flat)
}
/*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146
intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray())
intent.putExtra(
"subs.titles",
subs.map { it.name }.toTypedArray(),
)
intent.putExtra(
"subs.langs",
subs.map { it.languageCode }.toTypedArray(),
)
val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri()
intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf<Uri>() )*/
if (video.tvType.isEpisodeBased()) {
video.season?.let { intent.putExtra("introdb_season", it) }
video.episode.let { intent.putExtra("introdb_episode", it) }
}
val position = getViewPos(video.id)?.position
if (position != null)
putExtra("position", position.toInt())
}
}
override fun onResult(activity: Activity, intent: Intent?) {
val position = intent?.getIntExtra("position", -1) ?: -1
val duration = intent?.getIntExtra("duration", -1) ?: -1
Log.d("MPV", "Position: $position, Duration: $duration")
updateDurationAndPosition(position.toLong(), duration.toLong())
}
}

View file

@ -0,0 +1,44 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/Kindness-Kismet/only_player/tree/main
* https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */
class OnlyPlayer : OpenInAppAction(
txt("Only Player"),
"one.only.player",
intentClass = "one.only.player.feature.player.PlayerActivity"
) {
override val oneSource = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
/** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */
intent.apply {
val link = result.links[index!!]
setData(link.url.toUri())
putExtra("headers", Bundle().apply {
for ((key, value) in link.headers) {
putExtra(key, value)
}
})
}
}
override fun onResult(activity: Activity, intent: Intent?) {
/* onResult does not get called */
}
}

View file

@ -35,9 +35,11 @@ class PlayMirrorAction : VideoClickAction() {
) { ) {
//Implemented a generator to handle the single //Implemented a generator to handle the single
val activity = context as? Activity ?: return val activity = context as? Activity ?: return
val link = index?.let { result.links[it] }
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) { val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
override val hasCache: Boolean = false override val hasCache: Boolean = false
override val canSkipLoading: Boolean = false override val canSkipLoading: Boolean = false
override fun getId(index: Int): Int = video.id
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
@ -47,7 +49,7 @@ class PlayMirrorAction : VideoClickAction() {
offset: Int, offset: Int,
isCasting: Boolean isCasting: Boolean
): Boolean { ): Boolean {
index?.let { callback(result.links[it] to null) } index?.let { callback(link to null) }
result.subs.forEach { subtitle -> subtitleCallback(subtitle) } result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
return true return true
} }
@ -56,7 +58,7 @@ class PlayMirrorAction : VideoClickAction() {
activity.navigate( activity.navigate(
R.id.global_to_navigation_player, R.id.global_to_navigation_player,
GeneratorPlayer.newInstance( GeneratorPlayer.newInstance(
generatorMirror, result.syncData generatorMirror, 0, result.syncData
) )
) )
} }

View file

@ -1,16 +1,68 @@
package com.lagradost.cloudstream3.mvvm package com.lagradost.cloudstream3.mvvm
import android.view.View
import androidx.activity.ComponentActivity
import androidx.core.view.doOnAttach
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.ui.BaseFragment
/** NOTE: Only one observer at a time per value */ /** NOTE: Only one observer at a time per value */
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) { fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
liveData.removeObservers(this) observeNullable(liveData) { t -> t?.run(action) }
liveData.observe(this) { it?.let { t -> action(t) } }
} }
/** NOTE: Only one observer at a time per value */ /** NOTE: Only one observer at a time per value */
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) { fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
liveData.removeObservers(this) liveData.removeObservers(this)
liveData.observe(this) { action(it) } liveData.observe(this, action)
} }
/** NOTE: Only one observer at a time per value */
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/**
* Attaches an observable to the root binding, instead of the fragment. This is more efficient as
* it will not call observe if the view is in the background.
*
* NOTE: Only one observer at a time per value
* */
fun <T, V : ViewBinding> BaseFragment<V>.observeNullable(
liveData: LiveData<T>, action: (T?) -> Unit
) {
val root = this.binding?.root
if (root == null) {
liveData.removeObservers(this)
liveData.observe(this, action)
} else {
root.doOnAttach { view ->
// On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
liveData.removeObservers(owner)
liveData.observe(owner, action)
}
}
}
/** NOTE: Only one observer at a time per value */
fun <T> View.observe(liveData: LiveData<T>, action: (T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
fun <T> View.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
doOnAttach { view ->
// On attach should make findViewTreeLifecycleOwner non-null
val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
if(owner == null) {
debugException { "Expected non-null findViewTreeLifecycleOwner" }
return@doOnAttach
}
liveData.removeObservers(owner)
liveData.observe(owner, action)
}
}

View file

@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network
import android.content.Context import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.Prerelease
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt
import java.io.File import java.io.File
import java.security.Security import java.security.Security
// Backwards compatible constructor, mark as deprecated later
fun Requests.initClient(context: Context) { fun Requests.initClient(context: Context) {
this.baseClient = buildDefaultClient(context) this.baseClient = buildDefaultClient(context)
} }
/** Only use ignoreSSL if you know what you are doing*/
@Prerelease
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
this.baseClient = buildDefaultClient(context, ignoreSSL)
}
// Backwards compatible constructor, mark as deprecated later
fun buildDefaultClient(context: Context): OkHttpClient { fun buildDefaultClient(context: Context): OkHttpClient {
return buildDefaultClient(context, false)
}
/** Only use ignoreSSL if you know what you are doing*/
@Prerelease
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient {
val baseClient = OkHttpClient.Builder() val baseClient = OkHttpClient.Builder()
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.ignoreAllSSLErrors() .apply {
if (ignoreSSL) {
ignoreAllSSLErrors()
}
}
.cache( .cache(
// Note that you need to add a ResponseInterceptor to make this 100% active. // Note that you need to add a ResponseInterceptor to make this 100% active.
// The server response dictates if and when stuff should be cached. // The server response dictates if and when stuff should be cached.
@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient {
return baseClient return baseClient
} }
//val Request.cookies: Map<String, String>
// get() {
// return this.headers.getCookies("Cookie")
// }
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
/** /**

View file

@ -7,7 +7,6 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import kotlin.Throws import kotlin.Throws
abstract class Plugin : BasePlugin() { abstract class Plugin : BasePlugin() {
/** /**
* Called when your Plugin is loaded * Called when your Plugin is loaded
@ -26,9 +25,7 @@ abstract class Plugin : BasePlugin() {
fun registerVideoClickAction(element: VideoClickAction) { fun registerVideoClickAction(element: VideoClickAction) {
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
element.sourcePlugin = this.filename element.sourcePlugin = this.filename
synchronized(VideoClickActionHolder.allVideoClickActions) { VideoClickActionHolder.allVideoClickActions.add(element)
VideoClickActionHolder.allVideoClickActions.add(element)
}
} }
/** /**
@ -40,4 +37,4 @@ abstract class Plugin : BasePlugin() {
* This will add a button in the settings allowing you to add custom settings * This will add a button in the settings allowing you to add custom settings
*/ */
var openSettings: ((context: Context) -> Unit)? = null var openSettings: ((context: Context) -> Unit)? = null
} }

View file

@ -13,6 +13,7 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -26,9 +27,11 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.InternalAPI
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -43,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@ -51,7 +55,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader import dalvik.system.PathClassLoader
@ -76,6 +80,7 @@ data class PluginData(
@JsonProperty("filePath") val filePath: String, @JsonProperty("filePath") val filePath: String,
@JsonProperty("version") val version: Int, @JsonProperty("version") val version: Int,
) { ) {
@WorkerThread
fun toSitePlugin(): SitePlugin { fun toSitePlugin(): SitePlugin {
return SitePlugin( return SitePlugin(
this.filePath, this.filePath,
@ -90,7 +95,9 @@ data class PluginData(
null, null,
null, null,
null, null,
File(this.filePath).length() File(this.filePath).length(),
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
null
) )
} }
} }
@ -258,12 +265,8 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName", "DEPRECATION_ERROR") @Suppress("FunctionName")
@Deprecated( @InternalAPI
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -304,6 +307,7 @@ object PluginManager {
downloadPlugin( downloadPlugin(
activity, activity,
pluginData.onlineData.second.url, pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
File(pluginData.savedData.filePath), File(pluginData.savedData.filePath),
true true
@ -339,12 +343,8 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName", "DEPRECATION_ERROR") @Suppress("FunctionName")
@Deprecated( @InternalAPI
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
activity: Activity, activity: Activity,
@ -419,6 +419,7 @@ object PluginManager {
downloadPlugin( downloadPlugin(
activity, activity,
pluginData.onlineData.second.url, pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
pluginData.onlineData.first, pluginData.onlineData.first,
!pluginData.isDisabled !pluginData.isDisabled
@ -453,12 +454,8 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName", "DEPRECATION_ERROR") @Suppress("FunctionName")
@Deprecated( @InternalAPI
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -479,13 +476,9 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName", "DEPRECATION_ERROR") @Suppress("FunctionName")
@InternalAPI
@Throws @Throws
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -504,12 +497,8 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName", "DEPRECATION_ERROR") @Suppress("FunctionName")
@Deprecated( @InternalAPI
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -572,6 +561,11 @@ object PluginManager {
afterPluginsLoadedEvent.invoke(forceReload) afterPluginsLoadedEvent.invoke(forceReload)
} }
/** @return true if safe mode is enabled in any possible way. */
fun isSafeMode(): Boolean {
return checkSafeModeFile() || lastError != null
}
/** /**
* This can be used to override any extension loading to fix crashes! * This can be used to override any extension loading to fix crashes!
* @return true if safe mode file is present * @return true if safe mode file is present
@ -616,7 +610,7 @@ object PluginManager {
return false return false
} }
InputStreamReader(stream).use { reader -> InputStreamReader(stream).use { reader ->
manifest = parseJson(reader, BasePlugin.Manifest::class.java) manifest = parseJson<BasePlugin.Manifest>(reader.readText())
} }
} }
@ -657,9 +651,15 @@ object PluginManager {
context.resources.configuration context.resources.configuration
) )
} }
plugins[filePath] = pluginInstance synchronized(plugins) {
classLoaders[loader] = pluginInstance plugins[filePath] = pluginInstance
urlPlugins[data.url ?: filePath] = pluginInstance }
synchronized(classLoaders) {
classLoaders[loader] = pluginInstance
}
synchronized(urlPlugins) {
urlPlugins[data.url ?: filePath] = pluginInstance
}
if (pluginInstance is Plugin) { if (pluginInstance is Plugin) {
pluginInstance.load(context) pluginInstance.load(context)
} else { } else {
@ -695,25 +695,33 @@ object PluginManager {
} }
// remove all registered apis // remove all registered apis
synchronized(APIHolder.apis) { APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { removePluginMapping(it)
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
} }
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } APIHolder.allProviders.withLock {
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
} }
classLoaders.values.removeIf { v -> v == plugin } extractorApis.withLock {
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
}
plugins.remove(absolutePath) VideoClickActionHolder.allVideoClickActions.withLock {
urlPlugins.values.removeIf { v -> v == plugin } VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
}
synchronized(classLoaders) {
classLoaders.values.removeIf { v -> v == plugin }
}
synchronized(plugins) {
plugins.remove(absolutePath)
}
synchronized(urlPlugins) {
urlPlugins.values.removeIf { v -> v == plugin }
}
} }
/** /**
@ -743,25 +751,27 @@ object PluginManager {
suspend fun downloadPlugin( suspend fun downloadPlugin(
activity: Activity, activity: Activity,
pluginUrl: String, pluginUrl: String,
pluginHash: String?,
internalName: String, internalName: String,
repositoryUrl: String, repositoryUrl: String,
loadPlugin: Boolean loadPlugin: Boolean
): Boolean { ): Boolean {
val file = getPluginPath(activity, internalName, repositoryUrl) val file = getPluginPath(activity, internalName, repositoryUrl)
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
} }
suspend fun downloadPlugin( suspend fun downloadPlugin(
activity: Activity, activity: Activity,
pluginUrl: String, pluginUrl: String,
pluginHash: String?,
internalName: String, internalName: String,
file: File, file: File,
loadPlugin: Boolean loadPlugin: Boolean,
): Boolean { ): Boolean {
try { try {
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
val data = PluginData( val data = PluginData(
internalName, internalName,
@ -808,13 +818,9 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName", "DEPRECATION_ERROR") @Suppress("FunctionName")
@InternalAPI
@Throws @Throws
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -853,6 +859,7 @@ object PluginManager {
if (downloadPlugin( if (downloadPlugin(
activity, activity,
pluginData.onlineData.second.url, pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
existingFile, existingFile,
true true
@ -951,4 +958,4 @@ object PluginManager {
return null return null
} }
} }
} }

View file

@ -1,6 +1,7 @@
package com.lagradost.cloudstream3.plugins package com.lagradost.cloudstream3.plugins
import android.content.Context import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
@ -18,10 +19,12 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.InputStream import java.nio.file.AtomicMoveNotSupportedException
import java.io.OutputStream import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicInteger
/** /**
* Comes with the app, always available in the app, non removable. * Comes with the app, always available in the app, non removable.
@ -67,6 +70,7 @@ data class SitePlugin(
@JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("iconUrl") val iconUrl: String?,
// Automatically generated by the gradle plugin // Automatically generated by the gradle plugin
@JsonProperty("fileSize") val fileSize: Long?, @JsonProperty("fileSize") val fileSize: Long?,
@JsonProperty("fileHash") val fileHash: String?,
) )
@ -75,7 +79,26 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy { val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray() getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
} }
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") private val GH_REGEX =
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/** Returns a SHA-256 string of the file content.
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
@WorkerThread
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { fis ->
val buffer = ByteArray(8192)
var read = fis.read(buffer)
while (read != -1) {
digest.update(buffer, 0, read)
read = fis.read(buffer)
}
}
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
}
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String { fun convertRawGitUrl(url: String): String {
@ -140,21 +163,52 @@ object RepositoryManager {
}.flatten() }.flatten()
} }
suspend fun downloadPluginToFile( suspend fun downloadPluginToFile(
context: Context,
pluginUrl: String, pluginUrl: String,
file: File file: File,
expectedFileHash: String?
): File? { ): File? {
return safeAsync { return safeAsync {
file.mkdirs() val parentDir = file.parentFile ?: return@safeAsync null
parentDir.mkdirs()
// Overwrite if exists // Prevent corrupting the plugin file if the operation fails
if (file.exists()) { val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
file.delete()
}
file.createNewFile()
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
write(body.byteStream(), file.outputStream())
body.byteStream().use { body ->
tempFile.outputStream().use { fileSteam ->
body.copyTo(fileSteam)
}
}
if (expectedFileHash != null) {
val downloadHash = sha256(tempFile)
if (expectedFileHash != downloadHash) {
tempFile.delete()
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
}
}
// We prefer the operation to be atomic
try {
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
file file
} }
} }
@ -202,13 +256,4 @@ object RepositoryManager {
PluginManager.deleteRepositoryData(file.absolutePath) PluginManager.deleteRepositoryData(file.absolutePath)
} }
private fun write(stream: InputStream, output: OutputStream) {
val input = BufferedInputStream(stream)
val dataBuffer = ByteArray(512)
var readBytes: Int
while (input.read(dataBuffer).also { readBytes = it } != -1) {
output.write(dataBuffer, 0, readBytes)
}
}
} }

View file

@ -12,87 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
object VotingApi { // please do not cheat the votes lol object VotingApi {
private const val LOGKEY = "VotingApi" private const val LOGKEY = "VotingApi"
private const val API_DOMAIN = "https://api.countify.xyz"
private const val API_DOMAIN = "https://counterapi.com/api" private fun transformUrl(url: String): String =
private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest MessageDigest
.getInstance("SHA-256") .getInstance("SHA-256")
.digest("${url}#funny-salt".toByteArray()) .digest("${url}#funny-salt".toByteArray())
.fold("") { str, it -> str + "%02x".format(it) } .fold("") { str, it -> str + "%02x".format(it) }
suspend fun SitePlugin.getVotes(): Int { suspend fun SitePlugin.getVotes(): Int = getVotes(url)
return getVotes(url) fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
} suspend fun SitePlugin.vote(): Int = vote(url)
fun SitePlugin.canVote(): Boolean = canVote(this.url)
fun SitePlugin.hasVoted(): Boolean {
return hasVoted(url)
}
suspend fun SitePlugin.vote(): Int {
return vote(url)
}
fun SitePlugin.canVote(): Boolean {
return canVote(this.url)
}
// Plugin url to Int
private val votesCache = mutableMapOf<String, Int>() private val votesCache = mutableMapOf<String, Int>()
private fun getRepository(pluginUrl: String) = pluginUrl
.split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int { private suspend fun readVote(pluginUrl: String): Int {
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" val id = transformUrl(pluginUrl)
Log.d(LOGKEY, "Requesting: $url") val url = "$API_DOMAIN/get-total/$id"
return app.get(url).parsedSafe<Result>()?.value ?: 0 Log.d(LOGKEY, "Requesting GET: $url")
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
} }
private suspend fun writeVote(pluginUrl: String): Boolean { private suspend fun writeVote(pluginUrl: String): Boolean {
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" val id = transformUrl(pluginUrl)
Log.d(LOGKEY, "Requesting: $url") val url = "$API_DOMAIN/increment/$id"
return app.get(url).parsedSafe<Result>()?.value != null Log.d(LOGKEY, "Requesting POST: $url")
return app.post(url, emptyMap<String, String>())
.parsedSafe<CountifyResult>()?.count != null
} }
suspend fun getVotes(pluginUrl: String): Int = suspend fun getVotes(pluginUrl: String): Int =
votesCache[pluginUrl] ?: readVote(pluginUrl).also { votesCache[pluginUrl] ?: readVote(pluginUrl).also {
votesCache[pluginUrl] = it votesCache[pluginUrl] = it
} }
fun hasVoted(pluginUrl: String) = fun hasVoted(pluginUrl: String) =
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
fun canVote(pluginUrl: String): Boolean { fun canVote(pluginUrl: String): Boolean =
return PluginManager.urlPlugins.contains(pluginUrl) PluginManager.urlPlugins.contains(pluginUrl)
}
private val voteLock = Mutex() private val voteLock = Mutex()
suspend fun vote(pluginUrl: String): Int { suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time.
voteLock.withLock { voteLock.withLock {
if (!canVote(pluginUrl)) { if (!canVote(pluginUrl)) {
main { main {
Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT) Toast.makeText(
.show() context,
R.string.extension_install_first,
Toast.LENGTH_SHORT
).show()
} }
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
if (hasVoted(pluginUrl)) { if (hasVoted(pluginUrl)) {
main { main {
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) Toast.makeText(
.show() context,
R.string.already_voted,
Toast.LENGTH_SHORT
).show()
} }
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
if (writeVote(pluginUrl)) { if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true) setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol
} }
} }
private data class Result( private data class CountifyResult(
val value: Int? val id: String? = null,
val count: Int? = null
) )
} }

View file

@ -0,0 +1,279 @@
package com.lagradost.cloudstream3.services
import android.Manifest
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.MainActivity.Companion.setLastError
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugWarning
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.system.measureTimeMillis
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class DownloadQueueService : Service() {
companion object {
const val TAG = "DownloadQueueService"
const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue"
const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service"
const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification."
const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique
@Volatile
var isRunning = false
fun getIntent(
context: Context,
): Intent {
return Intent(context, DownloadQueueService::class.java)
}
private val _downloadInstances: MutableStateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
MutableStateFlow(emptyList())
/** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances.
* Completed or failed instances are automatically removed by the download queue service.
*
*/
val downloadInstances: StateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
_downloadInstances
private val totalDownloadFlow =
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
instances to queue
}
.combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads ->
Triple(instances, queue, currentDownloads)
}
}
private val baseNotification by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent =
PendingIntentCompat.getActivity(this, 0, intent, 0, false)
val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0)
val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0)
NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID)
.setOngoing(true) // Make it persistent
.setAutoCancel(false)
.setColorized(false)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
// If low priority then the notification might not show :(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(this.colorFromAttribute(R.attr.colorPrimary))
.setContentText(activeDownloads)
.setSubText(activeQueue)
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.download_icon_load)
}
private fun updateNotification(context: Context, downloads: Int, queued: Int) {
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) return
val activeDownloads =
resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
val activeQueue =
resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued)
val newNotification = baseNotification
.setContentText(activeDownloads)
.setSubText(activeQueue)
.build()
safe {
NotificationManagerCompat.from(context)
.notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification)
}
}
// We always need to listen to events, even before the download is launched.
// Stopping link loading is an event which can trigger before downloading.
val downloadEventListener = { event: Pair<Int, VideoDownloadManager.DownloadActionType> ->
when (event.second) {
VideoDownloadManager.DownloadActionType.Stop -> {
removeKey(KEY_RESUME_PACKAGES, event.first.toString())
removeKey(KEY_RESUME_IN_QUEUE, event.first.toString())
DownloadQueueManager.cancelDownload(event.first)
}
else -> {}
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() {
isRunning = true
val context: Context = this // To make code more readable
Log.d(TAG, "Download queue service started.")
this.createNotificationChannel(
DOWNLOAD_QUEUE_CHANNEL_ID,
DOWNLOAD_QUEUE_CHANNEL_NAME,
DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION
)
if (SDK_INT >= 29) {
startForeground(
DOWNLOAD_QUEUE_NOTIFICATION_ID,
baseNotification.build(),
FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build())
}
downloadEvent += downloadEventListener
val queueJob = ioSafe {
// Ensure this is up to date to prevent race conditions with MainActivity launches
setLastError(context)
// Early return, to prevent waiting for plugins in safe mode
if (lastError != null) return@ioSafe
// Try to ensure all plugins are loaded before starting the downloader.
// To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough
val timeout = 15.seconds
val timeTaken = withTimeoutOrNull(timeout) {
measureTimeMillis {
while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) {
delay(100.milliseconds)
}
}
}
debugWarning({ timeTaken == null || timeTaken > 3_000 }, {
"Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms"
})
debugAssert({ timeTaken == null }, { "Downloader startup should not time out" })
totalDownloadFlow
.debounce { (instances, queue) ->
// Filter away incorrect transient queue states.
// For example when we pop the queue and add a download instance there exists a transient state where
// there is no queue and no download instances (leading to an early exit)
if (instances.isEmpty() && queue.isEmpty()) {
500.milliseconds
} else {
0.milliseconds
}
}
.takeWhile { (instances, queue) ->
// Stop if destroyed
isRunning
// Run as long as there is a queue to process
&& (instances.isNotEmpty() || queue.isNotEmpty())
// Run as long as there are no app crashes
&& lastError == null
}
.collect { (_, queue, currentDownloads) ->
// Remove completed or failed
val newInstances = _downloadInstances.updateAndGet { currentInstances ->
currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled }
}
val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context)
val currentInstanceCount = newInstances.size
val newDownloads = minOf(
// Cannot exceed the max downloads
maxOf(0, maxDownloads - currentInstanceCount),
// Cannot start more downloads than the queue size
queue.size
)
// Cant start multiple downloads at once. If this is rerun it may start too many downloads.
if (newDownloads > 0) {
_downloadInstances.update { instances ->
val downloadInstance = DownloadQueueManager.popQueue(context)
if (downloadInstance != null) {
downloadInstance.startDownload()
instances + downloadInstance
} else {
instances
}
}
}
// The downloads actually displayed to the user with a notification
val currentVisualDownloads =
currentDownloads.size + newInstances.count {
currentDownloads.contains(it.downloadQueueWrapper.id)
.not()
}
// Just the queue
val currentVisualQueue = queue.size
updateNotification(context, currentVisualDownloads, currentVisualQueue)
}
}
// Stop self regardless of job outcome
queueJob.invokeOnCompletion { throwable ->
if (throwable != null) {
logError(throwable)
}
safe {
stopSelf()
}
}
}
override fun onDestroy() {
Log.d(TAG, "Download queue service stopped.")
downloadEvent -= downloadEventListener
isRunning = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY // We want the service restarted if its killed
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(reason: Int) {
stopSelf()
Log.e(TAG, "Service stopped due to timeout: $reason")
}
}

View file

@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
.build() .build()
) )
} }
@Suppress("DEPRECATION_ERROR")
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
try { try {
// println("Update subscriptions!") // println("Update subscriptions!")

View file

@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Handle notification actions such as pause/resume downloads */
class VideoDownloadService : Service() { class VideoDownloadService : Service() {
private val downloadScope = CoroutineScope(Dispatchers.Default) private val downloadScope = CoroutineScope(Dispatchers.Default)
@ -42,19 +43,3 @@ class VideoDownloadService : Service() {
super.onDestroy() super.onDestroy()
} }
} }
// override fun onHandleIntent(intent: Intent?) {
// if (intent != null) {
// val id = intent.getIntExtra("id", -1)
// val type = intent.getStringExtra("type")
// if (id != -1 && type != null) {
// val state = when (type) {
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
// else -> return
// }
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
// }
// }
// }
//}

View file

@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.LocalList
import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.MALApi
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
@ -12,12 +13,14 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
abstract class AccountManager { abstract class AccountManager {
companion object { companion object {
const val NONE_ID: Int = -1 const val NONE_ID: Int = -1
val malApi = MALApi() val malApi = MALApi()
val kitsuApi = KitsuApi()
val aniListApi = AniListApi() val aniListApi = AniListApi()
val simklApi = SimklApi() val simklApi = SimklApi()
val localListApi = LocalList() val localListApi = LocalList()
@ -26,6 +29,7 @@ abstract class AccountManager {
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
val subDlApi = SubDlApi() val subDlApi = SubDlApi()
val subSourceApi = SubSourceApi() val subSourceApi = SubSourceApi()
val animeSkipApi = AnimeSkipAuth()
var cachedAccounts: MutableMap<String, Array<AuthData>> var cachedAccounts: MutableMap<String, Array<AuthData>>
var cachedAccountIds: MutableMap<String, Int> var cachedAccountIds: MutableMap<String, Int>
@ -59,13 +63,14 @@ abstract class AccountManager {
val allApis = arrayOf( val allApis = arrayOf(
SyncRepo(malApi), SyncRepo(malApi),
SyncRepo(kitsuApi),
SyncRepo(aniListApi), SyncRepo(aniListApi),
SyncRepo(simklApi), SyncRepo(simklApi),
SyncRepo(localListApi), SyncRepo(localListApi),
SubtitleRepo(openSubtitlesApi), SubtitleRepo(openSubtitlesApi),
SubtitleRepo(addic7ed), SubtitleRepo(addic7ed),
SubtitleRepo(subDlApi) SubtitleRepo(subDlApi),
PlainAuthRepo(animeSkipApi)
) )
fun updateAccountIds() { fun updateAccountIds() {
@ -107,6 +112,7 @@ abstract class AccountManager {
// accessing other classes // accessing other classes
fun initMainAPI() { fun initMainAPI() {
LoadResponse.malIdPrefix = malApi.idPrefix LoadResponse.malIdPrefix = malApi.idPrefix
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix
LoadResponse.simklIdPrefix = simklApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix
} }
@ -118,6 +124,7 @@ abstract class AccountManager {
) )
val syncApis = arrayOf( val syncApis = arrayOf(
SyncRepo(malApi), SyncRepo(malApi),
SyncRepo(kitsuApi),
SyncRepo(aniListApi), SyncRepo(aniListApi),
SyncRepo(simklApi), SyncRepo(simklApi),
SyncRepo(localListApi) SyncRepo(localListApi)

View file

@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.LocalList
import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.MALApi
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
@ -35,11 +36,9 @@ import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import me.xdrop.fuzzywuzzy.FuzzySearch
import java.net.URL import java.net.URL
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Date import java.util.Date

View file

@ -9,6 +9,9 @@ import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
/** General-purpose repo */
class PlainAuthRepo(api: AuthAPI) : AuthRepo(api)
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ /** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
abstract class AuthRepo(open val api: AuthAPI) { abstract class AuthRepo(open val api: AuthAPI) {
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false

View file

@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
/** Stateless safe abstraction of SubtitleAPI */ /** Stateless safe abstraction of SubtitleAPI */
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
@ -24,26 +24,30 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
) )
// maybe make this a generic struct? right now there is a lot of boilerplate // maybe make this a generic struct? right now there is a lot of boilerplate
private val searchCache = threadSafeListOf<SavedSearchResponse>() private val searchCache = atomicListOf<SavedSearchResponse>()
private var searchCacheIndex: Int = 0 private var searchCacheIndex: Int = 0
private val resourceCache = threadSafeListOf<SavedResourceResponse>() private val resourceCache = atomicListOf<SavedResourceResponse>()
private var resourceCacheIndex: Int = 0 private var resourceCacheIndex: Int = 0
const val CACHE_SIZE = 20 const val CACHE_SIZE = 20
} }
@WorkerThread @WorkerThread
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching { suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
synchronized(resourceCache) { val cached = resourceCache.withLock {
var found: SubtitleResource? = null
for (item in resourceCache) { for (item in resourceCache) {
// 20 min save // 20 min save
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
return@runCatching item.response found = item.response
break
} }
} }
found
} }
if (cached != null) return@runCatching cached
val returnValue = api.resource(freshAuth(), data) val returnValue = api.resource(freshAuth(), data)
synchronized(resourceCache) { resourceCache.withLock {
val add = SavedResourceResponse(unixTime, returnValue, data) val add = SavedResourceResponse(unixTime, returnValue, data)
if (resourceCache.size > CACHE_SIZE) { if (resourceCache.size > CACHE_SIZE) {
resourceCache[resourceCacheIndex] = add // rolling cache resourceCache[resourceCacheIndex] = add // rolling cache
@ -58,22 +62,25 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
@WorkerThread @WorkerThread
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> { suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
return runCatching { return runCatching {
synchronized(searchCache) { val cached = searchCache.withLock {
var found: List<SubtitleEntity>? = null
for (item in searchCache) { for (item in searchCache) {
// 120 min save // 120 min save
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
return@runCatching item.response found = item.response
break
} }
} }
found
} }
val returnValue = if (cached != null) return@runCatching cached
api.search(freshAuth(), query) ?: emptyList() val returnValue = api.search(freshAuth(), query) ?: emptyList()
// only cache valid return values // only cache valid return values
if (returnValue.isNotEmpty()) { if (returnValue.isNotEmpty()) {
val add = SavedSearchResponse(unixTime, returnValue, query) val add = SavedSearchResponse(unixTime, returnValue, query)
synchronized(searchCache) { searchCache.withLock {
if (searchCache.size > CACHE_SIZE) { if (searchCache.size > CACHE_SIZE) {
searchCache[searchCacheIndex] = add // rolling cache searchCache[searchCacheIndex] = add // rolling cache
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
@ -86,4 +93,3 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
} }
} }
} }

View file

@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.Levenshtein
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch
import java.util.Date import java.util.Date
/** /**
@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() {
ListSorting.Query -> ListSorting.Query ->
if (query != null) { if (query != null) {
items.sortedBy { items.sortedBy {
-FuzzySearch.partialRatio( -Levenshtein.partialRatio(
query.lowercase(), it.name.lowercase() query.lowercase(), it.name.lowercase()
) )
} }
@ -191,4 +191,4 @@ abstract class SyncAPI : AuthAPI() {
override var score: Score? = null, override var score: Score? = null,
val tags: List<String>? = null val tags: List<String>? = null
) : SearchResponse ) : SearchResponse
} }

View file

@ -50,7 +50,8 @@ class AniListApi : SyncAPI() {
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
val sanitizer = splitRedirectUrl(redirectUrl) val sanitizer = splitRedirectUrl(redirectUrl)
val token = AuthToken( val token = AuthToken(
accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), accessToken = sanitizer["access_token"]
?: throw ErrorLoadingException("No access token"),
//refreshToken = sanitizer["refresh_token"], //refreshToken = sanitizer["refresh_token"],
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
) )
@ -83,8 +84,8 @@ class AniListApi : SyncAPI() {
return "$mainUrl/anime/$id" return "$mainUrl/anime/$id"
} }
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(name) ?: return null val data = searchShows(query) ?: return null
return data.data?.page?.media?.map { return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult( SyncAPI.SyncSearchResult(
it.title.romaji ?: return null, it.title.romaji ?: return null,
@ -96,7 +97,7 @@ class AniListApi : SyncAPI() {
} }
} }
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
val season = getSeason(internalId).data.media val season = getSeason(internalId).data.media
@ -158,7 +159,7 @@ class AniListApi : SyncAPI() {
) )
} }
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(auth ?: return null, internalId) ?: return null val data = getDataAboutId(auth ?: return null, internalId) ?: return null
@ -459,7 +460,7 @@ class AniListApi : SyncAPI() {
} }
} }
private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? {
val q = val q =
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
@ -506,7 +507,7 @@ class AniListApi : SyncAPI() {
} }
private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
return app.post( return app.post(
"https://graphql.anilist.co/", "https://graphql.anilist.co/",
headers = mapOf( headers = mapOf(
@ -638,7 +639,7 @@ class AniListApi : SyncAPI() {
} }
} }
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
convertAniListStringToStatus(it.status ?: "").stringRes convertAniListStringToStatus(it.status ?: "").stringRes
}?.mapValues { group -> }?.mapValues { group ->
@ -666,7 +667,7 @@ class AniListApi : SyncAPI() {
) )
} }
private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
val userID = auth.user.id val userID = auth.user.id
val mediaType = "ANIME" val mediaType = "ANIME"
@ -714,7 +715,7 @@ class AniListApi : SyncAPI() {
return text?.toKotlinObject() return text?.toKotlinObject()
} }
suspend fun toggleLike(auth : AuthData, id: Int): Boolean { suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
val q = """mutation (${'$'}animeId: Int = $id) { val q = """mutation (${'$'}animeId: Int = $id) {
ToggleFavourite (animeId: ${'$'}animeId) { ToggleFavourite (animeId: ${'$'}animeId) {
anime { anime {
@ -737,7 +738,7 @@ class AniListApi : SyncAPI() {
data class MediaListId(@JsonProperty("id") val id: Long? = null) data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId( private suspend fun postDataAboutId(
auth : AuthData, auth: AuthData,
id: Int, id: Int,
type: AniListStatusType, type: AniListStatusType,
score: Score?, score: Score?,
@ -786,7 +787,7 @@ class AniListApi : SyncAPI() {
return data != "" return data != ""
} }
private suspend fun getUser(token : AuthToken): AniListUser? { private suspend fun getUser(token: AuthToken): AniListUser? {
val q = """ val q = """
{ {
Viewer { Viewer {

View file

@ -1,8 +1,676 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.txt
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.Date
import java.util.Locale
const val KITSU_MAX_SEARCH_LIMIT = 20
class KitsuApi: SyncAPI() {
override var name = "Kitsu"
override val idPrefix = "kitsu"
private val apiUrl = "https://kitsu.io/api/edge"
private val fallbackApiUrl = "https://kitsu.app/api/edge"
private val oauthUrl = "https://kitsu.io/api/oauth"
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
override val hasInApp = true
override val mainUrl = "https://kitsu.app"
override val icon = R.drawable.kitsu_icon
override val syncIdName = SyncIdName.Kitsu
override val createAccountUrl = mainUrl
override val supportedWatchTypes = setOf(
SyncWatchType.WATCHING,
SyncWatchType.COMPLETED,
SyncWatchType.PLANTOWATCH,
SyncWatchType.DROPPED,
SyncWatchType.ONHOLD,
SyncWatchType.NONE
)
override val inAppLoginRequirement = AuthLoginRequirement(
password = true,
email = true
)
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
try {
val response = chain.proceed(request);
if (response.isSuccessful) return response
response.close()
} catch (_: Exception) {
}
val fallbackRequest: Request = request.newBuilder()
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
.build()
return chain.proceed(fallbackRequest)
}
}
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
override suspend fun login(form: AuthLoginResponse): AuthToken? {
val username = form.email ?: return null
val password = form.password ?: return null
val grantType = "password"
val token = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to grantType,
"username" to username,
"password" to password
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken,
accessToken = token.accessToken,
)
}
override suspend fun refreshToken(token: AuthToken): AuthToken {
val res = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to "refresh_token",
"refresh_token" to token.refreshToken!!
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessToken = res.accessToken,
refreshToken = res.refreshToken,
accessTokenLifetime = unixTime + res.expiresIn.toLong()
)
}
override suspend fun user(token: AuthToken?): AuthUser? {
val user = app.get(
"$apiUrl/users?filter[self]=true",
headers = mapOf(
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
if (user.data.isEmpty()) {
return null
}
return AuthUser(
id = user.data[0].id.toInt(),
name = user.data[0].attributes.name,
profilePicture = user.data[0].attributes.avatar?.original
)
}
override suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
return res.data.map {
val attributes = it.attributes
val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title"
SyncSearchResult(
title,
this.name,
it.id,
"$mainUrl/anime/${it.id}/",
attributes.posterImage?.large ?: attributes.posterImage?.medium
)
}
}
override suspend fun load(auth : AuthData?, id: String): SyncResult? {
val auth = auth?.token?.accessToken ?: return null
if (id.toIntOrNull() == null) {
return null
}
data class KitsuResponse(
@field:JsonProperty(value = "data")
val data: KitsuNode,
)
val url =
"$apiUrl/anime/$id"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.attributes
return SyncResult(
id = id,
totalEpisodes = anime.episodeCount,
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
publicScore = Score.from(anime.ratingTwenty, 20),
duration = anime.episodeLength,
synopsis = anime.synopsis,
airStatus = when(anime.status) {
"finished" -> ShowStatus.Completed
"current" -> ShowStatus.Ongoing
else -> null
},
nextAiring = null,
studio = null,
genres = null,
trailers = null,
startDate = LocalDate.parse(anime.startDate).toEpochDay(),
endDate = LocalDate.parse(anime.endDate).toEpochDay(),
recommendations = null,
nextSeason =null,
prevSeason = null,
actors = null,
)
}
override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? {
val accessToken = auth?.token?.accessToken ?: return null
val userId = auth.user.id
val selectedFields = arrayOf("status","ratingTwenty", "progress")
val url =
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $accessToken"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
if (anime == null) {
return SyncStatus(
score = null,
status = SyncWatchType.NONE,
isFavorite = null,
watchedEpisodes = null
)
}
return SyncStatus(
score = Score.from(anime.ratingTwenty, 20),
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
isFavorite = null,
watchedEpisodes = anime.progress,
)
}
suspend fun getAnimeIdByTitle(title: String): String? {
val animeSelectedFields = arrayOf("titles","canonicalTitle")
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
return res.data.firstOrNull()?.id
}
override fun urlToId(url: String): String? =
Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first()
override suspend fun updateStatus(
auth : AuthData?,
id: String,
newStatus: AbstractSyncStatus
): Boolean {
return setScoreRequest(
auth ?: return false,
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(newStatus.status),
newStatus.score?.toInt(20),
newStatus.watchedEpisodes
)
}
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: KitsuStatusType? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val libraryEntryId = getAnimeLibraryEntryId(auth, id)
// Exists entry for anime in library
if (libraryEntryId != null) {
// Delete anime from library
if (status == null || status == KitsuStatusType.None) {
val res = app.delete(
"$apiUrl/library-entries/$libraryEntryId",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
return setScoreRequest(
auth,
libraryEntryId,
kitsuStatusAsString[maxOf(0, status.value)],
score,
numWatchedEpisodes
)
}
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)],
),
"relationships" to mapOf(
"anime" to mapOf(
"data" to mapOf(
"type" to "anime",
"id" to id.toString()
)
),
"user" to mapOf(
"data" to mapOf(
"type" to "users",
"id" to auth.user.id
)
)
)
)
)
val res = app.post(
"$apiUrl/library-entries",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: String? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"id" to id.toString(),
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to status
)
)
)
val res = app.patch(
"$apiUrl/library-entries/$id",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? {
val userId = auth.user.id
val res = app.get(
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
return res.id.toInt()
}
override suspend fun library(auth : AuthData?): LibraryMetadata? {
val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy {
convertToStatus(it.attributes.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when Kitsu does not return them
val baseMap =
KitsuStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<LibraryItem>()
}
return LibraryMetadata(
(baseMap + list).map { LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
)
}
private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array<KitsuNode>? {
return if (requireLibraryRefresh) {
val list = getKitsuAnimeList(auth.token, auth.user.id)
setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
list
} else {
getKey<Array<KitsuNode>>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array<KitsuNode>
}
}
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
val limit = 500
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
val fullList = mutableListOf<KitsuNode>()
while (true) {
val data: KitsuResponse = getKitsuAnimeListSlice(token, url)
data.data.forEachIndexed { index, value ->
value.anime = data.included?.get(index)
}
fullList.addAll(data.data)
url = data.links?.next ?: break
}
return fullList.toTypedArray()
}
private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse {
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer ${token.accessToken}",
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
return res
}
data class ResponseToken(
@JsonProperty("token_type") val tokenType: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("refresh_token") val refreshToken: String,
)
data class KitsuNode(
@JsonProperty("id") val id: String,
@JsonProperty("attributes") val attributes: KitsuNodeAttributes,
/* User list anime node */
@JsonProperty("relationships") val relationships: KitsuRelationships?,
var anime: KitsuAnimeData?
) {
fun toLibraryItem(): LibraryItem {
val animeItem = this.anime
val numEpisodes = animeItem?.attributes?.episodeCount
val startDate = animeItem?.attributes?.startDate
val posterImage = animeItem?.attributes?.posterImage
val canonicalTitle = animeItem?.attributes?.canonicalTitle
val titles = animeItem?.attributes?.titles
val animeId = animeItem?.id
val synopsis: String? = animeItem?.attributes?.synopsis
return LibraryItem(
canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(),
"https://kitsu.app/anime/${animeId}/",
this.id,
this.attributes.progress,
numEpisodes,
Score.from(this.attributes.ratingTwenty, 20),
parseDateLong(this.attributes.updatedAt),
"Kitsu",
TvType.Anime,
posterImage?.large ?: posterImage?.medium,
null,
null,
plot = synopsis,
releaseDate = if (startDate == null) null else try {
Date.from(LocalDate.parse(startDate).atStartOfDay()
.atZone(ZoneId.systemDefault())
.toInstant())
} catch (_: RuntimeException) {
null
}
)
}
}
data class KitsuAnimeAttributes(
@JsonProperty("titles") val titles: KitsuTitles?,
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("startDate") val startDate: String?,
@JsonProperty("endDate") val endDate: String?,
@JsonProperty("episodeCount") val episodeCount: Int?,
@JsonProperty("episodeLength") val episodeLength: Int?,
)
data class KitsuAnimeData(
@JsonProperty("id") val id: String,
@JsonProperty("attributes") val attributes: KitsuAnimeAttributes,
)
data class KitsuNodeAttributes(
/* General attributes */
@JsonProperty("titles") val titles: KitsuTitles?,
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("startDate") val startDate: String?,
@JsonProperty("endDate") val endDate: String?,
@JsonProperty("episodeCount") val episodeCount: Int?,
@JsonProperty("episodeLength") val episodeLength: Int?,
/* User attributes */
@JsonProperty("name") val name: String?,
@JsonProperty("location") val location: String?,
@JsonProperty("createdAt") val createdAt: String?,
@JsonProperty("avatar") val avatar: KitsuUserAvatar?,
/* User list anime attributes */
@JsonProperty("progress") val progress: Int?,
@JsonProperty("ratingTwenty") val ratingTwenty: Int?,
@JsonProperty("updatedAt") val updatedAt: String?,
@JsonProperty("status") val status: String?,
)
data class KitsuRelationships(
@JsonProperty("anime") val anime: KitsuRelationshipsAnime?
)
data class KitsuRelationshipsAnime(
@JsonProperty("links") val links: KitsuLinks?
)
data class KitsuPosterImage(
@JsonProperty("large") val large: String?,
@JsonProperty("medium") val medium: String?,
)
data class KitsuTitles(
@JsonProperty("en_jp") val enJp: String?,
@JsonProperty("ja_jp") val jaJp: String?
)
data class KitsuUserAvatar(
@JsonProperty("original") val original: String?
)
data class KitsuLinks(
/* Pagination */
@JsonProperty("first") val first: String?,
@JsonProperty("next") val next: String?,
@JsonProperty("last") val last: String?,
/* Relationships */
@JsonProperty("related") val related: String?
)
data class KitsuResponse(
@JsonProperty("links") val links: KitsuLinks?,
@JsonProperty("data") val data: List<KitsuNode>,
/* When requesting related info (User library entry -> anime) */
@JsonProperty("included") val included: List<KitsuAnimeData>?,
)
companion object {
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
private fun parseDateLong(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
string ?: return null
)?.time?.div(1000)
} catch (e: Exception) {
null
}
}
private val kitsuStatusAsString =
arrayOf("current", "completed", "on_hold", "dropped", "planned")
private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType {
return when (inp) {
SyncWatchType.NONE -> KitsuStatusType.None
SyncWatchType.WATCHING -> KitsuStatusType.Watching
SyncWatchType.COMPLETED -> KitsuStatusType.Completed
SyncWatchType.ONHOLD -> KitsuStatusType.OnHold
SyncWatchType.DROPPED -> KitsuStatusType.Dropped
SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch
SyncWatchType.REWATCHING -> KitsuStatusType.Watching
}
}
enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) {
Watching(0, R.string.type_watching),
Completed(1, R.string.type_completed),
OnHold(2, R.string.type_on_hold),
Dropped(3, R.string.type_dropped),
PlanToWatch(4, R.string.type_plan_to_watch),
None(-1, R.string.type_none)
}
private fun convertToStatus(string: String): KitsuStatusType {
return when (string) {
"current" -> KitsuStatusType.Watching
"completed" -> KitsuStatusType.Completed
"on_hold" -> KitsuStatusType.OnHold
"dropped" -> KitsuStatusType.Dropped
"planned" -> KitsuStatusType.PlanToWatch
else -> KitsuStatusType.None
}
}
}
}
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md
@ -142,4 +810,4 @@ query {
val canonical: String? = null val canonical: String? = null
) )
} }
} }

View file

@ -98,9 +98,9 @@ class MALApi : SyncAPI() {
) )
} }
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null val auth = auth?.token?.accessToken ?: return null
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT"
val res = app.get( val res = app.get(
url, headers = mapOf( url, headers = mapOf(
"Authorization" to "Bearer $auth", "Authorization" to "Bearer $auth",
@ -122,7 +122,7 @@ class MALApi : SyncAPI() {
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
override suspend fun updateStatus( override suspend fun updateStatus(
auth : AuthData?, auth: AuthData?,
id: String, id: String,
newStatus: SyncAPI.AbstractSyncStatus newStatus: SyncAPI.AbstractSyncStatus
): Boolean { ): Boolean {
@ -225,7 +225,7 @@ class MALApi : SyncAPI() {
) )
} }
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
val auth = auth?.token?.accessToken ?: return null val auth = auth?.token?.accessToken ?: return null
val internalId = id.toIntOrNull() ?: return null val internalId = id.toIntOrNull() ?: return null
val url = val url =
@ -271,7 +271,7 @@ class MALApi : SyncAPI() {
} }
} }
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
val auth = auth?.token?.accessToken ?: return null val auth = auth?.token?.accessToken ?: return null
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
@ -477,7 +477,7 @@ class MALApi : SyncAPI() {
@JsonProperty("start_time") val startTime: String? @JsonProperty("start_time") val startTime: String?
) )
override suspend fun library(auth : AuthData?): LibraryMetadata? { override suspend fun library(auth: AuthData?): LibraryMetadata? {
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
convertToStatus(it.listStatus?.status ?: "").stringRes convertToStatus(it.listStatus?.status ?: "").stringRes
}?.mapValues { group -> }?.mapValues { group ->
@ -505,7 +505,7 @@ class MALApi : SyncAPI() {
) )
} }
private suspend fun getMalAnimeListSmart(auth : AuthData): Array<Data>? { private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
return if (requireLibraryRefresh) { return if (requireLibraryRefresh) {
val list = getMalAnimeList(auth.token) val list = getMalAnimeList(auth.token)
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)

View file

@ -16,7 +16,6 @@ import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mapper
import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
@ -30,6 +29,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import java.math.BigInteger import java.math.BigInteger
@ -117,13 +117,8 @@ class SimklApi : SyncAPI() {
* Gets cached object, if object is not fresh returns null and removes it from cache * Gets cached object, if object is not fresh returns null and removes it from cache
*/ */
inline fun <reified T : Any> getKey(path: String): T? { inline fun <reified T : Any> getKey(path: String): T? {
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
val type = mapper.typeFactory.constructParametricType(
SimklCacheWrapper::class.java,
T::class.java
)
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let { val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
mapper.readValue<SimklCacheWrapper<T>>(it, type) tryParseJson<SimklCacheWrapper<T>>(it)
} }
return if (cache?.isFresh() == true) { return if (cache?.isFresh() == true) {
@ -916,7 +911,7 @@ class SimklApi : SyncAPI() {
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
return app.get( return app.get(
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query)
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() } ).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
} }

View file

@ -17,7 +17,7 @@ import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.newSearchResponseList import com.lagradost.cloudstream3.newSearchResponseList
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -55,7 +55,7 @@ class APIRepository(val api: MainAPI) {
val hash: Pair<String, String> val hash: Pair<String, String>
) )
private val cache = threadSafeListOf<SavedLoadResponse>() private val cache = atomicListOf<SavedLoadResponse>()
private var cacheIndex: Int = 0 private var cacheIndex: Int = 0
const val CACHE_SIZE = 20 const val CACHE_SIZE = 20
@ -66,9 +66,7 @@ class APIRepository(val api: MainAPI) {
private fun afterPluginsLoaded(forceReload: Boolean) { private fun afterPluginsLoaded(forceReload: Boolean) {
if (forceReload) { if (forceReload) {
synchronized(cache) { cache.clear()
cache.clear()
}
} }
} }
@ -91,21 +89,25 @@ class APIRepository(val api: MainAPI) {
val fixedUrl = api.fixUrl(url) val fixedUrl = api.fixUrl(url)
val lookingForHash = Pair(api.name, fixedUrl) val lookingForHash = Pair(api.name, fixedUrl)
synchronized(cache) { val cached = cache.withLock {
var found: LoadResponse? = null
for (item in cache) { for (item in cache) {
// 10 min save // 10 min save
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
return@withTimeout item.response found = item.response
break
} }
} }
found
} }
if (cached != null) return@withTimeout cached
api.load(fixedUrl)?.also { response -> api.load(fixedUrl)?.also { response ->
// Remove all blank tags as early as possible // Remove all blank tags as early as possible
response.tags = response.tags?.filter { it.isNotBlank() } response.tags = response.tags?.filter { it.isNotBlank() }
val add = SavedLoadResponse(unixTime, response, lookingForHash) val add = SavedLoadResponse(unixTime, response, lookingForHash)
synchronized(cache) { cache.withLock {
if (cache.size > CACHE_SIZE) { if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache cache[cacheIndex] = add // rolling cache
cacheIndex = (cacheIndex + 1) % CACHE_SIZE cacheIndex = (cacheIndex + 1) % CACHE_SIZE
@ -215,4 +217,4 @@ class APIRepository(val api: MainAPI) {
return false return false
} }
} }
} }

View file

@ -1,5 +1,6 @@
package com.lagradost.cloudstream3.ui package com.lagradost.cloudstream3.ui
import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
@ -11,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import coil3.dispose import coil3.dispose
import java.util.WeakHashMap
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) { open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
@ -22,6 +24,33 @@ abstract class NoStateAdapter<T : Any>(
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback() diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : BaseAdapter<T, Any>(0, diffCallback) ) : BaseAdapter<T, Any>(0, diffCallback)
/** Creates a new shared pool, using the supplied lambda as a constructor.
*
* The reason for this complicated structure is that a pool should not be shared between contexts
* as it makes coil fuck up, and theming.
* */
fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit> =
WeakHashMap<Context, RecyclerView.RecycledViewPool>() to lambda
/** Sets the shared pool of the recyclerview */
fun RecyclerView.setRecycledViewPool(pool: Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>) {
val ctx = context ?: return
synchronized(pool.first) {
this.setRecycledViewPool(pool.first.getOrPut(ctx) {
RecyclerView.RecycledViewPool().apply(pool.second)
})
}
}
/** Clears the shared pool of views */
fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>.clear() {
synchronized(this.first) {
for (pool in this.first.values) {
pool?.clear()
}
}
}
/** /**
* BaseAdapter is a persistent state stored adapter that supports headers and footers. * BaseAdapter is a persistent state stored adapter that supports headers and footers.
* This should be used for restoring eg scroll or focus related to a view when it is recreated. * This should be used for restoring eg scroll or focus related to a view when it is recreated.

View file

@ -12,9 +12,6 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ListView import android.widget.ListView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaLoadOptions
import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaSeekOptions
@ -105,9 +102,6 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() { UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init { init {
view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
view.setOnClickListener { view.setOnClickListener {
@ -334,6 +328,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
}, subtitleCallback = { }, subtitleCallback = {
currentSubs.add(it) currentSubs.add(it)
}, },
offset = 0,
isCasting = true isCasting = true
) )
} }
@ -448,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() {
SkipNextEpisodeController(skipOpButton) SkipNextEpisodeController(skipOpButton)
) )
} }
} }

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.content.withStyledAttributes
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -154,10 +155,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
init { init {
if (attrs != null) { if (attrs != null) {
val attrsArray = intArrayOf(android.R.attr.columnWidth) context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) {
val array = context.obtainStyledAttributes(attrs, attrsArray) columnWidth = getDimensionPixelSize(0, -1)
columnWidth = array.getDimensionPixelSize(0, -1) }
array.recycle()
} }
layoutManager = manager layoutManager = manager

View file

@ -7,12 +7,12 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.core.content.withStyledAttributes
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import java.lang.ref.WeakReference
class MyMiniControllerFragment : MiniControllerFragment() { class MyMiniControllerFragment : MiniControllerFragment() {
@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() {
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
super.onInflate(context, attributeSet, bundle)
// somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks????
if (currentColor == 0) { if (currentColor == 0) {
WeakReference( context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) {
context.obtainStyledAttributes( if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) {
attributeSet, currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0)
R.styleable.CustomCast
)
).apply {
if (get()
?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true
) {
currentColor =
get()
?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0
} }
get()?.recycle() }
}.clear()
} }
super.onInflate(context, attributeSet, bundle)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -38,15 +38,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
class AccountSelectActivity : FragmentActivity(), BiometricCallback { class AccountSelectActivity : FragmentActivity(), BiometricCallback {
companion object {
var hasLoggedIn: Boolean = false
}
val accountViewModel: AccountViewModel by viewModels() val accountViewModel: AccountViewModel by viewModels()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
// Are we editing and coming from MainActivity? // Are we editing and coming from MainActivity?
val isEditingFromMainActivity = intent.getBooleanExtra( val isEditingFromMainActivity = intent.getBooleanExtra(
@ -54,6 +54,19 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
false false
) )
// Sometimes we start this activity when we have already logged in
// For example when using cloudstreamsearch://
// In those cases we want to just go to the main activity instantly
if (hasLoggedIn && !isEditingFromMainActivity) {
navigateToMainActivity()
return
}
loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val skipStartup = settingsManager.getBoolean( val skipStartup = settingsManager.getBoolean(
getString(R.string.skip_startup_account_select_key), false getString(R.string.skip_startup_account_select_key), false
@ -188,8 +201,11 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
askBiometricAuth() askBiometricAuth()
} }
@SuppressLint("UnsafeIntentLaunch")
private fun navigateToMainActivity() { private fun navigateToMainActivity() {
openActivity(MainActivity::class.java) hasLoggedIn = true
// We want to propagate any intent we get here to MainActivity since this is just an intermediary
openActivity(MainActivity::class.java, baseIntent = intent)
finish() // Finish the account selection activity finish() // Finish the account selection activity
} }
@ -200,4 +216,4 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
override fun onAuthenticationError() { override fun onAuthenticationError() {
finish() finish()
} }
} }

View file

@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1 const val DOWNLOAD_ACTION_DELETE_FILE = 1
@ -27,6 +27,7 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5 const val DOWNLOAD_ACTION_LONG_CLICK = 5
const val DOWNLOAD_ACTION_CANCEL_PENDING = 6
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1 const val DOWNLOAD_ACTION_LOAD_RESULT = 1
@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
sealed class VisualDownloadCached { sealed class VisualDownloadCached {
abstract val currentBytes: Long abstract val currentBytes: Long
abstract val totalBytes: Long abstract val totalBytes: Long
abstract val data: VideoDownloadHelper.DownloadCached abstract val data: DownloadObjects.DownloadCached
abstract var isSelected: Boolean abstract var isSelected: Boolean
data class Child( data class Child(
override val currentBytes: Long, override val currentBytes: Long,
override val totalBytes: Long, override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadEpisodeCached, override val data: DownloadObjects.DownloadEpisodeCached,
override var isSelected: Boolean, override var isSelected: Boolean,
) : VisualDownloadCached() ) : VisualDownloadCached()
data class Header( data class Header(
override val currentBytes: Long, override val currentBytes: Long,
override val totalBytes: Long, override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached, override val data: DownloadObjects.DownloadHeaderCached,
override var isSelected: Boolean, override var isSelected: Boolean,
val child: VideoDownloadHelper.DownloadEpisodeCached?, val child: DownloadObjects.DownloadEpisodeCached?,
val currentOngoingDownloads: Int, val currentOngoingDownloads: Int,
val totalDownloads: Int, val totalDownloads: Int,
) : VisualDownloadCached() ) : VisualDownloadCached()
@ -57,12 +58,12 @@ sealed class VisualDownloadCached {
data class DownloadClickEvent( data class DownloadClickEvent(
val action: Int, val action: Int,
val data: VideoDownloadHelper.DownloadEpisodeCached val data: DownloadObjects.DownloadEpisodeCached
) )
data class DownloadHeaderClickEvent( data class DownloadHeaderClickEvent(
val action: Int, val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached val data: DownloadObjects.DownloadHeaderCached
) )
class DownloadAdapter( class DownloadAdapter(
@ -170,6 +171,7 @@ class DownloadAdapter(
} }
} }
downloadButton.resetView()
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) { if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading // We do this here instead if we are finished downloading
@ -187,7 +189,6 @@ class DownloadAdapter(
} else { } else {
// We need to make sure we restore the correct progress // We need to make sure we restore the correct progress
// when we refresh data in the adapter. // when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let { val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it) ContextCompat.getDrawable(downloadButton.context, it)
} }
@ -277,6 +278,7 @@ class DownloadAdapter(
} }
} }
downloadButton.resetView()
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) { if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading // We do this here instead if we are finished downloading
@ -295,7 +297,6 @@ class DownloadAdapter(
} else { } else {
// We need to make sure we restore the correct progress // We need to make sure we restore the correct progress
// when we refresh data in the adapter. // when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let { val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it) ContextCompat.getDrawable(downloadButton.context, it)
} }

View file

@ -18,8 +18,9 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
object DownloadButtonSetup { object DownloadButtonSetup {
@ -82,7 +83,7 @@ object DownloadButtonSetup {
} else { } else {
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
if (pkg != null) { if (pkg != null) {
VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) DownloadQueueManager.addToQueue(pkg.toWrapper())
} else { } else {
VideoDownloadManager.downloadEvent.invoke( VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
@ -95,7 +96,7 @@ object DownloadButtonSetup {
DOWNLOAD_ACTION_LONG_CLICK -> { DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act -> activity?.let { act ->
val length = val length =
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( VideoDownloadManager.getDownloadFileInfo(
act, act,
click.data.id click.data.id
)?.fileLength )?.fileLength
@ -110,24 +111,31 @@ object DownloadButtonSetup {
} }
} }
DOWNLOAD_ACTION_CANCEL_PENDING -> {
DownloadQueueManager.cancelDownload(id)
}
DOWNLOAD_ACTION_PLAY_FILE -> { DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act -> activity?.let { act ->
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>( val parent = getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString() click.data.parentId.toString()
) ?: return ) ?: return
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
?.mapNotNull { ?.mapNotNull {
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) getKey<DownloadObjects.DownloadEpisodeCached>(it)
} }
?.filter { it.parentId == click.data.parentId } ?.filter { it.parentId == click.data.parentId }
val items = mutableListOf<ExtractorUri>() val items = mutableListOf<ExtractorUri>()
val allRelevantEpisodes = episodes?.sortedWith(compareBy<VideoDownloadHelper.DownloadEpisodeCached> { it.season ?: 0 }.thenBy { it.episode }) val allRelevantEpisodes =
episodes?.sortedWith(compareBy<DownloadObjects.DownloadEpisodeCached> {
it.season ?: 0
}.thenBy { it.episode })
allRelevantEpisodes?.forEach { allRelevantEpisodes?.forEach {
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>( val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO, VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString() it.id.toString()
) ?: return@forEach ) ?: return@forEach
@ -154,7 +162,8 @@ object DownloadButtonSetup {
} }
act.navigate( act.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance( R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } DownloadFileGenerator(items),
items.indexOfFirst { it.id == click.data.id }
) )
) )
} }

View file

@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.player.LinkGenerator
@ -58,6 +59,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
) { ) {
private val downloadViewModel: DownloadViewModel by activityViewModels() private val downloadViewModel: DownloadViewModel by activityViewModels()
private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
private fun View.setLayoutWidth(weight: Long) { private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams( val param = LinearLayout.LayoutParams(
@ -142,6 +144,17 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
binding.downloadApp binding.downloadApp
) )
} }
observe(downloadQueueViewModel.childCards) { cards ->
val size = cards.currentDownloads.size + cards.queue.size
val context = binding.root.context
val baseText = context.getString(R.string.download_queue)
binding.downloadQueueText.text = if (size > 0) {
"$baseText (${cards.currentDownloads.size}/$size)"
} else {
baseText
}
}
observe(downloadViewModel.selectedBytes) { observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
} }
@ -213,7 +226,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
setLinearListLayout( setLinearListLayout(
isHorizontal = false, isHorizontal = false,
nextRight = FOCUS_SELF, nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF, nextDown = R.id.download_queue_button,
) )
} }
@ -227,6 +240,10 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
setOnClickListener { showStreamInputDialog(it.context) } setOnClickListener { showStreamInputDialog(it.context) }
} }
downloadQueueButton.setOnClickListener {
activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
}
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
downloadAppbar.isFocusableInTouchMode = isLayout(TV) downloadAppbar.isFocusableInTouchMode = isLayout(TV)
@ -332,7 +349,8 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
listOf(BasicLink(url)), listOf(BasicLink(url)),
extract = true, extract = true,
refererUrl = referer, refererUrl = referer,
) id = url.hashCode()
), 0
) )
) )
dialog.dismissSafe(activity) dialog.dismissSafe(activity)

View file

@ -5,30 +5,46 @@ import android.content.DialogInterface
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.api.Log
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.DownloadQueueService
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.ConsistentLiveData import com.lagradost.cloudstream3.utils.ConsistentLiveData
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
import com.lagradost.cloudstream3.utils.ResourceLiveData import com.lagradost.cloudstream3.utils.ResourceLiveData
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() { class DownloadViewModel : ViewModel() {
companion object {
const val TAG = "DownloadViewModel"
}
private val _headerCards = private val _headerCards =
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading()) ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
@ -111,23 +127,109 @@ class DownloadViewModel : ViewModel() {
} }
fun removeRedundantEpisodeKeys(context: Context, keys: List<Pair<Int, Int>>) {
val settingsManager = context.getSharedPrefs()
ioSafe {
settingsManager.edit {
keys.forEach { (parentId, childId) ->
Log.i(TAG, "Removing download episode key: ${parentId}/${childId}")
val oldPath = getFolderName(
getFolderName(
DOWNLOAD_EPISODE_CACHE,
parentId.toString()
),
childId.toString()
)
val newPath = getFolderName(
getFolderName(
DOWNLOAD_EPISODE_CACHE_BACKUP,
parentId.toString()
),
childId.toString()
)
val oldPref = settingsManager.getString(oldPath, null)
// Cowardly future backup solution in case the key removal fails in some edge case.
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
this.putString(newPath, oldPref)
this.remove(oldPath)
}
}
}
}
fun removeRedundantHeaderKeys(
context: Context,
cached: List<DownloadObjects.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
) {
val settingsManager = context.getSharedPrefs()
ioSafe {
// Do not remove headers used by resume watching
val resumeWatchingIds =
getAllResumeStateIds()?.mapNotNull { id ->
getLastWatched(id)?.parentId
}?.toSet() ?: emptySet()
settingsManager.edit {
cached.forEach { header ->
val downloads = totalDownloads[header.id] ?: 0
val bytes = totalBytesUsedByChild[header.id] ?: 0
if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) {
Log.i(TAG, "Removing download header key: ${header.id}")
val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString())
val newPath =
getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString())
val oldPref = settingsManager.getString(oldPAth, null)
// Cowardly future backup solution in case the key removal fails in some edge case.
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
this.putString(newPath, oldPref)
this.remove(oldPAth)
}
}
}
}
}
fun updateHeaderList(context: Context) = viewModelScope.launchSafe { fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
// Do not push loading as it interrupts the UI // Do not push loading as it interrupts the UI
//_headerCards.postValue(Resource.Loading()) //_headerCards.postValue(Resource.Loading())
val visual = withContext(Dispatchers.IO) { val visual = ioWork {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) } .mapNotNull { context.getKey<DownloadObjects.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates .distinctBy { it.id } // Remove duplicates
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = val isCurrentlyDownloading =
DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty()
val downloadStats =
calculateDownloadStats(context, children) calculateDownloadStats(context, children)
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) } .mapNotNull { context.getKey<DownloadObjects.DownloadHeaderCached>(it) }
// Download stats and header keys may change when downloading.
// To prevent the downloader and key removal from colliding, simply do not prune keys when downloading.
if (!isCurrentlyDownloading) {
removeRedundantHeaderKeys(
context,
cached,
downloadStats.totalBytesUsedByChild,
downloadStats.totalDownloads
)
}
// calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required
removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads)
createVisualDownloadList( createVisualDownloadList(
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads context,
cached,
downloadStats.totalBytesUsedByChild,
downloadStats.currentBytesUsedByChild,
downloadStats.totalDownloads
) )
} }
@ -159,20 +261,38 @@ class DownloadViewModel : ViewModel() {
})) }))
} }
private data class DownloadStats(
val totalBytesUsedByChild: Map<Int, Long>,
val currentBytesUsedByChild: Map<Int, Long>,
val totalDownloads: Map<Int, Int>,
/** Parent ID to child ID. Keys to be removed. */
val redundantDownloads: List<Pair<Int, Int>>
)
private fun calculateDownloadStats( private fun calculateDownloadStats(
context: Context, context: Context,
children: List<VideoDownloadHelper.DownloadEpisodeCached> children: List<DownloadObjects.DownloadEpisodeCached>
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> { ): DownloadStats {
// parentId : bytes // parentId : bytes
val totalBytesUsedByChild = mutableMapOf<Int, Long>() val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes // parentId : bytes
val currentBytesUsedByChild = mutableMapOf<Int, Long>() val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount // parentId : downloadsCount
val totalDownloads = mutableMapOf<Int, Int>() val totalDownloads = mutableMapOf<Int, Int>()
val redundantDownloads = mutableListOf<Pair<Int, Int>>()
children.forEach { child -> children.forEach { child ->
val childFile = val childFile = getDownloadFileInfo(context, child.id)
getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
if (childFile == null) {
// It may not be a redundant child if something is currently downloading.
// DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader
// leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE
if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) {
redundantDownloads.add(child.parentId to child.id)
}
return@forEach
}
if (childFile.fileLength <= 1) return@forEach if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes val len = childFile.totalBytes
@ -182,12 +302,17 @@ class DownloadViewModel : ViewModel() {
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads.merge(child.parentId, 1, Int::plus) totalDownloads.merge(child.parentId, 1, Int::plus)
} }
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) return DownloadStats(
totalBytesUsedByChild,
currentBytesUsedByChild,
totalDownloads,
redundantDownloads
)
} }
private fun createVisualDownloadList( private fun createVisualDownloadList(
context: Context, context: Context,
cached: List<VideoDownloadHelper.DownloadHeaderCached>, cached: List<DownloadObjects.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>, totalBytesUsedByChild: Map<Int, Long>,
currentBytesUsedByChild: Map<Int, Long>, currentBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int> totalDownloads: Map<Int, Int>
@ -196,11 +321,14 @@ class DownloadViewModel : ViewModel() {
val downloads = totalDownloads[it.id] ?: 0 val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
if (bytes <= 0 || downloads <= 0) {
return@mapNotNull null
}
val isSelected = selectedItemIds.value?.contains(it.id) ?: false val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val movieEpisode = val movieEpisode =
if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>( if (it.type.isEpisodeBased()) null else context.getKey<DownloadObjects.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE, DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString()) getFolderName(it.id.toString(), it.id.toString())
) )
@ -233,11 +361,10 @@ class DownloadViewModel : ViewModel() {
val visual = withContext(Dispatchers.IO) { val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key -> context.getKeys(folder).mapNotNull { key ->
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key) context.getKey<DownloadObjects.DownloadEpisodeCached>(key)
}.mapNotNull { }.mapNotNull {
val isSelected = selectedItemIds.value?.contains(it.id) ?: false val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val info = val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null
getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
VisualDownloadCached.Child( VisualDownloadCached.Child(
currentBytes = info.fileLength, currentBytes = info.fileLength,
totalBytes = info.totalBytes, totalBytes = info.totalBytes,
@ -313,7 +440,7 @@ class DownloadViewModel : ViewModel() {
if (item.data.type.isEpisodeBased()) { if (item.data.type.isEpisodeBased()) {
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { .mapNotNull {
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>( context.getKey<DownloadObjects.DownloadEpisodeCached>(
it it
) )
} }
@ -337,7 +464,7 @@ class DownloadViewModel : ViewModel() {
is VisualDownloadCached.Child -> { is VisualDownloadCached.Child -> {
ids.add(item.data.id) ids.add(item.data.id)
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>( val parent = context.getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
item.data.parentId.toString() item.data.parentId.toString()
) )

View file

@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
typealias DownloadStatusTell = VideoDownloadManager.DownloadType typealias DownloadStatusTell = VideoDownloadManager.DownloadType
@ -76,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
currentMetaData.id = id currentMetaData.id = id
if (!doSetProgress) return if (!doSetProgress) return
val appContext = context.applicationContext
ioSafe { ioSafe {
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
mainWork { mainWork {
if (savedData != null) { if (savedData != null) {
val downloadedBytes = savedData.fileLength val downloadedBytes = savedData.fileLength
@ -87,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
setProgress(downloadedBytes, totalBytes) setProgress(downloadedBytes, totalBytes)
applyMetaData(id, downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes)
} else run { resetView() } }
} }
} }
} }
@ -216,4 +216,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
* Get a clean slate again, might be useful in recyclerview? * Get a clean slate again, might be useful in recyclerview?
* */ * */
abstract fun resetView() abstract fun resetView()
} }

View file

@ -8,7 +8,7 @@ import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
class DownloadButton(context: Context, attributeSet: AttributeSet) : class DownloadButton(context: Context, attributeSet: AttributeSet) :
PieFetchButton(context, attributeSet) { PieFetchButton(context, attributeSet) {
@ -18,6 +18,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
super.onAttachedToWindow() super.onAttachedToWindow()
progressText = findViewById(R.id.result_movie_download_text_precentage) progressText = findViewById(R.id.result_movie_download_text_precentage)
mainText = findViewById(R.id.result_movie_download_text) mainText = findViewById(R.id.result_movie_download_text)
setStatus(null)
} }
override fun setStatus(status: DownloadStatusTell?) { override fun setStatus(status: DownloadStatusTell?) {
@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
} }
override fun setDefaultClickListener( override fun setDefaultClickListener(
card: VideoDownloadHelper.DownloadEpisodeCached, card: DownloadObjects.DownloadEpisodeCached,
textView: TextView?, textView: TextView?,
callback: (DownloadClickEvent) -> Unit callback: (DownloadClickEvent) -> Unit
) { ) {

View file

@ -10,11 +10,14 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
@ -23,9 +26,10 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) { BaseFetchButton(context, attributeSet) {
@ -63,7 +67,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
open fun onInflate() {} open fun onInflate() {}
init { init {
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) {
try { try {
inflate( inflate(
overrideLayout ?: getResourceId( overrideLayout ?: getResourceId(
@ -72,6 +76,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
) )
) )
} catch (e: Exception) { } catch (e: Exception) {
recycle() // Manually call recycle first to avoid memory leaks
Log.e( Log.e(
"PieFetchButton", "Error inflating PieFetchButton, " + "PieFetchButton", "Error inflating PieFetchButton, " +
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
@ -79,11 +84,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
throw e throw e
} }
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
animateWaiting = getBoolean( animateWaiting = getBoolean(
R.styleable.PieFetchButton_download_animate_waiting, R.styleable.PieFetchButton_download_animate_waiting,
true true
@ -92,16 +92,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
R.styleable.PieFetchButton_download_hide_when_icon, R.styleable.PieFetchButton_download_hide_when_icon,
true true
) )
waitingAnimation = getResourceId( waitingAnimation = getResourceId(
R.styleable.PieFetchButton_download_waiting_animation, R.styleable.PieFetchButton_download_waiting_animation,
R.anim.rotate_around_center_point R.anim.rotate_around_center_point
) )
activeOutline = getResourceId( activeOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
) )
nonActiveOutline = getResourceId( nonActiveOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_non_active, R.styleable.PieFetchButton_download_outline_non_active,
R.drawable.circle_shape_dotted R.drawable.circle_shape_dotted
@ -129,19 +126,29 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
) )
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
progressDrawable = getResourceId( progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
) )
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
recycle()
} }
resetView()
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
// resetView()
onInflate() onInflate()
} }
override fun onAttachedToWindow() {
super.onAttachedToWindow()
// Re-run all animations when the view gets visible.
// Otherwise views may run without animations after recycled
setStatusInternal(currentStatus)
}
private var currentStatus: DownloadStatusTell? = null private var currentStatus: DownloadStatusTell? = null
/*private fun getActivity(): Activity? { /*private fun getActivity(): Activity? {
var context = context var context = context
@ -162,16 +169,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}*/ }*/
protected fun setDefaultClickListener( protected fun setDefaultClickListener(
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached,
callback: (DownloadClickEvent) -> Unit callback: (DownloadClickEvent) -> Unit
) { ) {
this.progressText = textView this.progressText = textView
this.setPersistentId(card.id) this.setPersistentId(card.id)
view.setOnClickListener { view.setOnClickListener {
if (isZeroBytes) { if (isZeroBytes) {
removeKey(KEY_RESUME_PACKAGES, card.id.toString()) val localQueue = queue.value
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) val localInstances = downloadInstances.value
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) val id = card.id
// If the download is already in queue or active downloads, provide an option to cancel it
if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) {
it.popupMenuNoIcons(
arrayListOf(
Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel),
)
) {
callback(DownloadClickEvent(itemId, card))
}
} else {
// Otherwise just start a download instantly
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
}
} else { } else {
val list = arrayListOf( val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
@ -212,7 +234,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
} }
open fun setDefaultClickListener( open fun setDefaultClickListener(
card: VideoDownloadHelper.DownloadEpisodeCached, card: DownloadObjects.DownloadEpisodeCached,
textView: TextView?, textView: TextView?,
callback: (DownloadClickEvent) -> Unit callback: (DownloadClickEvent) -> Unit
) { ) {
@ -282,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
override fun setStatus(status: DownloadStatusTell?) { override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status currentStatus = status
// Runs on the main thread, but also instant if it already is // Runs on the main thread, but also instant if it already is.
if (Looper.myLooper() == Looper.getMainLooper()) { if (Looper.getMainLooper().isCurrentThread) {
try { try {
setStatusInternal(status) setStatusInternal(status)
} catch (t: Throwable) { } catch (t: Throwable) {

View file

@ -0,0 +1,274 @@
package com.lagradost.cloudstream3.ui.download.queue
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO
/** An item in the adapter can either be a separator or a real item.
* isCurrentlyDownloading is used to fully update items as opposed to just moving them. */
class DownloadAdapterItem(val item: DownloadQueueWrapper?) {
val isSeparator = item == null
}
class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter<DownloadAdapterItem, Unit>(
diffCallback = BaseDiffCallback(
itemSame = { a, b -> a.item?.id == b.item?.id },
contentSame = { a, b ->
a.item == b.item
})
) {
var currentDownloads = 0
companion object {
val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG"
}
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Unit> {
val inflater = LayoutInflater.from(parent.context)
val binding = DownloadQueueItemBinding.inflate(inflater, parent, false)
return ViewHolderState(binding)
}
override fun onBindContent(
holder: ViewHolderState<Unit>,
item: DownloadAdapterItem,
position: Int
) {
when (val binding = holder.view) {
is DownloadQueueItemBinding -> {
if (item.item == null) {
holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG
bindSeparator(binding)
} else {
holder.itemView.tag = null
bind(binding, item.item)
}
}
}
}
fun submitQueue(newQueue: DownloadAdapterQueue) {
val index = newQueue.currentDownloads.size
val current = newQueue.currentDownloads
val queue = newQueue.queue
currentDownloads = current.size
val newList =
(current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList()
.apply {
// Only add the separator if it actually separates something
if (index < this.size) {
add(index, DownloadAdapterItem(null))
}
}
submitList(newList)
}
fun bindSeparator(binding: DownloadQueueItemBinding) {
binding.apply {
separatorHolder.isGone = false
downloadChildEpisodeHolder.isGone = true
}
}
fun bind(
binding: DownloadQueueItemBinding,
queueWrapper: DownloadQueueWrapper,
) {
val context = binding.root.context
binding.apply {
separatorHolder.isGone = true
downloadChildEpisodeHolder.isGone = false
// Only set the child-text if child and parent are not the same
// This prevents setting movie titles twice
if (queueWrapper.id != queueWrapper.parentId) {
val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName
downloadChildEpisodeTextExtra.text = mainName
} else {
downloadChildEpisodeTextExtra.text = null
}
downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank()
val status = VideoDownloadManager.downloadStatus[queueWrapper.id]
downloadButton.setOnClickListener { view ->
val episodeCached =
getKey<DownloadObjects.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString())
)
val downloadInfo = context.getKey<DownloadObjects.DownloadedFileInfo>(
KEY_DOWNLOAD_INFO,
queueWrapper.id.toString()
)
val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading()
val actionList = arrayListOf<Pair<Int,Int>>()
if (isCurrentlyDownloading && episodeCached != null) {
// KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything
if (downloadInfo != null) {
actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file))
} else {
actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel))
}
val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id]
when (currentStatus) {
VideoDownloadManager.DownloadType.IsDownloading -> {
actionList.add(
Pair(
DOWNLOAD_ACTION_PAUSE_DOWNLOAD,
R.string.popup_pause_download
)
)
}
VideoDownloadManager.DownloadType.IsPaused -> {
actionList.add(
Pair(
DOWNLOAD_ACTION_RESUME_DOWNLOAD,
R.string.popup_resume_download
)
)
}
else -> {}
}
view.popupMenuNoIcons(
actionList
) {
handleDownloadClick(DownloadClickEvent(itemId, episodeCached))
}
} else {
actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel))
view.popupMenuNoIcons(
actionList
) {
when (itemId) {
DOWNLOAD_ACTION_CANCEL_PENDING -> {
DownloadQueueManager.cancelDownload(queueWrapper.id)
}
}
}
}
}
downloadButton.resetView()
downloadButton.setStatus(status)
downloadButton.setPersistentId(queueWrapper.id)
downloadChildEpisodeText.apply {
val name = queueWrapper.downloadItem?.episode?.name
?: queueWrapper.resumePackage?.item?.ep?.name
val episode =
queueWrapper.downloadItem?.episode?.episode
?: queueWrapper.resumePackage?.item?.ep?.episode
val season =
queueWrapper.downloadItem?.episode?.season
?: queueWrapper.resumePackage?.item?.ep?.season
text = context.getNameFull(name, episode, season)
isSelected = true // Needed for text repeating
}
}
}
}
class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) :
ItemTouchHelper(
DragAndDropTouchHelperCallback(adapter)
)
private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) :
ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val item = adapter.getItem(viewHolder.absoluteAdapterPosition)
val isDownloading = item.item?.isCurrentlyDownloading() == true
val dragFlags = if (item.isSeparator || isDownloading) {
0
} else {
ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down
}
val swipeFlags = 0 // Disable swipe functionality
return makeMovementFlags(dragFlags, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
source: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPosition = source.absoluteAdapterPosition
val toPosition = target.absoluteAdapterPosition
val separatorPosition = adapter.currentDownloads
val toPositionNoSeparator =
if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition
if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) {
return false
} else {
adapter.getItem(fromPosition).item?.let { downloadQueueInfo ->
DownloadQueueManager.reorderItem(
downloadQueueInfo,
toPositionNoSeparator - 1
)
}
}
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
override fun isLongPressDragEnabled(): Boolean {
return true // Enable drag with long press
}
override fun isItemViewSwipeEnabled(): Boolean {
return false // Disable swipe by default
}
}

View file

@ -0,0 +1,79 @@
package com.lagradost.cloudstream3.ui.download.queue
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.txt
class DownloadQueueFragment :
BaseFragment<FragmentDownloadQueueBinding>(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) {
private val queueViewModel: DownloadQueueViewModel by activityViewModels()
override fun onBindingCreated(binding: FragmentDownloadQueueBinding) {
val adapter = DownloadQueueAdapter(this@DownloadQueueFragment)
val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all)
observe(queueViewModel.childCards) { cards ->
val size = cards.queue.size + cards.currentDownloads.size
val isEmptyQueue = size == 0
binding.downloadQueueList.isGone = isEmptyQueue
binding.textNoQueue.isGone = !isEmptyQueue
clearQueueItem?.isVisible = !isEmptyQueue
adapter.submitQueue(cards)
}
binding.apply {
downloadQueueToolbar.apply {
title = txt(R.string.download_queue).asString(context)
if (isLayout(PHONE or EMULATOR)) {
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener {
dispatchBackPressed()
}
}
setAppBarNoScrollFlagsOnTV()
clearQueueItem?.setOnMenuItemClickListener {
AlertDialog.Builder(context, R.style.AlertDialogCustom)
.setTitle(R.string.cancel_all)
.setMessage(R.string.cancel_queue_message)
.setPositiveButton(R.string.yes) { _, _ ->
DownloadQueueManager.removeAllFromQueue()
}
.setNegativeButton(R.string.no) { _, _ ->
}.show()
true
}
}
downloadQueueList.adapter = adapter
// Drag and drop
val helper = DragAndDropTouchHelper(adapter)
helper.attachToRecyclerView(downloadQueueList)
}
}
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padBottom = isLandscape(),
padLeft = isLayout(TV or EMULATOR)
)
}
}

View file

@ -0,0 +1,43 @@
package com.lagradost.cloudstream3.ui.download.queue
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
data class DownloadAdapterQueue(
val currentDownloads: List<DownloadObjects.DownloadQueueWrapper>,
val queue: List<DownloadObjects.DownloadQueueWrapper>,
)
class DownloadQueueViewModel : ViewModel() {
private val _childCards = MutableLiveData<DownloadAdapterQueue>()
val childCards: LiveData<DownloadAdapterQueue> = _childCards
private val totalDownloadFlow =
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
val current = instances.map { it.downloadQueueWrapper }
DownloadAdapterQueue(current, queue.toList())
}.combine(VideoDownloadManager.currentDownloads) { total, _ ->
// We want to update the flow when currentDownloads updates, but we do not care about its value
total
}
init {
viewModelScope.launch {
totalDownloadFlow.collect { queue ->
updateChildList(queue)
}
}
}
fun updateChildList(downloads: DownloadAdapterQueue) {
_childCards.postValue(downloads)
}
}

View file

@ -5,12 +5,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import coil3.load
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding
@ -20,6 +16,7 @@ import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
@ -165,7 +162,7 @@ open class HomeChildItemAdapter(
// The vast majority of the lag comes from creating the view // The vast majority of the lag comes from creating the view
// This simply shares the views between all HomeChildItemAdapter // This simply shares the views between all HomeChildItemAdapter
val sharedPool = val sharedPool =
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 20) } newSharedPool { setMaxRecycledViews(CONTENT, 20) }
var minPosterSize: Int = 0 var minPosterSize: Int = 0
var maxPosterSize: Int = 0 var maxPosterSize: Int = 0

View file

@ -15,6 +15,7 @@ import android.widget.ImageView
import android.widget.ListView import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.isGone import androidx.core.view.isGone
@ -51,6 +52,7 @@ import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE
import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -64,6 +66,9 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.EmptyEvent
@ -85,7 +90,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
// Used for configuration changed events to fix any popups that are not attached to a fragment // Used for configuration changed events to fix any popups that are not attached to a fragment
val configEvent = EmptyEvent() val configEvent = EmptyEvent()
var currentSpan = 1 var currentSpan = 1
val listHomepageItems = mutableListOf<SearchResponse>()
private val errorProfilePics = listOf( private val errorProfilePics = listOf(
R.drawable.monke_benene, R.drawable.monke_benene,
@ -567,6 +571,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
} }
override fun onDestroyView() { override fun onDestroyView() {
(activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress")
bottomSheetDialog?.ownHide() bottomSheetDialog?.ownHide()
super.onDestroyView() super.onDestroyView()
} }
@ -627,6 +632,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBindingCreated(binding: FragmentHomeBinding) { override fun onBindingCreated(binding: FragmentHomeBinding) {
context?.let { HomeChildItemAdapter.updatePosterSize(it) } context?.let { HomeChildItemAdapter.updatePosterSize(it) }
(activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") {
handleTvBackPress(this)
}
binding.apply { binding.apply {
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
@ -642,13 +650,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
activity?.showAccountSelectLinear() activity?.showAccountSelectLinear()
} }
homeRandom.setOnClickListener {
if (listHomepageItems.isNotEmpty()) {
activity.loadSearchResult(listHomepageItems.random())
}
}
homeMasterAdapter = HomeParentItemAdapterPreview( homeMasterAdapter = HomeParentItemAdapterPreview(
fragment = this@HomeFragment,
homeViewModel, accountViewModel homeViewModel, accountViewModel
) )
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
@ -725,8 +727,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
settingsManager.getBoolean( settingsManager.getBoolean(
getString(R.string.random_button_key), getString(R.string.random_button_key),
false false
) && isLayout(PHONE) )
binding.homeRandom.visibility = View.GONE binding.homeRandom.visibility = View.GONE
binding.homeRandomButtonTv.visibility = View.GONE
} }
observe(homeViewModel.apiName) { apiName -> observe(homeViewModel.apiName) { apiName ->
@ -752,23 +755,28 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
saveHomepageToTV(d) saveHomepageToTV(d)
listHomepageItems.clear()
homeLoading.isVisible = false homeLoading.isVisible = false
homeLoadingError.isVisible = false homeLoadingError.isVisible = false
homeMasterRecycler.isVisible = true homeMasterRecycler.isVisible = true
homeLoadingShimmer.stopShimmer() homeLoadingShimmer.stopShimmer()
//home_loaded?.isVisible = true //home_loaded?.isVisible = true
if (toggleRandomButton) { if (toggleRandomButton) {
//Flatten list val distinct = d.values
val mutableListOfResponse = mutableListOf<SearchResponse>() .flatMap { it.list.list }
d.values.forEach { dlist -> .distinctBy { it.url }
mutableListOfResponse.addAll(dlist.list.list) val hasItems = distinct.isNotEmpty()
val isPhone = isLayout(PHONE)
val randomClickListener = View.OnClickListener {
distinct.randomOrNull()?.let { activity.loadSearchResult(it) }
} }
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
homeRandom.isVisible = listHomepageItems.isNotEmpty() homeRandom.isVisible = isPhone && hasItems
homeRandom.setOnClickListener(randomClickListener)
homeRandomButtonTv.isVisible = !isPhone && hasItems
homeRandomButtonTv.setOnClickListener(randomClickListener)
} else { } else {
homeRandom.isGone = true homeRandom.isGone = true
homeRandomButtonTv.isGone = true
} }
} }
@ -884,4 +892,44 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
} }
}*/ }*/
} }
private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) {
// Only apply custom behavior on TV interface
if (!isLayout(TV)) {
helper.runDefault()
return
}
val currentFocus = activity?.currentFocus ?: run {
helper.runDefault()
return
}
// isInsideRecycle is true when focus is inside home_master_recycler
var parent = currentFocus.parent
var isInsideRecycler = false
while (parent != null) {
if (parent is View && parent.id == R.id.home_master_recycler) {
isInsideRecycler = true
break
}
parent = parent.parent
}
when {
// Case 1: Focus is within plugin content -> Move to plugin selector
isInsideRecycler -> {
binding?.homeMasterRecycler?.scrollToPosition(0)
// Defer focus request until after scroll ends
binding?.homeChangeApi?.post {
binding?.homeChangeApi?.requestFocus()
}
}
// Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation
currentFocus.id == R.id.home_change_api ||
currentFocus.id == R.id.home_preview_reload_provider ||
currentFocus.id == R.id.home_preview_search_button -> {
activity?.findViewById<View>(R.id.navigation_home)?.requestFocus()
}
// Case 3: Any other location -> Use default back behavior
else -> helper.runDefault()
}
}
} }

View file

@ -6,10 +6,8 @@ import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.databinding.HomepageParentBinding
@ -17,9 +15,11 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -48,7 +48,7 @@ open class ParentItemAdapter(
) { ) {
companion object { companion object {
val sharedPool = val sharedPool =
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 4) } newSharedPool { setMaxRecycledViews(CONTENT, 4) }
} }
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) { data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {

View file

@ -60,9 +60,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.populateChips
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import com.lagradost.cloudstream3.ui.setRecycledViewPool
class HomeParentItemAdapterPreview( class HomeParentItemAdapterPreview(
val fragment: LifecycleOwner,
private val viewModel: HomeViewModel, private val viewModel: HomeViewModel,
private val accountViewModel: AccountViewModel private val accountViewModel: AccountViewModel
) : ParentItemAdapter( ) : ParentItemAdapter(
@ -104,7 +104,7 @@ class HomeParentItemAdapterPreview(
) )
} }
return HeaderViewHolder(binding, viewModel, accountViewModel, fragment) return HeaderViewHolder(binding, viewModel, accountViewModel)
} }
override fun onBindHeader(holder: ViewHolderState<Bundle>) { override fun onBindHeader(holder: ViewHolderState<Bundle>) {
@ -131,7 +131,6 @@ class HomeParentItemAdapterPreview(
val binding: ViewBinding, val binding: ViewBinding,
val viewModel: HomeViewModel, val viewModel: HomeViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
fragment: LifecycleOwner,
) : ) :
ViewHolderState<Bundle>(binding) { ViewHolderState<Bundle>(binding) {
@ -543,7 +542,7 @@ class HomeParentItemAdapterPreview(
headProfilePicCard?.isGone = isLayout(TV or EMULATOR) headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
fragment.observe(viewModel.currentAccount) { currentAccount -> (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
headProfilePic?.loadImage(currentAccount?.image) headProfilePic?.loadImage(currentAccount?.image)
alternateHeadProfilePic?.loadImage(currentAccount?.image) alternateHeadProfilePic?.loadImage(currentAccount?.image)
} }
@ -774,7 +773,7 @@ class HomeParentItemAdapterPreview(
fun onViewAttachedToWindow() { fun onViewAttachedToWindow() {
previewViewpager.registerOnPageChangeCallback(previewCallback) previewViewpager.registerOnPageChangeCallback(previewCallback)
binding.root.findViewTreeLifecycleOwner()?.apply { previewViewpager.apply {
observe(viewModel.preview) { observe(viewModel.preview) {
updatePreview(it) updatePreview(it)
} }
@ -799,7 +798,7 @@ class HomeParentItemAdapterPreview(
} }
toggleListHolder?.isGone = visible.isEmpty() toggleListHolder?.isGone = visible.isEmpty()
} }
} ?: debugException { "Expected findViewTreeLifecycleOwner" } }
} }
} }
} }

View file

@ -9,12 +9,12 @@ import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
@ -40,6 +40,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
@ -49,13 +50,12 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import kotlin.collections.set
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
companion object { companion object {
@ -67,11 +67,26 @@ class HomeViewModel : ViewModel() {
} }
val resumeWatchingResult = withContext(Dispatchers.IO) { val resumeWatchingResult = withContext(Dispatchers.IO) {
resumeWatching?.mapNotNull { resume -> resumeWatching?.mapNotNull { resume ->
val headerCache = getKey<DownloadObjects.DownloadHeaderCached>(
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
resume.parentId.toString() resume.parentId.toString()
) ?: return@mapNotNull null )
val data = if (headerCache == null) {
// We store resume watching data in download header cache
// Because downloads automatically pruned outdated download headers we
// removed resume watching data. We should restore the data for affected users.
val oldData = getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE_BACKUP,
resume.parentId.toString()
) ?: return@mapNotNull null
// Restore data
setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData)
oldData
} else {
headerCache
}
val watchPos = getViewPos(resume.episodeId) val watchPos = getViewPos(resume.episodeId)
@ -118,7 +133,7 @@ class HomeViewModel : ViewModel() {
private var currentShuffledList: List<SearchResponse> = listOf() private var currentShuffledList: List<SearchResponse> = listOf()
private fun autoloadRepo(): APIRepository { private fun autoloadRepo(): APIRepository {
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) return APIRepository(apis.withLock { apis.first { it.hasMainPage } })
} }
private val _availableWatchStatusTypes = private val _availableWatchStatusTypes =
@ -501,9 +516,6 @@ class HomeViewModel : ViewModel() {
return@ioSafe return@ioSafe
} }
HomeChildItemAdapter.sharedPool.clear()
ParentItemAdapter.sharedPool.clear()
val api = getApiFromNameNull(preferredApiName) val api = getApiFromNameNull(preferredApiName)
if (preferredApiName == noneApi.name) { if (preferredApiName == noneApi.name) {
// just set to random // just set to random
@ -523,7 +535,7 @@ class HomeViewModel : ViewModel() {
} else if (api == null) { } else if (api == null) {
// API is not found aka not loaded or removed, post the loading // API is not found aka not loaded or removed, post the loading
// progress if waiting for plugins, otherwise nothing // progress if waiting for plugins, otherwise nothing
if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) {
loadAndCancel(noneApi) loadAndCancel(noneApi)
} else { } else {
_page.postValue(Resource.Loading()) _page.postValue(Resource.Loading())

View file

@ -80,8 +80,6 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
) { ) {
companion object { companion object {
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
fun newInstance() = LibraryFragment() fun newInstance() = LibraryFragment()
/** /**
@ -104,14 +102,19 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
private fun updateRandom(binding: FragmentLibraryBinding) { private fun updateRandomVisibility(binding: FragmentLibraryBinding) {
if (!toggleRandomButton) {
binding.libraryRandom.isGone = true
binding.libraryRandomButtonTv.isGone = true
return
}
val position = libraryViewModel.currentPage.value ?: 0 val position = libraryViewModel.currentPage.value ?: 0
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
if (toggleRandomButton) { val hasItems = pages[position].items.isNotEmpty()
listLibraryItems.clear() val isPhone = isLayout(PHONE)
listLibraryItems.addAll(pages[position].items)
binding.libraryRandom.isVisible = listLibraryItems.isNotEmpty() binding.libraryRandom.isVisible = isPhone && hasItems
} else binding.libraryRandom.isGone = true binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems
} }
override fun fixLayout(view: View) { override fun fixLayout(view: View) {
@ -194,17 +197,9 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
settingsManager.getBoolean( settingsManager.getBoolean(
getString(R.string.random_button_key), getString(R.string.random_button_key),
false false
) && isLayout(PHONE) )
binding.libraryRandom.visibility = View.GONE binding.libraryRandom.visibility = View.GONE
} binding.libraryRandomButtonTv.visibility = View.GONE
binding.libraryRandom.setOnClickListener {
if (listLibraryItems.isNotEmpty()) {
val listLibraryItem = listLibraryItems.random()
libraryViewModel.currentSyncApi?.syncIdName?.let {
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
}
}
} }
/** /**
@ -215,14 +210,13 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
syncId: SyncIdName, syncId: SyncIdName,
apiName: String? = null, apiName: String? = null,
) { ) {
val availableProviders = synchronized(allProviders) { val availableProviders = allProviders.filter {
allProviders.filter { it.supportedSyncNames.contains(syncId)
it.supportedSyncNames.contains(syncId) }.map { it.name } +
}.map { it.name } + // Add the api if it exists
// Add the api if it exists (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList())
?: emptyList())
}
val baseOptions = listOf( val baseOptions = listOf(
LibraryOpenerType.Default, LibraryOpenerType.Default,
LibraryOpenerType.None, LibraryOpenerType.None,
@ -387,7 +381,19 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
binding.searchBar.setExpanded(true) binding.searchBar.setExpanded(true)
} }
updateRandom(binding) // Set up random button click listener
if (toggleRandomButton) {
val randomClickListener = View.OnClickListener {
val position = libraryViewModel.currentPage.value ?: 0
val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener
pages[position].items.randomOrNull()?.let { item ->
loadLibraryItem(syncIdName, item.syncId, item)
}
}
libraryRandom.setOnClickListener(randomClickListener)
libraryRandomButtonTv.setOnClickListener(randomClickListener)
}
updateRandomVisibility(binding)
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
// Without this there would be a flashing effect: // Without this there would be a flashing effect:
@ -466,7 +472,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
} }
observe(libraryViewModel.currentPage) { position -> observe(libraryViewModel.currentPage) { position ->
updateRandom(binding) updateRandomVisibility(binding)
val all = binding.viewpager.allViews.toList() val all = binding.viewpager.allViews.toList()
.filterIsInstance<AutofitRecyclerView>() .filterIsInstance<AutofitRecyclerView>()

View file

@ -1,64 +1,16 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.AnimatedVectorDrawable
import android.media.metrics.PlaybackErrorEvent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.media3.common.PlaybackException
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar import androidx.viewbinding.ViewBinding
import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.github.rubensousa.previewseekbar.PreviewBar
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import java.net.SocketTimeoutException
enum class PlayerResize(@StringRes val nameRes: Int) { enum class PlayerResize(@StringRes val nameRes: Int) {
Fit(R.string.resize_fit), Fit(R.string.resize_fit),
@ -79,677 +31,131 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
abstract class AbstractPlayerFragment( abstract class AbstractPlayerFragment<T : ViewBinding>(
var player: IPlayer = CS3IPlayer() bindingCreator: BindingCreator<T>
) : Fragment() { ) : BaseFragment<T>(bindingCreator), PlayerView.Callbacks {
var resizeMode: Int = 0
var subView: SubtitleView? = null
protected open var hasPipModeSupport = true
var playerPausePlayHolderHolder: FrameLayout? = null // Stored pre-initialization so subclasses can set them before onBindingCreated.
var playerPausePlay: ImageView? = null private var _player: IPlayer = CS3IPlayer()
var playerBuffering: ProgressBar? = null
var playerView: PlayerView? = null
var piphide: FrameLayout? = null
var subtitleHolder: FrameLayout? = null
var currentPlayerStatus = CSPlayerLoading.IsBuffering
@LayoutRes /** The shared [PlayerView] host that owns all player state and view references. */
protected open var layout: Int = R.layout.fragment_player protected var playerHostView: PlayerView? = null
open fun nextEpisode() { var player: IPlayer
throw NotImplementedError() get() = playerHostView?.player ?: _player
} set(value) {
_player = value
open fun prevEpisode() { playerHostView?.player = value
throw NotImplementedError()
}
open fun playerPositionChanged(position: Long, duration: Long) {
throw NotImplementedError()
}
open fun playerStatusChanged() {}
open fun playerDimensionsLoaded(width: Int, height: Int) {
throw NotImplementedError()
}
open fun subtitlesChanged() {
throw NotImplementedError()
}
open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
throw NotImplementedError()
}
open fun onTracksInfoChanged() {
throw NotImplementedError()
}
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
}
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
}
open fun exitedPipMode() {
throw NotImplementedError()
}
private fun keepScreenOn(on: Boolean) {
if (on) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
private fun updateIsPlaying(
wasPlaying: CSPlayerLoading,
isPlaying: CSPlayerLoading
) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
currentPlayerStatus = isPlaying
keepScreenOn(!isPausedRightNow)
val isBuffering = CSPlayerLoading.IsBuffering == isPlaying
if (isBuffering) {
playerPausePlayHolderHolder?.isVisible = false
playerBuffering?.isVisible = true
} else {
playerPausePlayHolderHolder?.isVisible = true
playerBuffering?.isVisible = false
if(isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)){
playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24)
} else if (wasPlaying != isPlaying) {
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
val drawable = playerPausePlay?.drawable
var startedAnimation = false
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
if (drawable is AnimatedImageDrawable) {
drawable.start()
startedAnimation = true
}
}
if (drawable is AnimatedVectorDrawable) {
drawable.start()
startedAnimation = true
}
if (drawable is AnimatedVectorDrawableCompat) {
drawable.start()
startedAnimation = true
}
// somehow the phone is wacked
if (!startedAnimation) {
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
}
} else {
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
}
} }
PlayerPipHelper.updatePIPModeActions( val subView: SubtitleView? get() = playerHostView?.subView
activity, val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
isPlaying,
hasPipModeSupport, /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
player.getAspectRatio() val playerView: androidx.media3.ui.PlayerView?
) get() = playerHostView?.exoPlayerView
var currentPlayerStatus: CSPlayerLoading
get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering
set(value) { playerHostView?.currentPlayerStatus = value }
protected var mMediaSession: MediaSession?
get() = playerHostView?.mMediaSession
set(value) { playerHostView?.mMediaSession = value }
// No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as
// open so subclasses can override only what they need. The ones below throw
// to make it obvious when an implementation is missing.
override fun nextEpisode() {
throw NotImplementedError()
}
override fun prevEpisode() {
throw NotImplementedError()
}
override fun playerPositionChanged(position: Long, duration: Long) {
throw NotImplementedError()
}
override fun playerDimensionsLoaded(width: Int, height: Int) {
throw NotImplementedError()
}
override fun subtitlesChanged() {
throw NotImplementedError()
}
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
throw NotImplementedError()
}
override fun onTracksInfoChanged() {
throw NotImplementedError()
}
override fun exitedPipMode() {
throw NotImplementedError()
}
override fun hasNextMirror(): Boolean {
throw NotImplementedError()
}
override fun nextMirror() {
throw NotImplementedError()
}
/** Delegates to [PlayerView.playerError] by default; override to customize. */
override fun playerError(exception: Throwable) {
playerHostView?.playerError(exception)
}
/** Player fragments don't need system-bar padding adjustment by default. */
override fun fixLayout(view: View) = Unit
override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
val ctx = context ?: return
playerHostView = PlayerView(ctx)
playerHostView?.player = _player
playerHostView?.callbacks = this
playerHostView?.bindViews(binding.root)
playerHostView?.initialize()
} }
private var pipReceiver: BroadcastReceiver? = null
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode) super.onPictureInPictureModeChanged(isInPictureInPictureMode)
try { playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity)
isInPIPMode = isInPictureInPictureMode
if (isInPictureInPictureMode) {
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
piphide?.isVisible = false
pipReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
if (ACTION_MEDIA_CONTROL != intent.action) {
return
}
player.handleEvent(
CSPlayerEvent.entries[intent.getIntExtra(
EXTRA_CONTROL_TYPE,
0
)], source = PlayerEventSource.UI
)
}
}
val filter = IntentFilter()
filter.addAction(ACTION_MEDIA_CONTROL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
@SuppressLint("UnspecifiedRegisterReceiverFlag")
activity?.registerReceiver(pipReceiver, filter)
}
val isPlaying = player.getIsPlaying()
val isPlayingValue =
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
updateIsPlaying(isPlayingValue, isPlayingValue)
} else {
// Restore the full-screen UI.
piphide?.isVisible = true
exitedPipMode()
pipReceiver?.let {
// Prevents java.lang.IllegalArgumentException: Receiver not registered
safe {
activity?.unregisterReceiver(it)
}
}
activity?.hideSystemUI()
this.view?.let { UIHelper.hideKeyboard(it) }
}
} catch (e: Exception) {
logError(e)
}
}
open fun hasNextMirror(): Boolean {
throw NotImplementedError()
}
open fun nextMirror() {
throw NotImplementedError()
}
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
}
}
open fun playerError(exception: Throwable) {
fun showToast(message: String, gotoNext: Boolean = false) {
if (gotoNext && hasNextMirror()) {
showToast(
message,
Toast.LENGTH_SHORT
)
nextMirror()
} else {
showToast(
context?.getString(R.string.no_links_found_toast) + "\n" + message,
Toast.LENGTH_LONG
)
activity?.popCurrentPage()
}
}
val ctx = context ?: return
when (exception) {
is PlaybackException -> {
val msg = exception.message ?: ""
val errorName = exception.errorCodeName
when (val code = exception.errorCode) {
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> {
showToast(
"${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_REMOTE_ERROR,
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_TIMEOUT,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> {
showToast(
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
PlaybackException.ERROR_CODE_DECODING_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> {
showToast(
"${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> {
showToast(
"${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> {
showToast(
"${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
else -> {
showToast(
"${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg",
gotoNext = false
)
}
}
}
is InvalidFileException -> {
showToast(
"${ctx.getString(R.string.source_error)}\n${exception.message}",
gotoNext = true
)
}
is SocketTimeoutException -> {
/**
* Ensures this is run on the UI thread to prevent issues
* caused by SocketTimeoutException in torrents. Running
* on another thread can break player interactions or
* prevent switching to the next source.
*/
activity?.runOnUiThread {
showToast(
"${ctx.getString(R.string.remote_error)}\n${exception.message}",
gotoNext = true
)
}
}
is ErrorLoadingException -> {
exception.message?.let {
showToast(
it,
gotoNext = true
)
} ?: showToast(
exception.toString(),
gotoNext = true
)
}
else -> {
exception.message?.let {
showToast(
it,
gotoNext = false
)
} ?: showToast(
exception.toString(),
gotoNext = false
)
}
}
}
private fun onSubStyleChanged(style: SaveCaptionStyle) {
player.updateSubtitleStyle(style)
// Forcefully update the subtitle encoding in case the edge size is changed
player.seekTime(-1)
}
@SuppressLint("UnsafeOptInUsageError")
open fun playerUpdated(player: Any?) {
if (player is ExoPlayer) {
context?.let { ctx ->
mMediaSession?.release()
mMediaSession = MediaSession.Builder(ctx, player)
// Ensure unique ID for concurrent players
.setId(System.currentTimeMillis().toString())
.build()
}
// Necessary for multiple combined videos
@Suppress("DEPRECATION")
playerView?.setShowMultiWindowTimeBar(true)
playerView?.player = player
playerView?.performClick()
}
}
protected var mMediaSession: MediaSession? = null
// this can be used in the future for players other than exoplayer
//private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
// override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
// val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent?
// if (keyEvent != null) {
// if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP
// val consumed = when (keyEvent.keyCode) {
// KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause()
// KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay()
// KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop()
// KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext()
// else -> false
// }
// if (consumed) return true
// }
// }
//
// return super.onMediaButtonEvent(mediaButtonEvent)
// }
//}
open fun onDownload(event: DownloadEvent) = Unit
/** This receives the events from the player, if you want to append functionality you do it here,
* do note that this only receives events for UI changes,
* and returning early WONT stop it from changing in eg the player time or pause status */
open fun mainCallback(event: PlayerEvent) {
// we don't want to spam DownloadEvent
if (event !is DownloadEvent) {
Log.i(TAG, "Handle event: $event")
}
when (event) {
is DownloadEvent -> {
onDownload(event)
}
is ResizedEvent -> {
playerDimensionsLoaded(event.width, event.height)
}
is PlayerAttachedEvent -> {
playerUpdated(event.player)
}
is SubtitlesUpdatedEvent -> {
subtitlesChanged()
}
is TimestampSkippedEvent -> {
onTimestampSkipped(event.timestamp)
}
is TimestampInvokedEvent -> {
onTimestamp(event.timestamp)
}
is TracksChangedEvent -> {
onTracksInfoChanged()
}
is EmbeddedSubtitlesFetchedEvent -> {
embeddedSubtitlesFetched(event.tracks)
}
is ErrorEvent -> {
playerError(event.error)
}
is RequestAudioFocusEvent -> {
requestAudioFocus()
}
is EpisodeSeekEvent -> {
when (event.offset) {
-1 -> prevEpisode()
1 -> nextEpisode()
else -> {}
}
}
is StatusEvent -> {
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
playerStatusChanged()
}
is PositionEvent -> {
playerPositionChanged(position = event.toMs, duration = event.durationMs)
}
is VideoEndedEvent -> {
context?.let { ctx ->
// Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(ctx)
?.getBoolean(
ctx.getString(R.string.autoplay_next_key),
true
) == true
) {
player.handleEvent(
CSPlayerEvent.NextEpisode,
source = PlayerEventSource.Player
)
}
}
}
is PauseEvent -> Unit
is PlayEvent -> Unit
}
}
@SuppressLint("SetTextI18n", "UnsafeOptInUsageError")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
resizeMode = DataStoreHelper.resizeMode
resize(resizeMode, false)
player.releaseCallbacks()
player.initCallbacks(
eventHandler = ::mainCallback,
requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE,
),
)
val player = player
if (player is CS3IPlayer) {
// preview bar
val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress)
val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView)
val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout)
if (progressBar != null && previewImageView != null && previewFrameLayout != null) {
var resume = false
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
override fun onScrubStart(previewBar: PreviewBar?) {
val hasPreview = player.hasPreview()
progressBar.isPreviewEnabled = hasPreview
resume = player.getIsPlaying()
if (resume) player.handleEvent(
CSPlayerEvent.Pause,
PlayerEventSource.Player
)
// No clashing UI
if (hasPreview) {
subView?.isVisible = false
}
}
override fun onScrubMove(
previewBar: PreviewBar?,
progress: Int,
fromUser: Boolean
) {
}
override fun onScrubStop(previewBar: PreviewBar?) {
if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
// Delay to prevent the small flicker of subtitle before seeking
subView?.postDelayed({
// If we are not scrubbing then show subtitles again
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
subView?.isVisible = true
}
}, 200)
}
})
progressBar.attachPreviewView(previewFrameLayout)
progressBar.setPreviewLoader { currentPosition, max ->
val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat()))
previewImageView.isGone = bitmap == null
previewImageView.setImageBitmap(bitmap)
}
}
subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
player.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth)
/*previewImageView?.doOnLayout {
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams(
it.measuredWidth,
it.measuredHeight
)
}*/
/** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
* and once by the UI even if it should only be registered once by the UI */
playerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
?.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
if (canceled) return
val playerDuration = player.getDuration() ?: return
val playerPosition = player.getPosition() ?: return
mainCallback(
PositionEvent(
source = PlayerEventSource.UI,
durationMs = playerDuration,
fromMs = playerPosition,
toMs = position
)
)
}
})
SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged
try {
context?.let { ctx ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(
ctx
)
val currentPrefCacheSize =
settingsManager.getInt(getString(R.string.video_buffer_size_key), 0)
val currentPrefDiskSize =
settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0)
val currentPrefBufferSec =
settingsManager.getInt(getString(R.string.video_buffer_length_key), 0)
player.cacheSize = currentPrefCacheSize * 1024L * 1024L
player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L
player.videoBufferMs = currentPrefBufferSec * 1000L
}
} catch (e: Exception) {
logError(e)
}
}
/*context?.let { ctx ->
player.loadPlayer(
ctx,
false,
ExtractorLink(
"idk",
"bunny",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
"",
Qualities.P720.value,
false
),
)
}*/
} }
override fun onDestroy() { override fun onDestroy() {
player.release() playerHostView?.release()
player.releaseCallbacks()
player = CS3IPlayer()
playerEventListener = null
keyEventListener = null
PlayerPipHelper.updatePIPModeActions(activity, CSPlayerLoading.IsPaused, false, null)
mMediaSession?.release()
mMediaSession = null
playerView?.player = null
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
keepScreenOn(false)
super.onDestroy() super.onDestroy()
} }
fun nextResize() { override fun onPause() {
resizeMode = (resizeMode + 1) % PlayerResize.entries.size playerHostView?.releaseKeyEventListener()
resize(resizeMode, true) super.onPause()
}
fun resize(resize: Int, showToast: Boolean) {
resize(PlayerResize.entries[resize], showToast)
}
@SuppressLint("UnsafeOptInUsageError")
open fun resize(resize: PlayerResize, showToast: Boolean) {
DataStoreHelper.resizeMode = resize.ordinal
val type = when (resize) {
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
playerView?.resizeMode = type
if (showToast)
showToast(resize.nameRes, Toast.LENGTH_SHORT)
} }
override fun onStop() { override fun onStop() {
player.onStop() playerHostView?.onStop()
super.onStop() super.onStop()
} }
override fun onResume() { override fun onResume() {
context?.let { ctx -> context?.let { ctx ->
player.onResume(ctx) playerHostView?.onResume(ctx)
} }
super.onResume() super.onResume()
} }
override fun onCreateView( fun nextResize() {
inflater: LayoutInflater, playerHostView?.nextResize()
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val root = inflater.inflate(layout, container, false)
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
playerPausePlay = root.findViewById(R.id.player_pause_play)
playerBuffering = root.findViewById(R.id.player_buffering)
playerView = root.findViewById(R.id.player_view)
piphide = root.findViewById(R.id.piphide)
subtitleHolder = root.findViewById(R.id.subtitle_holder)
return root
} }
}
open fun resize(resize: PlayerResize, showToast: Boolean) {
playerHostView?.resize(resize, showToast)
}
}

View file

@ -12,9 +12,11 @@ import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TIME_UNSET
import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_AUDIO
import androidx.media3.common.C.TRACK_TYPE_TEXT import androidx.media3.common.C.TRACK_TYPE_TEXT
@ -28,6 +30,7 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize import androidx.media3.common.VideoSize
// import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
@ -39,23 +42,28 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_ENABLED
import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.Renderer.STATE_STARTED
import androidx.media3.exoplayer.SeekParameters import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker
import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ClippingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 import androidx.media3.exoplayer.source.ConcatenatingMediaSource2
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.text.TextRenderer
@ -65,6 +73,7 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AudioFile
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
@ -73,43 +82,43 @@ import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.AudioFile
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.player.live.LiveHelper
import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle
import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.DrmExtractorLink
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.CLEARKEY_UUID
import com.lagradost.cloudstream3.utils.WIDEVINE_UUID
import com.lagradost.cloudstream3.utils.PLAYREADY_UUID
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import okhttp3.Interceptor
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import java.io.File import java.io.File
import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import java.util.concurrent.Executors import java.util.concurrent.Executors
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSession import javax.net.ssl.SSLSession
import kotlin.collections.HashSet import kotlin.uuid.toJavaUuid
import kotlin.text.StringBuilder
import androidx.core.net.toUri
import okhttp3.Interceptor
const val TAG = "CS3ExoPlayer" const val TAG = "CS3ExoPlayer"
const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language"
@ -199,16 +208,14 @@ class CS3IPlayer : IPlayer {
private var requestedListeningPercentages: List<Int>? = null private var requestedListeningPercentages: List<Int>? = null
private var eventHandler: ((PlayerEvent) -> Unit)? = null private var eventHandler: ((PlayerEvent) -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper())
@AnyThread
fun event(event: PlayerEvent) { fun event(event: PlayerEvent) {
// Ensure that all work is done on the main looper, aka main thread // Ensure that all work is done on the main thread.
if (Looper.myLooper() == mainHandler.looper) { if (Looper.getMainLooper().isCurrentThread) {
eventHandler?.invoke(event)
} else runOnMainThread {
eventHandler?.invoke(event) eventHandler?.invoke(event)
} else {
mainHandler.post {
eventHandler?.invoke(event)
}
} }
} }
@ -228,8 +235,9 @@ class CS3IPlayer : IPlayer {
} }
} }
@AnyThread
override fun initCallbacks( override fun initCallbacks(
eventHandler: ((PlayerEvent) -> Unit), @MainThread eventHandler: ((PlayerEvent) -> Unit),
requestedListeningPercentages: List<Int>?, requestedListeningPercentages: List<Int>?,
) { ) {
this.requestedListeningPercentages = requestedListeningPercentages this.requestedListeningPercentages = requestedListeningPercentages
@ -240,23 +248,6 @@ class CS3IPlayer : IPlayer {
} }
} }
// I know, this is not a perfect solution, however it works for fixing subs
private fun reloadSubs() {
exoPlayer?.applicationLooper?.let {
try {
Handler(it).post {
try {
seekTime(1L, source = PlayerEventSource.Player)
} catch (e: Exception) {
logError(e)
}
}
} catch (e: Exception) {
logError(e)
}
}
}
fun String.stripTrackId(): String { fun String.stripTrackId(): String {
return this.replace(Regex("""^\d+:"""), "") return this.replace(Regex("""^\d+:"""), "")
} }
@ -270,6 +261,10 @@ class CS3IPlayer : IPlayer {
} }
override fun hasPreview(): Boolean { override fun hasPreview(): Boolean {
// No previews on livestreams because the previews get outdated
if (exoPlayer?.isCurrentMediaItemDynamic == true) {
return false
}
return imageGenerator.hasPreview() return imageGenerator.hasPreview()
} }
@ -383,44 +378,47 @@ class CS3IPlayer : IPlayer {
?: return ?: return
} }
override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) {
preferredAudioTrackLanguage = trackLanguage preferredAudioTrackLanguage = trackLanguage
id?.let { trackId ->
if (id != null) { val trackFormatIndex = formatIndex ?: 0
val audioTrack = exoPlayer?.currentTracks?.groups
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } ?.filter { it.type == TRACK_TYPE_AUDIO }
?.getTrack(id) ?.find { group ->
group.getFormats().any { (format, _) ->
if (audioTrack != null) { format.id == trackId
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters }
?.buildUpon() }
?.setOverrideForType( ?.let { group ->
TrackSelectionOverride( exoPlayer?.trackSelectionParameters
audioTrack.first, ?.buildUpon()
audioTrack.second ?.setOverrideForType(
TrackSelectionOverride(
group.mediaTrackGroup,
trackFormatIndex
)
) )
) ?.build()
?.build() }
?: return ?.let { newParams ->
return exoPlayer?.trackSelectionParameters = newParams
} return
}
} }
// Fallback to language-based selection
exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters
?.buildUpon() ?.buildUpon()
?.setPreferredAudioLanguage(trackLanguage) ?.setPreferredAudioLanguage(trackLanguage)
?.build() ?.build() ?: return
?: return
} }
/** /**
* Gets all supported formats in a list * Gets all supported formats in a list
* */ * */
private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> { private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> {
return this.map { return this.flatMap {
it.getFormats() it.getFormats()
}.flatten() }
} }
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> { private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
@ -431,11 +429,14 @@ class CS3IPlayer : IPlayer {
} }
} }
private fun Format.toAudioTrack(): AudioTrack { private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack {
return AudioTrack( return AudioTrack(
this.id?.stripTrackId(), this.id,
this.label, this.label,
this.language this.language,
this.sampleMimeType,
this.channelCount,
formatIndex ?: 0,
) )
} }
@ -444,7 +445,7 @@ class CS3IPlayer : IPlayer {
this.id?.stripTrackId(), this.id?.stripTrackId(),
this.label, this.label,
this.language, this.language,
this.sampleMimeType this.sampleMimeType,
) )
} }
@ -455,27 +456,35 @@ class CS3IPlayer : IPlayer {
this.language, this.language,
this.width, this.width,
this.height, this.height,
this.sampleMimeType
) )
} }
override fun getVideoTracks(): CurrentTracks { override fun getVideoTracks(): CurrentTracks {
val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList()
val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO }
.getFormats() .getFormats()
.map { it.first.toVideoTrack() } .map { it.first.toVideoTrack() }
val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() var currentAudioTrack: AudioTrack? = null
.map { it.first.toAudioTrack() } val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO }
.flatMap { group ->
val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats() group.getFormats().map { (format, formatIndex) ->
val audioTrack = format.toAudioTrack(formatIndex)
if (group.isTrackSelected(formatIndex)) {
currentAudioTrack = audioTrack
}
audioTrack
}
}
val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT }
.getFormats()
.map { it.first.toSubtitleTrack() } .map { it.first.toSubtitleTrack() }
val currentTextTracks = textTracks.filter { track -> val currentTextTracks = textTracks.filter { track ->
playerSelectedSubtitleTracks.any { it.second && it.first == track.id } playerSelectedSubtitleTracks.any { it.second && it.first == track.id }
} }
return CurrentTracks( return CurrentTracks(
exoPlayer?.videoFormat?.toVideoTrack(), exoPlayer?.videoFormat?.toVideoTrack(),
exoPlayer?.audioFormat?.toAudioTrack(), currentAudioTrack,
currentTextTracks, currentTextTracks,
videoTracks, videoTracks,
audioTracks, audioTracks,
@ -489,60 +498,43 @@ class CS3IPlayer : IPlayer {
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
Log.i(TAG, "setPreferredSubtitles init $subtitle") Log.i(TAG, "setPreferredSubtitles init $subtitle")
currentSubtitles = subtitle currentSubtitles = subtitle
val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false
// Disable subtitles if null
if (subtitle == null) {
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
.clearOverridesOfType(TRACK_TYPE_TEXT)
)
return false
}
// Handle subtitle based on status
when (subtitleHelper.subtitleStatus(subtitle)) {
SubtitleStatus.REQUIRES_RELOAD -> {
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
return true
}
fun getTextTrack(id: String) = SubtitleStatus.NOT_FOUND -> {
exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT } Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
?.getTrack(id) return true
}
return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector ->
if (subtitle == null) {
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
.clearOverridesOfType(TRACK_TYPE_TEXT)
)
} else {
when (subtitleHelper.subtitleStatus(subtitle)) {
SubtitleStatus.REQUIRES_RELOAD -> {
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
return@let true
}
SubtitleStatus.IS_ACTIVE -> {
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
SubtitleStatus.IS_ACTIVE -> {
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
exoPlayer?.currentTracks?.groups
?.filter { it.type == TRACK_TYPE_TEXT }
?.getTrack(subtitle.getId())
?.let { (trackGroup, trackIndex) ->
trackSelector.setParameters( trackSelector.setParameters(
trackSelector.buildUponParameters() trackSelector.buildUponParameters()
.apply { .setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
val track = getTextTrack(subtitle.getId()) .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex))
if (track != null) {
setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
setOverrideForType(
TrackSelectionOverride(
track.first,
track.second
)
)
}
}
) )
// ugliest code I have written, it seeks 1ms to *update* the subtitles
//exoPlayer?.applicationLooper?.let {
// Handler(it).postDelayed({
// seekTime(1L)
// }, 1)
//}
} }
return false
SubtitleStatus.NOT_FOUND -> {
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
return@let true
}
}
} }
return false }
} ?: false
} }
private var currentSubtitleOffset: Long = 0 private var currentSubtitleOffset: Long = 0
@ -551,10 +543,10 @@ class CS3IPlayer : IPlayer {
currentSubtitleOffset = offset currentSubtitleOffset = offset
CustomDecoder.subtitleOffset = offset CustomDecoder.subtitleOffset = offset
if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) {
exoPlayer?.currentPosition?.let { pos -> exoPlayer?.currentPosition?.also { pos ->
// This seems to properly refresh all subtitles // This seems to properly refresh all subtitles
// It needs to be done as all subtitle cues with timings are pre-processed // It needs to be done as all subtitle cues with timings are pre-processed
currentTextRenderer?.resetPosition(pos) currentTextRenderer?.resetPosition(pos, false)
} }
} }
} }
@ -742,13 +734,23 @@ class CS3IPlayer : IPlayer {
private var simpleCache: SimpleCache? = null private var simpleCache: SimpleCache? = null
/// Create a small factory for small things, no cache, no cronet /// Create a small factory for small things, no cache, no cronet
private fun createOnlineSource(headers: Map<String, String>?): HttpDataSource.Factory { private fun createOnlineSource(
val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) headers: Map<String, String>?,
return source.apply { interceptor: Interceptor?
if (!headers.isNullOrEmpty()) { ): HttpDataSource.Factory {
setDefaultRequestProperties(headers) val client = if (interceptor == null) {
} app.baseClient
} else {
app.baseClient.newBuilder()
.addInterceptor(interceptor)
.build()
} }
val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT)
if (!headers.isNullOrEmpty()) {
source.setDefaultRequestProperties(headers)
}
return source
} }
fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? {
@ -787,10 +789,9 @@ class CS3IPlayer : IPlayer {
private fun createVideoSource( private fun createVideoSource(
link: ExtractorLink, link: ExtractorLink,
engine: CronetEngine? engine: CronetEngine?,
interceptor: Interceptor?,
): HttpDataSource.Factory { ): HttpDataSource.Factory {
val provider = getApiFromNameNull(link.source)
val interceptor: Interceptor? = provider?.getVideoInterceptor(link)
val userAgent = link.headers.entries.find { val userAgent = link.headers.entries.find {
it.key.equals("User-Agent", ignoreCase = true) it.key.equals("User-Agent", ignoreCase = true)
}?.value ?: USER_AGENT }?.value ?: USER_AGENT
@ -822,14 +823,7 @@ class CS3IPlayer : IPlayer {
// These are extra headers the browser like to insert, not sure if we want to include them // These are extra headers the browser like to insert, not sure if we want to include them
// for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue.
val headers = mapOf( val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization
"accept" to "*/*",
"sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
"sec-ch-ua-mobile" to "?0",
"sec-fetch-user" to "?1",
"sec-fetch-mode" to "navigate",
"sec-fetch-dest" to "video"
) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization
return source.apply { return source.apply {
setDefaultRequestProperties(headers) setDefaultRequestProperties(headers)
@ -889,10 +883,10 @@ class CS3IPlayer : IPlayer {
private var currentTextRenderer: TextRenderer? = null private var currentTextRenderer: TextRenderer? = null
} }
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? {
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
for (lastTimeStamp in lastTimeStamps) { for (lastTimeStamp in lastTimeStamps) {
if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) {
return lastTimeStamp return lastTimeStamp
} }
} }
@ -951,6 +945,22 @@ class CS3IPlayer : IPlayer {
when (event) { when (event) {
CSPlayerEvent.Play -> { CSPlayerEvent.Play -> {
event(PlayEvent(source)) event(PlayEvent(source))
// If the player was stopped (e.g. notification dismissed) it lands in
// STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and
// then resume to the current position once we are in STATE_READY again.
if (playbackState == Player.STATE_IDLE) {
val seekPosition = currentPosition
exoPlayer?.addListener(object : Player.Listener {
private var seekApplied = false
override fun onPlaybackStateChanged(playbackState: Int) {
if (seekApplied || playbackState != Player.STATE_READY) return
seekApplied = true
exoPlayer?.seekTo(currentWindow, seekPosition)
exoPlayer?.removeListener(this)
}
})
prepare()
}
play() play()
} }
@ -1004,7 +1014,7 @@ class CS3IPlayer : IPlayer {
if (lastTimeStamp.skipToNextEpisode) { if (lastTimeStamp.skipToNextEpisode) {
handleEvent(CSPlayerEvent.NextEpisode, source) handleEvent(CSPlayerEvent.NextEpisode, source)
} else { } else {
seekTo(lastTimeStamp.endMs + 1L) seekTo(lastTimeStamp.timestamp.endMs + 1L)
} }
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
} }
@ -1073,28 +1083,44 @@ class CS3IPlayer : IPlayer {
): ExoPlayer { ): ExoPlayer {
val exoPlayerBuilder = val exoPlayerBuilder =
ExoPlayer.Builder(context) ExoPlayer.Builder(context)
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> .setMediaSourceFactory(
DefaultMediaSourceFactory(context).setLiveTargetOffsetMs(
PREFERRED_LIVE_OFFSET
)
)
.setLivePlaybackSpeedControl(
DefaultLivePlaybackSpeedControl.Builder()
.setFallbackMaxPlaybackSpeed(1.03f)
.setFallbackMinPlaybackSpeed(0.97f)
.build()
)
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
val current = settingsManager.getInt( val current = settingsManager.getInt(
context.getString(R.string.software_decoding_key), context.getString(R.string.software_decoding_key),
-1 -1
) )
val softwareDecoding = when (current) { val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) {
0 -> true // yes 0 -> true to false // HW+SW, aka on but prefer hw
1 -> false // no 2 -> true to true // SW+HW, aka on but prefer sw
1 -> false to false // HW, aka off
// -1 = automatic // -1 = automatic
else -> { // We do not want tv to have software decoding, because of crashes
// we do not want tv to have software decoding, because of crashes else -> isLayout(PHONE or EMULATOR) to false
!isLayout(TV)
}
} }
val factory = if (softwareDecoding) { val factory = if (isSoftwareDecodingEnabled) {
NextRenderersFactory(context).apply { FixedNextRenderersFactory(context).apply {
setEnableDecoderFallback(true) setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) setExtensionRendererMode(
if (isSoftwareDecodingPreferred)
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
else
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
)
} }
} else { } else {
// no nextlib = EXTENSION_RENDERER_MODE_OFF
DefaultRenderersFactory(context) DefaultRenderersFactory(context)
} }
@ -1102,7 +1128,7 @@ class CS3IPlayer : IPlayer {
// Custom TextOutput to apply cue styling and rules to all subtitles // Custom TextOutput to apply cue styling and rules to all subtitles
val customTextOutput = TextOutput { cue -> val customTextOutput = TextOutput { cue ->
// Do not remove filterNotNull as Java typesystem is fucked // Do not remove filterNotNull as Java typesystem is fucked
val (bitmapCues, textCues) = cue.cues.filterNotNull() val (bitmapCues, textCues) = cue.cues.toList()
.partition { it.bitmap != null } .partition { it.bitmap != null }
val styledBitmapCues = bitmapCues.map { bitmapCue -> val styledBitmapCues = bitmapCues.map { bitmapCue ->
@ -1170,6 +1196,7 @@ class CS3IPlayer : IPlayer {
CustomDecoder.subtitleOffset = subtitleOffset CustomDecoder.subtitleOffset = subtitleOffset
val decoder = CustomSubtitleDecoderFactory() val decoder = CustomSubtitleDecoderFactory()
// @OptIn(ExperimentalApi::class)
val currentTextRenderer = TextRenderer( val currentTextRenderer = TextRenderer(
customTextOutput, customTextOutput,
eventHandler.looper, eventHandler.looper,
@ -1252,7 +1279,7 @@ class CS3IPlayer : IPlayer {
item.drm?.let { drm -> item.drm?.let { drm ->
when (drm.uuid) { when (drm.uuid) {
CLEARKEY_UUID -> { CLEARKEY_DRM_UUID.toJavaUuid() -> {
// Use headers from DrmMetadata for media requests // Use headers from DrmMetadata for media requests
val client = dataSourceFactory val client = dataSourceFactory
?: throw IllegalArgumentException("Must supply onlineSource") ?: throw IllegalArgumentException("Must supply onlineSource")
@ -1273,8 +1300,8 @@ class CS3IPlayer : IPlayer {
.createMediaSource(item.mediaItem) .createMediaSource(item.mediaItem)
} }
WIDEVINE_UUID, WIDEVINE_DRM_UUID.toJavaUuid(),
PLAYREADY_UUID -> { PLAYREADY_DRM_UUID.toJavaUuid() -> {
// Use headers from DrmMetadata for media requests // Use headers from DrmMetadata for media requests
val client = dataSourceFactory val client = dataSourceFactory
?: throw IllegalArgumentException("Must supply onlineSource") ?: throw IllegalArgumentException("Must supply onlineSource")
@ -1308,7 +1335,7 @@ class CS3IPlayer : IPlayer {
} else { } else {
try { try {
val source = ConcatenatingMediaSource2.Builder() val source = ConcatenatingMediaSource2.Builder()
mediaItemSlices.map { item -> mediaItemSlices.forEach { item ->
source.add( source.add(
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
ClippingMediaSource( ClippingMediaSource(
@ -1322,7 +1349,7 @@ class CS3IPlayer : IPlayer {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val source = val source =
ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only
mediaItemSlices.map { item -> mediaItemSlices.forEach { item ->
source.addMediaSource( source.addMediaSource(
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
ClippingMediaSource( ClippingMediaSource(
@ -1345,6 +1372,7 @@ class CS3IPlayer : IPlayer {
) )
setHandleAudioBecomingNoisy(true) setHandleAudioBecomingNoisy(true)
setPlaybackSpeed(playBackSpeed) setPlaybackSpeed(playBackSpeed)
this.addAnalyticsListener(tracksAnalyticsListener)
} }
} }
@ -1386,6 +1414,23 @@ class CS3IPlayer : IPlayer {
event(PlayerAttachedEvent(exoPlayer)) event(PlayerAttachedEvent(exoPlayer))
exoPlayer?.prepare() exoPlayer?.prepare()
// For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map
// incrementally as data is buffered. The initial seek resolves to the nearest merged
// entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position.
// This may only be reproducible on large and fairly long fragmented MP4 files with
// multiple sidx boxes.
if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) {
exoPlayer?.addListener(object : Player.Listener {
private var seekApplied = false
override fun onPlaybackStateChanged(playbackState: Int) {
if (seekApplied || playbackState != Player.STATE_READY) return
seekApplied = true
exoPlayer?.seekTo(currentWindow, playbackPosition)
exoPlayer?.removeListener(this)
}
})
}
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
isPlaying = exo.isPlaying isPlaying = exo.isPlaying
@ -1398,6 +1443,8 @@ class CS3IPlayer : IPlayer {
return return
} }
LiveHelper.registerPlayer(exoPlayer)
exoPlayer?.addListener(object : Player.Listener { exoPlayer?.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
safe { safe {
@ -1506,6 +1553,23 @@ class CS3IPlayer : IPlayer {
exoPlayer?.prepare() exoPlayer?.prepare()
} }
// PlaylistStuckException usually happens when the player position is ahead of the live window.
// Seek to the default location in that case
error.cause is HlsPlaylistTracker.PlaylistStuckException -> {
val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0
// Seek to live head
val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0
if (aheadOfLive > 100) {
exoPlayer?.seekTo(position - aheadOfLive)
} else {
exoPlayer?.seekToDefaultPosition()
}
exoPlayer?.prepare()
}
else -> { else -> {
event(ErrorEvent(error)) event(ErrorEvent(error))
} }
@ -1577,9 +1641,9 @@ class CS3IPlayer : IPlayer {
} }
} }
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList() private var lastTimeStamps: List<VideoSkipStamp> = emptyList()
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) { override fun addTimeStamps(timeStamps: List<VideoSkipStamp>) {
lastTimeStamps = timeStamps lastTimeStamps = timeStamps
timeStamps.forEach { timestamp -> timeStamps.forEach { timestamp ->
exoPlayer?.createMessage { _, _ -> exoPlayer?.createMessage { _, _ ->
@ -1588,7 +1652,7 @@ class CS3IPlayer : IPlayer {
// onTimestampInvoked?.invoke(payload) // onTimestampInvoked?.invoke(payload)
} }
?.setLooper(Looper.getMainLooper()) ?.setLooper(Looper.getMainLooper())
?.setPosition(timestamp.startMs) ?.setPosition(timestamp.timestamp.startMs)
//?.setPayload(timestamp) //?.setPayload(timestamp)
?.setDeleteAfterDelivery(false) ?.setDeleteAfterDelivery(false)
?.send() ?.send()
@ -1635,7 +1699,8 @@ class CS3IPlayer : IPlayer {
val (subSources, activeSubtitles) = getSubSources( val (subSources, activeSubtitles) = getSubSources(
offlineSourceFactory = offlineSourceFactory, offlineSourceFactory = offlineSourceFactory,
subtitleHelper, subHelper = subtitleHelper,
interceptor = null,
) )
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
@ -1649,6 +1714,7 @@ class CS3IPlayer : IPlayer {
private fun getSubSources( private fun getSubSources(
offlineSourceFactory: DataSource.Factory?, offlineSourceFactory: DataSource.Factory?,
subHelper: PlayerSubtitleHelper, subHelper: PlayerSubtitleHelper,
interceptor: Interceptor?,
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> { ): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
val activeSubtitles = ArrayList<SubtitleData>() val activeSubtitles = ArrayList<SubtitleData>()
val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
@ -1670,8 +1736,9 @@ class CS3IPlayer : IPlayer {
} }
SubtitleOrigin.URL -> { SubtitleOrigin.URL -> {
val dataSourceFactory = createOnlineSource(sub.headers, interceptor)
activeSubtitles.add(sub) activeSubtitles.add(sub)
SingleSampleMediaSource.Factory(createOnlineSource(sub.headers)) SingleSampleMediaSource.Factory(dataSourceFactory)
.createMediaSource(subConfig, TIME_UNSET) .createMediaSource(subConfig, TIME_UNSET)
} }
} }
@ -1686,14 +1753,13 @@ class CS3IPlayer : IPlayer {
*/ */
private fun getAudioSources( private fun getAudioSources(
audioTracks: List<AudioFile>, audioTracks: List<AudioFile>,
interceptor: Interceptor?,
): List<MediaSource> { ): List<MediaSource> {
if (audioTracks.isEmpty()) return emptyList()
return audioTracks.mapNotNull { audio -> return audioTracks.mapNotNull { audio ->
try { try {
val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url)
DefaultMediaSourceFactory(createOnlineSource(audio.headers)).createMediaSource( val dataSourceFactory = createOnlineSource(audio.headers, interceptor)
mediaItem DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem)
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}")
null null
@ -1705,7 +1771,6 @@ class CS3IPlayer : IPlayer {
return exoPlayer != null return exoPlayer != null
} }
@MainThread @MainThread
private fun loadTorrent(context: Context, link: ExtractorLink) { private fun loadTorrent(context: Context, link: ExtractorLink) {
ioSafe { ioSafe {
@ -1755,7 +1820,7 @@ class CS3IPlayer : IPlayer {
defaultSet defaultSet
) )
?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null }
} catch (e: Throwable) { } catch (_: Throwable) {
null null
} ?: default } ?: default
@ -1828,7 +1893,7 @@ class CS3IPlayer : IPlayer {
if (ignoreSSL) { if (ignoreSSL) {
// Disables ssl check // Disables ssl check
val sslContext: SSLContext = SSLContext.getInstance("TLS") val sslContext: SSLContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom())
sslContext.createSSLEngine() sslContext.createSSLEngine()
HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession ->
true true
@ -1850,7 +1915,7 @@ class CS3IPlayer : IPlayer {
drm = DrmMetadata( drm = DrmMetadata(
kid = link.kid, kid = link.kid,
key = link.key, key = link.key,
uuid = link.uuid, uuid = link.uuid.toJavaUuid(),
kty = link.kty, kty = link.kty,
licenseUrl = link.licenseUrl, licenseUrl = link.licenseUrl,
keyRequestParameters = link.keyRequestParameters, keyRequestParameters = link.keyRequestParameters,
@ -1865,19 +1930,35 @@ class CS3IPlayer : IPlayer {
) )
} }
// For DASH or HLS single streams (non-playlist), prefer the player's default
// live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick
// the live/default position when no explicit start position was provided.
if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) {
playbackPosition = TIME_UNSET
}
val provider = getApiFromNameNull(link.source)
val interceptor: Interceptor? = provider?.getVideoInterceptor(link)
val onlineSourceFactory = val onlineSourceFactory =
createVideoSource(link, tryCreateEngine(context, simpleCacheSize)) createVideoSource(
link = link,
engine = tryCreateEngine(context, simpleCacheSize),
interceptor = interceptor
)
val offlineSourceFactory = context.createOfflineSource() val offlineSourceFactory = context.createOfflineSource()
val (subSources, activeSubtitles) = getSubSources( val (subSources, activeSubtitles) = getSubSources(
offlineSourceFactory = offlineSourceFactory, offlineSourceFactory = offlineSourceFactory,
subtitleHelper subHelper = subtitleHelper,
interceptor = interceptor, // Backwards compatibility, needs a new api to work properly
) )
// Create audio sources from ExtractorLink's audioTracks // Create audio sources from ExtractorLink's audioTracks
val audioSources = getAudioSources( val audioSources = getAudioSources(
audioTracks = link.audioTracks, audioTracks = link.audioTracks,
interceptor = interceptor, // Backwards compatibility, needs a new api to work properly
) )
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
@ -1905,4 +1986,38 @@ class CS3IPlayer : IPlayer {
loadOfflinePlayer(context, it) loadOfflinePlayer(context, it)
} }
} }
private val tracksAnalyticsListener = object : AnalyticsListener {
override fun onVideoInputFormatChanged(
eventTime: AnalyticsListener.EventTime,
format: Format,
decoderReuseEvaluation: DecoderReuseEvaluation?
) {
event(TracksChangedEvent())
}
override fun onAudioInputFormatChanged(
eventTime: AnalyticsListener.EventTime,
format: Format,
decoderReuseEvaluation: DecoderReuseEvaluation?
) {
event(TracksChangedEvent())
}
override fun onVideoDisabled(
eventTime: AnalyticsListener.EventTime,
decoderCounters: DecoderCounters
) {
event(TracksChangedEvent())
}
override fun onAudioDisabled(
eventTime: AnalyticsListener.EventTime,
decoderCounters: DecoderCounters
) {
event(TracksChangedEvent())
}
}
} }

View file

@ -0,0 +1,296 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes.
*/
package com.lagradost.cloudstream3.ui.player
import android.text.Html
import android.text.Spanned
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.Format.CueReplacementBehavior
import androidx.media3.common.text.Cue
import androidx.media3.common.text.Cue.AnchorType
import androidx.media3.common.util.Consumer
import androidx.media3.common.util.Log
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.text.CuesWithTiming
import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.text.SubtitleParser.OutputOptions
import com.google.common.base.Preconditions.checkNotNull
import com.google.common.collect.ImmutableList
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.util.regex.Matcher
import java.util.regex.Pattern
/** A [SubtitleParser] for SubRip. */
@UnstableApi
class CustomSubripParser : SubtitleParser {
private val textBuilder: StringBuilder = StringBuilder()
private val tags: ArrayList<String> = ArrayList()
private val parsableByteArray: ParsableByteArray = ParsableByteArray()
override fun getCueReplacementBehavior(): @CueReplacementBehavior Int {
return CUE_REPLACEMENT_BEHAVIOR
}
override fun parse(
data: ByteArray,
offset: Int,
length: Int,
outputOptions: OutputOptions,
output: Consumer<CuesWithTiming>
) {
parsableByteArray.reset(data, /* limit= */offset + length)
parsableByteArray.setPosition(offset)
val charset = detectUtfCharset(parsableByteArray)
val cuesWithTimingBeforeRequestedStartTimeUs: MutableList<CuesWithTiming>? =
if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues)
ArrayList<CuesWithTiming>()
else
null
var currentLine: String?
while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) {
if (currentLine!!.isEmpty()) {
// Skip blank lines.
continue
}
// Parse and check the index line.
try {
currentLine.toInt()
} catch (_: NumberFormatException) {
Log.w(TAG, "Skipping invalid index: $currentLine")
continue
}
// Read and parse the timing line.
currentLine = parsableByteArray.readLine(charset)
if (currentLine == null) {
Log.w(TAG, "Unexpected end")
break
}
val startTimeUs: Long
val endTimeUs: Long
val matcher = SUBRIP_TIMING_LINE.matcher(currentLine)
if (matcher.matches()) {
startTimeUs = parseTimecode(matcher, /* groupOffset= */1)
endTimeUs = parseTimecode(matcher, /* groupOffset= */6)
} else {
Log.w(TAG, "Skipping invalid timing: $currentLine")
continue
}
// Read and parse the text and tags.
textBuilder.setLength(0)
tags.clear()
currentLine = parsableByteArray.readLine(charset)
while (!TextUtils.isEmpty(currentLine)) {
if (textBuilder.isNotEmpty()) {
textBuilder.append("<br>")
}
textBuilder.append(processLine(currentLine!!, tags))
currentLine = parsableByteArray.readLine(charset)
}
@Suppress("DEPRECATION")
val text = Html.fromHtml(textBuilder.toString())
var alignmentTag: String? = null
for (i in tags.indices) {
val tag = tags[i]
if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) {
alignmentTag = tag
// Subsequent alignment tags should be ignored.
break
}
}
if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) {
output.accept(
CuesWithTiming(
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
startTimeUs, /* durationUs= */
endTimeUs - startTimeUs
)
)
} else cuesWithTimingBeforeRequestedStartTimeUs?.add(
CuesWithTiming(
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
startTimeUs, /* durationUs= */
endTimeUs - startTimeUs
)
)
}
if (cuesWithTimingBeforeRequestedStartTimeUs != null) {
for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) {
output.accept(cuesWithTiming)
}
}
}
/**
* Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if
* no BOM is found.
*/
private fun detectUtfCharset(data: ParsableByteArray): Charset {
val charset = data.readUtfCharsetFromBom()
return charset ?: StandardCharsets.UTF_8
}
/**
* Trims and removes tags from the given line. The removed tags are added to `tags`.
*
* @param line The line to process.
* @param tags A list to which removed tags will be added.
* @return The processed line.
*/
private fun processLine(line: String, tags: ArrayList<String>): String {
var line = line
line = line.trim { it <= ' ' }
var removedCharacterCount = 0
val processedLine = StringBuilder(line)
val matcher = SUBRIP_TAG_PATTERN.matcher(line)
while (matcher.find()) {
val tag = matcher.group()
tags.add(tag)
val start = matcher.start() - removedCharacterCount
val tagLength = tag.length
processedLine.replace(start, /* end= */start + tagLength, /* str= */"")
removedCharacterCount += tagLength
}
return processedLine.toString()
}
/**
* Build a [Cue] based on the given text and alignment tag.
*
* @param text The text.
* @param alignmentTag The alignment tag, or `null` if no alignment tag is available.
* @return Built cue
*/
private fun buildCue(text: Spanned, alignmentTag: String?): Cue {
val cue = Cue.Builder().setText(text)
if (alignmentTag == null) {
return cue.build()
}
// Horizontal alignment.
when (alignmentTag) {
ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START)
ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END)
ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
}
// Vertical alignment.
when (alignmentTag) {
ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END)
ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START)
ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
}
return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor()))
.setLine(
getFractionalPositionForAnchorType(cue.getLineAnchor()),
Cue.LINE_TYPE_FRACTION
)
.build()
}
companion object {
/**
* The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this
* implementation.
*/
const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int =
Format.CUE_REPLACEMENT_BEHAVIOR_MERGE
// Fractional positions for use when alignment tags are present.
private const val START_FRACTION = 0.08f
private const val END_FRACTION = 1 - START_FRACTION
private const val MID_FRACTION = 0.5f
private const val TAG = "SubripParser"
// The google devs are useless, this entire class is just to override this
private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?"
private val SUBRIP_TIMING_LINE: Pattern =
Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*")
// NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183].
private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}")
private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}"
// Alignment tags for SSA V4+.
private const val ALIGN_BOTTOM_LEFT = "{\\an1}"
private const val ALIGN_BOTTOM_MID = "{\\an2}"
private const val ALIGN_BOTTOM_RIGHT = "{\\an3}"
private const val ALIGN_MID_LEFT = "{\\an4}"
private const val ALIGN_MID_MID = "{\\an5}"
private const val ALIGN_MID_RIGHT = "{\\an6}"
private const val ALIGN_TOP_LEFT = "{\\an7}"
private const val ALIGN_TOP_MID = "{\\an8}"
private const val ALIGN_TOP_RIGHT = "{\\an9}"
private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long {
val hours = matcher.group(groupOffset + 1)
var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0
timestampMs += checkNotNull(matcher.group(groupOffset + 2))
.toLong() * 60 * 1000
timestampMs += checkNotNull(matcher.group(groupOffset + 3))
.toLong() * 1000
val millis = matcher.group(groupOffset + 4)
timestampMs += when (millis?.length) {
null -> 0L
1 -> millis.toLong() * 100L
2 -> millis.toLong() * 10L
3 -> millis.toLong() * 1L
else -> millis.substring(0, 3).toLong()
}
return timestampMs * 1000
}
// TODO(b/289983417): Make package-private again, once it is no longer needed in
// DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed)
@VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE)
fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float {
return when (anchorType) {
Cue.ANCHOR_TYPE_START -> START_FRACTION
Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION
Cue.ANCHOR_TYPE_END -> END_FRACTION
Cue.TYPE_UNSET -> // Should never happen.
throw IllegalArgumentException()
else ->
throw IllegalArgumentException()
}
}
}
}

View file

@ -18,7 +18,6 @@ import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.dvb.DvbParser
import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.pgs.PgsParser
import androidx.media3.extractor.text.ssa.SsaParser import androidx.media3.extractor.text.ssa.SsaParser
import androidx.media3.extractor.text.subrip.SubripParser
import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.ttml.TtmlParser
import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.tx3g.Tx3gParser
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
@ -251,14 +250,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
ignoreCase = true ignoreCase = true
)) -> SsaParser(fallbackFormat?.initializationData) )) -> SsaParser(fallbackFormat?.initializationData)
trimmedText.startsWith("1", ignoreCase = true) -> SubripParser() trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser()
fallbackFormat != null -> { fallbackFormat != null -> {
when (val mimeType = fallbackFormat.sampleMimeType) { when (fallbackFormat.sampleMimeType) {
MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_VTT -> WebvttParser()
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
MimeTypes.APPLICATION_TTML -> TtmlParser() MimeTypes.APPLICATION_TTML -> TtmlParser()
MimeTypes.APPLICATION_SUBRIP -> SubripParser() MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser()
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
// These decoders are not converted to parsers yet // These decoders are not converted to parsers yet
// TODO // TODO

View file

@ -10,16 +10,17 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
class DownloadFileGenerator( class DownloadFileGenerator(
episodes: List<ExtractorUri>, episodes: List<ExtractorUri>
currentIndex: Int = 0 ) : VideoGenerator<ExtractorUri>(episodes) {
) : VideoGenerator<ExtractorUri>(episodes, currentIndex) {
override val hasCache = false override val hasCache = false
override val canSkipLoading = false override val canSkipLoading = false
override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,
@ -28,14 +29,14 @@ class DownloadFileGenerator(
offset: Int, offset: Int,
isCasting: Boolean isCasting: Boolean
): Boolean { ): Boolean {
val meta = getCurrent(offset) ?: return false val meta = videos.getOrNull(offset) ?: return false
if (meta.uri == Uri.EMPTY) { if (meta.uri == Uri.EMPTY) {
// We do this here so that we only load it when // We do this here so that we only load it when
// we actually need it as it can be more expensive. // we actually need it as it can be more expensive.
val info = meta.id?.let { id -> val info = meta.id?.let { id ->
activity?.let { act -> activity?.let { act ->
getDownloadFileInfoAndUpdateSettings(act, id) getDownloadFileInfo(act, id)
} }
} }

View file

@ -14,7 +14,9 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPres
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
class DownloadedPlayerActivity : AppCompatActivity() { class DownloadedPlayerActivity : AppCompatActivity() {
private val dTAG = "DownloadedPlayerAct" companion object {
const val TAG = "DownloadedPlayerActivity"
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean = override fun dispatchKeyEvent(event: KeyEvent): Boolean =
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
@ -27,53 +29,83 @@ class DownloadedPlayerActivity : AppCompatActivity() {
CommonActivity.onUserLeaveHint(this) CommonActivity.onUserLeaveHint(this)
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Ignore same intent so the player doesnt totally
// reload if you are playing the same thing.
if (isSameIntent(intent)) return
setIntent(intent)
Log.i(TAG, "onNewIntent")
handleIntent(intent)
}
private fun isSameIntent(newIntent: Intent): Boolean {
val old = intent ?: return false
// Compare URIs first
val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri
val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri
if (oldUri != null && oldUri == newUri) return true
// Fall back to comparing EXTRA_TEXT links
val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) }
val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) }
return oldText != null && oldText == newText
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
CommonActivity.loadThemes(this) CommonActivity.loadThemes(this)
CommonActivity.init(this) CommonActivity.init(this)
enableEdgeToEdgeCompat() enableEdgeToEdgeCompat()
setContentView(R.layout.empty_layout) setContentView(R.layout.empty_layout)
Log.i(dTAG, "onCreate") Log.i(TAG, "onCreate")
handleIntent(intent)
/**
* Use moveTaskToBack instead of finish() so there is always exactly one task
* entry in recents, always reflecting the current file.
*
* finish() destroys the Activity but may leave the task in recents. Each new file
* open can create a new task entry, so recents accumulates stale entries for old
* files. The user then taps a stale entry and gets the wrong file.
*
* moveTaskToBack keeps the Activity alive in the background. There is only ever
* one task entry in recents. New files opened from the file manager arrive via
* onNewIntent on the live instance, updating the player immediately. The single
* recents entry always reflects the current state, ensuring we load the
* correct file.
*/
attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) }
}
private fun handleIntent(intent: Intent) {
val data = intent.data val data = intent.data
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
return return
} }
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) { if (
val extraText = safe { // I dont trust android intent.action == Intent.ACTION_SEND ||
intent.getStringExtra(Intent.EXTRA_TEXT) intent.action == Intent.ACTION_OPEN_DOCUMENT ||
} intent.action == Intent.ACTION_VIEW
) {
val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
val cd = intent.clipData val cd = intent.clipData
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
val url = item?.text?.toString() val url = item?.text?.toString()
when {
// idk what I am doing, just hope any of these work item?.uri != null -> playUri(this, item.uri)
if (item?.uri != null) url != null -> playLink(this, url)
playUri(this, item.uri) data != null -> playUri(this, data)
else if (url != null) extraText != null -> playLink(this, extraText)
playLink(this, url) else -> finishAndRemoveTask()
else if (data != null)
playUri(this, data)
else if (extraText != null)
playLink(this, extraText)
else {
finish()
return
} }
} else if (data?.scheme == "content") { } else if (data?.scheme == "content") {
playUri(this, data) playUri(this, data)
} else { } else finishAndRemoveTask()
finish()
return
}
attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
CommonActivity.setActivityInstance(this) CommonActivity.setActivityInstance(this)
} }
} }

View file

@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
class ExtractorLinkGenerator( class ExtractorLinkGenerator(
private val links: List<ExtractorLink>, private val links: List<ExtractorLink>,
private val subtitles: List<SubtitleData>, private val subtitles: List<SubtitleData>,
) : NoVideoGenerator() { ) : NoVideoGenerator(null) {
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,

View file

@ -0,0 +1,28 @@
package com.lagradost.cloudstream3.ui.player
import android.content.Context
import android.os.Looper
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
@UnstableApi
class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) {
/** Somehow the nextlib authors decided that we need a text renderer that causes
* "ERROR_CODE_FAILED_RUNTIME_CHECK".
*
* Core issue: https://github.com/anilbeesetti/nextlib/pull/158
* Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718
* */
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>
) {
out.add(TextRenderer(output, outputLooper))
}
}

View file

@ -1,10 +1,7 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import kotlin.math.max
import kotlin.math.min
val LOADTYPE_INAPP = setOf( val LOADTYPE_INAPP = setOf(
ExtractorLinkType.VIDEO, ExtractorLinkType.VIDEO,
@ -28,71 +25,27 @@ val LOADTYPE_CHROMECAST = setOf(
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
abstract class NoVideoGenerator : VideoGenerator<Nothing>(emptyList(), 0) { abstract class NoVideoGenerator(val id : Int?) : VideoGenerator<Nothing>(emptyList()) {
override val hasCache = false override val hasCache = false
override val canSkipLoading = false override val canSkipLoading = false
override fun getId(index: Int): Int? = id
} }
abstract class VideoGenerator<T : Any>(val videos: List<T>, var videoIndex: Int = 0) : abstract class VideoGenerator<T : Any>(val videos: List<T>) {
IGenerator { abstract val hasCache: Boolean
abstract val canSkipLoading: Boolean
abstract fun getId(index : Int) : Int?
override fun hasNext(): Boolean = videoIndex < videos.lastIndex fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex
override fun hasPrev(): Boolean = videoIndex > 0 fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0
override fun getAll(): List<T>? = videos
override fun getCurrent(offset: Int): T? = videos.getOrNull(videoIndex + offset)
override fun next() {
if (hasNext()) {
videoIndex += 1
}
}
override fun prev() { @Throws
if (hasPrev()) { abstract suspend fun generateLinks(
videoIndex -= 1
}
}
override fun goto(index: Int) {
videoIndex = min(videos.lastIndex, max(0, index))
}
override fun getCurrentId(): Int? {
return when (val current = getCurrent()) {
is ResultEpisode -> {
current.id
}
is ExtractorUri -> {
current.id
}
else -> null
}
}
}
// TODO deprecate/remove IGenerator in favor of a more ergonomic and correct implementation
interface IGenerator {
val hasCache: Boolean
val canSkipLoading: Boolean
fun hasNext(): Boolean
fun hasPrev(): Boolean
fun next()
fun prev()
fun goto(index: Int)
fun getCurrentId(): Int? // this is used to save data or read data about this id
fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
/* not safe, must use try catch */
suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit, callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit, subtitleCallback: (SubtitleData) -> Unit,
offset: Int = 0, offset: Int,
isCasting: Boolean = false isCasting: Boolean
): Boolean ): Boolean
} }

View file

@ -3,30 +3,11 @@ package com.lagradost.cloudstream3.ui.player
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Rational import android.util.Rational
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
enum class PlayerEventType(val value: Int) {
Pause(0),
Play(1),
SeekForward(2),
SeekBack(3),
SkipCurrentChapter(4),
NextEpisode(5),
PrevEpisode(6),
PlayPauseToggle(7),
ToggleMute(8),
Lock(9),
ToggleHide(10),
ShowSpeed(11),
ShowMirrors(12),
Resize(13),
SearchSubtitlesOnline(14),
SkipOp(15),
Restart(16),
}
enum class CSPlayerEvent(val value: Int) { enum class CSPlayerEvent(val value: Int) {
Pause(0), Pause(0),
@ -86,13 +67,13 @@ data class ErrorEvent(
/** Event when timestamps appear, null when it should disappear */ /** Event when timestamps appear, null when it should disappear */
data class TimestampInvokedEvent( data class TimestampInvokedEvent(
val timestamp: EpisodeSkip.SkipStamp, val timestamp: VideoSkipStamp,
override val source: PlayerEventSource = PlayerEventSource.Player, override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent() ) : PlayerEvent()
/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ /** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */
data class TimestampSkippedEvent( data class TimestampSkippedEvent(
val timestamp: EpisodeSkip.SkipStamp, val timestamp: VideoSkipStamp,
override val source: PlayerEventSource = PlayerEventSource.Player, override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent() ) : PlayerEvent()
@ -182,6 +163,7 @@ interface Track {
val id: String? val id: String?
val label: String? val label: String?
val language: String? val language: String?
val sampleMimeType : String?
} }
data class VideoTrack( data class VideoTrack(
@ -190,19 +172,23 @@ data class VideoTrack(
override val language: String?, override val language: String?,
val width: Int?, val width: Int?,
val height: Int?, val height: Int?,
override val sampleMimeType: String?,
) : Track ) : Track
data class AudioTrack( data class AudioTrack(
override val id: String?, override val id: String?,
override val label: String?, override val label: String?,
override val language: String?, override val language: String?,
override val sampleMimeType: String?,
val channelCount: Int?,
val formatIndex: Int?,
) : Track ) : Track
data class TextTrack( data class TextTrack(
override val id: String?, override val id: String?,
override val label: String?, override val label: String?,
override val language: String?, override val language: String?,
val mimeType: String?, override val sampleMimeType: String?,
) : Track ) : Track
@ -215,8 +201,6 @@ data class CurrentTracks(
val allTextTracks: List<TextTrack>, val allTextTracks: List<TextTrack>,
) )
class InvalidFileException(msg: String) : Exception(msg)
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
const val ACTION_MEDIA_CONTROL = "media_control" const val ACTION_MEDIA_CONTROL = "media_control"
const val EXTRA_CONTROL_TYPE = "control_type" const val EXTRA_CONTROL_TYPE = "control_type"
@ -238,8 +222,9 @@ interface IPlayer {
fun getSubtitleOffset(): Long // in ms fun getSubtitleOffset(): Long // in ms
fun setSubtitleOffset(offset: Long) // in ms fun setSubtitleOffset(offset: Long) // in ms
@AnyThread
fun initCallbacks( fun initCallbacks(
eventHandler: ((PlayerEvent) -> Unit), @MainThread eventHandler: ((PlayerEvent) -> Unit),
/** this is used to request when the player should report back view percentage */ /** this is used to request when the player should report back view percentage */
requestedListeningPercentages: List<Int>? = null, requestedListeningPercentages: List<Int>? = null,
) )
@ -249,7 +234,7 @@ interface IPlayer {
fun updateSubtitleStyle(style: SaveCaptionStyle) fun updateSubtitleStyle(style: SaveCaptionStyle)
fun saveData() fun saveData()
fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) fun addTimeStamps(timeStamps: List<VideoSkipStamp>)
fun loadPlayer( fun loadPlayer(
context: Context, context: Context,
@ -302,8 +287,8 @@ interface IPlayer {
fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null)
/** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */
fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null) fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null)
/** Get the current subtitle cues, for use with syncing */ /** Get the current subtitle cues, for use with syncing */
fun getSubtitleCues(): List<SubtitleCue> fun getSubtitleCues(): List<SubtitleCue>
} }

View file

@ -40,7 +40,8 @@ class LinkGenerator(
private val links: List<BasicLink>, private val links: List<BasicLink>,
private val extract: Boolean = true, private val extract: Boolean = true,
private val refererUrl: String? = null, private val refererUrl: String? = null,
) : NoVideoGenerator() { id: Int?
) : NoVideoGenerator(id) {
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,
@ -78,10 +79,8 @@ class LinkGenerator(
class MinimalLinkGenerator( class MinimalLinkGenerator(
private val links: List<CloudStreamPackage.MinimalVideoLink>, private val links: List<CloudStreamPackage.MinimalVideoLink>,
private val subs: List<CloudStreamPackage.MinimalSubtitleLink>, private val subs: List<CloudStreamPackage.MinimalSubtitleLink>,
private val id: Int? = null id: Int?
) : NoVideoGenerator() { ) : NoVideoGenerator(id) {
override fun getCurrentId(): Int? = id
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,

View file

@ -1,10 +1,10 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.app.Activity import android.app.Activity
import android.content.ContentUris
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.content.ContextCompat.getString import androidx.core.content.ContextCompat.getString
import androidx.navigation.NavOptions
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
@ -13,15 +13,25 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.safefile.SafeFile import com.lagradost.safefile.SafeFile
object OfflinePlaybackHelper { object OfflinePlaybackHelper {
/**
* Pop any existing player off the nav back stack before pushing the new one,
* keeping the stack flat (at most one player at a time). This prevents an
* OOM when many files are opened in sequence via DownloadedPlayerActivity.
*/
private val replacePlayerNavOptions = NavOptions.Builder()
.setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false)
.build()
fun playLink(activity: Activity, url: String) { fun playLink(activity: Activity, url: String) {
activity.navigate( activity.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance( R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
LinkGenerator( LinkGenerator(
listOf( listOf(
BasicLink(url) BasicLink(url)
) ), id = url.hashCode()
) ), 0
) ),
replacePlayerNavOptions
) )
} }
@ -52,8 +62,9 @@ object OfflinePlaybackHelper {
links, links,
subs, subs,
if (id != -1) id else null, if (id != -1) id else null,
) ), 0
) ),
replacePlayerNavOptions
) )
return true return true
} }
@ -73,12 +84,12 @@ object OfflinePlaybackHelper {
name = name ?: getString(activity, R.string.downloaded_file), name = name ?: getString(activity, R.string.downloaded_file),
// well not the same as a normal id, but we take it as users may want to // well not the same as a normal id, but we take it as users may want to
// play downloaded files and save the location // play downloaded files and save the location
id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull() id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode()
?.hashCode()
) )
) )
) ), 0
) ),
replacePlayerNavOptions
) )
} }
} }

View file

@ -9,34 +9,188 @@ import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.videoskip.SkipAPI
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.annotations.Contract
import java.util.concurrent.ConcurrentHashMap
typealias VideoLink = Pair<ExtractorLink?, ExtractorUri?>
data class GeneratorState(
val meta: Any?,
val nextMeta: Any?,
val allMeta: List<*>?,
val response: LoadResponse?,
val index: Int,
val id: Int?,
)
/** Immutable state of all current links relevant to displaying the video */
// @MustUseReturnValues
// @Immutable
data class VideoState(
val subtitles: PersistentSet<SubtitleData> = persistentSetOf(),
val links: PersistentSet<VideoLink> = persistentSetOf(),
val stamps: PersistentList<VideoSkipStamp> = persistentListOf(),
val loading: Resource<Unit> = Resource.Loading(),
val generatorState: GeneratorState? = null,
val instance: Int,
) {
/**
* This acts as a local cache for sorted links that are not copied over by the copy constructor.
*
* sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation
* */
private val sortedLinks: ConcurrentHashMap<Int, List<VideoLink>> = ConcurrentHashMap()
fun clearSortedLinksCache() = sortedLinks.clear()
// Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result
// It is by all standards, idempotent and by extension also pure as it has no "visible" side effect
/** Returns .links in the sorted order according to the qualityProfile.
* Use .links if order is not needed */
@Contract(pure = true)
fun sortLinks(qualityProfile: Int): List<VideoLink> {
return sortedLinks[qualityProfile] ?: links.sortedBy { link ->
// negative because we want to sort highest quality first
-getLinkPriority(qualityProfile, link.first)
}.also { value -> sortedLinks[qualityProfile] = value }
}
@Contract(pure = true)
fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item))
@Contract(pure = true)
fun add(item: VideoLink): VideoState = copy(links = links.add(item))
@Contract(pure = true)
fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item))
@JvmName("addSubtitleData")
@Contract(pure = true)
fun add(items: Collection<SubtitleData>): VideoState = copy(subtitles = subtitles.addAll(items))
@JvmName("addVideoLink")
@Contract(pure = true)
fun add(items: Collection<VideoLink>): VideoState = copy(links = links.addAll(items))
@JvmName("addVideoSkipStamp")
@Contract(pure = true)
fun add(items: Collection<VideoSkipStamp>): VideoState = copy(stamps = stamps.addAll(items))
@Contract(pure = true)
fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item))
@Contract(pure = true)
fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item))
@Contract(pure = true)
fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item))
@JvmName("setSubtitleData")
@Contract(pure = true)
fun set(items: Collection<SubtitleData>): VideoState = copy(subtitles = items.toPersistentSet())
@JvmName("setVideoLink")
@Contract(pure = true)
fun set(items: Collection<VideoLink>): VideoState = copy(links = items.toPersistentSet())
@JvmName("setVideoSkipStamp")
@Contract(pure = true)
fun set(items: Collection<VideoSkipStamp>): VideoState = copy(stamps = items.toPersistentList())
}
data class VideoLive<T>(
val value: T,
val instance: Int,
)
class PlayerGeneratorViewModel : ViewModel() { class PlayerGeneratorViewModel : ViewModel() {
companion object { companion object {
const val TAG = "PlayViewGen" const val TAG = "PlayViewGen"
} }
private var generator: IGenerator? = null @Volatile
var generator: VideoGenerator<*>? = null
private val _currentLinks = MutableLiveData<Set<Pair<ExtractorLink?, ExtractorUri?>>>(setOf()) @Volatile
val currentLinks: LiveData<Set<Pair<ExtractorLink?, ExtractorUri?>>> = _currentLinks var episodeIndex: Int = 0
private val _currentSubs = MutableLiveData<Set<SubtitleData>>(setOf()) /**
val currentSubs: LiveData<Set<SubtitleData>> = _currentSubs * The state of the video player, only modify it by modifyState to make sure observe is called,
* and avoid concurrency issues.
*
* This value can be used without Synchronized or locking when reading, as all fields are immutable.
* */
@Volatile
var state = VideoState(instance = 0)
private set
private val _loadingLinks = MutableLiveData<Resource<Boolean?>>() private val _currentLinks =
val loadingLinks: LiveData<Resource<Boolean?>> = _loadingLinks MutableLiveData<VideoLive<Set<Pair<ExtractorLink?, ExtractorUri?>>>>(null)
val currentLinks: LiveData<VideoLive<Set<Pair<ExtractorLink?, ExtractorUri?>>>> = _currentLinks
private val _currentStamps = MutableLiveData<List<EpisodeSkip.SkipStamp>>(emptyList()) private val _currentSubtitles = MutableLiveData<VideoLive<Set<SubtitleData>>>(null)
val currentStamps: LiveData<List<EpisodeSkip.SkipStamp>> = _currentStamps val currentSubtitles: LiveData<VideoLive<Set<SubtitleData>>> = _currentSubtitles
private val _loadingLinks = MutableLiveData<VideoLive<Resource<Unit>>>()
val loadingLinks: LiveData<VideoLive<Resource<Unit>>> = _loadingLinks
private val _currentStamps = MutableLiveData<VideoLive<List<VideoSkipStamp>>>(null)
val currentStamps: LiveData<VideoLive<List<VideoSkipStamp>>> = _currentStamps
/**
* Modifies the `state` variable safely, and with the correct observe behavior.
*
* Synchronized to avoid concurrency issues, and make this operation atomic.
* Otherwise, one update may be lost if they are done in parallel.
* */
@Synchronized
fun modifyState(op: VideoState.() -> VideoState) {
val oldState = state
state = op.invoke(oldState)
/** New instance, always push state */
if (state.instance != oldState.instance) {
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
_currentLinks.postValue(VideoLive(state.links, state.instance))
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
return
}
/**
* Only post the changed values, this makes sure we do not invoke the "observe"
*
* We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality
* to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged.
* */
if (state.links !== oldState.links)
_currentLinks.postValue(VideoLive(state.links, state.instance))
if (state.stamps !== oldState.stamps)
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
if (state.subtitles !== oldState.subtitles)
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
/** Normal equality here as it is not a collection */
if (state.loading != oldState.loading)
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
}
private val _currentSubtitleYear = MutableLiveData<Int?>(null) private val _currentSubtitleYear = MutableLiveData<Int?>(null)
val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear
@ -52,41 +206,32 @@ class PlayerGeneratorViewModel : ViewModel() {
_currentSubtitleYear.postValue(year) _currentSubtitleYear.postValue(year)
} }
fun getId(): Int? {
return generator?.getCurrentId()
}
fun loadLinks(episode: Int) {
generator?.goto(episode)
loadLinks()
}
fun loadLinksPrev() { fun loadLinksPrev() {
Log.i(TAG, "loadLinksPrev") Log.i(TAG, "loadLinksPrev")
if (generator?.hasPrev() == true) { if (generator?.hasPrev(episodeIndex) == true) {
generator?.prev() episodeIndex += 1
loadLinks() loadLinks()
} }
} }
fun loadLinksNext() { fun loadLinksNext() {
Log.i(TAG, "loadLinksNext") Log.i(TAG, "loadLinksNext")
if (generator?.hasNext() == true) { if (generator?.hasNext(episodeIndex) == true) {
generator?.next() episodeIndex += 1
loadLinks() loadLinks()
} }
} }
fun hasNextEpisode(): Boolean? { fun hasNextEpisode(): Boolean? {
return generator?.hasNext() return generator?.hasNext(episodeIndex)
} }
fun hasPrevEpisode(): Boolean? { fun hasPrevEpisode(): Boolean? {
return generator?.hasPrev() return generator?.hasPrev(episodeIndex)
} }
fun preLoadNextLinks() { fun preLoadNextLinks() {
val id = getId() val id = generator?.getId(episodeIndex)
// Do not preload if already loading // Do not preload if already loading
if (id == currentLoadingEpisodeId) return if (id == currentLoadingEpisodeId) return
@ -96,14 +241,15 @@ class PlayerGeneratorViewModel : ViewModel() {
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
try { try {
if (generator?.hasCache == true && generator?.hasNext() == true) { if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) {
safeApiCall { safeApiCall {
generator?.generateLinks( generator?.generateLinks(
sourceTypes = LOADTYPE_INAPP, sourceTypes = LOADTYPE_INAPP,
clearCache = false, clearCache = false,
isCasting = false,
callback = {}, callback = {},
subtitleCallback = {}, subtitleCallback = {},
offset = 1 offset = episodeIndex + 1
) )
} }
} }
@ -117,129 +263,137 @@ class PlayerGeneratorViewModel : ViewModel() {
} }
} }
fun getLoadResponse(): LoadResponse? { fun loadThisEpisode(index: Int) {
return safe { (generator as? RepoLinkGenerator?)?.page } episodeIndex = index
}
fun getMeta(): Any? {
return safe { generator?.getCurrent() }
}
fun getAllMeta(): List<Any>? {
return safe { generator?.getAll() }
}
fun getNextMeta(): Any? {
return safe {
if (generator?.hasNext() == false) return@safe null
generator?.getCurrent(offset = 1)
}
}
fun loadThisEpisode(index:Int) {
generator?.goto(index)
loadLinks() loadLinks()
} }
fun getCurrentIndex():Int?{ fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) {
val repoGen = generator as? RepoLinkGenerator ?: return null Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index")
return repoGen.videoIndex generator = newGenerator
episodeIndex = index
} }
fun attachGenerator(newGenerator: IGenerator?) {
if (generator == null) {
generator = newGenerator
}
}
private var extraSubtitles : MutableSet<SubtitleData> = mutableSetOf()
/** /**
* If duplicate nothing will happen * If duplicate nothing will happen
* */ * */
fun addSubtitles(file: Set<SubtitleData>) = synchronized(extraSubtitles) { fun addSubtitles(file: Set<SubtitleData>) {
extraSubtitles += file val validFile = file.filter(::isValidSubtitle)
val current = _currentSubs.value ?: emptySet() if (validFile.isNotEmpty())
val next = extraSubtitles + current modifyState {
add(validFile)
// if it is of a different size then we have added distinct items }
if (next.size != current.size) {
// Posting will refresh subtitles which will in turn
// make the subs to english if previously unselected
_currentSubs.postValue(next)
}
} }
private var currentJob: Job? = null private var currentJob: Job? = null
private var currentStampJob: Job? = null private var currentStampJob: Job? = null
fun loadStamps(duration: Long) { fun loadStamps(duration: Long) {
//currentStampJob?.cancel()
currentStampJob = ioSafe { currentStampJob = ioSafe {
val meta = generator?.getCurrent() val genState = state.generatorState ?: return@ioSafe
val page = (generator as? RepoLinkGenerator?)?.page val meta = genState.meta
if (page != null && meta is ResultEpisode) { val page = genState.response
_currentStamps.postValue(listOf()) val id = genState.id
_currentStamps.postValue( if (page == null || meta !is ResultEpisode) {
EpisodeSkip.getStamps( return@ioSafe
page,
meta,
duration,
hasNextEpisode() ?: false
)
)
} }
val stamps = SkipAPI.videoStamps(
page,
meta,
duration,
hasNextEpisode() ?: false
)
/** Avoid adding stamps to the wrong video */
modifyState {
if (id != this.generatorState?.id) {
this
} else {
set(stamps)
}
}
}
}
var langFilterList = listOf<String>()
var filterSubByLang = false
fun isValidSubtitle(subtitle: SubtitleData): Boolean {
if (langFilterList.isEmpty() || !filterSubByLang) {
return true
}
/** Only filter out subtitles fetched online */
if (subtitle.origin != SubtitleOrigin.URL) {
return true
}
return langFilterList.any { lang ->
subtitle.originalName.contains(lang, ignoreCase = true)
} }
} }
fun loadLinks(sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP) { fun loadLinks(sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP) {
Log.i(TAG, "loadLinks") Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex")
currentJob?.cancel() currentJob?.cancel()
val index = episodeIndex
// Clear old data and reset the state
modifyState {
VideoState(
loading = Resource.Loading(),
generatorState = generator?.let { gen ->
GeneratorState(
meta = gen.videos.getOrNull(index),
nextMeta = gen.videos.getOrNull(index + 1),
id = gen.getId(index),
response = (gen as? RepoLinkGenerator)?.page,
index = index,
allMeta = gen.videos
)
},
instance = instance + 1
)
}
currentJob = viewModelScope.launchSafe { currentJob = viewModelScope.launchSafe {
// if we load links then we clear the prev loaded links // Load more data
synchronized(extraSubtitles) {
extraSubtitles.clear()
}
val currentLinks = mutableSetOf<Pair<ExtractorLink?, ExtractorUri?>>()
val currentSubs = mutableSetOf<SubtitleData>()
// clear old data
_currentSubs.postValue(emptySet())
_currentLinks.postValue(emptySet())
// load more data
_loadingLinks.postValue(Resource.Loading())
val loadingState = safeApiCall { val loadingState = safeApiCall {
generator?.generateLinks( generator?.generateLinks(
sourceTypes = sourceTypes, sourceTypes = sourceTypes,
clearCache = forceClearCache, clearCache = forceClearCache,
callback = { callback = { link ->
synchronized(currentLinks) { if (isActive)
currentLinks.add(it) modifyState {
// Clone to prevent ConcurrentModificationException add(link)
safe {
// Extra safe since .toSet() iterates.
_currentLinks.postValue(currentLinks.toSet())
} }
}
}, },
subtitleCallback = { isCasting = false,
synchronized(extraSubtitles) { offset = index,
currentSubs.add(it) subtitleCallback = { link ->
safe { if (isActive && isValidSubtitle(link))
_currentSubs.postValue(currentSubs + extraSubtitles) modifyState {
add(link)
} }
}
}) })
Unit
} }
_loadingLinks.postValue(loadingState) if (!isActive) {
_currentLinks.postValue(currentLinks) return@launchSafe
synchronized(extraSubtitles) { }
_currentSubs.postValue(currentSubs + extraSubtitles)
/** Only mark as success if we have not skipped loading */
modifyState {
if (!isActive) {
this
} else {
when (loading) {
is Resource.Loading -> copy(loading = loadingState)
else -> this
}
}
} }
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -128,7 +128,7 @@ object PlayerPipHelper {
getRemoteAction( getRemoteAction(
activity, activity,
R.drawable.baseline_headphones_24, R.drawable.baseline_headphones_24,
R.string.audio_singluar, R.string.audio_singular,
CSPlayerEvent.PlayAsAudio CSPlayerEvent.PlayAsAudio
) )
) )

View file

@ -11,6 +11,7 @@ import androidx.media3.ui.SubtitleView
import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
enum class SubtitleStatus { enum class SubtitleStatus {
@ -47,6 +48,16 @@ data class SubtitleData(
else "$url|$name" else "$url|$name"
} }
/** Returns true if langCode is the same as the IETF tag */
fun matchesLanguageCode(langCode: String): Boolean {
return getIETF_tag() == langCode
}
/** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */
fun getIETF_tag(): String? {
return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true)
}
val name = "$originalName $nameSuffix" val name = "$originalName $nameSuffix"
/** /**

View file

@ -0,0 +1,842 @@
package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.AnimatedVectorDrawable
import android.media.metrics.PlaybackErrorEvent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.text.format.DateUtils
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.OptIn
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.FragmentActivity
import androidx.media3.common.PlaybackException
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar
import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.github.rubensousa.previewseekbar.PreviewBar
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
import com.lagradost.cloudstream3.utils.UserPreferenceDelegate
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
import java.net.SocketTimeoutException
/**
* Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event
* dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper]
* ([PlayerGestureHelper]), which is exposed via delegate properties for easier access.
*/
@OptIn(UnstableApi::class)
class PlayerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
companion object {
private const val TAG = "PlayerView"
}
/** All gesture, volume, brightness and key-event logic lives here. */
val gestureHelper = PlayerGestureHelper(this)
/** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */
var isFullScreen: Boolean
get() = gestureHelper.isFullScreen
set(value) { gestureHelper.isFullScreen = value }
var isLocked: Boolean
get() = gestureHelper.isLocked
set(value) { gestureHelper.isLocked = value }
var videoOutline: View?
get() = gestureHelper.videoOutline
set(value) { gestureHelper.videoOutline = value }
/** Delegate methods */
fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode)
fun verifyVolume() = gestureHelper.verifyVolume()
fun setupKeyEventListener() = gestureHelper.setupKeyEventListener()
fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener()
fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout()
fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener()
/** Callbacks */
/** Host-fragment-level callbacks invoked by [mainCallback]. */
interface Callbacks {
fun nextEpisode() {}
fun prevEpisode() {}
fun playerPositionChanged(position: Long, duration: Long) {}
fun playerStatusChanged() {}
fun playerDimensionsLoaded(width: Int, height: Int) {}
fun subtitlesChanged() {}
fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
fun onTracksInfoChanged() {}
fun onTimestamp(timestamp: VideoSkipStamp?) {}
fun onTimestampSkipped(timestamp: VideoSkipStamp) {}
fun exitedPipMode() {}
fun hasNextMirror(): Boolean = false
fun nextMirror() {}
fun onDownload(event: DownloadEvent) {}
fun playerError(exception: Throwable) {}
/** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */
fun playerUpdated(player: Any?) {}
/** Called on a short single-tap on empty player area (no swipe, no double-tap). */
fun onSingleTap() {}
/** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */
fun onHoldSpeedUp(show: Boolean) {}
/** Called during brightness swipe with the current extra-brightness alpha (01). */
fun onBrightnessExtra(alpha: Float) {}
/** Touch event callbacks */
/** Returns whether the player UI (controls overlay) is currently visible. */
fun isUIShowing(): Boolean = false
/** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */
fun onTouchDown() {}
/** Called with seek-preview text during a horizontal-swipe, or null to clear it. */
fun onSeekPreviewText(text: String?) {}
/** Called when a swipe gesture begins; hide the player UI if desired. */
fun onHidePlayerUI() {}
/**
* Called at the end of each touch sequence.
* @param hadSwipe true if a swipe (brightness/volume/time) was in progress.
* @param wasUiShowing true if the UI was visible when the swipe began.
*/
fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {}
/**
* Called when the auto-hide timer fires: UI is showing, no touch is active.
* Implement to hide the player controls.
*/
fun onAutoHideUI() {}
}
var callbacks: Callbacks? = null
/** Player state */
var player: IPlayer = CS3IPlayer()
var resizeMode: Int = 0
var hasPipModeSupport: Boolean = true
var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering
var mMediaSession: MediaSession? = null
private var pipReceiver: BroadcastReceiver? = null
/** Auto-hide */
private var autoHideToken = 0
private val autoHideHandler = Handler(Looper.getMainLooper())
/** View references (populated by bindViews) */
var subView: SubtitleView? = null
var playerPausePlayHolderHolder: FrameLayout? = null
var playerPausePlay: ImageView? = null
var playerBuffering: ProgressBar? = null
/** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */
var exoPlayerView: androidx.media3.ui.PlayerView? = null
var piphide: FrameLayout? = null
var subtitleHolder: FrameLayout? = null
internal var playerRew: View? = null
internal var playerFfwd: View? = null
internal var exoRewText: TextView? = null
internal var exoFfwdText: TextView? = null
internal var playerCenterMenu: View? = null
internal var playerRewHolder: View? = null
internal var playerFfwdHolder: View? = null
internal var playerVideoHolder: View? = null
var playerProgressbarLeftHolder: RelativeLayout? = null
var playerProgressbarLeftIcon: ImageView? = null
var playerProgressbarLeftLevel1: ProgressBar? = null
var playerProgressbarLeftLevel2: ProgressBar? = null
var playerProgressbarRightHolder: RelativeLayout? = null
var playerProgressbarRightIcon: ImageView? = null
var playerProgressbarRightLevel1: ProgressBar? = null
var playerProgressbarRightLevel2: ProgressBar? = null
/** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */
internal var playerSpeedupButton: View? = null
var playerHolder: FrameLayout? = null
private var exoDuration: TextView? = null
private var timeLeft: TextView? = null
private var exoPosition: TextView? = null
private var timeLive: View? = null
private var exoProgress: LivePreviewTimeBar? = null
/** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */
var seekTime: Long = 10_000L
/** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */
var isVerticalOrientation: Boolean = false
/** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */
var autoPlayerRotateEnabled: Boolean = false
var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false)
// Kept so SubtitlesFragment can unsubscribe the exact same reference.
private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged
/** View discovery */
/**
* Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply
* remain null, all usage is null-safe.
*/
fun bindViews(root: View) {
exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration)
exoFfwdText = root.findViewById(R.id.exo_ffwd_text)
exoPlayerView = root.findViewById(R.id.player_view)
exoPosition = root.findViewById(R.id.exo_position)
exoRewText = root.findViewById(R.id.exo_rew_text)
piphide = root.findViewById(R.id.piphide)
playerBuffering = root.findViewById(R.id.player_buffering)
playerCenterMenu = root.findViewById(R.id.player_center_menu)
playerFfwd = root.findViewById(R.id.player_ffwd)
playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder)
playerHolder = root.findViewById(R.id.player_holder)
playerPausePlay = root.findViewById(R.id.player_pause_play)
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder)
playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon)
playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1)
playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2)
playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder)
playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon)
playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1)
playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2)
playerRew = root.findViewById(R.id.player_rew)
playerRewHolder = root.findViewById(R.id.player_rew_holder)
playerSpeedupButton = root.findViewById(R.id.player_speedup_button)
playerVideoHolder = root.findViewById(R.id.player_video_holder)
subtitleHolder = root.findViewById(R.id.subtitle_holder)
timeLeft = root.findViewById(R.id.time_left)
timeLive = root.findViewById(R.id.time_live)
}
/**
* Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener,
* player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper].
*/
fun initialize() {
resizeMode = DataStoreHelper.resizeMode
resize(resizeMode, false)
player.releaseCallbacks()
player.initCallbacks(
eventHandler = ::mainCallback,
requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE,
),
)
if (player is CS3IPlayer) {
// Preview bar
val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress)
exoProgress = progressBar as? LivePreviewTimeBar
val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView)
val previewFrameLayout: FrameLayout? =
exoPlayerView?.findViewById(R.id.previewFrameLayout)
/** Hide the previewFrameLayout on TV to make the skip op button not float,
* as previewFrameLayout is normally invisible */
if(isLayout(TV)) {
previewFrameLayout?.isVisible = false
}
if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) {
var resume = false
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
override fun onScrubStart(previewBar: PreviewBar?) {
val cs3 = player as? CS3IPlayer ?: return
val hasPreview = cs3.hasPreview()
progressBar.isPreviewEnabled = hasPreview
resume = cs3.getIsPlaying()
if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
// No clashing UI
if (hasPreview) subView?.isVisible = false
}
override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {}
override fun onScrubStop(previewBar: PreviewBar?) {
val cs3 = player as? CS3IPlayer ?: return
if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
// Delay to prevent the small flicker of subtitle before seeking.
subView?.postDelayed({
// If we are not scrubbing then show subtitles again.
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
subView?.isVisible = true
}
}, 200)
}
})
progressBar.attachPreviewView(previewFrameLayout)
progressBar.setPreviewLoader { currentPosition, max ->
val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader
val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat()))
previewImageView.isGone = bitmap == null
previewImageView.setImageBitmap(bitmap)
}
}
subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
(player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
(player as? CS3IPlayer)?.let {
(it.imageGenerator as? PreviewGenerator)?.params =
ImageParams.new16by9(screenWidth)
}
/**
* This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
* and once by the UI even if it should only be registered once by the UI.
*/
exoPlayerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
?.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
if (canceled) return
val playerDuration = player.getDuration() ?: return
val playerPosition = player.getPosition() ?: return
mainCallback(
PositionEvent(
source = PlayerEventSource.UI,
durationMs = playerDuration,
fromMs = playerPosition,
toMs = position
)
)
}
})
// Read seek time and rotation settings.
try {
val sm = PreferenceManager.getDefaultSharedPreferences(context)
seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10)
.toLong() * 1000L
autoPlayerRotateEnabled = sm.getBoolean(
context.getString(R.string.auto_rotate_video_key), true
)
} catch (_: Exception) {
}
val seekSecs = (seekTime / 1000).toInt()
exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs)
exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs)
playerPausePlay?.setOnClickListener {
scheduleAutoHide()
if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI)
} else {
player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI)
}
}
playerRew?.setOnClickListener {
scheduleAutoHide()
gestureHelper.rewind()
}
playerFfwd?.setOnClickListener {
scheduleAutoHide()
gestureHelper.fastForward()
}
SubtitlesFragment.applyStyleEvent += subStyleListener
try {
val ctx = context
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
val cs3 = player as? CS3IPlayer ?: return
cs3.cacheSize =
settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L
cs3.simpleCacheSize =
settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L
cs3.videoBufferMs =
settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L
} catch (e: Exception) {
logError(e)
}
// Duration toggle click listeners
exoDuration?.setOnClickListener { setRemainingTimeCounter(true) }
timeLeft?.setOnClickListener { setRemainingTimeCounter(false) }
// Keep remaining-time text in sync with playback position
exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() }
// Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener)
gestureHelper.initialize()
setupKeyEventListener()
// Apply duration-mode display (remaining time vs elapsed); TV always shows remaining
setRemainingTimeCounter(durationMode || isLayout(TV))
}
}
/** Lifecycle delegation */
var fullscreenNotch: Boolean = true // TODO SETTING
fun enterFullscreen(updateOrientation: () -> Unit = {}) {
val activity = context as? Activity
if (isFullScreen) {
activity?.hideSystemUI()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) {
val params = activity?.window?.attributes
params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
activity?.window?.attributes = params
}
}
updateOrientation()
}
fun exitFullscreen() {
val activity = context as? Activity
gestureHelper.resetZoomToDefault()
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
// Simply resets brightness and notch settings that might have been overridden.
val lp = activity?.window?.attributes
lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
activity?.window?.attributes = lp
activity?.showSystemUI()
}
fun onStop() {
player.onStop()
}
fun onResume(ctx: Context) {
player.onResume(ctx)
}
/** Releases all player resources. */
fun release() {
player.release()
player.releaseCallbacks()
player = CS3IPlayer()
// keyEventListener is deregistered in onPause so that the incoming player's
// onResume can register its own listener without racing against release().
PlayerPipHelper.updatePIPModeActions(
context as? Activity,
CSPlayerLoading.IsPaused,
false,
null
)
mMediaSession?.release()
mMediaSession = null
exoPlayerView?.player = null
SubtitlesFragment.applyStyleEvent -= subStyleListener
gestureHelper.release()
autoHideHandler.removeCallbacksAndMessages(null)
keepScreenOn(false)
}
fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
activity: Activity?
) {
try {
isInPIPMode = isInPictureInPictureMode
if (isInPictureInPictureMode) {
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
piphide?.isVisible = false
pipReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_MEDIA_CONTROL != intent.action) return
player.handleEvent(
CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)],
source = PlayerEventSource.UI
)
}
}
val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
@SuppressLint("UnspecifiedRegisterReceiverFlag")
activity?.registerReceiver(pipReceiver, filter)
}
val isPlaying = player.getIsPlaying()
val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
updateIsPlaying(status, status)
} else {
// Restore the full-screen UI.
piphide?.isVisible = true
callbacks?.exitedPipMode()
pipReceiver?.let {
// Prevents java.lang.IllegalArgumentException: Receiver not registered
safe { activity?.unregisterReceiver(it) }
}
activity?.hideSystemUI()
hideKeyboard(this)
}
} catch (e: Exception) {
logError(e)
}
}
/** Player UI helpers */
private fun keepScreenOn(on: Boolean) {
val window = (context as? Activity)?.window ?: return
if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isBuffering = CSPlayerLoading.IsBuffering == isPlaying
currentPlayerStatus = isPlaying
keepScreenOn(isPlayingRightNow || isBuffering)
if (isBuffering) {
playerPausePlayHolderHolder?.isVisible = false
playerBuffering?.isVisible = true
} else {
playerPausePlayHolderHolder?.isVisible = true
playerBuffering?.isVisible = false
if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24)
} else if (wasPlaying != isPlaying) {
playerPausePlay?.setImageResource(
if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play
)
val drawable = playerPausePlay?.drawable
var startedAnimation = false
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true }
}
if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true }
if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true }
// Somehow the phone is wacked
if (!startedAnimation) {
playerPausePlay?.setImageResource(
if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play
)
}
} else {
playerPausePlay?.setImageResource(
if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play
)
}
}
PlayerPipHelper.updatePIPModeActions(
context as? Activity,
isPlaying,
hasPipModeSupport,
player.getAspectRatio()
)
}
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
}
}
private fun playerUpdated(player: Any?) {
if (player is ExoPlayer) {
mMediaSession?.release()
mMediaSession = MediaSession.Builder(context, player)
// Ensure unique ID for concurrent players.
.setId(System.currentTimeMillis().toString())
.build()
// Necessary for multiple combined videos.
@Suppress("DEPRECATION")
exoPlayerView?.setShowMultiWindowTimeBar(true)
exoPlayerView?.player = player
exoPlayerView?.performClick()
}
callbacks?.playerUpdated(player)
}
private fun onSubStyleChanged(style: SaveCaptionStyle) {
player.updateSubtitleStyle(style)
// Forcefully update the subtitle encoding in case the edge size is changed.
player.seekTime(-1)
}
/** Error handling */
@MainThread
fun playerError(exception: Throwable) {
fun showErrorToast(message: String) {
if (callbacks?.hasNextMirror() == true) {
showToast(message, Toast.LENGTH_SHORT)
callbacks?.nextMirror()
} else {
showToast(
context.getString(R.string.no_links_found_toast) + "\n" + message,
Toast.LENGTH_LONG
)
(context as? FragmentActivity)?.popCurrentPage()
}
}
when (exception) {
is PlaybackException -> {
val msg = exception.message ?: ""
val errorName = exception.errorCodeName
when (val code = exception.errorCode) {
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED ->
showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg")
PlaybackException.ERROR_CODE_REMOTE_ERROR,
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_TIMEOUT,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg")
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
PlaybackException.ERROR_CODE_DECODING_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED ->
showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg")
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES ->
showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg")
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED ->
showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg")
else ->
showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg")
}
}
is SocketTimeoutException ->
showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}")
is ErrorLoadingException ->
exception.message?.let { showErrorToast(it) }
?: showErrorToast(exception.toString())
else ->
exception.message?.let { showErrorToast(it) }
?: showErrorToast(exception.toString())
}
}
/** Resize */
fun nextResize() {
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
resize(resizeMode, true)
}
fun resize(resize: Int, showToast: Boolean) {
// Clear all zoom state before applying the new resize mode
gestureHelper.clearZoomState()
resize(PlayerResize.entries[resize], showToast)
}
fun resize(resize: PlayerResize, showToast: Boolean) {
DataStoreHelper.resizeMode = resize.ordinal
val type = when (resize) {
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
exoPlayerView?.resizeMode = type
if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT)
}
/** Orientation */
/**
* Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation]
* and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape.
* Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation.
*/
fun dynamicOrientation(): Int {
if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
return if (autoPlayerRotateEnabled && isVerticalOrientation)
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
/** Event dispatch */
/**
* This receives the events from the player, if you want to append functionality
* you do it here, do note that this only receives events for UI changes,
* and returning early WON'T stop it from changing in e.g. the player time
* or pause status.
*/
@MainThread
fun mainCallback(event: PlayerEvent) {
// We don't want to spam DownloadEvent.
if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event")
when (event) {
is DownloadEvent -> callbacks?.onDownload(event)
is ResizedEvent -> {
// Skip 0x0 dimensions that the player emits when going to STATE_IDLE
// to avoid incorrectly resetting the auto-detected orientation.
if (event.width > 0 && event.height > 0) {
// TV never rotates; otherwise track whether the video is portrait.
isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width
}
callbacks?.playerDimensionsLoaded(event.width, event.height)
}
is PlayerAttachedEvent -> playerUpdated(event.player)
is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged()
is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp)
is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp)
is TracksChangedEvent -> callbacks?.onTracksInfoChanged()
is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks)
is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error)
is RequestAudioFocusEvent -> requestAudioFocus()
is EpisodeSeekEvent -> when (event.offset) {
-1 -> callbacks?.prevEpisode()
1 -> callbacks?.nextEpisode()
}
is StatusEvent -> {
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
scheduleAutoHide()
callbacks?.playerStatusChanged()
}
is PositionEvent -> callbacks?.playerPositionChanged(
position = event.toMs,
duration = event.durationMs
)
is VideoEndedEvent -> {
// Only play next episode if autoplay is on (default).
val ctx = context
if (PreferenceManager.getDefaultSharedPreferences(ctx)
?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true
) {
player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player)
}
}
is PauseEvent -> Unit
is PlayEvent -> Unit
}
}
/** Duration display */
fun setRemainingTimeCounter(showRemaining: Boolean) {
durationMode = showRemaining
exoDuration?.isInvisible = showRemaining
timeLeft?.isVisible = showRemaining
if (showRemaining) updateRemainingTime()
}
fun updateRemainingTime() {
val duration = player.getDuration()
val position = player.getPosition()
if (exoProgress?.isAtLiveEdge() == true) {
timeLeft?.alpha = 0f
exoDuration?.alpha = 0f
timeLive?.isVisible = true
} else {
timeLeft?.alpha = 1f
exoDuration?.alpha = 1f
timeLive?.isVisible = false
}
if (duration != null && duration > 1 && position != null) {
val remainingTimeSeconds = (duration - position + 500) / 1000
@SuppressLint("SetTextI18n")
timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}"
}
}
/** Auto-hide */
/**
* Schedules a delayed auto-hide of the player UI after [delayMs] ms.
* Any previously pending hide is canceled first.
* The hide fires only when no touch is active and [Callbacks.isUIShowing] is true;
* the actual hide action is delegated to [Callbacks.onAutoHideUI].
*/
fun scheduleAutoHide(delayMs: Long = 3000L) {
val token = ++autoHideToken
autoHideHandler.removeCallbacksAndMessages(null)
autoHideHandler.postDelayed({
if (token != autoHideToken) return@postDelayed
if (gestureHelper.isCurrentTouchValid) return@postDelayed
if (callbacks?.isUIShowing() != true) return@postDelayed
callbacks?.onAutoHideUI()
}, delayMs)
}
/** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */
fun cancelAutoHide() {
autoHideToken++
autoHideHandler.removeCallbacksAndMessages(null)
}
}

View file

@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import kotlin.math.max import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min import java.util.concurrent.atomic.AtomicInteger
data class Cache( data class Cache(
val linkCache: MutableSet<ExtractorLink>, val linkCache: MutableSet<ExtractorLink>,
@ -23,9 +23,8 @@ data class Cache(
class RepoLinkGenerator( class RepoLinkGenerator(
episodes: List<ResultEpisode>, episodes: List<ResultEpisode>,
currentIndex: Int = 0,
val page: LoadResponse? = null, val page: LoadResponse? = null,
) : VideoGenerator<ResultEpisode>(episodes, currentIndex) { ) : VideoGenerator<ResultEpisode>(episodes) {
companion object { companion object {
const val TAG = "RepoLink" const val TAG = "RepoLink"
val cache: HashMap<Pair<String, Int>, Cache> = val cache: HashMap<Pair<String, Int>, Cache> =
@ -34,6 +33,7 @@ class RepoLinkGenerator(
override val hasCache = true override val hasCache = true
override val canSkipLoading = true override val canSkipLoading = true
override fun getId(index: Int): Int? = videos.getOrNull(index)?.id
// this is a simple array that is used to instantly load links if they are already loaded // this is a simple array that is used to instantly load links if they are already loaded
//var linkCache = Array<Set<ExtractorLink>>(size = episodes.size, init = { setOf() }) //var linkCache = Array<Set<ExtractorLink>>(size = episodes.size, init = { setOf() })
@ -48,7 +48,7 @@ class RepoLinkGenerator(
offset: Int, offset: Int,
isCasting: Boolean, isCasting: Boolean,
): Boolean { ): Boolean {
val current = getCurrent(offset) ?: return false val current = videos.getOrNull(offset) ?: return false
val currentCache = synchronized(cache) { val currentCache = synchronized(cache) {
cache[current.apiName to current.id] ?: Cache( cache[current.apiName to current.id] ?: Cache(
@ -61,10 +61,12 @@ class RepoLinkGenerator(
} }
} }
// these act as a general filter to prevent duplication of links or names // These act as a general filter to prevent duplication of links or names
val currentLinksUrls = mutableSetOf<String>() // makes all urls unique // Avoid any possible ConcurrentModificationException
val currentSubsUrls = mutableSetOf<String>() // makes all subs urls unique val currentLinksUrls = ConcurrentHashMap.newKeySet<String>()
val lastCountedSuffix = mutableMapOf<String, UInt>() val currentSubsUrls = ConcurrentHashMap.newKeySet<String>()
// Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen!
val lastCountedSuffix = ConcurrentHashMap<String, AtomicInteger>()
synchronized(currentCache) { synchronized(currentCache) {
val outdatedCache = val outdatedCache =
@ -75,7 +77,10 @@ class RepoLinkGenerator(
currentCache.subtitleCache.clear() currentCache.subtitleCache.clear()
currentCache.saturated = false currentCache.saturated = false
} else if (currentCache.linkCache.isNotEmpty()) { } else if (currentCache.linkCache.isNotEmpty()) {
Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago") Log.d(
TAG,
"Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago"
)
} }
// call all callbacks // call all callbacks
@ -88,8 +93,7 @@ class RepoLinkGenerator(
currentCache.subtitleCache.forEach { sub -> currentCache.subtitleCache.forEach { sub ->
currentSubsUrls.add(sub.url) currentSubsUrls.add(sub.url)
val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet()
lastCountedSuffix[sub.originalName] = suffixCount
subtitleCallback(sub) subtitleCallback(sub)
} }
@ -108,17 +112,15 @@ class RepoLinkGenerator(
subtitleCallback = { file -> subtitleCallback = { file ->
Log.d(TAG, "Loaded SubtitleFile: $file") Log.d(TAG, "Loaded SubtitleFile: $file")
val correctFile = PlayerSubtitleHelper.getSubtitleData(file) val correctFile = PlayerSubtitleHelper.getSubtitleData(file)
if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) { if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) {
return@loadLinks return@loadLinks
} }
currentSubsUrls.add(correctFile.url)
// this part makes sure that all names are unique for UX // this part makes sure that all names are unique for UX
val nameDecoded = correctFile.originalName.html().toString()
val nameDecoded = correctFile.originalName.html().toString().trim() // `%3Ch1%3Esub%20name…` → `<h1>sub name…` → `sub name…` .trim() // `%3Ch1%3Esub%20name…` → `<h1>sub name…` → `sub name…`
val suffixCount =
val suffixCount = lastCountedSuffix.getOrDefault(nameDecoded, 0u) +1u lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet()
lastCountedSuffix[nameDecoded] = suffixCount
val updatedFile = val updatedFile =
correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount")
@ -132,10 +134,9 @@ class RepoLinkGenerator(
}, },
callback = { link -> callback = { link ->
Log.d(TAG, "Loaded ExtractorLink: $link") Log.d(TAG, "Loaded ExtractorLink: $link")
if (link.url.isBlank() || currentLinksUrls.contains(link.url)) { if (link.url.isBlank() || !currentLinksUrls.add(link.url)) {
return@loadLinks return@loadLinks
} }
currentLinksUrls.add(link.url)
synchronized(currentCache) { synchronized(currentCache) {
if (currentCache.linkCache.add(link)) { if (currentCache.linkCache.add(link)) {

View file

@ -13,6 +13,7 @@ package com.lagradost.cloudstream3.ui.player
import android.net.Uri import android.net.Uri
import androidx.annotation.GuardedBy import androidx.annotation.GuardedBy
import androidx.media3.common.C
import androidx.media3.common.FileTypes import androidx.media3.common.FileTypes
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.util.TimestampAdjuster import androidx.media3.common.util.TimestampAdjuster
@ -48,7 +49,6 @@ import java.lang.reflect.Constructor
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
/** /**
* An [ExtractorsFactory] that provides an array of extractors for the following formats: * An [ExtractorsFactory] that provides an array of extractors for the following formats:
* *
@ -103,13 +103,16 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
private var tsTimestampSearchBytes: Int private var tsTimestampSearchBytes: Int
private var textTrackTranscodingEnabled: Boolean private var textTrackTranscodingEnabled: Boolean
private var subtitleParserFactory: SubtitleParser.Factory private var subtitleParserFactory: SubtitleParser.Factory
private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int
private var jpegFlags: @JpegExtractor.Flags Int = 0 private var jpegFlags: @JpegExtractor.Flags Int = 0
private var heifFlags: @HeifExtractor.Flags Int = 0
init { init {
tsMode = TsExtractor.MODE_SINGLE_PMT tsMode = TsExtractor.MODE_SINGLE_PMT
tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
subtitleParserFactory = DefaultSubtitleParserFactory() subtitleParserFactory = DefaultSubtitleParserFactory()
textTrackTranscodingEnabled = true textTrackTranscodingEnabled = true
codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265
} }
/** /**
@ -346,6 +349,14 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
return this return this
} }
@Synchronized
override fun experimentalSetCodecsToParseWithinGopSampleDependencies(
codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int
): UpdatedDefaultExtractorsFactory {
this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies
return this
}
/** /**
* Sets flags for [JpegExtractor] instances created by the factory. * Sets flags for [JpegExtractor] instances created by the factory.
* *
@ -361,6 +372,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
return this return this
} }
/**
* Sets flags for [HeifExtractor] instances created by the factory.
*
* @see HeifExtractor.HeifExtractor
* @param flags The flags to use.
* @return The factory, for convenience.
*/
@Synchronized
fun setHeifExtractorFlags(
flags: @HeifExtractor.Flags Int
): UpdatedDefaultExtractorsFactory {
this.heifFlags = flags
return this
}
@Synchronized @Synchronized
override fun createExtractors(): Array<Extractor> { override fun createExtractors(): Array<Extractor> {
return createExtractors(Uri.EMPTY, HashMap()) return createExtractors(Uri.EMPTY, HashMap())
@ -468,21 +494,26 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
extractors.add( extractors.add(
FragmentedMp4Extractor( FragmentedMp4Extractor(
subtitleParserFactory, subtitleParserFactory,
fragmentedMp4Flags fragmentedMp4Flags or
or (if (textTrackTranscodingEnabled) FragmentedMp4Extractor
0 .codecsToParseWithinGopSampleDependenciesAsFlags(
else codecsToParseWithinGopSampleDependencies
FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) or
if (textTrackTranscodingEnabled) 0
else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA
) )
) )
extractors.add( extractors.add(
Mp4Extractor( Mp4Extractor(
subtitleParserFactory, subtitleParserFactory,
mp4Flags mp4Flags or
or (if (textTrackTranscodingEnabled) Mp4Extractor
0 .codecsToParseWithinGopSampleDependenciesAsFlags(
else codecsToParseWithinGopSampleDependencies
Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) or
if (textTrackTranscodingEnabled) 0
else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA
) )
) )
} }
@ -524,12 +555,7 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory {
FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.PNG -> extractors.add(PngExtractor())
FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor())
FileTypes.BMP -> extractors.add(BmpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor())
FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags))
&& (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0
) {
extractors.add(HeifExtractor())
}
FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.AVIF -> extractors.add(AvifExtractor())
FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} FileTypes.WEBVTT, FileTypes.UNKNOWN -> {}
else -> {} else -> {}

View file

@ -0,0 +1,77 @@
package com.lagradost.cloudstream3.ui.player.live
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.mvvm.debugWarning
import java.util.WeakHashMap
object LiveHelper {
private val liveManagers = WeakHashMap<Player, Pair<LiveManager, Player.Listener>>()
@OptIn(UnstableApi::class)
fun registerPlayer(player: Player?) {
if (player == null) {
debugWarning { "LiveHelper registerPlayer called with null player!" }
return
}
// Prevent duplicates
if (liveManagers.contains(player)) {
return
}
val liveManager = LiveManager(player)
val listener = object : Player.Listener {
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val window = Timeline.Window()
timeline.getWindow(player.currentMediaItemIndex, window)
if (window.isDynamic) {
liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs))
}
super.onTimelineChanged(timeline, reason)
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs)
// Seek back to the optimal live spot
if (timeAheadOfLive > 100) {
player.seekTo(newPosition.positionMs - timeAheadOfLive)
}
}
}
synchronized(liveManagers) {
player.addListener(listener)
liveManagers[player] = liveManager to listener
}
}
fun unregisterPlayer(player: Player?) {
if (player == null) {
debugWarning { "LiveHelper unregisterPlayer called with null player!" }
return
}
// Prevent duplicates
if (!liveManagers.contains(player)) {
return
}
synchronized(liveManagers) {
liveManagers[player]?.let { (_, listener) ->
player.removeListener(listener)
}
liveManagers.remove(player)
}
}
fun getLiveManager(player: Player?) = liveManagers[player]?.first
}

View file

@ -0,0 +1,97 @@
package com.lagradost.cloudstream3.ui.player.live
import androidx.media3.common.C
import androidx.media3.common.Player
import java.lang.ref.WeakReference
// How much margin from the live point is still considered "live"
const val LIVE_MARGIN = 6_000L
// How many ms should we be behind the real live point?
// Too low, and we cannot pre-buffer
// Too high, and we are no longer live
const val PREFERRED_LIVE_OFFSET = 5_000L
// An extra offset from the optimal calculated timestamp
// This is to account for chunk updates not always being the same size
const val CHUNK_VARIANCE = 3000L
// A livestream chunk from the player, the time we get it and the duration can be used to calculate
// the expected live timestamp.
class LivestreamChunk(
durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis()
) {
// We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point.
// If we are ahead of the middle point we will reach the end before the new chunk is expected to be released.
val targetPosition = maxOf(0,minOf(
durationMs - PREFERRED_LIVE_OFFSET,
durationMs / 2 - CHUNK_VARIANCE
))
fun isPositionLive(position: Long): Boolean {
val currentTime = System.currentTimeMillis()
val livePosition = targetPosition + (currentTime - receiveTimeMs)
val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET
// println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive")
return withinLive
}
fun getTimeAheadOfLive(position: Long): Long {
val currentTime = System.currentTimeMillis()
val livePosition = targetPosition + (currentTime - receiveTimeMs)
// println("Ahead of live: ${position-livePosition}")
return position - livePosition
}
}
// There are two types of livestreams we need to manage
// 1. A livestream with no history, a continually sliding window.
// This livestream has no currentLiveOffset, which means we need to calculate
// the real live point based on when we receive the latest update and the size of that update.
// 2. A livestream with history.
// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point.
// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations.
class LiveManager {
private var _currentPlayer: WeakReference<Player>? = null
val currentPlayer: Player? get() = _currentPlayer?.get()
constructor(player: Player?) {
_currentPlayer = WeakReference(player)
}
private var lastLivestreamChunk: LivestreamChunk? = null
fun submitLivestreamChunk(chunk: LivestreamChunk) {
lastLivestreamChunk = chunk
}
/** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */
fun getTimeAheadOfLive(position: Long): Long {
val player = currentPlayer ?: return 0
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0
// If the currentLiveOffset is wrong we fall back to manual calculations
val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
val relativeOffset = player.currentLiveOffset - player.currentPosition + position
PREFERRED_LIVE_OFFSET - relativeOffset
} else {
lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0
}
// Ensure min of 0
return maxOf(0, ahead)
}
/** Check if the stream is currently at the expected live edge, with margins */
fun isAtLiveEdge(): Boolean {
val player = currentPlayer ?: return false
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false
// If the currentLiveOffset is wrong we fall back to manual calculations
return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET
} else {
lastLivestreamChunk?.isPositionLive(player.currentPosition) == true
}
}
}

View file

@ -0,0 +1,38 @@
package com.lagradost.cloudstream3.ui.player.live
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import androidx.media3.ui.R
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
import java.lang.ref.WeakReference
@OptIn(UnstableApi::class)
class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) {
private var _currentPlayerView: WeakReference<PlayerView>? = null
val currentPlayer: Player? get() = _currentPlayerView?.get()?.player
fun registerPlayerView(player: PlayerView?) {
_currentPlayerView = WeakReference(player)
val controller =
_currentPlayerView?.get()?.findViewById<PlayerControlView>(R.id.exo_controller)
controller?.setProgressUpdateListener { position, bufferedPosition ->
currentPlayer?.let { player ->
if (isAtLiveEdge()) {
setPosition(player.duration)
}
}
}
}
fun isAtLiveEdge(): Boolean {
return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true
}
}

View file

@ -20,7 +20,7 @@ import com.lagradost.cloudstream3.utils.drawableToBitmap
import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setText
class ProfilesAdapter( class ProfilesAdapter(
val usedProfile: Int, val usedProfile: Int?,
val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit,
) : ) :
NoStateAdapter<QualityDataHelper.QualityProfile>(diffCallback = BaseDiffCallback(itemSame = { a, b -> NoStateAdapter<QualityDataHelper.QualityProfile>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
@ -68,25 +68,27 @@ class ProfilesAdapter(
val profileBg: ImageView = binding.profileImageBackground val profileBg: ImageView = binding.profileImageBackground
val wifiText: TextView = binding.textIsWifi val wifiText: TextView = binding.textIsWifi
val dataText: TextView = binding.textIsMobileData val dataText: TextView = binding.textIsMobileData
val downloadText: TextView = binding.textIsDownloadData
val outline: View = binding.outline val outline: View = binding.outline
val cardView: View = binding.cardView val cardView: View = binding.cardView
val itemView = holder.itemView val itemView = holder.itemView
priorityText.setText(item.name) priorityText.setText(item.name)
dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data)
wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi)
downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download)
fun setCurrentItem() { fun setCurrentItem() {
val prevIndex = currentItem?.first val prevIndex = currentItem
// Prevent UI bug when re-selecting the item quickly // Prevent UI bug when re-selecting the item quickly
if (prevIndex == position) { if (prevIndex == position) {
return return
} }
currentItem = position to item currentItem = position
clickCallback.invoke(prevIndex, position) clickCallback.invoke(prevIndex, position)
} }
outline.isVisible = currentItem?.second?.id == item.id outline.isVisible = currentItem == position
val drawableResId = art[position % art.size] val drawableResId = art[position % art.size]
profileBg.loadImage(drawableResId) profileBg.loadImage(drawableResId)
@ -107,6 +109,7 @@ class ProfilesAdapter(
if (color != null) { if (color != null) {
wifiText.backgroundTintList = ColorStateList.valueOf(color) wifiText.backgroundTintList = ColorStateList.valueOf(color)
dataText.backgroundTintList = ColorStateList.valueOf(color) dataText.backgroundTintList = ColorStateList.valueOf(color)
downloadText.backgroundTintList = ColorStateList.valueOf(color)
} }
} }
} }
@ -126,9 +129,9 @@ class ProfilesAdapter(
} }
} }
private var currentItem: Pair<Int, QualityDataHelper.QualityProfile>? = null private var currentItem: Int? = null
fun getCurrentProfile(): QualityDataHelper.QualityProfile? { fun getCurrentProfile(): QualityDataHelper.QualityProfile? {
return currentItem?.second return currentItem?.let { index -> immutableCurrentList.getOrNull(index) }
} }
} }

View file

@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player.source_priority
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -9,14 +10,23 @@ import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
import kotlin.math.abs
object QualityDataHelper { object QualityDataHelper {
private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" private const val VIDEO_SOURCE_PRIORITY = "video_source_priority"
private const val VIDEO_PROFILE_NAME = "video_profile_name" private const val VIDEO_PROFILE_NAME = "video_profile_name"
private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority"
// Old key only supporting one type per profile
@Deprecated("Changed to support multiple types per profile")
private const val VIDEO_PROFILE_TYPE = "video_profile_type" private const val VIDEO_PROFILE_TYPE = "video_profile_type"
// New key supporting more than one type per profile
private const val VIDEO_PROFILE_TYPES = "video_profile_types_2"
private const val DEFAULT_SOURCE_PRIORITY = 1 private const val DEFAULT_SOURCE_PRIORITY = 1
/** /**
* Automatically skip loading links once this priority is reached * Automatically skip loading links once this priority is reached
**/ **/
@ -33,13 +43,14 @@ object QualityDataHelper {
enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) {
None(R.string.none, false), None(R.string.none, false),
WiFi(R.string.wifi, true), WiFi(R.string.wifi, true),
Data(R.string.mobile_data, true) Data(R.string.mobile_data, true),
Download(R.string.download, true)
} }
data class QualityProfile( data class QualityProfile(
val name: UiText, val name: UiText,
val id: Int, val id: Int,
val type: QualityProfileType val types: Set<QualityProfileType>
) )
fun getSourcePriority(profile: Int, name: String?): Int { fun getSourcePriority(profile: Int, name: String?): Int {
@ -51,8 +62,21 @@ object QualityDataHelper {
) ?: DEFAULT_SOURCE_PRIORITY ) ?: DEFAULT_SOURCE_PRIORITY
} }
fun getAllSourcePriorityNames(profile: Int): List<String> {
val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile"
return getKeys(folder)?.map { key ->
key.substringAfter("$folder/")
} ?: emptyList()
}
fun setSourcePriority(profile: Int, name: String, priority: Int) { fun setSourcePriority(profile: Int, name: String, priority: Int) {
setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile"
// Prevent unnecessary keys
if (priority == DEFAULT_SOURCE_PRIORITY) {
removeKey(folder, name)
} else {
setKey(folder, name, priority)
}
} }
fun setProfileName(profile: Int, name: String?) { fun setProfileName(profile: Int, name: String?) {
@ -85,16 +109,40 @@ object QualityDataHelper {
) )
} }
fun getQualityProfileType(profile: Int): QualityProfileType {
return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None @Suppress("DEPRECATION")
fun getQualityProfileTypes(profile: Int): Set<QualityProfileType> {
val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile"
// Use arrays for to make with work with setKey properly (weird crashes otherwise)
val newProfiles = getKey<Array<QualityProfileType>>(newKey)?.toSet()
// Migrate to new profile key
if (newProfiles == null) {
val oldProfile =
getKey<QualityProfileType>("$currentAccount/$VIDEO_PROFILE_TYPE/$profile")
val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf()
setKey(newKey, newSet)
return newSet.toSet()
} else {
return newProfiles
}
} }
fun setQualityProfileType(profile: Int, type: QualityProfileType?) { fun addQualityProfileType(profile: Int, type: QualityProfileType) {
val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile"
if (type == QualityProfileType.None) { val currentTypes = getQualityProfileTypes(profile)
removeKey(path)
} else { if (type != QualityProfileType.None) {
setKey(path, type) setKey(path, (currentTypes + type).toTypedArray())
}
}
fun removeQualityProfileType(profile: Int, type: QualityProfileType) {
val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile"
val currentTypes = getQualityProfileTypes(profile)
if (type != QualityProfileType.None) {
setKey(path, (currentTypes - type).toTypedArray())
} }
} }
@ -106,37 +154,39 @@ object QualityDataHelper {
val availableTypes = QualityProfileType.entries.toMutableList() val availableTypes = QualityProfileType.entries.toMutableList()
val profiles = (1..PROFILE_COUNT).map { profileNumber -> val profiles = (1..PROFILE_COUNT).map { profileNumber ->
// Get the real type // Get the real type
val type = getQualityProfileType(profileNumber) val types = getQualityProfileTypes(profileNumber)
// This makes it impossible to get more than one of each type val uniqueTypes = types.mapNotNull { type ->
// Duplicates will be turned to None // This makes it impossible to get more than one of each type
val uniqueType = if (type.unique && !availableTypes.remove(type)) { if (type.unique && !availableTypes.remove(type)) {
QualityProfileType.None null
} else { } else {
type type
} }
}.toSet()
QualityProfile( QualityProfile(
getProfileName(profileNumber), getProfileName(profileNumber),
profileNumber, profileNumber,
uniqueType uniqueTypes
) )
}.toMutableList() }.toMutableList()
/** /**
* If no profile of this type exists: insert it on the earliest profile with None type * If no profile of this type exists: insert it on the earliest profile
**/ **/
fun insertType( fun insertType(
list: MutableList<QualityProfile>, list: MutableList<QualityProfile>,
type: QualityProfileType type: QualityProfileType
) { ) {
if (list.any { it.type == type }) return if (list.any { it.types.contains(type) }) return
val index =
list.indexOfFirst { it.type == QualityProfileType.None } synchronized(list) {
list.getOrNull(index)?.copy(type = type) val firstItem = list.firstOrNull() ?: return
?.let { fixed -> val fixedTypes = firstItem.types + type
list.set(index, fixed) val fixedItem = firstItem.copy(types = fixedTypes)
} list.set(0, fixedItem)
}
} }
QualityProfileType.entries.forEach { QualityProfileType.entries.forEach {
@ -145,7 +195,7 @@ object QualityDataHelper {
debugAssert({ debugAssert({
!QualityProfileType.entries.all { type -> !QualityProfileType.entries.all { type ->
!type.unique || profiles.any { it.type == type } !type.unique || profiles.any { it.types.contains(type) }
} }
}, { "All unique quality types do not exist" }) }, { "All unique quality types do not exist" })
@ -155,4 +205,22 @@ object QualityDataHelper {
return profiles return profiles
} }
fun getLinkPriority(
qualityProfile: Int,
linkData: ExtractorLink?
): Int {
val qualityPriority = getQualityPriority(
qualityProfile,
closestQuality(linkData?.quality)
)
val sourcePriority = getSourcePriority(qualityProfile, linkData?.source)
return qualityPriority + sourcePriority
}
private fun closestQuality(target: Int?): Qualities {
if (target == null) return Qualities.Unknown
return Qualities.entries.minBy { abs(it.value - target) }
}
} }

View file

@ -2,45 +2,74 @@ package com.lagradost.cloudstream3.ui.player.source_priority
import android.app.Dialog import android.app.Dialog
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setText
class QualityProfileDialog( /** Simplified ExtractorLink for the quality profile dialog */
data class LinkSource(
val source: String
) {
constructor(extractorLink: ExtractorLink) : this(extractorLink.source)
}
class QualityProfileDialog private constructor(
val activity: FragmentActivity, val activity: FragmentActivity,
@StyleRes val themeRes: Int, @StyleRes val themeRes: Int,
private val links: List<ExtractorLink>, private val links: List<LinkSource>,
private val usedProfile: Int, private val usedProfile: Int?,
private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?,
private val useProfileSelection: Boolean
) : Dialog(activity, themeRes) { ) : Dialog(activity, themeRes) {
override fun show() { constructor(
activity: FragmentActivity,
@StyleRes themeRes: Int,
links: List<LinkSource>,
usedProfile: Int,
profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit),
) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true)
constructor(
activity: FragmentActivity,
@StyleRes themeRes: Int,
links: List<LinkSource>
) : this(activity, themeRes, links, null, null, false)
companion object {
// Run on IO as this may be a heavy operation
suspend fun getAllDefaultSources(): List<LinkSource> = ioWork {
getProfiles().flatMap {
getAllSourcePriorityNames(it.id)
}.distinct().map { LinkSource(it) }
}
}
override fun show() {
val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false)
setContentView(binding.root)//R.layout.player_quality_profile_dialog) setContentView(binding.root)
fixSystemBarsPadding(binding.root) fixSystemBarsPadding(binding.root)
/*val profilesRecyclerView: RecyclerView = profiles_recyclerview
val useBtt: View = use_btt
val editBtt: View = edit_btt
val cancelBtt: View = cancel_btt
val defaultBtt: View = set_default_btt
val currentProfileText: TextView = currently_selected_profile_text
val selectedItemActionsHolder: View = selected_item_holder*/
binding.apply { binding.apply {
fun getCurrentProfile(): QualityDataHelper.QualityProfile? { fun getCurrentProfile(): QualityDataHelper.QualityProfile? {
return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile()
} }
fun refreshProfiles() { fun refreshProfiles() {
currentlySelectedProfileText.setText(getProfileName(usedProfile)) if (usedProfile != null) {
currentlySelectedProfileText.setText(getProfileName(usedProfile))
}
(profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles())
} }
@ -67,37 +96,52 @@ class QualityProfileDialog(
setDefaultBtt.setOnClickListener { setDefaultBtt.setOnClickListener {
val currentProfile = getCurrentProfile() ?: return@setOnClickListener val currentProfile = getCurrentProfile() ?: return@setOnClickListener
val choices = QualityDataHelper.QualityProfileType.entries val choices =
.filter { it != QualityDataHelper.QualityProfileType.None } QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None }
val choiceNames = choices.map { txt(it.stringRes).asString(context) } val choiceNames = choices.map { txt(it.stringRes).asString(context) }
val selectedIndices = choices.mapIndexed { index, type -> index to type }
.filter { currentProfile.types.contains(it.second) }.map { it.first }
activity.showBottomDialog( activity.showMultiDialog(
choiceNames, choiceNames,
choices.indexOf(currentProfile.type), selectedIndices,
txt(R.string.set_default).asString(context), txt(R.string.set_default).asString(context),
false,
{}, {},
{ index -> { index ->
val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog val pickedChoices = index.mapNotNull { choices.getOrNull(it) }
// Remove previous picks
if (pickedChoice.unique) { pickedChoices.forEach { pickedChoice ->
getProfiles().filter { it.type == pickedChoice }.forEach { // Remove previous picks
QualityDataHelper.setQualityProfileType(it.id, null) if (pickedChoice.unique) {
getProfiles().filter { it.types.contains(pickedChoice) }.forEach {
QualityDataHelper.removeQualityProfileType(it.id, pickedChoice)
}
} }
QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice)
} }
QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice)
refreshProfiles() refreshProfiles()
}) })
} }
cancelBtt.setOnClickListener { cancelBtt.isVisible = useProfileSelection
this@QualityProfileDialog.dismissSafe() useBtt.isVisible = useProfileSelection
} applyBtt.isVisible = !useProfileSelection
useBtt.setOnClickListener { if (useProfileSelection) {
getCurrentProfile()?.let { cancelBtt.setOnClickListener {
profileSelectionCallback.invoke(it) this@QualityProfileDialog.dismissSafe()
}
useBtt.setOnClickListener {
getCurrentProfile()?.let {
profileSelectionCallback?.invoke(it)
this@QualityProfileDialog.dismissSafe()
}
}
} else {
applyBtt.setOnClickListener {
this@QualityProfileDialog.dismissSafe() this@QualityProfileDialog.dismissSafe()
} }
} }

View file

@ -8,7 +8,6 @@ import androidx.appcompat.app.AlertDialog
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
@ -16,7 +15,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
class SourcePriorityDialog( class SourcePriorityDialog(
val ctx: Context, val ctx: Context,
@StyleRes themeRes: Int, @StyleRes themeRes: Int,
val links: List<ExtractorLink>, val links: List<LinkSource>,
private val profile: QualityDataHelper.QualityProfile, private val profile: QualityDataHelper.QualityProfile,
/** /**
* Notify that the profile overview should be updated, for example if the name has been updated * Notify that the profile overview should be updated, for example if the name has been updated

View file

@ -32,6 +32,7 @@ import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.search.SearchViewModel import com.lagradost.cloudstream3.ui.search.SearchViewModel
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV

View file

@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.ActorRole
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -14,6 +13,7 @@ import com.lagradost.cloudstream3.databinding.CastItemBinding
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
@ -26,7 +26,7 @@ class ActorAdaptor(
})) { })) {
companion object { companion object {
val sharedPool = val sharedPool =
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } newSharedPool { setMaxRecycledViews(CONTENT, 10) }
} }
// Easier to store it here than to store it in the ActorData // Easier to store it here than to store it in the ActorData

View file

@ -1,6 +1,5 @@
package com.lagradost.cloudstream3.ui.result package com.lagradost.cloudstream3.ui.result
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
@ -8,8 +7,6 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setPadding import androidx.core.view.setPadding
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil3.dispose import coil3.dispose
import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity
@ -24,6 +21,7 @@ import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -32,7 +30,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setText
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import java.text.DateFormat import java.text.DateFormat
@ -92,11 +91,10 @@ class EpisodeAdapter(
} }
val sharedPool = val sharedPool =
RecyclerView.RecycledViewPool() newSharedPool {
.apply { setMaxRecycledViews(HAS_POSTER or CONTENT, 10)
this.setMaxRecycledViews(HAS_POSTER or CONTENT, 10) setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10)
this.setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) }
}
} }
override fun onClearView(holder: ViewHolderState<Any>) { override fun onClearView(holder: ViewHolderState<Any>) {
@ -160,7 +158,7 @@ class EpisodeAdapter(
downloadButton.isVisible = hasDownloadSupport downloadButton.isVisible = hasDownloadSupport
downloadButton.setDefaultClickListener( downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached( DownloadObjects.DownloadEpisodeCached(
name = item.name, name = item.name,
poster = item.poster, poster = item.poster,
episode = item.episode, episode = item.episode,
@ -199,6 +197,11 @@ class EpisodeAdapter(
} }
} }
val status = VideoDownloadManager.downloadStatus[item.id]
downloadButton.resetView()
downloadButton.setPersistentId(item.id)
downloadButton.setStatus(status)
val name = val name =
if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}"
episodeFiller.isVisible = item.isFiller == true episodeFiller.isVisible = item.isFiller == true
@ -376,7 +379,7 @@ class EpisodeAdapter(
binding.apply { binding.apply {
downloadButton.isVisible = hasDownloadSupport downloadButton.isVisible = hasDownloadSupport
downloadButton.setDefaultClickListener( downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached( DownloadObjects.DownloadEpisodeCached(
name = item.name, name = item.name,
poster = item.poster, poster = item.poster,
episode = item.episode, episode = item.episode,
@ -415,6 +418,11 @@ class EpisodeAdapter(
} }
} }
val status = VideoDownloadManager.downloadStatus[item.id]
downloadButton.resetView()
downloadButton.setPersistentId(item.id)
downloadButton.setStatus(status)
val name = val name =
if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}"
episodeFiller.isVisible = item.isFiller == true episodeFiller.isVisible = item.isFiller == true

View file

@ -2,11 +2,11 @@ package com.lagradost.cloudstream3.ui.result
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
@ -27,7 +27,7 @@ class ImageAdapter(
) { ) {
companion object { companion object {
val sharedPool = val sharedPool =
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } newSharedPool { setMaxRecycledViews(CONTENT, 10) }
} }
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> { override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {

View file

@ -4,12 +4,11 @@ import android.annotation.SuppressLint
import android.app.Dialog import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AlphaAnimation import android.view.animation.AlphaAnimation
@ -18,18 +17,19 @@ import android.view.animation.DecelerateInterpolator
import android.widget.AbsListView import android.widget.AbsListView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView import androidx.core.widget.NestedScrollView
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.discord.panels.OverlappingPanelsLayout import com.discord.panels.OverlappingPanelsLayout
import com.discord.panels.PanelState import com.discord.panels.PanelState
import com.discord.panels.PanelsChildGestureRegionObserver import com.discord.panels.PanelsChildGestureRegionObserver
import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder
@ -45,25 +45,34 @@ import com.lagradost.cloudstream3.databinding.FragmentResultBinding
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding
import com.lagradost.cloudstream3.databinding.ResultSyncBinding import com.lagradost.cloudstream3.databinding.ResultSyncBinding
import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.services.SubscriptionWorkManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup
import com.lagradost.cloudstream3.ui.player.CS3IPlayer
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
import com.lagradost.cloudstream3.ui.player.FullScreenPlayer import com.lagradost.cloudstream3.ui.player.IPlayer
import com.lagradost.cloudstream3.ui.player.PlayerView
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData
import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral.Companion.pickDownloadPath
import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
@ -72,6 +81,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
@ -87,14 +97,20 @@ import com.lagradost.cloudstream3.utils.UIHelper.populateChips
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.getImageFromDrawable
import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setText
import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.setTextHtml
import com.lagradost.cloudstream3.utils.txt
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.ConcurrentLinkedDeque
import kotlin.math.roundToInt import kotlin.math.roundToInt
open class ResultFragmentPhone : FullScreenPlayer() { open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
BindingCreator.Inflate(FragmentResultSwipeBinding::inflate)
), PlayerView.Callbacks {
private val gestureRegionsListener = private val gestureRegionsListener =
object : PanelsChildGestureRegionObserver.GestureRegionsListener { object : PanelsChildGestureRegionObserver.GestureRegionsListener {
override fun onGestureRegionsUpdate(gestureRegions: List<Rect>) { override fun onGestureRegionsUpdate(gestureRegions: List<Rect>) {
@ -102,34 +118,105 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
} }
/** Queue of pending actions that is deferred to after a custom path is set */
private val pendingPathActions = ConcurrentLinkedDeque<Pair<Int, ResultEpisode>>()
/**
* Appends all actions to a queue, and asks for a user to enter the download folder if not already set up.
*
* Then processes the queue in the given order, only after the user has selected a folder.
* This is to defer the download to after a file path is set, due to perms.
* */
private fun requirePathForActions(list: Collection<Pair<Int, ResultEpisode>>) {
pendingPathActions.addAll(list)
val (_, path) = context?.getBasePath() ?: return
if (path == null) {
/** If we have not set any download path, then ask the user for it before we download it */
try {
/** Give the user some info of what we are doing and why, even if it may be missed */
showToast(R.string.download_path_pref)
pathPicker.launch(Uri.EMPTY)
} catch (t: Throwable) {
logError(t)
/** Something went wrong, TV Device?
* Use the fallback behavior of just downloading it even if no path is selected,
* and hope it works */
processPendingActions()
}
} else {
/**
* Otherwise dispatch everything, as we already have a valid download path
* Even if this is "wrong", we do not care as the user has entered something
* */
processPendingActions()
}
}
/** Clear all the items in the queue and dispatch them to the viewmodel in order */
private fun processPendingActions() = viewModel.viewModelScope.launchSafe {
while (!pendingPathActions.isEmpty()) {
try {
val (action, data) = pendingPathActions.pop()
viewModel.handleAction(
EpisodeClickEvent(
action,
data
)
)
} catch (_: NoSuchElementException) {
/** In case of a race */
}
}
}
private val pathPicker = getChooseFolderLauncher { uri, path ->
if (uri == null) {
/** No path selected, clear the list without acting on it, canceling */
if (!pendingPathActions.isEmpty()) {
/** Only show on non-empty, just in case */
showToast(R.string.download_canceled)
pendingPathActions.clear()
}
} else {
/** Select the folder, and dispatch everything */
pickDownloadPath(uri, path)
processPendingActions()
}
}
protected lateinit var viewModel: ResultViewModel2 protected lateinit var viewModel: ResultViewModel2
protected lateinit var syncModel: SyncViewModel protected lateinit var syncModel: SyncViewModel
protected var binding: FragmentResultSwipeBinding? = null
protected var resultBinding: FragmentResultBinding? = null protected var resultBinding: FragmentResultBinding? = null
protected var recommendationBinding: ResultRecommendationsBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null
protected var syncBinding: ResultSyncBinding? = null protected var syncBinding: ResultSyncBinding? = null
override var layout = R.layout.fragment_result_swipe var player: IPlayer = CS3IPlayer()
protected open var hasPipModeSupport: Boolean = false
protected open var isFullScreenPlayer: Boolean = true
protected open var lockRotation: Boolean = true
protected var playerBinding: TrailerCustomLayoutBinding? = null
protected var isShowing: Boolean = false
override fun onCreateView( protected var playerHostView: PlayerView? = null
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
syncModel = ViewModelProvider(this)[SyncViewModel::class.java]
updateUIEvent += ::updateUI
val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null open fun updateUIVisibility() {}
FragmentResultSwipeBinding.bind(root).let { bind ->
resultBinding = bind.fragmentResult
recommendationBinding = bind.resultRecommendations
syncBinding = bind.resultSync
binding = bind
}
return root protected fun uiReset() {
isShowing = false
updateUIVisibility()
}
open fun showMirrorsDialogue() {}
open fun showTracksDialogue() {}
open fun openOnlineSubPicker(
context: android.content.Context,
loadResponse: LoadResponse?,
dismissCallback: () -> Unit
) {}
override fun fixLayout(view: View) {
fixSystemBarsPadding(view)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -153,7 +240,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
override fun playerError(exception: Throwable) { override fun playerError(exception: Throwable) {
if (player.getIsPlaying()) { // because we don't want random toasts in player if (player.getIsPlaying()) { // because we don't want random toasts in player
super.playerError(exception) playerHostView?.playerError(exception)
} else { } else {
nextMirror() nextMirror()
} }
@ -253,7 +340,8 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
updateUIEvent -= ::updateUI updateUIEvent -= ::updateUI
binding = null playerHostView?.release()
playerBinding = null
resultBinding?.resultScroll?.setOnClickListener(null) resultBinding?.resultScroll?.setOnClickListener(null)
resultBinding = null resultBinding = null
syncBinding = null syncBinding = null
@ -277,7 +365,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
var selectSeason: String? = null var selectSeason: String? = null
var selectEpisodeRange: String? = null var selectEpisodeRange: String? = null
var selectSort: EpisodeSortType? = null
private fun setUrl(url: String?) { private fun setUrl(url: String?) {
if (url == null) { if (url == null) {
@ -320,6 +407,10 @@ open class ResultFragmentPhone : FullScreenPlayer() {
override fun onResume() { override fun onResume() {
afterPluginsLoadedEvent += ::reloadViewModel afterPluginsLoadedEvent += ::reloadViewModel
activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground)
context?.let { ctx ->
playerHostView?.onResume(ctx)
playerHostView?.setupKeyEventListener()
}
super.onResume() super.onResume()
PanelsChildGestureRegionObserver.Provider.get() PanelsChildGestureRegionObserver.Provider.get()
.addGestureRegionsUpdateListener(gestureRegionsListener) .addGestureRegionsUpdateListener(gestureRegionsListener)
@ -327,30 +418,44 @@ open class ResultFragmentPhone : FullScreenPlayer() {
override fun onStop() { override fun onStop() {
afterPluginsLoadedEvent -= ::reloadViewModel afterPluginsLoadedEvent -= ::reloadViewModel
playerHostView?.onStop()
super.onStop() super.onStop()
} }
@Suppress("UNUSED_PARAMETER")
private fun updateUI(id: Int?) { private fun updateUI(id: Int?) {
syncModel.updateUserData() syncModel.updateUserData()
viewModel.reloadEpisodes() viewModel.reloadEpisodes()
} }
override fun onConfigurationChanged(newConfig: Configuration) { override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) {
super.onConfigurationChanged(newConfig) // Set up sub-binding references
view?.let { fixSystemBarsPadding(it) } viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
} syncModel = ViewModelProvider(this)[SyncViewModel::class.java]
updateUIEvent += ::updateUI
@SuppressLint("SetTextI18n") resultBinding = binding.fragmentResult
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { recommendationBinding = binding.resultRecommendations
super.onViewCreated(view, savedInstanceState) syncBinding = binding.resultSync
// Set up trailer player
val ctx = context ?: return
playerHostView = PlayerView(ctx)
playerHostView?.player = player
playerHostView?.hasPipModeSupport = hasPipModeSupport
playerHostView?.callbacks = this
playerHostView?.bindViews(binding.root)
playerBinding = binding.root.findViewById<View?>(R.id.player_holder)?.let {
TrailerCustomLayoutBinding.bind(it)
}
playerHostView?.initialize()
// ===== setup ===== // ===== setup =====
fixSystemBarsPadding(view)
val storedData = getStoredData() ?: return val storedData = getStoredData() ?: return
activity?.window?.decorView?.clearFocus() activity?.window?.decorView?.clearFocus()
activity?.loadCache() activity?.loadCache()
context?.updateHasTrailers() context?.updateHasTrailers()
hideKeyboard() hideKeyboard(binding.root)
if (storedData.restart || !viewModel.hasLoaded()) if (storedData.restart || !viewModel.hasLoaded())
viewModel.load( viewModel.load(
activity, activity,
@ -368,7 +473,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
// This may not be 100% reliable, and may delay for small period // This may not be 100% reliable, and may delay for small period
// before resultCastItems will be scrollable again, but this does work // before resultCastItems will be scrollable again, but this does work
// most of the time. // most of the time.
binding?.resultOverlappingPanels?.registerEndPanelStateListeners( binding.resultOverlappingPanels.registerEndPanelStateListeners(
object : OverlappingPanelsLayout.PanelStateListener { object : OverlappingPanelsLayout.PanelStateListener {
override fun onPanelStateChange(panelState: PanelState) { override fun onPanelStateChange(panelState: PanelState) {
PanelsChildGestureRegionObserver.Provider.get().apply { PanelsChildGestureRegionObserver.Provider.get().apply {
@ -380,8 +485,8 @@ open class ResultFragmentPhone : FullScreenPlayer() {
// ===== ===== ===== // ===== ===== =====
binding?.resultSearch?.isGone = storedData.name.isBlank() binding.resultSearch.isGone = storedData.name.isBlank()
binding?.resultSearch?.setOnClickListener { binding.resultSearch.setOnClickListener {
QuickSearchFragment.pushSearch(activity, storedData.name) QuickSearchFragment.pushSearch(activity, storedData.name)
} }
@ -410,7 +515,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
focused: View? focused: View?
): Boolean { ): Boolean {
// Make the cast always focus the first visible item when focused // Make the cast always focus the first visible item when focused
// from somewhere else. Otherwise it jumps to the last item. // from somewhere else. Otherwise, it jumps to the last item.
return if (parent.focusedChild == null) { return if (parent.focusedChild == null) {
scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) scrollToPosition(this.findFirstCompletelyVisibleItemPosition())
true true
@ -428,7 +533,13 @@ open class ResultFragmentPhone : FullScreenPlayer() {
EpisodeAdapter( EpisodeAdapter(
api?.hasDownloadSupport == true, api?.hasDownloadSupport == true,
{ episodeClick -> { episodeClick ->
viewModel.handleAction(episodeClick) when (episodeClick.action) {
ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> {
requirePathForActions(listOf(episodeClick.action to episodeClick.data))
}
else -> viewModel.handleAction(episodeClick)
}
}, },
{ downloadClickEvent -> { downloadClickEvent ->
DownloadButtonSetup.handleDownloadClick(downloadClickEvent) DownloadButtonSetup.handleDownloadClick(downloadClickEvent)
@ -463,9 +574,9 @@ open class ResultFragmentPhone : FullScreenPlayer() {
resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val dy = scrollY - oldScrollY val dy = scrollY - oldScrollY
if (dy > 0) { //check for scroll down if (dy > 0) { //check for scroll down
binding?.resultBookmarkFab?.shrink() binding.resultBookmarkFab.shrink()
} else if (dy < -5) { } else if (dy < -5) {
binding?.resultBookmarkFab?.extend() binding.resultBookmarkFab.extend()
} }
if (!isFullScreenPlayer && player.getIsPlaying()) { if (!isFullScreenPlayer && player.getIsPlaying()) {
if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height
@ -477,7 +588,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
}) })
} }
binding?.apply { binding.apply {
resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
resultBack.setOnClickListener { resultBack.setOnClickListener {
@ -490,6 +601,13 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} else resultOverlappingPanels.closePanels() } else resultOverlappingPanels.closePanels()
} }
resultMiniSync.setOnClickListener {
if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) {
resultOverlappingPanels.openStartPanel()
} else resultOverlappingPanels.closePanels()
}
/*
resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool)
resultMiniSync.adapter = ImageAdapter( resultMiniSync.adapter = ImageAdapter(
nextFocusDown = R.id.result_sync_set_score, nextFocusDown = R.id.result_sync_set_score,
@ -500,6 +618,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} else resultOverlappingPanels.closePanels() } else resultOverlappingPanels.closePanels()
} }
}) })
*/
resultSubscribe.setOnClickListener { resultSubscribe.setOnClickListener {
viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
if (newStatus == null) return@toggleSubscriptionStatus if (newStatus == null) return@toggleSubscriptionStatus
@ -662,7 +781,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
observeNullable(viewModel.subscribeStatus) { isSubscribed -> observeNullable(viewModel.subscribeStatus) { isSubscribed ->
binding?.resultSubscribe?.isVisible = isSubscribed != null binding.resultSubscribe.isVisible = isSubscribed != null
if (isSubscribed == null) return@observeNullable if (isSubscribed == null) return@observeNullable
val drawable = if (isSubscribed) { val drawable = if (isSubscribed) {
@ -671,11 +790,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
R.drawable.baseline_notifications_none_24 R.drawable.baseline_notifications_none_24
} }
binding?.resultSubscribe?.setImageResource(drawable) binding.resultSubscribe.setImageResource(drawable)
} }
observeNullable(viewModel.favoriteStatus) { isFavorite -> observeNullable(viewModel.favoriteStatus) { isFavorite ->
binding?.resultFavorite?.isVisible = isFavorite != null binding.resultFavorite.isVisible = isFavorite != null
if (isFavorite == null) return@observeNullable if (isFavorite == null) return@observeNullable
val drawable = if (isFavorite) { val drawable = if (isFavorite) {
@ -684,7 +803,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
R.drawable.ic_baseline_favorite_border_24 R.drawable.ic_baseline_favorite_border_24
} }
binding?.resultFavorite?.setImageResource(drawable) binding.resultFavorite.setImageResource(drawable)
} }
observeNullable(viewModel.episodes) { episodes -> observeNullable(viewModel.episodes) { episodes ->
@ -692,8 +811,58 @@ open class ResultFragmentPhone : FullScreenPlayer() {
// no failure? // no failure?
resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodeLoading.isVisible = episodes is Resource.Loading
resultEpisodes.isVisible = episodes is Resource.Success resultEpisodes.isVisible = episodes is Resource.Success
resultBatchDownloadButton.isVisible =
episodes is Resource.Success && episodes.value.isNotEmpty()
if (episodes is Resource.Success) { if (episodes is Resource.Success) {
(resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value)
// Show quality dialog with all sources
resultBatchDownloadButton.setOnLongClickListener {
ioSafe {
val defaultSources = QualityProfileDialog.getAllDefaultSources()
val activity = activity ?: return@ioSafe
activity.runOnUiThread {
QualityProfileDialog(
activity,
R.style.DialogFullscreenPlayer,
defaultSources,
).show()
}
}
true
}
resultBatchDownloadButton.setOnClickListener { view ->
val episodeStart =
episodes.value.firstOrNull()?.episode ?: return@setOnClickListener
val episodeEnd =
episodes.value.lastOrNull()?.episode ?: return@setOnClickListener
val episodeRange = if (episodeStart == episodeEnd) {
episodeStart.toString()
} else {
txt(
R.string.episodes_range,
episodeStart,
episodeEnd
).asString(view.context)
}
val rangeMessage = txt(
R.string.download_episode_range,
episodeRange
).asString(view.context)
AlertDialog.Builder(view.context, R.style.AlertDialogCustom)
.setTitle(R.string.download_all)
.setMessage(rangeMessage)
.setPositiveButton(R.string.yes) { _, _ ->
requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it })
}
.setNegativeButton(R.string.cancel) { _, _ -> }.show()
}
} }
} }
} }
@ -723,8 +892,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
) )
return@setOnLongClickListener true return@setOnLongClickListener true
} }
val status = VideoDownloadManager.downloadStatus[ep.id]
downloadButton.setStatus(status)
downloadButton.setDefaultClickListener( downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached( DownloadObjects.DownloadEpisodeCached(
name = ep.name, name = ep.name,
poster = ep.poster, poster = ep.poster,
episode = 0, episode = 0,
@ -741,18 +913,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
when (click.action) { when (click.action) {
DOWNLOAD_ACTION_DOWNLOAD -> { DOWNLOAD_ACTION_DOWNLOAD -> {
viewModel.handleAction( requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep))
EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep)
)
} }
DOWNLOAD_ACTION_LONG_CLICK -> { DOWNLOAD_ACTION_LONG_CLICK -> {
viewModel.handleAction( requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep))
EpisodeClickEvent(
ACTION_DOWNLOAD_MIRROR,
ep
)
)
} }
else -> DownloadButtonSetup.handleDownloadClick(click) else -> DownloadButtonSetup.handleDownloadClick(click)
@ -826,8 +991,15 @@ open class ResultFragmentPhone : FullScreenPlayer() {
resultComingSoon.isVisible = d.comingSoon resultComingSoon.isVisible = d.comingSoon
resultDataHolder.isGone = d.comingSoon resultDataHolder.isGone = d.comingSoon
resultCastItems.isGone = d.actors.isNullOrEmpty() val prefs =
(resultCastItems.adapter as? ActorAdaptor)?.submitList(d.actors) androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
val showCast = prefs.getBoolean(
root.context.getString(R.string.show_cast_in_details_key),
true
)
resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty()
(resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList())
if (d.contentRatingText == null) { if (d.contentRatingText == null) {
// If there is no rating to display, we don't want an empty gap // If there is no rating to display, we don't want an empty gap
@ -841,7 +1013,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
syncModel.addFromUrl(d.url) syncModel.addFromUrl(d.url)
} }
binding?.apply { binding.apply {
resultSearch.isGone = d.title.isBlank() resultSearch.isGone = d.title.isBlank()
resultSearch.setOnClickListener { resultSearch.setOnClickListener {
QuickSearchFragment.pushSearch(activity, d.title) QuickSearchFragment.pushSearch(activity, d.title)
@ -876,10 +1048,11 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
(data as? Resource.Failure)?.let { data -> (data as? Resource.Failure)?.let { data ->
@SuppressLint("SetTextI18n")
resultErrorText.text = storedData.url.plus("\n") + data.errorString resultErrorText.text = storedData.url.plus("\n") + data.errorString
} }
binding?.resultBookmarkFab?.isVisible = data is Resource.Success binding.resultBookmarkFab.isVisible = data is Resource.Success
resultFinishLoading.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success
resultLoading.isVisible = data is Resource.Loading resultLoading.isVisible = data is Resource.Loading
@ -927,7 +1100,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
observe(viewModel.trailers) { trailers -> observe(viewModel.trailers) { trailers ->
setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet!
} }
observe(syncModel.synced) { list -> observe(syncModel.synced) { list ->
@ -936,8 +1109,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
val newList = list.filter { it.isSynced && it.hasAccount } val newList = list.filter { it.isSynced && it.hasAccount }
binding?.resultMiniSync?.isVisible = newList.isNotEmpty() binding.resultMiniSync.isVisible = newList.isNotEmpty()
(binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon })
} }
@ -1032,7 +1204,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
} }
} }
binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED)
} }
observe(viewModel.recommendations) { recommendations -> observe(viewModel.recommendations) { recommendations ->
setRecommendations(recommendations, null) setRecommendations(recommendations, null)
@ -1093,7 +1265,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
observe(viewModel.watchStatus) { watchType -> observe(viewModel.watchStatus) { watchType ->
binding?.resultBookmarkFab?.apply { binding.resultBookmarkFab.apply {
setText(watchType.stringRes) setText(watchType.stringRes)
if (watchType == WatchType.NONE) { if (watchType == WatchType.NONE) {
context?.colorFromAttribute(R.attr.white) context?.colorFromAttribute(R.attr.white)
@ -1148,6 +1320,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
viewModel.skipLoading() viewModel.skipLoading()
} }
isVisible = true isVisible = true
@SuppressLint("SetTextI18n")
text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})"
} }
} }
@ -1268,6 +1441,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
override fun onPause() { override fun onPause() {
playerHostView?.releaseKeyEventListener()
super.onPause() super.onPause()
PanelsChildGestureRegionObserver.Provider.get() PanelsChildGestureRegionObserver.Provider.get()
.addGestureRegionsUpdateListener(gestureRegionsListener) .addGestureRegionsUpdateListener(gestureRegionsListener)

View file

@ -42,6 +42,7 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED
import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
@ -61,7 +62,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.populateChips
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
import com.lagradost.cloudstream3.utils.UiImage
import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.getImageFromDrawable
import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setText
import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.setTextHtml
@ -559,7 +559,7 @@ class ResultFragmentTv : BaseFragment<FragmentResultTvBinding>(
ExtractorLinkGenerator( ExtractorLinkGenerator(
extractedTrailerLinks, extractedTrailerLinks,
emptyList() emptyList()
) ), 0
) )
) )
} }
@ -925,11 +925,16 @@ class ResultFragmentTv : BaseFragment<FragmentResultTvBinding>(
resultTvComingSoon.isVisible = d.comingSoon resultTvComingSoon.isVisible = d.comingSoon
populateChips(resultTag, d.tags) populateChips(resultTag, d.tags)
resultCastItems.isGone = d.actors.isNullOrEmpty() val prefs =
(resultCastItems.adapter as? ActorAdaptor)?.submitList( androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
d.actors val showCast = prefs.getBoolean(
root.context.getString(R.string.show_cast_in_details_key),
true
) )
resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty()
(resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList())
if (d.contentRatingText == null) { if (d.contentRatingText == null) {
// If there is no rating to display, we don't want an empty gap // If there is no rating to display, we don't want an empty gap
resultMetaContentRating.width = 0 resultMetaContentRating.width = 0

View file

@ -5,41 +5,74 @@ import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.ViewCompat
import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenHeight
import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
import com.lagradost.cloudstream3.ui.player.CSPlayerLoading
import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.PlayerEventSource
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
open class ResultTrailerPlayer : ResultFragmentPhone() { class ResultTrailerPlayer : ResultFragmentPhone() {
override var lockRotation = false override var lockRotation = false
override var isFullScreenPlayer = false override var isFullScreenPlayer = false
override var hasPipModeSupport = false override var hasPipModeSupport = false
companion object { companion object {
const val TAG = "RESULT_TRAILER" const val TAG = "ResultTrailerPlayer"
} }
private var playerWidthHeight: Pair<Int, Int>? = null private var playerWidthHeight: Pair<Int, Int>? = null
private var introVisible = true
// Single-tap on empty player area: toggle controls.
override fun onSingleTap() {
if (introVisible) return
if (isShowing) uiReset() else showControls()
}
private fun showControls() {
if (introVisible) return
isShowing = true
updateUIVisibility()
playerHostView?.scheduleAutoHide()
}
override fun isUIShowing(): Boolean = isShowing
override fun onAutoHideUI() {
if (player.getIsPlaying()) uiReset()
}
override fun onHidePlayerUI() = uiReset()
// When the hold-speedup gesture fires, hide controls so the video is unobstructed.
// The speedup button show/hide and speed change are handled by PlayerView.
override fun onHoldSpeedUp(show: Boolean) {
if (show && isShowing) uiReset()
}
override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {
if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) {
isShowing = true
showControls()
} else playerHostView?.scheduleAutoHide()
}
override fun nextEpisode() {} override fun nextEpisode() {}
override fun prevEpisode() {} override fun prevEpisode() {}
override fun playerPositionChanged(position: Long, duration: Long) {}
override fun playerPositionChanged(position: Long, duration : Long) {}
override fun nextMirror() {} override fun nextMirror() {}
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
@ -49,33 +82,28 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
} }
private fun fixPlayerSize() { private fun fixPlayerSize() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { binding?.apply {
binding?.apply { if (isFullScreenPlayer) {
if (isFullScreenPlayer) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Remove listener
ViewCompat.setOnApplyWindowInsetsListener(root, null) ViewCompat.setOnApplyWindowInsetsListener(root, null)
root.overlay.clear() // Clear the cutout overlay root.overlay.clear()
root.setPadding(0, 0, 0, 0) // Reset padding for full screen }
} else { root.setPadding(0, 0, 0, 0)
// Reapply padding when not in full screen } else {
fixSystemBarsPadding(root) fixSystemBarsPadding(root)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ViewCompat.requestApplyInsets(root) ViewCompat.requestApplyInsets(root)
} }
} }
} }
playerWidthHeight?.let { (w, h) -> playerWidthHeight?.let { (w, h) ->
if(w <= 0 || h <= 0) return@let if (w <= 0 || h <= 0) return@let
val orientation = context?.resources?.configuration?.orientation ?: return val orientation = context?.resources?.configuration?.orientation ?: return
val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight
screenWidth
} else {
screenHeight
}
//result_trailer_loading?.isVisible = false
resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer
binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer
@ -83,35 +111,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
resultBinding?.fragmentTrailer?.playerBackground?.apply { resultBinding?.fragmentTrailer?.playerBackground?.apply {
isVisible = true isVisible = true
layoutParams = layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT, if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to
if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to )
)
} }
playerBinding?.playerIntroPlay?.apply { playerBinding?.playerIntroPlay?.apply {
layoutParams = layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT, resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT
resultBinding?.resultTopHolder?.measuredHeight )
?: FrameLayout.LayoutParams.MATCH_PARENT
)
} }
if (playerBinding?.playerIntroPlay?.isGone == true) { if (playerBinding?.playerIntroPlay?.isGone == true) {
resultBinding?.resultTopHolder?.apply { resultBinding?.resultTopHolder?.apply {
val anim = ValueAnimator.ofInt( val anim = ValueAnimator.ofInt(
measuredHeight, measuredHeight,
if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to
) )
anim.addUpdateListener { valueAnimator -> anim.addUpdateListener { va ->
val `val` = valueAnimator.animatedValue as Int val v = va.animatedValue as Int
val layoutParams: ViewGroup.LayoutParams = val lp: ViewGroup.LayoutParams = layoutParams
layoutParams lp.height = v
layoutParams.height = `val` layoutParams = lp
setLayoutParams(layoutParams)
} }
anim.duration = 200 anim.duration = 200
anim.start() anim.start()
@ -120,9 +143,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
} }
} }
override fun playerDimensionsLoaded(width: Int, height : Int) { override fun playerDimensionsLoaded(width: Int, height: Int) {
playerWidthHeight = width to height playerWidthHeight = width to height
fixPlayerSize() fixPlayerSize()
// Apply autorotation when fullscreen (lockRotation = true).
// PlayerView already set isVerticalOrientation before this callback fires.
if (lockRotation) {
activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return
}
} }
override fun showMirrorsDialogue() {} override fun showMirrorsDialogue() {}
@ -132,33 +160,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
context: Context, context: Context,
loadResponse: LoadResponse?, loadResponse: LoadResponse?,
dismissCallback: () -> Unit dismissCallback: () -> Unit
) { ) {}
}
override fun subtitlesChanged() {} override fun subtitlesChanged() {}
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {} override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
override fun onTracksInfoChanged() {} override fun onTracksInfoChanged() {}
override fun exitedPipMode() {} override fun exitedPipMode() {}
override fun onSeekPreviewText(text: String?) {
playerBinding?.playerTimeText?.apply {
isVisible = text != null
if (text != null) this.text = text
}
}
private fun updateFullscreen(fullscreen: Boolean) { private fun updateFullscreen(fullscreen: Boolean) {
isFullScreenPlayer = fullscreen isFullScreenPlayer = fullscreen
lockRotation = fullscreen lockRotation = fullscreen
playerHostView?.isFullScreen = fullscreen
playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) playerBinding?.playerFullscreen?.setImageResource(
if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24
)
if (fullscreen) { if (fullscreen) {
enterFullscreen() playerHostView?.enterFullscreen()
binding?.apply { binding?.apply {
resultTopBar.isVisible = false resultTopBar.isVisible = false
resultFullscreenHolder.isVisible = true resultFullscreenHolder.isVisible = true
resultMainHolder.isVisible = false resultMainHolder.isVisible = false
} }
resultBinding?.fragmentTrailer?.playerBackground?.let { view -> resultBinding?.fragmentTrailer?.playerBackground?.let { view ->
(view.parent as ViewGroup?)?.removeView(view) (view.parent as ViewGroup?)?.removeView(view)
binding?.resultFullscreenHolder?.addView(view) binding?.resultFullscreenHolder?.addView(view)
} }
} else { } else {
binding?.apply { binding?.apply {
resultTopBar.isVisible = true resultTopBar.isVisible = true
@ -169,36 +203,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
resultBinding?.resultSmallscreenHolder?.addView(view) resultBinding?.resultSmallscreenHolder?.addView(view)
} }
} }
exitFullscreen() playerHostView?.exitFullscreen()
} }
fixPlayerSize() fixPlayerSize()
uiReset() uiReset()
if (isFullScreenPlayer) { if (isFullScreenPlayer) {
activity?.attachBackPressedCallback("ResultTrailerPlayer") { activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) }
updateFullscreen(false) } else {
} activity?.detachBackPressedCallback("ResultTrailerPlayer")
} else activity?.detachBackPressedCallback("ResultTrailerPlayer") }
} }
override fun updateUIVisibility() { override fun updateUIVisibility() {
super.updateUIVisibility() super.updateUIVisibility()
playerBinding?.playerGoBackHolder?.isVisible = false playerBinding?.apply {
playerGoBackHolder.isVisible = false
val controlsVisible = isShowing && !introVisible
playerTopHolder.isVisible = controlsVisible
playerVideoHolder.isVisible = controlsVisible
shadowOverlay.isVisible = controlsVisible
playerPausePlayHolderHolder.isVisible =
controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering
}
// Fade center controls in/out; also resets stale fillAfter alpha from seek animations.
playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun playerStatusChanged() {
super.onViewCreated(view, savedInstanceState) if (introVisible) {
playerBinding?.playerFullscreen?.setOnClickListener { playerBinding?.playerPausePlayHolderHolder?.isVisible = false
updateFullscreen(!isFullScreenPlayer)
} }
}
override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
playerHostView?.videoOutline = playerBinding?.videoOutline
playerHostView?.requestUpdateBrightnessOverlayOnNextLayout()
playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) }
updateFullscreen(isFullScreenPlayer) updateFullscreen(isFullScreenPlayer)
uiReset() uiReset()
playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.setOnClickListener {
playerBinding?.playerIntroPlay?.isGone = true playerBinding?.playerIntroPlay?.isGone = true
introVisible = false
player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
updateUIVisibility()
fixPlayerSize() fixPlayerSize()
showControls()
} }
} }
} }

View file

@ -1,7 +1,8 @@
package com.lagradost.cloudstream3.ui.result package com.lagradost.cloudstream3.ui.result
import android.app.Activity import android.app.Activity
import android.content.* import android.content.Context
import android.content.DialogInterface
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.MainThread import androidx.annotation.MainThread
@ -10,33 +11,67 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.actions.AlwaysAskAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.AnimeLoadResponse
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.getCastSession
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.EpisodeResponse
import com.lagradost.cloudstream3.IDownloadableMinimum
import com.lagradost.cloudstream3.LiveStreamLoadResponse
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MovieLoadResponse
import com.lagradost.cloudstream3.ProviderType
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SeasonData
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TorrentLoadResponse
import com.lagradost.cloudstream3.TrackerType
import com.lagradost.cloudstream3.TrailerData
import com.lagradost.cloudstream3.TvSeriesLoadResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.VPNStatus
import com.lagradost.cloudstream3.actions.AlwaysAskAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.isLiveStream
import com.lagradost.cloudstream3.metaproviders.SyncRedirector import com.lagradost.cloudstream3.metaproviders.SyncRedirector
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugException
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.runAllAsync
import com.lagradost.cloudstream3.sortUrls
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.IGenerator
import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL
import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
@ -44,21 +79,22 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.CastHelper.startCast
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStore
import com.lagradost.cloudstream3.utils.DataStore.editor import com.lagradost.cloudstream3.utils.DataStore.editor
import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData
@ -85,10 +121,31 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.Editor
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.FillerEpisodeCheck
import com.lagradost.cloudstream3.utils.INFER_TYPE
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle
import com.lagradost.cloudstream3.utils.loadExtractor
import com.lagradost.cloudstream3.utils.newExtractorLink
import com.lagradost.cloudstream3.utils.txt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.*
/** This starts at 1 */ /** This starts at 1 */
data class EpisodeRange( data class EpisodeRange(
@ -262,11 +319,12 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData {
TvType.Live -> R.string.live_singular TvType.Live -> R.string.live_singular
TvType.Others -> R.string.other_singular TvType.Others -> R.string.other_singular
TvType.NSFW -> R.string.nsfw_singular TvType.NSFW -> R.string.nsfw_singular
TvType.Music -> R.string.music_singlar TvType.Music -> R.string.music_singular
TvType.AudioBook -> R.string.audio_book_singular TvType.AudioBook -> R.string.audio_book_singular
TvType.CustomMedia -> R.string.custom_media_singluar TvType.CustomMedia -> R.string.custom_media_singular
TvType.Audio -> R.string.audio_singluar TvType.Audio -> R.string.audio_singular
TvType.Podcast -> R.string.podcast_singluar TvType.Podcast -> R.string.podcast_singular
TvType.Video -> R.string.video_singular
} }
), ),
yearText = txt(year?.toString()), yearText = txt(year?.toString()),
@ -391,7 +449,7 @@ fun SelectPopup.getOptions(context: Context): List<String> {
} }
data class ExtractedTrailerData( data class ExtractedTrailerData(
var mirros: List<Pair<ExtractorLink,String>>,//Pair of extracted trailer link and original trailer link var mirros: List<Pair<ExtractorLink, String>>,//Pair of extracted trailer link and original trailer link
var subtitles: List<SubtitleFile> = emptyList(), var subtitles: List<SubtitleFile> = emptyList(),
) )
@ -421,8 +479,8 @@ class ResultViewModel2 : ViewModel() {
private var currentShowFillers: Boolean = false private var currentShowFillers: Boolean = false
var currentRepo: APIRepository? = null var currentRepo: APIRepository? = null
private var currentId: Int? = null private var currentId: Int? = null
private var fillers: Map<Int, Boolean> = emptyMap() private var fillers: HashSet<Int> = hashSetOf()
private var generator: IGenerator? = null private var generator: RepoLinkGenerator? = null
private var preferDubStatus: DubStatus? = null private var preferDubStatus: DubStatus? = null
private var preferStartEpisode: Int? = null private var preferStartEpisode: Int? = null
private var preferStartSeason: Int? = null private var preferStartSeason: Int? = null
@ -667,228 +725,6 @@ class ResultViewModel2 : ViewModel() {
index to list index to list
}.toMap() }.toMap()
} }
private fun downloadSubtitle(
context: Context?,
link: ExtractorSubtitleLink,
fileName: String,
folder: String
) {
ioSafe {
VideoDownloadManager.downloadThing(
context ?: return@ioSafe,
link,
"$fileName ${link.name}",
folder,
if (link.url.contains(".srt")) "srt" else "vtt",
false,
null, createNotificationCallback = {}
)
}
}
private fun getFolder(currentType: TvType, titleName: String): String {
return if (currentType.isEpisodeBased()) {
val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName)
"${currentType.getFolderPrefix()}/$sanitizedFileName"
} else currentType.getFolderPrefix()
}
private fun downloadSubtitle(
context: Context?,
link: SubtitleData,
meta: VideoDownloadManager.DownloadEpisodeMetadata,
) {
context?.let { ctx ->
val fileName = VideoDownloadManager.getFileName(ctx, meta)
val folder = getFolder(meta.type ?: return, meta.mainName)
downloadSubtitle(
ctx,
ExtractorSubtitleLink(link.name, link.url, "", link.headers),
fileName,
folder
)
}
}
fun startDownload(
context: Context?,
episode: ResultEpisode,
currentIsMovie: Boolean,
currentHeaderName: String,
currentType: TvType,
currentPoster: String?,
apiName: String,
parentId: Int,
url: String,
links: List<ExtractorLink>,
subs: List<SubtitleData>?
) {
try {
if (context == null) return
val meta =
getMeta(
episode,
currentHeaderName,
apiName,
currentPoster,
currentIsMovie,
currentType
)
val folder = getFolder(currentType, currentHeaderName)
val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let
// SET VISUAL KEYS
setKey(
DOWNLOAD_HEADER_CACHE,
parentId.toString(),
VideoDownloadHelper.DownloadHeaderCached(
apiName = apiName,
url = url,
type = currentType,
name = currentHeaderName,
poster = currentPoster,
id = parentId,
cacheTime = System.currentTimeMillis(),
)
)
setKey(
DataStore.getFolderName(
DOWNLOAD_EPISODE_CACHE,
parentId.toString()
), // 3 deep folder for faster acess
episode.id.toString(),
VideoDownloadHelper.DownloadEpisodeCached(
name = episode.name,
poster = episode.poster,
episode = episode.episode,
season = episode.season,
id = episode.id,
parentId = parentId,
score = episode.score,
description = episode.description,
cacheTime = System.currentTimeMillis(),
)
)
// DOWNLOAD VIDEO
VideoDownloadManager.downloadEpisodeUsingWorker(
context,
src,//url ?: return,
folder,
meta,
links
)
// 1. Checks if the lang should be downloaded
// 2. Makes it into the download format
// 3. Downloads it as a .vtt file
val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF()
subs?.filter { subtitle ->
downloadList.any { langTagIETF ->
subtitle.languageCode == langTagIETF ||
subtitle.originalName.contains(
fromTagToEnglishLanguageName(
langTagIETF
) ?: langTagIETF
)
}
}
?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) }
?.take(3) // max subtitles download hardcoded (?_?)
?.forEach { link ->
val fileName = VideoDownloadManager.getFileName(context, meta)
downloadSubtitle(context, link, fileName, folder)
}
} catch (e: Exception) {
logError(e)
}
}
suspend fun downloadEpisode(
activity: Activity?,
episode: ResultEpisode,
currentIsMovie: Boolean,
currentHeaderName: String,
currentType: TvType,
currentPoster: String?,
apiName: String,
parentId: Int,
url: String,
) {
ioSafe {
val generator = RepoLinkGenerator(listOf(episode))
val currentLinks = mutableSetOf<ExtractorLink>()
val currentSubs = mutableSetOf<SubtitleData>()
generator.generateLinks(
clearCache = false,
sourceTypes = LOADTYPE_INAPP_DOWNLOAD,
callback = {
it.first?.let { link ->
currentLinks.add(link)
}
},
subtitleCallback = { sub ->
currentSubs.add(sub)
})
if (currentLinks.isEmpty()) {
main {
showToast(
R.string.no_links_found_toast,
Toast.LENGTH_SHORT
)
}
return@ioSafe
} else {
main {
showToast(
R.string.download_started,
Toast.LENGTH_SHORT
)
}
}
startDownload(
activity,
episode,
currentIsMovie,
currentHeaderName,
currentType,
currentPoster,
apiName,
parentId,
url,
sortUrls(currentLinks),
sortSubs(currentSubs),
)
}
}
private fun getMeta(
episode: ResultEpisode,
titleName: String,
apiName: String,
currentPoster: String?,
currentIsMovie: Boolean,
tvType: TvType,
): VideoDownloadManager.DownloadEpisodeMetadata {
return VideoDownloadManager.DownloadEpisodeMetadata(
episode.id,
VideoDownloadManager.sanitizeFilename(titleName),
apiName,
episode.poster ?: currentPoster,
episode.name,
if (currentIsMovie) null else episode.season,
if (currentIsMovie) null else episode.episode,
tvType,
)
}
} }
private val _watchStatus: MutableLiveData<WatchType> = MutableLiveData(WatchType.NONE) private val _watchStatus: MutableLiveData<WatchType> = MutableLiveData(WatchType.NONE)
@ -1067,6 +903,28 @@ class ResultViewModel2 : ViewModel() {
} }
} }
private fun getMeta(
episode: ResultEpisode,
titleName: String,
apiName: String,
currentPoster: String?,
currentIsMovie: Boolean,
tvType: TvType,
): DownloadObjects.DownloadEpisodeMetadata {
return DownloadObjects.DownloadEpisodeMetadata(
episode.id,
episode.parentId,
sanitizeFilename(titleName),
apiName,
episode.poster ?: currentPoster,
episode.name,
if (currentIsMovie) null else episode.season,
if (currentIsMovie) null else episode.episode,
tvType,
)
}
/** /**
* Toggles the favorite status of an item. * Toggles the favorite status of an item.
* *
@ -1435,9 +1293,10 @@ class ResultViewModel2 : ViewModel() {
subs += sub subs += sub
updatePage() updatePage()
}, },
isCasting = isCasting isCasting = isCasting,
offset = 0
) )
} catch (e: CancellationException) { } catch (_: CancellationException) {
// Do nothing // Do nothing
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -1466,7 +1325,7 @@ class ResultViewModel2 : ViewModel() {
episodeIds: Array<String>, episodeIds: Array<String>,
watchState: VideoWatchState watchState: VideoWatchState
) { ) {
val watchStateString = DataStore.mapper.writeValueAsString(watchState) val watchStateString = watchState.toJson()
episodeIds.forEach { episodeIds.forEach {
if (getVideoWatchState(it.toInt()) != watchState) { if (getVideoWatchState(it.toInt()) != watchState) {
editor.setKeyRaw( editor.setKeyRaw(
@ -1612,16 +1471,17 @@ class ResultViewModel2 : ViewModel() {
ACTION_DOWNLOAD_EPISODE -> { ACTION_DOWNLOAD_EPISODE -> {
val response = currentResponse ?: return val response = currentResponse ?: return
downloadEpisode( DownloadQueueManager.addToQueue(
activity, DownloadObjects.DownloadQueueItem(
click.data, click.data,
response.isMovie(), response.isMovie(),
response.name, response.name,
response.type, response.type,
response.posterUrl, response.posterUrl,
response.apiName, response.apiName,
response.getId(), response.getId(),
response.url response.url,
).toWrapper()
) )
} }
@ -1632,9 +1492,8 @@ class ResultViewModel2 : ViewModel() {
LOADTYPE_INAPP_DOWNLOAD, LOADTYPE_INAPP_DOWNLOAD,
txt(R.string.episode_action_download_mirror) txt(R.string.episode_action_download_mirror)
) { (result, index) -> ) { (result, index) ->
ioSafe { DownloadQueueManager.addToQueue(
startDownload( DownloadObjects.DownloadQueueItem(
activity,
click.data, click.data,
response.isMovie(), response.isMovie(),
response.name, response.name,
@ -1645,8 +1504,8 @@ class ResultViewModel2 : ViewModel() {
response.url, response.url,
listOf(result.links[index]), listOf(result.links[index]),
result.subs, result.subs,
) ).toWrapper()
} )
showToast( showToast(
R.string.download_started, R.string.download_started,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
@ -1686,26 +1545,24 @@ class ResultViewModel2 : ViewModel() {
ACTION_PLAY_EPISODE_IN_PLAYER -> { ACTION_PLAY_EPISODE_IN_PLAYER -> {
val list = HashMap<String, String>(currentResponse?.syncData ?: emptyMap()) val list = HashMap<String, String>(currentResponse?.syncData ?: emptyMap())
val generator = generator ?: return
// I know kinda shit to iterate all, but it is 100% sure to work
val index = generator.videos.indexOfFirst { value -> value.id == click.data.id }
generator?.also {
it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work
?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id }
?.let { index ->
if (index >= 0)
it.goto(index)
}
}
if (currentResponse?.type == TvType.CustomMedia) { if (currentResponse?.type == TvType.CustomMedia) {
generator?.generateLinks( generator.generateLinks(
offset = index,
clearCache = true, clearCache = true,
LOADTYPE_ALL, isCasting = false,
sourceTypes = LOADTYPE_ALL,
callback = {}, callback = {},
subtitleCallback = {}) subtitleCallback = {})
} else { } else {
activity?.navigate( activity?.navigate(
R.id.global_to_navigation_player, R.id.global_to_navigation_player,
GeneratorPlayer.newInstance( GeneratorPlayer.newInstance(
generator ?: return, list generator, index,list
) )
) )
} }
@ -1829,14 +1686,13 @@ class ResultViewModel2 : ViewModel() {
} }
val realRecommendations = ArrayList<SearchResponse>() val realRecommendations = ArrayList<SearchResponse>()
val apiNames = synchronized(apis) { val apiNames = apis.filter {
apis.filter { it.name.contains("gogoanime", true) ||
it.name.contains("gogoanime", true) || it.name.contains("9anime", true)
it.name.contains("9anime", true) }.map {
}.map { it.name
it.name
}
} }
meta.recommendations?.forEach { rec -> meta.recommendations?.forEach { rec ->
apiNames.forEach { name -> apiNames.forEach { name ->
realRecommendations.add(rec.copy(apiName = name)) realRecommendations.add(rec.copy(apiName = name))
@ -1855,7 +1711,7 @@ class ResultViewModel2 : ViewModel() {
{ {
if (this !is AnimeLoadResponse) return@runAllAsync if (this !is AnimeLoadResponse) return@runAllAsync
// already exist, no need to run getTracker // already exist, no need to run getTracker
if (this.getAniListId() != null && this.getMalId() != null) return@runAllAsync if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync
val res = APIHolder.getTracker( val res = APIHolder.getTracker(
listOfNotNull( listOfNotNull(
@ -1873,9 +1729,12 @@ class ResultViewModel2 : ViewModel() {
this.year this.year
) )
val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name)
val ids = arrayOf( val ids = arrayOf(
AccountManager.malApi.idPrefix to res?.malId?.toString(), AccountManager.malApi.idPrefix to res?.malId?.toString(),
AccountManager.aniListApi.idPrefix to res?.aniId AccountManager.aniListApi.idPrefix to res?.aniId,
AccountManager.kitsuApi.idPrefix to kitsuId
) )
if (ids.any { (id, new) -> if (ids.any { (id, new) ->
@ -1972,11 +1831,10 @@ class ResultViewModel2 : ViewModel() {
} }
private suspend fun updateFillers(name: String) { private suspend fun updateFillers(data: LoadResponse) {
fillers = fillers = ioWorkSafe {
ioWorkSafe { FillerEpisodeCheck.getFillerEpisodes(data)
FillerEpisodeCheck.getFillerEpisodes(name) } ?: hashSetOf()
} ?: emptyMap()
} }
fun changeDubStatus(status: DubStatus) { fun changeDubStatus(status: DubStatus) {
@ -2313,8 +2171,8 @@ class ResultViewModel2 : ViewModel() {
) { ) {
_episodes.postValue(Resource.Loading()) _episodes.postValue(Resource.Loading())
if (updateFillers && loadResponse is AnimeLoadResponse) { if (updateFillers) {
updateFillers(loadResponse.name) updateFillers(loadResponse)
} }
val allEpisodes = when (loadResponse) { val allEpisodes = when (loadResponse) {
@ -2355,7 +2213,7 @@ class ResultViewModel2 : ViewModel() {
index, index,
i.score, i.score,
i.description, i.description,
fillers.getOrDefault(episode, false), fillers.contains(episode),
loadResponse.type, loadResponse.type,
mainId, mainId,
totalIndex, totalIndex,
@ -2595,26 +2453,34 @@ class ResultViewModel2 : ViewModel() {
loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> loadResponse.trailers.windowed(limit, limit, true).takeWhile { list ->
list.amap { trailerData -> list.amap { trailerData ->
try { try {
val links = arrayListOf<Pair<ExtractorLink,String>>() val links = arrayListOf<Pair<ExtractorLink, String>>()
val subs = arrayListOf<SubtitleFile>() val subs = arrayListOf<SubtitleFile>()
if (!loadExtractor( if (!loadExtractor(
trailerData.extractorUrl, trailerData.extractorUrl,
trailerData.referer, trailerData.referer,
{ subs.add(it) }, { subs.add(it) },
{ links.add(Pair(it,trailerData.extractorUrl))}) && trailerData.raw {
links.add(
Pair(
it,
trailerData.extractorUrl
)
)
}) && trailerData.raw
) { ) {
arrayListOf( arrayListOf(
Pair( Pair(
newExtractorLink( newExtractorLink(
"", "",
"Trailer", "Trailer",
trailerData.extractorUrl, trailerData.extractorUrl,
type = INFER_TYPE type = INFER_TYPE
) { ) {
this.referer = trailerData.referer ?: "" this.referer = trailerData.referer ?: ""
this.quality = Qualities.Unknown.value this.quality = Qualities.Unknown.value
this.headers = trailerData.headers this.headers = trailerData.headers
},trailerData.extractorUrl) }, trailerData.extractorUrl
)
) to arrayListOf() ) to arrayListOf()
} else { } else {
links to subs links to subs
@ -2812,7 +2678,7 @@ class ResultViewModel2 : ViewModel() {
setKey( setKey(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
mainId.toString(), mainId.toString(),
VideoDownloadHelper.DownloadHeaderCached( DownloadObjects.DownloadHeaderCached(
apiName = apiName, apiName = apiName,
url = validUrl, url = validUrl,
type = loadResponse.type, type = loadResponse.type,
@ -2840,4 +2706,4 @@ class ResultViewModel2 : ViewModel() {
} }
} }
} }
} }

View file

@ -11,6 +11,7 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.throwAbleToResource import com.lagradost.cloudstream3.mvvm.throwAbleToResource
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
@ -276,6 +277,7 @@ class SyncViewModel : ViewModel() {
// fix because of bad old data :pensive: // fix because of bad old data :pensive:
val realName = when (syncName) { val realName = when (syncName) {
"MAL" -> malApi.idPrefix "MAL" -> malApi.idPrefix
"Kitsu" -> kitsuApi.idPrefix
"Simkl" -> simklApi.idPrefix "Simkl" -> simklApi.idPrefix
"AniList" -> aniListApi.idPrefix "AniList" -> aniListApi.idPrefix
else -> syncName else -> syncName

View file

@ -4,7 +4,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
@ -12,6 +11,7 @@ import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -43,7 +43,7 @@ class SearchAdapter(
})) { })) {
companion object { companion object {
val sharedPool = val sharedPool =
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } newSharedPool { setMaxRecycledViews(CONTENT, 10) }
} }
var hasNext: Boolean = false var hasNext: Boolean = false

View file

@ -21,7 +21,9 @@ import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.doOnLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@ -55,7 +57,9 @@ import com.lagradost.cloudstream3.ui.home.HomeViewModel
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
@ -71,6 +75,8 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
@ -137,6 +143,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
override fun onDestroyView() { override fun onDestroyView() {
hideKeyboard() hideKeyboard()
bottomSheetDialog?.ownHide() bottomSheetDialog?.ownHide()
activity?.detachBackPressedCallback("SearchFragment")
super.onDestroyView() super.onDestroyView()
} }
@ -400,17 +407,29 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) }
val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true
val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true
selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList()
if (isLayout(TV)) { if (!isLayout(PHONE)) {
binding.searchFilter.isFocusable = true binding.searchFilter.isFocusable = true
binding.searchFilter.isFocusableInTouchMode = true binding.searchFilter.isFocusableInTouchMode = true
} }
// Hide suggestions when search view loses focus (phone only)
if (isLayout(PHONE)) {
binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
searchViewModel.clearSuggestions()
}
}
}
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean { override fun onQueryTextSubmit(query: String): Boolean {
search(query) search(query)
searchViewModel.clearSuggestions()
binding.mainSearch.let { binding.mainSearch.let {
hideKeyboard(it) hideKeyboard(it)
@ -425,51 +444,25 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
if (showHistory) { if (showHistory) {
searchViewModel.clearSearch() searchViewModel.clearSearch()
searchViewModel.updateHistory() searchViewModel.updateHistory()
searchViewModel.clearSuggestions()
} else {
// Fetch suggestions when user is typing (if enabled)
if (isSearchSuggestionsEnabled) {
searchViewModel.fetchSuggestions(newText)
}
} }
binding.apply { binding.apply {
searchHistoryHolder.isVisible = showHistory searchHistoryRecycler.isVisible = showHistory
searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch
searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch
// Hide suggestions when showing history or showing search results
searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled
} }
return true return true
} }
}) })
binding.searchClearCallHistory.setOnClickListener {
activity?.let { ctx ->
val builder: AlertDialog.Builder = AlertDialog.Builder(ctx)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
removeKeys("$currentAccount/$SEARCH_HISTORY_KEY")
searchViewModel.updateHistory()
}
DialogInterface.BUTTON_NEGATIVE -> {
}
}
}
try {
builder.setTitle(R.string.clear_history).setMessage(
ctx.getString(R.string.delete_message).format(
ctx.getString(R.string.history)
)
)
.setPositiveButton(R.string.sort_clear, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (e: Exception) {
logError(e)
// ye you somehow fucked up formatting did you?
}
}
}
observe(searchViewModel.searchResponse) { observe(searchViewModel.searchResponse) {
when (it) { when (it) {
is Resource.Success -> { is Resource.Success -> {
@ -503,24 +496,30 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
try { try {
// https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist
listLock.lock() listLock.lock()
val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray()
val sortedList = list.toList().sortedWith(compareBy { (providerName, _) ->
val index = pinnedOrder.indexOf(providerName)
if (index == -1) Int.MAX_VALUE else index
})
(binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply {
val newItems = list.map { ongoing -> val newItems = sortedList.map { (providerName, providerData) ->
val dataList = ongoing.value.list val dataList = providerData.list
val dataListFiltered = val dataListFiltered =
context?.filterSearchResultByFilmQuality(dataList) ?: dataList context?.filterSearchResultByFilmQuality(dataList) ?: dataList
val homePageList = HomePageList( val homePageList = HomePageList(
ongoing.key, providerName,
dataListFiltered dataListFiltered
) )
val expandableList = HomeViewModel.ExpandableHomepageList( HomeViewModel.ExpandableHomepageList(
homePageList, homePageList,
ongoing.value.currentPage, providerData.currentPage,
ongoing.value.hasNext providerData.hasNext
) )
expandableList
} }
submitList(newItems) submitList(newItems)
@ -559,6 +558,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
val searchItem = click.item val searchItem = click.item
when (click.clickAction) { when (click.clickAction) {
SEARCH_HISTORY_OPEN -> { SEARCH_HISTORY_OPEN -> {
if (searchItem == null) return@SearchHistoryAdaptor
searchViewModel.clearSearch() searchViewModel.clearSearch()
if (searchItem.type.isNotEmpty()) if (searchItem.type.isNotEmpty())
updateChips( updateChips(
@ -569,21 +569,76 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
} }
SEARCH_HISTORY_REMOVE -> { SEARCH_HISTORY_REMOVE -> {
if (searchItem == null) return@SearchHistoryAdaptor
removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key)
searchViewModel.updateHistory() searchViewModel.updateHistory()
} }
SEARCH_HISTORY_CLEAR -> {
// Show confirmation dialog (from footer button)
activity?.let { ctx ->
val builder: AlertDialog.Builder = AlertDialog.Builder(ctx)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
removeKeys("$currentAccount/$SEARCH_HISTORY_KEY")
searchViewModel.updateHistory()
}
DialogInterface.BUTTON_NEGATIVE -> {
}
}
}
try {
builder.setTitle(R.string.clear_history).setMessage(
ctx.getString(R.string.delete_message).format(
ctx.getString(R.string.history)
)
)
.setPositiveButton(R.string.sort_clear, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (e: Exception) {
logError(e)
}
}
}
else -> { else -> {
// wth are you doing??? // wth are you doing???
} }
} }
} }
val suggestionAdapter = SearchSuggestionAdapter { callback ->
when (callback.clickAction) {
SEARCH_SUGGESTION_CLICK -> {
// Search directly
binding.mainSearch.setQuery(callback.suggestion, true)
searchViewModel.clearSuggestions()
}
SEARCH_SUGGESTION_FILL -> {
// Fill the search box without searching
binding.mainSearch.setQuery(callback.suggestion, false)
}
SEARCH_SUGGESTION_CLEAR -> {
// Clear suggestions (from footer button)
searchViewModel.clearSuggestions()
}
}
}
binding.apply { binding.apply {
searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.adapter = historyAdapter
searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
//searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1)
// Setup suggestions RecyclerView
searchSuggestionsRecycler.adapter = suggestionAdapter
searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context)
searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
searchMasterRecycler.adapter = masterAdapter searchMasterRecycler.adapter = masterAdapter
//searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
@ -599,7 +654,11 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
sq?.let { query -> sq?.let { query ->
if (query.isBlank()) return@let if (query.isBlank()) return@let
mainSearch.setQuery(query, true)
// Queries are dropped if you are submitted before layout finishes
mainSearch.doOnLayout {
mainSearch.setQuery(query, true)
}
// Clear the query as to not make it request the same query every time the page is opened // Clear the query as to not make it request the same query every time the page is opened
arguments?.remove(SEARCH_QUERY) arguments?.remove(SEARCH_QUERY)
savedInstanceState?.remove(SEARCH_QUERY) savedInstanceState?.remove(SEARCH_QUERY)
@ -608,8 +667,34 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
} }
observe(searchViewModel.currentHistory) { list -> observe(searchViewModel.currentHistory) { list ->
binding.searchClearCallHistory.isVisible = list.isNotEmpty()
(binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list)
// Scroll to top to show newest items (list is sorted by newest first)
if (list.isNotEmpty()) {
binding.searchHistoryRecycler.scrollToPosition(0)
}
}
// Observe search suggestions
observe(searchViewModel.searchSuggestions) { suggestions ->
val hasSuggestions = suggestions.isNotEmpty()
binding.searchSuggestionsRecycler.isVisible = hasSuggestions
(binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions)
// On non-phone layouts, redirect focus and handle back button
if (!isLayout(PHONE)) {
if (hasSuggestions) {
binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler
// Attach back button callback to clear suggestions
activity?.attachBackPressedCallback("SearchFragment") {
searchViewModel.clearSuggestions()
}
} else {
// Reset to default focus target (history)
binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler
// Detach back button callback when no suggestions
activity?.detachBackPressedCallback("SearchFragment")
}
}
} }
searchViewModel.updateHistory() searchViewModel.updateHistory()

View file

@ -11,7 +11,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
object SearchHelper { object SearchHelper {
fun handleSearchClickCallback(callback: SearchClickCallback) { fun handleSearchClickCallback(callback: SearchClickCallback) {
@ -31,7 +31,7 @@ object SearchHelper {
handleDownloadClick( handleDownloadClick(
DownloadClickEvent( DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE, DOWNLOAD_ACTION_PLAY_FILE,
VideoDownloadHelper.DownloadEpisodeCached( DownloadObjects.DownloadEpisodeCached(
name = card.name, name = card.name,
poster = card.posterUrl, poster = card.posterUrl,
episode = card.episode ?: 0, episode = card.episode ?: 0,

Some files were not shown because too many files have changed in this diff Show more