Compare commits

..

585 commits

Author SHA1 Message Date
firelight
748d425d63
Fixed subdl, closes #2937 (#2938) 2026-06-20 16:29:00 +02:00
Luna712
0894e5041b
AuthAPI: use unixTime from APIHolder (#2926) 2026-06-20 16:02:06 +02:00
KingLucius
84ae5fc024
Move MAL & Anilist Keys to BuildConfig (#2933) 2026-06-20 15:56:06 +02:00
Luna712
14f2a29dcc
UnshortenUrl: replace ByteArray.toString (#2928) 2026-06-20 15:51:39 +02:00
Luna712
f2d7483136
Subdl: migrate to kotlinx serialization (#2890)
* Subdl: migrate to kotlinx serialization

* Keep JsonProperty where different than variable name

* Keep all JsonProperty for now
2026-06-20 11:31:36 +00:00
Luna712
fd934a8b7f
[skip ci] Add tests for StringUtils (#2934) 2026-06-20 11:19:16 +00:00
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
firelight
58c84f0f33
Initial AI Policy (#2432) 2026-01-21 21:45:09 +01:00
Bnyro
7925aacf50
feat(extractors): add CineMM extractor (hglink, dhcplay, ...) (#2438) 2026-01-21 21:44:50 +01:00
Luna712
fb806b339f
Use wrap_content in trailer layout (#2437)
Consistent with other player layouts, and fixes error level lint.
2026-01-21 21:44:27 +01:00
Bnyro
2a60145314
feat(extractors): add vidoza/videzz extractor (#2436) 2026-01-21 21:43:37 +01:00
firelight
71b87d09e7
Fix: Video headers jank, Closes #2435 2026-01-21 17:50:41 +00:00
Cloudburst
8a4480dc42 Merge remote-tracking branch 'weblate/master' 2026-01-21 14:58:18 +01:00
Luna712
2abb7e2d3d
Merge pull request #2373 from Luna712/bump-json
Bump json lib
2026-01-19 23:18:03 +00:00
firelight
179f7e29eb
Merge pull request #2252 from Luna712/directories
Replace srcDirs with directories
2026-01-19 23:14:03 +00:00
firelight
f7a73bfb89
Merge pull request #2371 from recloudstream/weblate
Translations update from Hosted Weblate
2026-01-19 23:02:06 +00:00
Hosted Weblate
79801ec694
Translated using Weblate (Spanish)
Currently translated at 99.7% (704 of 706 strings)

Translated using Weblate (Dutch)

Currently translated at 87.8% (620 of 706 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 (Russian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Romanian)

Currently translated at 87.3% (617 of 706 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Spanish)

Currently translated at 98.7% (697 of 706 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 (Korean)

Currently translated at 96.8% (684 of 706 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (705 of 706 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'

Translated using Weblate (French)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Latvian)

Currently translated at 82.5% (583 of 706 strings)

Translated using Weblate (Turkish)

Currently translated at 99.8% (705 of 706 strings)

Translated using Weblate (German)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Belarusian)

Currently translated at 25.7% (182 of 706 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (French)

Currently translated at 99.5% (703 of 706 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Belarusian)

Currently translated at 25.3% (179 of 706 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (French)

Currently translated at 99.0% (699 of 706 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (706 of 706 strings)

Co-authored-by: Daniel Navarro-Gomez <daniel.navarro.gomez@gmail.com>
Co-authored-by: Doen1el <weblate@mail.danielmuenstermann.de>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Friso de Boer <collorfrisie@hotmail.com>
Co-authored-by: Haru Ijima <haruijimakun@gmail.com>
Co-authored-by: Heeheon Ryu <heeheon.ryu001@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: João Pedro Nunes <nunes@disroot.org>
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: Pose marto <weblate.drainage895@passmail.net>
Co-authored-by: PrivateKeyy <rumaevvadim@gmail.com>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: Takeru Mikenu <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Tom G <luq6x@airsworld.net>
Co-authored-by: Wrapty <wrapty@gmail.com>
Co-authored-by: alex <contact@alexionut.ro>
Co-authored-by: avv-dev <vildan.abdullin@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: therry47 <soulietherry@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@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/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/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/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_BR/
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/tr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ro/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2026-01-19 23:02:05 +00:00
Luna712
fe9eedce39
Update Kotlin to 2.3.0 (#2341) 2026-01-19 23:01:46 +00:00
Hosted Weblate
317cb5dc1a
Translated using Weblate (Dutch)
Currently translated at 87.8% (620 of 706 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 (Russian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Romanian)

Currently translated at 87.3% (617 of 706 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Spanish)

Currently translated at 98.7% (697 of 706 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 (Korean)

Currently translated at 96.8% (684 of 706 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (705 of 706 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'

Translated using Weblate (French)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Latvian)

Currently translated at 82.5% (583 of 706 strings)

Translated using Weblate (Turkish)

Currently translated at 99.8% (705 of 706 strings)

Translated using Weblate (German)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Belarusian)

Currently translated at 25.7% (182 of 706 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (French)

Currently translated at 99.5% (703 of 706 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Belarusian)

Currently translated at 25.3% (179 of 706 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (706 of 706 strings)

Translated using Weblate (French)

Currently translated at 99.0% (699 of 706 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (706 of 706 strings)

Co-authored-by: Daniel Navarro-Gomez <daniel.navarro.gomez@gmail.com>
Co-authored-by: Doen1el <weblate@mail.danielmuenstermann.de>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Friso de Boer <collorfrisie@hotmail.com>
Co-authored-by: Heeheon Ryu <heeheon.ryu001@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: João Pedro Nunes <nunes@disroot.org>
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: Pose marto <weblate.drainage895@passmail.net>
Co-authored-by: PrivateKeyy <rumaevvadim@gmail.com>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: Takeru Mikenu <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Tom G <luq6x@airsworld.net>
Co-authored-by: Wrapty <wrapty@gmail.com>
Co-authored-by: alex <contact@alexionut.ro>
Co-authored-by: avv-dev <vildan.abdullin@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: therry47 <soulietherry@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@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/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/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/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_BR/
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/tr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ro/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2026-01-19 09:01:52 +00:00
Luna712
ee1009a4e3
Don't suppress SimpleDateFormat lint in BackupUtils (#2372)
Just use `Locale.getDefault()` like we do everywhere else in the app.
2026-01-17 15:03:03 +01:00
gittinrep
dd3be24db1
Removes check for available devices before displaying chromecast button (#2412)
Once the button is selected, the chrome cast service will search for devices and notify the user if none have been found
2026-01-17 15:02:38 +01:00
firelight
7b00fce5c0
Fix: Remove duplicate string and unused code from #2416 2026-01-17 14:57:36 +01:00
Pawloland
ed759d6f50
Add extra brightness feature (#2416) 2026-01-17 14:55:54 +01:00
Phisher98
0431d879e3
Minor fix for HomeHead BG poster adding back shadow and minor fixes (#2422)
* Minor fix for HomeHead BG poster adding back shadow and minor fixes
2026-01-17 14:37:15 +01:00
Phisher98
66cf668c58
Minor fix for HomeHead BG poster dull #2409 (#2420) 2026-01-16 11:31:20 +01:00
firelight
ecd8f8b3f6
Fix: Styling fixes on white theme for hero banner #2409 (Part 2) 2026-01-15 20:17:20 +01:00
firelight
51ab4bfffb
Fix: Code cleanup and edgecases fix for hero card #2409 (Part 1) 2026-01-15 19:59:11 +01:00
Jaidev
3b28313fb5
Enhance Hero Card with Dynamic Rating Badge and Larger Layout (#2409) 2026-01-15 19:26:27 +01:00
Bnyro
91983b38ea
feat(extractors): add stream wish mirror streamhls.to (#2397)
- example url: https://streamhls.to/e/uszo5fi7zdda?33
2026-01-12 17:12:59 +01:00
rockhero1234
6090d0f219
Episode title in downloaded & offline logo (#2375) 2026-01-12 17:10:17 +01:00
Phisher98
124288c829
Add background posters on mobile (vertical) (#2408)
* Add background posters on mobile (vertical)
2026-01-12 16:54:43 +01:00
saimuel
1abb9d35ae
Add new StreamWish mainUrl (#2415) 2026-01-12 15:34:06 +01:00
Kraptor123
79d82b3150
fix: make YouTube subtitles nullable (#2405)
* fix: make YouTube subtitles nullable

* Add the comment back
2026-01-11 19:31:53 +01:00
firelight
73f258ca17
Fix: logoUrl final fix 2026-01-09 03:33:26 +01:00
firelight
bd7a90b064
Fix: Phone bindLogo + Prerelease annotation on logo 2026-01-09 03:31:40 +01:00
firelight
57c3d332ae
Fix: Race conditon fix, and code cleanup 2026-01-09 03:24:18 +01:00
Phisher98
fbc588b173
Add logo image (#2384) 2026-01-09 03:21:05 +01:00
firelight
2b7ff8b336
Fix: YT cleanup and subtitle fix 2026-01-09 02:23:37 +01:00
Kraptor123
2aaf99b3fd
Youtube Extractor now plays videos up to 1080p (#2386) 2026-01-09 02:22:47 +01:00
Bnyro
5c0f715973
feat(extractors): add gupload.xyz extractor (#2391) 2026-01-05 02:28:23 +01:00
saimuel
368ee2aca9
new mainUrl DoodExtractor (#2388) 2026-01-05 02:27:25 +01:00
Bnyro
2129c2f982
feat(extractors): add streamembed extractor (#2394) 2026-01-05 02:26:41 +01:00
Kraptor123
f84414dbf6
New Theme - Silent Blue (#2392) 2026-01-04 11:35:15 +01:00
rockhero1234
5e54552338
remove check icon in tvtype chips (#2363) 2026-01-04 11:29:04 +01:00
Bnyro
dc6b9f435d
feat(extractors): add up4stream extractor (#2389) 2026-01-04 10:09:50 +01:00
Bnyro
2795e9e0e2
feat(extractors): add vidnest extractor (#2390) 2026-01-04 10:08:15 +01:00
Luna712
81d9ecde67
Move untranslatable strings to seperate file (#2273)
This could cause crashes or poisoned data on some languages as some untranslatable strings were being translated, including keys and format strings that shouldn't be translatable. Also when translating the episodes key on weblate it caused a conflict between the plural version (which weblate does support) and the actual episodes key, meaning the episodes key was translating as the singular version of the plural episodes version in some cases. Moving to a separate resource file should hopefully prevent these issues.
2025-12-24 02:21:59 +00:00
firelight
b7e38ebc4e
Merge pull request #2361 from recloudstream/weblate
Translations update from Hosted Weblate
2025-12-24 02:07:55 +00:00
Hosted Weblate
ea194d9b48
Merge remote-tracking branch 'origin/master' 2025-12-24 02:07:14 +00:00
Luna712
3fe6a7853a
Replace QuickJS with Zipline (#2256)
QuickJS was renamed to Zipline all the way back in 2021. Unlike old QuickJS, newer Zipline versions are 16kb aligned. Current Zipline is also compatible back to minSdk 21.
2025-12-24 02:07:08 +00:00
Hosted Weblate
fb0df7991c
Merge remote-tracking branch 'origin/master' 2025-12-24 01:57:37 +00:00
Luna712
063d960c3a
Pin rhino version (#2369) 2025-12-24 01:57:28 +00:00
Hosted Weblate
7fd4902180
Translated using Weblate (Belarusian)
Currently translated at 33.4% (277 of 828 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Belarusian)

Currently translated at 27.5% (228 of 828 strings)

Translated using Weblate (Latvian)

Currently translated at 84.9% (703 of 828 strings)

Translated using Weblate (Belarusian)

Currently translated at 25.4% (211 of 828 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Belarusian)

Currently translated at 23.0% (191 of 828 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (828 of 828 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: soldado-do-wolfenstein <luigi.rebelato1234@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@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/apc/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/
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/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translation: Cloudstream/App
2025-12-23 20:00:29 +01:00
Luna712
6c2228b964
Improve caching system for actions (#2249) 2025-12-21 02:48:37 +00:00
Luna712
be78306c55
Minor order fix for lint plugin (#2355) 2025-12-21 02:22:07 +00:00
recloudstream[bot]
bc68b3d7c6 chore(locales): fix locale issues 2025-12-21 02:19:29 +00:00
firelight
8067742192
Merge pull request #2340 from recloudstream/weblate
Translations update from Hosted Weblate
2025-12-21 02:19:17 +00:00
firelight
bdcb9b4807
Merge branch 'master' into weblate 2025-12-21 02:17:33 +00:00
Luna712
0593cfbc01
Add linting to library (#2301) 2025-12-21 02:11:54 +00:00
Luna712
caf6704c54
Merge branch 'recloudstream:master' into directories 2025-12-20 18:36:50 -07:00
recloudstream[bot]
0d77f7b91a chore(locales): fix locale issues 2025-12-21 01:27:09 +00:00
Hosted Weblate
b3c44becc7
Merge remote-tracking branch 'origin/master' 2025-12-21 02:26:58 +01:00
Luna712
db2ef08b0a
Add lint.xml and add ignores to it (#2300)
It's more expandable later on this way.
2025-12-21 01:26:53 +00:00
Luna712
568223a496
Use new KMP plugin for library (#2251)
For compatibility with AGP 9, but this works on past AGP also, so we do it now to prepare for AGP 9 and make that simpler.
2025-12-21 01:22:16 +00:00
Hosted Weblate
fd11b5dd6a
Translated using Weblate (Czech)
Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Belarusian)

Currently translated at 20.8% (172 of 825 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Belarusian)

Currently translated at 14.7% (122 of 825 strings)

Added translation using Weblate (Belarusian)

Translated using Weblate (Hindi)

Currently translated at 64.1% (529 of 825 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (822 of 825 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (825 of 825 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Latvian)

Currently translated at 84.9% (701 of 825 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (825 of 825 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (825 of 825 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (825 of 825 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (825 of 825 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (825 of 825 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (825 of 825 strings)

Translated using Weblate (Spanish)

Currently translated at 98.4% (811 of 824 strings)

Translated using Weblate (German)

Currently translated at 99.8% (823 of 824 strings)

Co-authored-by: Adrian Rodriguez Rodriguez <arrodriguez1809@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Oliver <oliver.puff@gmx.de>
Co-authored-by: P Patel <pdvadalia2007@gmail.com>
Co-authored-by: Sasha Glazko <lenify@tutanota.com>
Co-authored-by: Takeru Mikenu <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Thiago Papageorgiou <tnpapa.simply253@aleeas.com>
Co-authored-by: korn3r <korn3r@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@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/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/hi/
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/lv/
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/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/be/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2025-12-21 02:01:18 +01:00
Luna712
9cd6e64120
Don't remove trailing newlines in fix locales (#2238)
It seems that weblate actually does add newlines so "to be consistent" we shouldn't remove them. I'm not 100% sure if this just changed at some point or if we do actually want to remove them still.
2025-12-21 01:00:51 +00:00
Luna712
ba74a9062f
Fix broken seeking on some fragmented MP4 files (#2352)
Seek doesn't work (only shows duration of a few seconds and seeking then restarts it) on some fragmented MP4 files if they have multiple sidx boxes. Setting this flag merges those and thus will allow seeking. This can be tested using https://github.com/androidx/media/blob/release/libraries/test_data/src/test/assets/media/mp4/sample_fragmented_seekable_multiple_sidx.mp4
2025-12-20 22:29:23 +01:00
rockhero1234
c048426601
feat:separate episode text in continue watching (#2093) 2025-12-20 16:52:33 +01:00
Luna712
7ef57537ac
Bump material (#2348) 2025-12-20 16:07:48 +01:00
Luna712
bac6939444
Use VERSION_NAME from BuildConfig for app version (#2337) 2025-12-18 19:24:17 +01:00
firelight
ac6b20c178
Fix: Circular progress track color, Closes #2346 2025-12-18 18:20:30 +00:00
rockhero1234
47fac5dd4d
replaybtn (#2335) 2025-12-18 16:09:48 +01:00
Luna712
729ede5484
Fix scroll issue on bottom dialogs and add drag handle to UI (#2333) 2025-12-17 21:40:48 +01:00
rockhero1234
e97645c753
open in browser btn:original trailer url instead of extractedlink (#2332) 2025-12-17 21:34:11 +01:00
firelight
bd77965efb
Merge pull request #2307 from Luna712/minimum-duration-api
Remove "Too short playback" error
2025-12-16 22:16:29 +00:00
firelight
764201bbbd
Chore: Cleanup InAppUpdater 2025-12-16 21:56:26 +00:00
firelight
7cb8e7c846
Merge pull request #2326 from Luna712/remove-prerelease-setting
Replace the "Update to prereleases" setting with a new button
2025-12-16 21:50:48 +00:00
Luna712
8012339137
Merge branch 'recloudstream:master' into remove-prerelease-setting 2025-12-16 14:27:47 -07:00
firelight
1e9e9b4173
Fixed issue with selecting subtitles and source at the same time, Closes #2339 2025-12-16 21:19:34 +00:00
firelight
c555ec1375
Merge pull request #2338 from Luna712/no-install-backup
Don't create backup on first install
2025-12-16 17:31:36 +00:00
Luna712
fc1c0fc1c5
Don't create backup on first install
There is no reason to create a mostly empty backup when the app is first installed.
2025-12-15 15:06:56 -07:00
Luna712
5a3158ce6f
No change there 2025-12-15 11:39:58 -07:00
Luna712
ce944a9193
Remove entirely 2025-12-15 11:38:41 -07:00
Luna712
f684bd59b5
Merge branch 'master' into remove-prerelease-setting 2025-12-15 10:58:33 -07:00
recloudstream[bot]
3c9120f6fa chore(locales): fix locale issues 2025-12-15 17:55:11 +00:00
firelight
66ef8518b8
Merge pull request #2257 from recloudstream/weblate
Translations update from Hosted Weblate
2025-12-15 17:54:57 +00:00
Hosted Weblate
6c8c93958f
Merge remote-tracking branch 'origin/master' 2025-12-15 18:52:05 +01:00
rockhero1234
249b36e8b3
tv episodes layout minor fix (#2334) 2025-12-15 17:52:00 +00:00
Hosted Weblate
e88aa3be44
Merge remote-tracking branch 'origin/master' 2025-12-15 18:47:34 +01:00
Luna712
aa08fa126b
Bump AGP to 8.13.2 (#2318) 2025-12-15 17:47:30 +00:00
Luna712
604a9197d4
Add param doc 2025-12-15 10:18:13 -07:00
Hosted Weblate
97e1c1d3ff
Merge remote-tracking branch 'origin/master' 2025-12-15 00:41:10 +01:00
Nivin
029b72c17b
Add support for external audio tracks in player (#2288) 2025-12-15 00:41:02 +01:00
Hosted Weblate
c74f5dc61f
Merge remote-tracking branch 'origin/master' 2025-12-14 23:25:10 +00:00
Luna712
45cd0e6e3f
Add helper for default back press callback handling (#2243) 2025-12-15 00:25:04 +01:00
Luna712
42a1e58527
Remove native-lib.cpp (#2331)
It was first disabled in 8193e39 and eventually later on in 
f5d1f68 CMakeLists was removed completely so removing this should be fine too.
2025-12-13 23:45:06 +01:00
Hosted Weblate
362949bc23
Translated using Weblate (Latvian)
Currently translated at 85.0% (701 of 824 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Latvian)

Currently translated at 83.9% (692 of 824 strings)

Translated using Weblate (Hindi)

Currently translated at 54.2% (447 of 824 strings)

Translated using Weblate (Latvian)

Currently translated at 83.8% (691 of 824 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'

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (824 of 824 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (824 of 824 strings)

Translated using Weblate (Latvian)

Currently translated at 83.7% (690 of 824 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Latvian)

Currently translated at 82.7% (682 of 824 strings)

Translated using Weblate (Romanian)

Currently translated at 88.8% (732 of 824 strings)

Translated using Weblate (Latvian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Latvian)

Currently translated at 77.5% (639 of 824 strings)

Translated using Weblate (Latvian)

Currently translated at 76.0% (627 of 824 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 (French)

Currently translated at 99.7% (822 of 824 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'

Translated using Weblate (French)

Currently translated at 99.7% (822 of 824 strings)

Translated using Weblate (Russian)

Currently translated at 99.7% (822 of 824 strings)

Translated using Weblate (Tamil)

Currently translated at 96.3% (794 of 824 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (824 of 824 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (824 of 824 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (824 of 824 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'

Translated using Weblate (Turkish)

Currently translated at 100.0% (829 of 829 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Macedonian)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (829 of 829 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Italian)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (829 of 829 strings)

Co-authored-by: Anthony Cyndora <anthony270777@gmail.com>
Co-authored-by: Esat Tuna BECAN <esattunabecan4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jaidev Subramanian <tarunjai415@gmail.com>
Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Co-authored-by: Kerim Demirkaynak <aschannel111@gmail.com>
Co-authored-by: MagElwis <m.mhelheli@gmail.com>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mateus Liberale Gomes <sergiogomes209403@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mikenu Takeru <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Pascariu Alex <pascariu610@gmail.com>
Co-authored-by: QSkill <QSkull@protonmail.com>
Co-authored-by: Sisitenr <sisiton2019@gmail.com>
Co-authored-by: VKing9 <vaibhavrathod2282@gmail.com>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@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/cs/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hi/
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/lv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mk/
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/ro/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
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/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/lv/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2025-12-13 18:00:23 +00:00
Luna712
5ec30d64d2
Fix comment 2025-12-12 23:44:36 -07:00
Luna712
77e4ca32ec
Use install prerelease button preference 2025-12-12 23:17:08 -07:00
Luna712
5c396013b8
Update upload-artifact action to v6 (#2325) 2025-12-13 04:54:22 +01:00
Phisher98
15c70ded91
Adding ByseSX Extractor (Filemoon New Domain) (#2310) 2025-12-13 04:54:10 +01:00
Luna712
50173aaaf6
Remove "Update to prereleases" setting
This setting is very confusing on stable, when there is a seperate flavor for prerelease that is needed. Also causes other issues and conflicts with what you are updating on stable, and is annoying to always get update dialogs on debug versions when it doesn't work or do anything in debug version.
2025-12-12 17:17:49 -07:00
Luna712
6aa856e93c
Readd SearchAutoComplete check for now (#2322)
Seems to break focus on TV, not sure why, I can figure out why later, but for now just fixing like this as this is a critical bug, in my opinion.
2025-12-11 22:24:50 +01:00
Luna712
eaf2ac0792
Add some tools:targetApi to styles to appease lint (#2319)
Part of my work to fix all error level lint issues, in order to eventually enable `failOnError` and ensure better compatability with older API levels and a more consistent reporting of issues.
2025-12-11 21:04:11 +01:00
Luna712
5d2e432614
Make InAppUpdater an object (#2321)
Better than a class with only a companion object I think.
2025-12-11 21:03:37 +01:00
Luna712
70121f4548
Fix crash on Android 5 (#2320)
I just realized I hadn't done a PR to fix this issue yet but this issue is why I've been working on fixing all error level lint issues so that we can enable `failOnError` which would have prevented this.
2025-12-11 20:41:51 +01:00
Luna712
a836b26849
Cleanup InAppUpdater (#2298)
The only functional change here is that the commit in the updater dialog was normalized to what it is everywhere else, meaning it is 7 not 10 characters now.

I also have another patch prepared to convert this entire class to an actual object rather than just a class with only a companion object but since that touches every single line due to indentation changes, I decided to split it in order to make it easier to review.
2025-12-11 20:32:32 +01:00
Luna712
74ceaf9a3f
Add a lint suppression for RestrictedApi (#2312) 2025-12-11 20:27:36 +01:00
Luna712
350d19bd6b
Some minor miscellaneous cleanup (#2306)
* Some minor miscellaneous cleanup

* Remove classes
2025-12-11 18:27:54 +01:00
Luna712
7ded6a4fa1
Add tools:targetApi to appease lint (#2315)
Part of my work to fix all error level lint issues, in order to eventually enable `failOnError` and ensure better compatability with older API levels and a more consistent reporting of issues.
2025-12-11 17:56:34 +01:00
Luna712
9a9e71354c
Remove check for SearchAutoComplete (#2313) 2025-12-11 17:55:00 +01:00
rockhero1234
e0231520d5
added mpvex (#2309) 2025-12-11 17:37:19 +01:00
Luna712
ae5e25726d
Use String.toUri consistently (#2304) 2025-12-11 17:31:36 +01:00
Luna712
d5eba57bc0
Cleanup UnstableApi usage (#2314)
* Remove `@UnstableApi` from GeneratorPlayer and use OptIn instead.
* Remove `@OptIn` from WebviewFragment as it was unnecessary.
* Move `@OptIn` in SaveCaptionStyle to the actual single line we need to OptIn.
* Split `setCues` logic to a new method in ChromcastSubtitlesFragment and only add `@OptIn` to that method as it's only necessary there.
* Add some missing `@OptIn` annotations to fix all remaining `UnsafeOptInUsageError` lint errors.
2025-12-11 17:20:22 +01:00
Luna712
8fabb5c572
Suppress an UnspecifiedRegisterReceiverFlag lint issue (#2316)
Part of my work to fix all error level lint issues, in order to eventually enable `failOnError` and ensure better compatability with older API levels and a more consistent reporting of issues.
2025-12-11 17:17:25 +01:00
Osten
a46b0ac6e6
Download selection fix + sub del fix + Del dialog fix (#2308) 2025-12-08 22:35:11 +01:00
Luna712
e25847cb64
Add API for minimum media duration 2025-12-07 15:24:42 -07:00
Luna712
1a852f1f4c
Use SharedPreferences.edit extension function (#2299) 2025-12-06 15:35:14 +00:00
Luna712
fdad31c10e
Add backward compatibility for one more AcraApplication method (#2302)
`removeKeys()` only seems to be used by one single extension, but I suppose it doesn't hurt to still add back compat for it.
2025-12-06 15:29:57 +00:00
Luna712
f77df2f3bf
Use temurin distribution for setup-java action (#2297)
Per the note on the README for `actions/setup-java`: "AdoptOpenJDK got moved to Eclipse Temurin and won't be updated anymore. It is highly recommended to migrate workflows from `adopt` and `adopt-openj9`, to `temurin` and `semeru` respectively, to keep receiving software and security updates."
2025-12-05 01:52:24 +01:00
Luna712
f2a008922d
Bump rhino to 1.8.1 (#2295) 2025-12-05 01:51:12 +01:00
Luna712
472d0bab8b
Remove unused swiperefreshlayout dependency (#2296) 2025-12-05 01:48:29 +01:00
Luna712
93255dfc22
Add explicit dependency on fragment (#2233)
As with some of my other PRs, explicit dependencies allow for better version control.
2025-12-05 01:33:30 +01:00
Luna712
e47349af7a
Bump material (#2241) 2025-12-05 01:25:18 +01:00
Luna712
cd69597a54
Move app version to BuildConfig (#2291)
Also, the intent seems to be to be to set the version to `-PRE` when in pre release, which doesn't currently work, but this fixes that.
2025-12-05 01:20:08 +01:00
Luna712
b2e06c5966
Remove BuildConfig.BETA (#2290)
It's unused and can be accessed with `BuildConfig.FLAVOR == "prerelease"`
2025-12-05 01:13:38 +01:00
Luna712
2c0fa70101
Clear home page adapter pools when reloading (#2272) 2025-12-05 01:12:14 +01:00
Luna712
0b3aa24e66
Some cleanup/improvements to layouts (#2274) 2025-12-05 01:09:54 +01:00
firelight
d4d273f010
Fix: Configuration change view invalidation on AutofitRecyclerView popup 2025-12-04 23:50:56 +00:00
rockhero1234
81b2718129
horizontal poster in expanded list (#2286) 2025-12-05 00:34:52 +01:00
Luna712
2ac0698bd2
Handle new Android 16 biometrics error type (#2275)
Adds handling for `BiometricManager.BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS` which was added in API level 36.
2025-11-30 19:32:20 +00:00
Luna712
1dd477a965
Disable MissingTranslation lint (#2276)
Translations are handled by weblate, so we don't really care about missing translations here.
2025-11-30 19:21:09 +00:00
Luna712
110cf571bd
Fix no poster showing wrong poster (#2278) 2025-11-30 19:19:52 +00:00
Luna712
dad6b92ae3
Fix downloads loading background (#2279) 2025-11-30 19:15:11 +00:00
Luna712
d794f6182e
Add Prerelease annotation to extractors that are not in stable (#2281) 2025-11-30 19:11:05 +00:00
Osten
b68fadc956
Minor fixes to recycled DownloadAdapter cards 2025-11-30 00:24:08 +01:00
Osten
1aa6a6215d
Minor fix to ConsistentLiveData 2025-11-28 21:37:55 +01:00
Osten
38296bfb1a Fixed the atrocity of download selection along with some crash fixes and bugs. 2025-11-28 21:24:31 +01:00
Luna712
b05ccb2bc7
Merge branch 'recloudstream:master' into directories 2025-11-28 10:57:57 -07:00
Luna712
7fb6f3f535
Add explicit dependsOn for copyJar (#2261) 2025-11-27 18:37:47 +00:00
Luna712
d43a371b15
Better backward compatibility for AcraApplication (#2265) 2025-11-27 18:34:14 +00:00
Luna712
9d651f1f82
Remove work-runtime dependency (#2234)
We only really need to include the Kotlin version, work-runtime-ktx here.
2025-11-25 15:24:21 +01:00
Luna712
7f9f89cbf6
Use version catalog bundles for coil and lifecycle (#2237) 2025-11-25 15:16:37 +01:00
Luna712
009dcc2b89
Use version catalog for plugins (#2206) 2025-11-24 18:26:07 +01:00
Luna712
3be396216f
Remove acra and replace AcraApplication with CloudStreamApp (#2207) 2025-11-24 18:04:51 +01:00
Luna712
a95d8ddc78
Remove unnecessary overrideLibrary for torrServer (#2235) 2025-11-24 17:53:55 +01:00
rockhero1234
f2de69a1ee
UI improvement (#2209) 2025-11-24 17:50:32 +01:00
firelight
6332f1c344
Merge pull request #2239 from Luna712/panels-backpress
Close overlapping panels on back press
2025-11-24 16:33:53 +00:00
Luna712
2111049cec
Use unique callback ID 2025-11-24 08:50:40 -07:00
firelight
c310ee7ed0
Merge pull request #2250 from Luna712/resvalues
Enable resValues in buildFeatures
2025-11-24 14:31:14 +00:00
firelight
9a7654ebb7
Merge pull request #2258 from phisher98/HubCloud-Improve
Hub cloud improve
2025-11-24 14:29:41 +00:00
075c7440bf HubCloud Improvement 2025-11-24 12:49:37 +05:30
Phisher98
dc2fc15ae3
Merge branch 'recloudstream:master' into master 2025-11-24 12:41:00 +05:30
recloudstream[bot]
511536fda7 chore(locales): fix locale issues 2025-11-23 21:11:24 +00:00
firelight
e40cc27369
Translated using Weblate (Indonesian) (#2236)
Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (829 of 829 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Italian)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (829 of 829 strings)










Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/
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/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

Co-authored-by: Esat Tuna BECAN <esattunabecan4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mikenu Takeru <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
2025-11-23 21:11:11 +00:00
Luna712
0caddd84d4
Minor cleanup to workflows (#2255) 2025-11-23 21:09:41 +00:00
Luna712
a9771d8a03
Replace srcDirs with directories
This is an error in AGP 9, but works in past AGP releases also. Just to prepare for AGP 9 we can just do this now.
2025-11-22 14:37:49 -07:00
Luna712
003544ad44
Enable resValues in buildFeatures
This will be required in AGP 9, but just to prepare for that, we can do it now. Only the default changed, this should have no effect on older AGP versions.
2025-11-22 13:34:32 -07:00
Hosted Weblate
9d20b9f322
Merge remote-tracking branch 'origin/master' 2025-11-22 20:18:44 +00:00
Luna712
f4b5acd401
Bump checkout action (#2242) 2025-11-22 20:18:37 +00:00
Hosted Weblate
a4ab7086a7
Merge remote-tracking branch 'origin/master' 2025-11-22 21:18:03 +01:00
Phisher98
3a5e29b77d
Vidsrc is Dead and Minor Fix for hubcloud (#2245) 2025-11-22 20:17:57 +00:00
Hosted Weblate
e2622b557c
Merge remote-tracking branch 'origin/master' 2025-11-22 21:13:16 +01:00
Luna712
36594604a8
Migrate freeCompilerArgs to new DSL (#2248)
`jvmDefault.set(JvmDefaultMode.ENABLE)` is equivalent to `-jvm-default=enable`, which replaces `-Xjvm-default=all-compatibility` in Kotlin 2.2, it doesn't yet warn, but does in Kotlin 2.3.

https://youtrack.jetbrains.com/issue/KT-61649
https://youtrack.jetbrains.com/issue/KT-74590
https://youtrack.jetbrains.com/issue/KT-76353
https://kotlinlang.org/docs/gradle-compiler-options.html#migrate-freecompilerargs
2025-11-22 20:13:11 +00:00
0b310ef87c Vidsrc is Dead and Minor Fix for hubcloud 2025-11-21 23:57:55 +05:30
6dabce4e93 Vidsrc is Dead and Minor Fix for hubcloud 2025-11-21 23:42:11 +05:30
90091eb305 Vidsrc is Dead and Minor Fix for hubcloud 2025-11-21 23:41:25 +05:30
Hosted Weblate
a4a2604962
Translated using Weblate (Indonesian)
Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (829 of 829 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Italian)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (829 of 829 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (829 of 829 strings)

Co-authored-by: Esat Tuna BECAN <esattunabecan4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mikenu Takeru <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: opakholis <opakholis@users.noreply.hosted.weblate.org>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/
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/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
2025-11-21 07:51:24 +00:00
Luna712
032c8404d9
Close overlapping panels on back press 2025-11-19 15:19:46 -07:00
Osten
a3a7b7067b
Include submitList commitCallback to BaseAdapter (from QuickNovel) 2025-11-18 20:29:13 +01:00
Luna712
29f8f02eef
Merge pull request #2146 from Luna712/toolchain
Add jdk toolchain
2025-11-18 17:22:57 +00:00
recloudstream[bot]
4bf659e44a chore(locales): fix locale issues 2025-11-18 17:08:49 +00:00
firelight
d83f324457
Merge pull request #2181 from recloudstream/weblate
Translations update from Hosted Weblate
2025-11-18 17:08:32 +00:00
Hosted Weblate
94552c1a66
Merge remote-tracking branch 'origin/master' 2025-11-18 18:07:23 +01:00
Luna712
10192553ce
Upgrade Gradle wrapper to 9.2.1 (#2230) 2025-11-18 17:07:17 +00:00
rockhero1234
dcc107df09
full series btn (#2220) 2025-11-18 16:18:24 +01:00
Hosted Weblate
34a1a38be0
Merge remote-tracking branch 'origin/master' 2025-11-18 15:12:50 +00:00
Luna712
1f58160630
UI fixes for portrait subtitle offset dialog (#2228) 2025-11-18 16:12:45 +01:00
Hosted Weblate
1d61894d2d
Merge remote-tracking branch 'origin/master' 2025-11-18 15:02:05 +00:00
Luna712
96558ba61b
Handle out of range error in player (#2215) 2025-11-18 16:01:56 +01:00
Kraptor123
c0d5356d81
VkExtractor (#2223) 2025-11-18 15:59:26 +01:00
Hosted Weblate
d75c084b3a
Translated using Weblate (English)
Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Russian)

Currently translated at 99.7% (826 of 828 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (828 of 828 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (828 of 828 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'

Translated using Weblate (Polish)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Assamese)

Currently translated at 91.7% (760 of 828 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 97.4% (807 of 828 strings)

Translated using Weblate (Filipino)

Currently translated at 21.0% (174 of 828 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (German)

Currently translated at 99.7% (826 of 828 strings)

Translated using Weblate (Spanish)

Currently translated at 98.6% (817 of 828 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Tamil)

Currently translated at 96.1% (796 of 828 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Turkish)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Swedish)

Currently translated at 93.2% (772 of 828 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Macedonian)

Currently translated at 99.7% (826 of 828 strings)

Translated using Weblate (Italian)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Indonesian)

Currently translated at 99.7% (826 of 828 strings)

Translated using Weblate (Croatian)

Currently translated at 98.1% (813 of 828 strings)

Translated using Weblate (French)

Currently translated at 98.4% (815 of 828 strings)

Translated using Weblate (Greek)

Currently translated at 94.9% (786 of 828 strings)

Translated using Weblate (Czech)

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (825 of 828 strings)

Translated using Weblate (Bulgarian)

Currently translated at 96.3% (798 of 828 strings)

Translated using Weblate (Arabic)

Currently translated at 98.5% (816 of 828 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (828 of 828 strings)

Co-authored-by: Ahmed Al-Nassif <mr.ahmed.nassif@gmail.com>
Co-authored-by: Carrillo Rodriguez <carrillorodriguez672@gmail.com>
Co-authored-by: Deleted User <anonymous2676@users.noreply.hosted.weblate.org>
Co-authored-by: Deleted User <noreply+110771@weblate.org>
Co-authored-by: Esspel <eric.soderstrom06@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Itsmechinmoy <167056923+itsmechinmoy@users.noreply.github.com>
Co-authored-by: Ivan Kostov <jiveq1@gmail.com>
Co-authored-by: John Kennedy Peña <jkhp.jkpa@gmail.com>
Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Co-authored-by: Konstantin <konstantinkreutz@gmail.com>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mikenu Takeru <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nguyễn Tiến Đạt <dn16092000@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Rere Doloi <reredolire@gmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Saúl Palacios <palacios22c@gmail.com>
Co-authored-by: Serdar Sağlam <teknomobil@msn.com>
Co-authored-by: Sergey Ponomarev <stokito@gmail.com>
Co-authored-by: g333fed <fedorn990@gmail.com>
Co-authored-by: leyakid803 <leyakid803@minduls.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: william piti <loolyowo@gmail.com>
Co-authored-by: zmni <zmni@outlook.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
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/as/
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/el/
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/fil/
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/mk/
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/ru/
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/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
2025-11-17 12:51:29 +01:00
firelight
0579a15f9b
Fix: PIP problems caused by #2199 + Old PIP problems on low API versions. Closes #2202 2025-11-13 13:58:57 +00:00
Luna712
ac3d057358
Upgrade to targetSdk 36 (#2043) 2025-11-12 17:17:30 +01:00
rockhero1234
5370d019ff
minor ui fixes (#2191) 2025-11-12 17:10:52 +01:00
Luna712
73a98be437
Add explicit jsoup dependency (#2143)
We use it in both app and library, allows better dependency management, and also when I was testing AGP 9 alpha with built in Kotlin this was needed for it not to fail, so just doing this now to kinda prepare a little for that, even though it's a long ways out since dokka also doesn't fully support it yet and it's still in alpha, not stable yet.
2025-11-12 17:09:55 +01:00
rockhero1234
e1aec1d1d3
fix auto subtiltle select downloaded (#2200) 2025-11-12 17:01:21 +01:00
Luna712
ed55cb2a0b
Lock orientation when using the rotate button (#2172) 2025-11-12 00:42:26 +01:00
Luna712
523fbc1325
Bump AGP to 8.13.1 (#2186) 2025-11-12 00:29:26 +01:00
firelight
cc242088cf
Merge pull request #2188 from Luna712/player-scroll-fix
Reset player hide delay when scrolling controls
2025-11-11 23:20:33 +00:00
firelight
7b5e213c1f
Merge pull request #2196 from Luna712/packages
Add some missing explicit packages and minor cleanup
2025-11-11 23:12:17 +00:00
firelight
501186a004
Merge pull request #2169 from Luna712/items-size
More response player quality profile items
2025-11-11 22:56:36 +00:00
Luna712
6a5f0815c1
Use 110dp 2025-11-11 15:37:54 -07:00
firelight
a7e8b1bee3
Merge pull request #2184 from Luna712/fix-future-build
Fix one more build issue on future version of Kotlin
2025-11-11 22:35:52 +00:00
Luna712
4107fe1767
Merge branch 'master' into packages 2025-11-11 15:27:05 -07:00
firelight
0ad0577b17
Merge pull request #2195 from Luna712/activity-ktx
Use activity-ktx version
2025-11-11 22:25:05 +00:00
firelight
808b08e1ab
Merge pull request #2187 from Luna712/replace-url-constructor
Replace deprecated URL constructor
2025-11-11 22:19:10 +00:00
firelight
bdab23d967
Merge pull request #2199 from Luna712/pip-buffering
Allow to enter PIP mode if buffering
2025-11-11 22:17:46 +00:00
firelight
eff2c81cde
Merge pull request #2192 from rockhero1234/overlay_fix
retain previous play status ep. overlay
2025-11-11 22:15:42 +00:00
firelight
feea825ba6
Merge pull request #2197 from Luna712/cronet
Enable http2 and brotli in cronet
2025-11-11 22:08:27 +00:00
firelight
217bbeb3ca
Merge pull request #2198 from Luna712/patch-15
Minor update to english translation for a string
2025-11-11 22:07:26 +00:00
Luna712
37e550adce
Allow to enter PIP mode if buffering 2025-11-11 14:55:33 -07:00
Luna712
1ad6ab3411
Minor update to english translation for a string
This may not only be the case for phones but other devices as well.
2025-11-11 13:48:57 -07:00
Luna712
70a810656a
Enable http2 and brotli in cronet
Not everything supports QUIC.
2025-11-11 12:21:54 -07:00
Luna712
7af45a8318
Add some missing explicit packages and minor cleanup 2025-11-11 11:12:40 -07:00
Luna712
7ea89f7b91
Use activity-ktx version 2025-11-11 10:52:32 -07:00
rockhero1234
1cb8171aa5 retain previous play status ep. overlay 2025-11-11 16:54:54 +05:30
Luna712
f834c851b3
Reset player hide delay when scrolling controls
This also increases the delay time just a bit as before it seems it is to fast once scroll finishes if you don't think fast enough, however, I can remove that part if it isn't wanted.
2025-11-10 15:25:07 -07:00
Luna712
d82f471e82
Replace deprecated URL constructor 2025-11-10 12:30:15 -07:00
Luna712
ea474eeef4
Fix one more build issue on future version of Kotlin
It's a warning on Kotlin 2.3 and will be an error in 2.4:

`Type annotation class 'Nullable' of the inferred type is inaccessible. Check the module classpath for missing or conflicting dependencies. This will become an error in language version 2.4.`
2025-11-10 10:45:15 -07:00
firelight
7efc636413
Merge pull request #2167 from Luna712/player-source-portrait
Add portrait mode layouts for dialogs in player
2025-11-10 12:49:35 +00:00
firelight
f321e98be5
Merge pull request #2177 from Luna712/cleanup
Some cleanup to redundant calls
2025-11-10 12:41:50 +00:00
firelight
cefe43dbc2
Merge pull request #2178 from Luna712/m.ok.ru
Add ok.ru extractor version for m.ok.ru
2025-11-10 12:17:47 +00:00
Luna712
354bf947b7
Fix 2025-11-09 16:04:08 -07:00
Luna712
0775b5e3de
Add ok.ru extractor version for m.ok.ru 2025-11-09 16:02:40 -07:00
recloudstream[bot]
5401867a46 chore(locales): fix locale issues 2025-11-09 21:26:01 +00:00
firelight
0acbeecdbc
Merge pull request #2003 from recloudstream/weblate
Translations update from Hosted Weblate
2025-11-09 21:25:48 +00:00
Luna712
6c8f2a0c5c
Upgrade dokka to 2.1.0 (#2007) 2025-11-09 21:24:27 +00:00
Phisher98
a46fe80ba2
Adding HubCloud and PixelDrain Improvement (#2161) 2025-11-09 21:20:36 +00:00
Luna712
05752bf5ee
Some cleanup to redundant calls
These are all build warnings in Kotlin 2.3, and some are errors in 2.4.
2025-11-09 14:14:56 -07:00
Hosted Weblate
30ce24720a
Merge remote-tracking branch 'origin/master' 2025-11-09 15:33:04 +01:00
Luna712
ef114f9271
Use hidden password in add account input (#2168)
Passwords probably shouldn't be visible by default.
2025-11-09 15:32:59 +01:00
Hosted Weblate
a833cda47c
Merge remote-tracking branch 'origin/master' 2025-11-09 15:17:33 +01:00
Luna712
b7e59a01d3
Fix a few warnings (#2165) 2025-11-09 15:17:27 +01:00
Hosted Weblate
46198951bc
Translated using Weblate (German)
Currently translated at 99.8% (827 of 828 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'

Translated using Weblate (Turkish)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Hebrew)

Currently translated at 79.1% (655 of 828 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Assamese)

Currently translated at 91.9% (761 of 828 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 97.5% (808 of 828 strings)

Translated using Weblate (Burmese)

Currently translated at 76.0% (630 of 828 strings)

Translated using Weblate (Galician)

Currently translated at 49.5% (410 of 828 strings)

Translated using Weblate (Korean)

Currently translated at 89.1% (738 of 828 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 54.8% (454 of 828 strings)

Translated using Weblate (Malay)

Currently translated at 73.3% (607 of 828 strings)

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

Currently translated at 90.4% (749 of 828 strings)

Translated using Weblate (Slovak)

Currently translated at 66.5% (551 of 828 strings)

Translated using Weblate (Portuguese)

Currently translated at 98.3% (814 of 828 strings)

Translated using Weblate (Somali)

Currently translated at 68.4% (567 of 828 strings)

Translated using Weblate (Hungarian)

Currently translated at 79.8% (661 of 828 strings)

Translated using Weblate (Urdu)

Currently translated at 87.1% (722 of 828 strings)

Translated using Weblate (Tamil)

Currently translated at 96.2% (797 of 828 strings)

Translated using Weblate (Hebrew)

Currently translated at 76.0% (630 of 828 strings)

Translated using Weblate (Bengali)

Currently translated at 56.8% (471 of 828 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 91.4% (757 of 828 strings)

Translated using Weblate (Swedish)

Currently translated at 93.3% (773 of 828 strings)

Translated using Weblate (Romanian)

Currently translated at 88.4% (732 of 828 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 73.0% (605 of 828 strings)

Translated using Weblate (Croatian)

Currently translated at 98.3% (814 of 828 strings)

Translated using Weblate (Greek)

Currently translated at 95.0% (787 of 828 strings)

Translated using Weblate (Bulgarian)

Currently translated at 96.4% (799 of 828 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Dutch)

Currently translated at 85.0% (704 of 828 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Spanish)

Currently translated at 98.9% (819 of 828 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (827 of 828 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (828 of 828 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Macedonian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Macedonian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Italian)

Currently translated at 98.9% (819 of 828 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Hindi)

Currently translated at 54.4% (451 of 828 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (828 of 828 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (828 of 828 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (818 of 818 strings)

Translated using Weblate (Catalan)

Currently translated at 55.0% (450 of 818 strings)

Translated using Weblate (Catalan)

Currently translated at 53.1% (435 of 818 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Catalan)

Currently translated at 51.3% (420 of 818 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Spanish)

Currently translated at 99.5% (814 of 818 strings)

Added translation using Weblate (Catalan)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (German)

Currently translated at 99.8% (817 of 818 strings)

Translated using Weblate (French)

Currently translated at 99.8% (817 of 818 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (818 of 818 strings)

Translated using Weblate (Latvian)

Currently translated at 76.4% (625 of 818 strings)

Co-authored-by: Ahmed Abd Elfattah <escuro.anjo@gmail.com>
Co-authored-by: Alejandro Calduch <alexddf@gmail.com>
Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Co-authored-by: Alexander Kryllov <sashakryllov58@gmail.com>
Co-authored-by: Alexandru <negrualexandru52@gmail.com>
Co-authored-by: Ana Coelho <coelhotraduz@gmail.com>
Co-authored-by: Andreas <andreas.blindheim.koppen@gmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Feike Donia <feikedonia@proton.me>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Itsmechinmoy <167056923+itsmechinmoy@users.noreply.github.com>
Co-authored-by: Kaan <kaandogan2820@gmail.com>
Co-authored-by: Lacey Anaya <yecakeh263@anawalls.com>
Co-authored-by: LagradOst <46196380+Blatzar@users.noreply.github.com>
Co-authored-by: LiJu09 <lisojuraj@gmail.com>
Co-authored-by: Man <thebroker2308@gmail.com>
Co-authored-by: Marco Moreno <hibarioath@proton.me>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mikenu Takeru <mikenu-jp@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Muhammad Fahad Khan <itxmfahadkhan@gmail.com>
Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Potorochin Max <ornaras.us@gmail.com>
Co-authored-by: Radoslav Vasilev Vasilev <fifata@gmail.com>
Co-authored-by: Riko Miko <rihardslaskovs@gmail.com>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Co-authored-by: Samuel Gadiel <samuelgadiel@gmail.com>
Co-authored-by: Shafici Isxariifshe <mega12xhaphiee@gmail.com>
Co-authored-by: The Initiator <eithansten@gmail.com>
Co-authored-by: Turkish Language Team 🇹🇷 <turkishmark@yandex.com>
Co-authored-by: Willy Anjaya <black.neotrouz@gmail.com>
Co-authored-by: ene-sword-group <enguerrand.neyroud@sword-group.com>
Co-authored-by: htet <htetoh2006@outlook.com>
Co-authored-by: jhihyu lin <thomas.jy.lin@gmail.com>
Co-authored-by: jinu147 <nesqea20@gmail.com>
Co-authored-by: rattenpriester <rattenpriester6840@gmail.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: sundarlal sharma <sundelalsharma11@gmail.com>
Co-authored-by: tabtomi8 <tabtomi88@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
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/as/
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/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/es/
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/ko/
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/ms/
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/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/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/sv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
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/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ca/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/es/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/mk/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2025-11-09 13:51:21 +00:00
Luna712
52100dc5de
Move strictly to version catalog (#2134)
* Move strictly to version catalog

* Update libs.versions.toml
2025-11-08 22:41:08 +00:00
Luna712
3fda1415ae
Bump navigation to 2.9.6 (#2149) 2025-11-08 22:35:21 +00:00
Luna712
8f41722604
Minor improvement to encoding format text 2025-11-08 13:11:11 -07:00
Luna712
b11b324850
Minor padding fixes 2025-11-08 11:52:28 -07:00
Luna712
5049e56f5a
Minor fixes 2025-11-08 11:33:30 -07:00
Luna712
ddbb56a8de
Remove minor outdated APIs (#2159)
* Deprecated for awhile
* Extremely essy to fix (IDE can do it automatically)
* Not even really implemented or functional methods
* Extensions that do still use them are likely broken in other ways at this point due to being mostly unmaintained in that case
2025-11-08 16:15:50 +01:00
Luna712
76ae7cefb3
Use DeprecationLevel.ERROR for old AuthAPI (#2158)
It has been deprecated for 3 months now.
2025-11-08 16:13:14 +01:00
Luna712
3ccb252c09
Fix unchecked cast (#2160) 2025-11-08 16:12:00 +01:00
Luna712
b50ce3977a
Remove APPLICATION_RAWCC from HlsPlaylistParser (#2163)
Per it's own deprecation warning. It isn't supported anyway.
2025-11-08 16:09:07 +01:00
Luna712
829f8edf0b
Replace deprecated url in YoutubeExtractor (#2164) 2025-11-08 16:08:12 +01:00
Luna712
6bb144d10c
Replace deprecated Thread.id in ExceptionHandler (#2166) 2025-11-08 16:05:30 +01:00
Luna712
4425888097
More response player quality profile items
More response for portrait mode as well, so they don't grow massive. Uses the same size as account select linear profile sizes.
2025-11-07 18:18:20 -07:00
Luna712
70dd9e2387
Add portrait layout for source priorities 2025-11-07 16:46:53 -07:00
Luna712
8633682261
A bit better version 2025-11-07 15:48:13 -07:00
Luna712
2afbce665b
Add for subtitle offset dialog also 2025-11-07 14:19:05 -07:00
Luna712
4d40ecc75c
Add portrait mode layout for souece and subtitles select dialog
This doesn't yet fix it for subtitle sync dialog or improvements to quality dialog. For now ir just fixes the source and subtitles select dialog, which is probably the most important. If this method is accepted, I will fix the orhers in similar ways.

Putting a new layout in `layout-port` will make it auto use it instead of the default layout when in portrait mode, and switch back to landscape layout if it's rotated back to landscape mode.

Fixes #2145
Refs #830
2025-11-07 13:59:29 -07:00
Luna712
20fa05b4eb
Require fixLayout() to be overridden (#2155) 2025-11-06 19:04:08 +01:00
Luna712
011a6e0d50
Rename fixPadding to fixLayout and expand usage (#2127) 2025-11-04 21:48:24 +01:00
Luna712
b699e2f3bc
Fix updating UI when changing some settings (#2139) 2025-11-04 21:41:43 +01:00
Luna712
559edb44f2
Fix setting summary (#2141) 2025-11-04 21:35:28 +01:00
Luna712
2307f15bff
Fix library orientation bug (#2140)
Fixes #2129
2025-11-04 14:48:26 +01:00
Luna712
acbedd1c51
Update gradlew (#2115) 2025-10-31 22:00:14 +01:00
Luna712
aec346920e
Fix entering PiP mode even when we shouldn't (#2118) 2025-10-31 03:37:03 +01:00
firelight
38b9734fdb
Fix: Limit video title length to 2 lines, Closes #2105 2025-10-30 21:40:49 +01:00
Luna712
ede1fad282
Make fixSystemBarsPadding not accept a null View (#2114) 2025-10-30 21:25:41 +01:00
firelight
ec06da62ce
Fix: Restore backup now also invalidates library cache, Closes #2112 2025-10-30 20:56:51 +01:00
Luna712
17761e8adf
Fix system bars padding on orientation change in ResultFragmentPhone (#2113) 2025-10-30 20:49:53 +01:00
Yashas
224438cd92
fix: remove listeners before releasing exoplayer (#2104) 2025-10-30 20:44:34 +01:00
Luna712
ae042708e4
Remove handleConfigurationChanged from BaseFragmentHelper (#2111) 2025-10-30 20:20:40 +01:00
Luna712
604880158e
Migrate ResultFragmentTv to use BaseFragment (#2108) 2025-10-30 19:32:03 +01:00
Luna712
3081728afd
Upgrade Gradle wrapper from 9.1.0 to 9.2.0 and use URL validation (#2096) 2025-10-30 17:29:00 +01:00
Luna712
a565f50c46
Add explicit dependency on activity (#2098)
It's implicit right now, but making it explicit makes dependency management easier, especially now that new versions (starting with 1.12.0) require minSdk 23.
2025-10-30 17:10:48 +01:00
Luna712
9600781c4f
Migrate subtitles fragments to use BaseFragment (#2101) 2025-10-30 17:07:29 +01:00
firelight
dbeb815c06
Docs: Additional fixPadding documentation 2025-10-30 17:02:59 +01:00
Luna712
accf840730
Migrate WebviewFragment to use BaseFragment (#2103) 2025-10-30 17:01:33 +01:00
Luna712
3f2d81632f
Migrate EasterEggMonkeFragment to use BaseFragment (#2102) 2025-10-30 17:00:22 +01:00
Luna712
9afaf37091
Migrate settings fragments to use BaseFragment (#2100) 2025-10-30 16:55:44 +01:00
Luna712
cd25048212
Add BaseFragment compat for other Fragment subclasses (#2097) 2025-10-30 00:47:43 +01:00
Luna712
b385c72127
Migrate setup fragments to use BaseFragment (#2095) 2025-10-30 00:35:29 +01:00
firelight
69e8d90ad2
Fix: Correct boundscheck for next episode, Closes #2084 and Closes #2090 2025-10-30 00:26:22 +01:00
Luna712
532c375aad
Migrate SearchFragment to BaseFragment (#2087) 2025-10-29 13:08:13 +01:00
Luna712
a37a56bbfd
Migrate downloads fragments to use BaseFragment (#2088) 2025-10-29 13:05:03 +01:00
Luna712
1ffac969ff
Minor update to english translation for a string (#2092)
Not only phones but other devices too. We should clarify that a bit and use devices here instead of phones.
2025-10-29 12:49:24 +01:00
Luna712
f64be19d15
Add new BaseFragment (#2080) 2025-10-28 22:53:05 +01:00
firelight
993dcd92f0
Fix: Some white theme issues with dialog text and quality profiles 2025-10-28 22:42:34 +01:00
firelight
bcd9d65962
Chore: Refactor AccountAdapter and SelectAdaptor to use BaseAdapter 2025-10-28 18:39:42 +01:00
firelight
2d177cb330
Chore: Refactor ImageAdapter, AccountAdapter, TestResultAdapter and LogcatAdapter to use BaseAdapter 2025-10-28 18:15:34 +01:00
firelight
297e6de658
Fix: Temporarily removed voting api due to bad gateway 2025-10-28 17:42:59 +01:00
firelight
fe68f72e9d
Fix: loadImage fix for resources with attr, Closes #2082 2025-10-28 17:34:18 +01:00
firelight
bc07b86445
Feat: Added CronetDataSource for better cache and networking (#2079) 2025-10-28 17:24:43 +01:00
firelight
a1a5c36db4
Fix: Recycle cache theme invalidation, Closes #2082 2025-10-27 19:02:43 +01:00
firelight
6c30af3790
Fix: Unified resume logic to allow resume from external apps, Closes #2070 2025-10-26 16:37:09 +01:00
firelight
df091502fe
Fix: Homepage switching provider pop-in, might also fix #2072 2025-10-26 14:03:56 +01:00
firelight
9295795ab2
Fix: Upcoming episodes without hourglass icon if we have an faulty poster, also clear image 2025-10-26 02:17:27 +01:00
firelight
856245a23b
Chore: Refactor EpisodeAdapter to use BaseAdapter, and expanded BaseAdapter to allow custom content 2025-10-26 02:50:45 +02:00
firelight
73fec27d00
Chore: Refactor SearchHistoryAdaptor to use BaseAdapter 2025-10-26 02:06:29 +02:00
firelight
95db2d728e
Chore: Refactor PluginAdapter to use BaseAdapter, and fixed ghosting issue 2025-10-26 01:53:57 +02:00
firelight
ce5816ae77
Fix: Random crash on long running sessions, Closes #2071 2025-10-26 00:30:06 +02:00
firelight
68b215cada
Chore: Refactor RepoAdapter to use BaseAdapter 2025-10-26 00:13:12 +02:00
firelight
eb3519731a
Chore: Refactor ProfilesAdapter to use BaseAdapter 2025-10-25 22:36:43 +02:00
firelight
d77fcde347
Chore: Refactor PriorityAdapter to use BaseAdapter 2025-10-25 22:24:39 +02:00
firelight
38e52e24db
Fix: Watch status on unwatched episodes, whoops 2025-10-25 22:00:47 +02:00
firelight
ee3b07916e
Chore: Refactor SubtitleOffsetItemAdapter to use BaseAdapter 2025-10-25 21:55:21 +02:00
firelight
9e72ab2394
Chore: Refactor PageAdapter to use BaseAdapter 2025-10-25 21:41:31 +02:00
firelight
e61715cd99
Chore: Minor refactor to displayPos 2025-10-25 21:26:14 +02:00
rockhero1234
99eaa369ea
feat:check icon on watched episode and auto mark watched (#2023)
* check icon on watched episode

* auto mark episode as watched

* set check above 95 and removed auto watched
2025-10-25 21:24:37 +02:00
rockhero1234
b56e858d48
fix (#2022) 2025-10-25 21:19:07 +02:00
firelight
8dcb38a3bb
Chore: Refactor SearchAdapter to use BaseAdapter, and made NoStateAdapter easier to use 2025-10-25 21:11:19 +02:00
firelight
66b8fe963d
Chore: Refactor BaseAdapter for better view recycling, migrate Actors to BaseAdapter 2025-10-25 20:03:48 +02:00
Luna712
526372a577
Bump version to 4.6.1 (#2068)
Since we've had a major change with edge to edge, bumping the version could help know if it's related if an issue is created.
2025-10-25 19:22:54 +02:00
Luna712
5e3c9ee726
Bump tmdb lib (#2054) 2025-10-25 18:55:51 +02:00
Luna712
b0c78f7820
Unify clear search icons logic (#2062)
Reduces code duplication, and also adds support for them to show on repo and extensions search.
2025-10-25 18:51:10 +02:00
Luna712
eb93b8803d
Add full support for edge-to-edge (#2002) 2025-10-25 18:38:57 +02:00
firelight
29491ae634
Fix: Improved shared pool recycling for actors, quicksearch and recommendations 2025-10-25 15:26:41 +02:00
firelight
071b50e4ae
Fix: Improved shared pool recycling for episodes, search and homepage 2025-10-25 15:09:22 +02:00
Luna712
a100c94d40
Remove backwards compatible constructors that have been deprecated for over a year (#2044) 2025-10-25 14:37:07 +02:00
Luna712
dccfae8506
Bump KGP to 2.2.21 (#2057) 2025-10-25 14:36:18 +02:00
Luna712
2cc86bcb6a
Bump material (#2055) 2025-10-25 14:30:00 +02:00
Luna712
bda260525b
[skip ci] Bump upload-artifact action (#2058) 2025-10-25 12:15:55 +02:00
firelight
168c2e8432
Fix: Skip loading visibility in white theme, Closes #2049 2025-10-24 13:55:34 +02:00
firelight
eaa26b20e3
Fix: Inconsistent icon color for certain icons in settings on light themes, Closes #2047 2025-10-23 21:41:54 +02:00
firelight
ffd3c47dd9
Fix: Contrast issues on AccountSelectActivity on light themes, Closes #2048 2025-10-23 21:30:02 +02:00
firelight
a03079e48c
Fix: Contrast issues on light themes, Closes #2049 2025-10-23 21:06:42 +02:00
firelight
b3054e9bd1
Fix: Subtitle preview with elevation, Closes #2053 2025-10-23 20:16:20 +02:00
Luna712
5898eb2593
Enable deprecations in SubtitleHelper (#2036) 2025-10-23 19:03:44 +02:00
Luna712
4270f159b8
Make old rating system use DeprecationLevel.ERROR (#2041)
It's been deprecated for about 3 months now. This also deprecated `fromOld` and `toOld` since in theory those should never have been used in extensions and also `toRatingInt()` since again, in theory, wouldn't be used once converting to use the new score API.
2025-10-20 21:18:31 +02:00
firelight
5b1400e212
Fix: Subtitles not showing on cancel press, Closes #2042 2025-10-20 20:58:36 +02:00
firelight
28f044ad33
Fix: OOM on removeKeys, Closes #2035 2025-10-19 19:53:10 +02:00
firelight
fe0475f441
Fix(UI): Varius highlight/color changes for better accessibility, Closes #2024 2025-10-18 20:34:05 +02:00
Luna712
50e746c51d
Suppress some internal deprecations (#2019) 2025-10-18 20:10:56 +02:00
Luna712
34833d23d0
Remove usage of deprecated unsafeCheckOpNoThrow (#2018) 2025-10-18 20:10:11 +02:00
Luna712
98e6572bab
Replace deprecated StaticLayout constructor (#2020) 2025-10-18 20:08:31 +02:00
Luna712
7dd650b236
Update some depreciations (#2028) 2025-10-18 20:06:21 +02:00
Luna712
6dc0d6ed73
Replace use of deprecated Util.SDK_INT (#2030) 2025-10-18 20:04:07 +02:00
firelight
1f8cccdbd0
Fix: Next player episode and episode list progress issue, Closes #2025 2025-10-18 18:56:04 +02:00
Luna712
5bcf457814
Enable auto rotate preference by default (#2014)
* Enable auto rotate preference by default

It makes sense to adjust the screen orientation based on the orientation of the video by default.

* Update callsite also
2025-10-18 00:13:37 +02:00
Luna712
760dea2b4f
Provider tests: handle if implementing paginated search (#2016) 2025-10-18 00:12:34 +02:00
Luna712
2e255e6586
Use DeprecationLevel.ERROR for some methods (#2017) 2025-10-18 00:11:38 +02:00
firelight
d84a241026
Feat: Added subtitle alignment setting, and added 400dp elevation closes #2009 2025-10-16 16:34:27 +02:00
Luna712
535cb86934
Make constructor for UpdatedMatroskaExtractor private (#1999)
`'internal' declaration exposes 'public/*package*/' type 'EbmlReader'. This will become an error in language version 2.4.`
2025-10-16 15:54:37 +02:00
rockhero1234
505f869dbf
feat:resume btn with circular progress (#1998) 2025-10-16 15:52:19 +02:00
rockhero1234
e113cee434
fix(tv):not show rotate btn (#2010)
* fix(tv):not show rotate btn

* also emulator
2025-10-16 15:36:22 +02:00
Luna712
2bfee2e4a3
Fix library crash (#2006) 2025-10-16 01:18:15 +02:00
539 changed files with 30542 additions and 19133 deletions

4
.github/locales.py vendored
View file

@ -1,7 +1,6 @@
import re
import glob
import requests
import os
import lxml.etree as ET # builtin library doesn't preserve comments
@ -62,8 +61,5 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
with open(file, 'wb') as fp:
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
# Remove trailing new line to be consistent with weblate
fp.seek(-1, os.SEEK_END)
fp.truncate()
except ET.ParseError as ex:
print(f"[{file}] {ex}")

View file

@ -9,6 +9,9 @@ on:
- '**/wcokey.txt'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: "Archive-build"
cancel-in-progress: true
@ -33,16 +36,17 @@ jobs:
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive"
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
distribution: temurin
java-version: 17
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Fetch keystore
id: fetch_keystore
run: |
@ -54,24 +58,32 @@ jobs:
echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run Gradle
run: |
./gradlew assemblePrerelease
run: ./gradlew assemblePrereleaseRelease
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- uses: actions/checkout@v5
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
MAL_KEY: ${{ secrets.MAL_KEY }}
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
- uses: actions/checkout@v6
with:
repository: "recloudstream/cloudstream-archive"
token: ${{ steps.generate_archive_token.outputs.token }}
path: "archive"
- name: Move build
run: |
cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
- name: Push archive
run: |

View file

@ -1,19 +1,18 @@
name: Dokka
# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
concurrency:
group: "dokka"
cancel-in-progress: true
on:
push:
branches:
# choose your default branch
- master
- main
branches: [ master ]
paths-ignore:
- '*.md'
permissions:
contents: read
concurrency:
group: "dokka"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
@ -25,13 +24,14 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka"
- name: Checkout
uses: actions/checkout@master
uses: actions/checkout@v6
with:
path: "src"
- name: Checkout dokka
uses: actions/checkout@master
uses: actions/checkout@v6
with:
repository: "recloudstream/dokka"
path: "dokka"
@ -46,12 +46,13 @@ jobs:
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
distribution: 'adopt'
cache: gradle
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Generate Dokka
run: |
@ -60,8 +61,7 @@ jobs:
./gradlew docs:dokkaGeneratePublicationHtml
- name: Copy Dokka
run: |
cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
- name: Push builds
run: |

View file

@ -1,88 +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@v5
- 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"
cancel-in-progress: true
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
@ -24,16 +27,17 @@ jobs:
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
distribution: temurin
java-version: 17
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Fetch keystore
id: fetch_keystore
run: |
@ -45,20 +49,26 @@ jobs:
echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Run Gradle
run: |
./gradlew assemblePrerelease build androidSourcesJar
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
MAL_KEY: ${{ secrets.MAL_KEY }}
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
- name: Create pre-release
uses: "marvinpinto/action-automatic-releases@latest"
uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release"

View file

@ -2,27 +2,35 @@ name: Artifact Build
on: [pull_request]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'adopt'
cache: gradle
distribution: temurin
java-version: 17
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache-read-only: false
- name: Run Gradle
run: ./gradlew assemblePrereleaseDebug
run: ./gradlew assemblePrereleaseDebug lint check
- name: Upload Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"

View file

@ -1,17 +1,19 @@
name: Fix locale issues
on:
workflow_dispatch:
push:
branches: [ master ]
paths:
- '**.xml'
branches:
- master
workflow_dispatch:
concurrency:
group: "locale"
cancel-in-progress: true
permissions:
contents: read
jobs:
create:
runs-on: ubuntu-latest
@ -23,15 +25,17 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream"
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Install dependencies
run: |
pip3 install lxml requests
run: pip3 install lxml requests
- name: Edit files
run: |
python3 .github/locales.py
run: python3 .github/locales.py
- name: Commit to the repo
run: |
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"

11
AI-POLICY.md Normal file
View file

@ -0,0 +1,11 @@
# AI Policy
AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions.
1. Always state any AI usage in pull requests and issues.
2. Always test code before making a pull request. We do not want to test your AI generated code.
3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI.
4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions.

View file

@ -1,54 +1,96 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins {
id("com.android.application")
id("kotlin-android")
id("org.jetbrains.dokka")
alias(libs.plugins.android.application)
alias(libs.plugins.dokka)
alias(libs.plugins.kotlin.serialization)
}
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 {
return try {
val headFile = file("${project.rootDir}/.git/HEAD")
abstract class GenerateGitHashTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val headFile: RegularFileProperty
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val headsDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val head = headFile.get().asFile
val hash = try {
if (head.exists()) {
// Read the commit hash from .git/HEAD
if (headFile.exists()) {
val headContent = headFile.readText().trim()
val headContent = head.readText().trim()
if (headContent.startsWith("ref:")) {
val refPath = headContent.substring(5) // e.g., refs/heads/main
val commitFile = file("${project.rootDir}/.git/$refPath")
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
}.take(7) // Return the short commit hash
} else "" // If .git/HEAD doesn't exist
} catch (_: Throwable) {
"" // Just return an empty string if any exception occurs
"" // 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 {
@Suppress("UnstableApiUsage")
testOptions {
unitTests.isReturnDefaultValues = true
}
viewBinding {
enable = true
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(
generateGitHash,
GenerateGitHashTask::outputDir
)
}
}
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") {
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")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@ -62,12 +104,10 @@ android {
applicationId = "com.lagradost.cloudstream3"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 67
versionName = "4.6.0"
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", getGitCommitHash())
resValue("bool", "is_prerelease", "false")
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
// Reads local.properties
val localProperties = gradleLocalProperties(rootDir, project.providers)
@ -87,6 +127,16 @@ android {
"SIMKL_CLIENT_SECRET",
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
buildConfigField(
"String",
"MAL_KEY",
"\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\""
)
buildConfigField(
"String",
"ANILIST_KEY",
"\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -114,12 +164,9 @@ android {
productFlavors {
create("stable") {
dimension = "state"
resValue("bool", "is_prerelease", "false")
}
create("prerelease") {
dimension = "state"
resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease")
@ -137,13 +184,29 @@ android {
targetCompatibility = JavaVersion.toVersion(javaTarget.target)
}
java {
// Use Java 17 toolchain even if a higher JDK runs the build.
// We still use Java 8 for now which higher JDKs have deprecated.
toolchain {
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
}
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
buildFeatures {
buildConfig = true
viewBinding = true
}
packaging {
jniLibs {
// Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
// Note: This may increase app startup time slightly.
useLegacyPackaging = true
}
}
namespace = "com.lagradost.cloudstream3"
@ -154,43 +217,46 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.json)
androidTestImplementation(libs.core)
implementation(libs.junit.ktx)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.instancio.core)
androidTestImplementation(libs.junit.ktx)
androidTestImplementation(libs.kotlin.test)
// Android Core & Lifecycle
implementation(libs.core.ktx)
implementation(libs.activity.ktx)
implementation(libs.annotation)
implementation(libs.appcompat)
implementation(libs.bundles.navigationKtx)
implementation(libs.lifecycle.livedata.ktx)
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.fragment.ktx)
implementation(libs.bundles.lifecycle)
implementation(libs.bundles.navigation)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.serialization.json) // JSON Parser
// Design & UI
implementation(libs.preference.ktx)
implementation(libs.material)
implementation(libs.constraintlayout)
implementation(libs.swiperefreshlayout)
// Coil Image Loading
implementation(libs.coil)
implementation(libs.coil.network.okhttp)
implementation(libs.bundles.coil)
// Media 3 (ExoPlayer)
implementation(libs.bundles.media3)
implementation(libs.video)
// FFmpeg Decoding
implementation(libs.bundles.nextlib)
// Anime-db for filler
implementation(libs.anime.db)
// PlayBack
implementation(libs.colorpicker) // Subtitle Color Picker
implementation(libs.newpipeextractor) // For Trailers
implementation(libs.juniversalchardet) // Subtitle Decoding
// FFmpeg Decoding
implementation(libs.bundles.nextlibMedia3)
// Crash Reports (AcraApplication.kt)
implementation(libs.acra.core)
implementation(libs.acra.toast)
// UI Stuff
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
implementation(libs.palette.ktx) // Palette for Images -> Colors
@ -201,50 +267,34 @@ dependencies {
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
// Extensions & Other Libs
implementation(libs.jsoup) // HTML Parser
implementation(libs.rhino) // Run JavaScript
implementation(libs.quickjs)
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
implementation(libs.safefile) // To Prevent the URI File Fu*kery
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
implementation(libs.conscrypt.android) {
version {
strictly("2.5.2")
}
because("2.5.3 crashes everything for everyone.")
} // To Fix SSL Fu*kery on Android 9
implementation(libs.jackson.module.kotlin) {
version {
strictly("2.13.1")
}
because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.")
} // JSON Parser
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
implementation(libs.jackson.module.kotlin) // JSON Parser
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
implementation(libs.torrentserver)
// Downloading & Networking
implementation(libs.work.runtime)
implementation(libs.work.runtime.ktx)
implementation(libs.nicehttp) // HTTP Lib
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)
})
implementation(project(":library"))
}
tasks.register<Jar>("androidSourcesJar") {
archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
from(android.sourceSets.getByName("main").java.directories) // Full Sources
}
tasks.register<Copy>("copyJar") {
dependsOn("build", ":library:jvmJar")
from(
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
"../library/build/libs"
@ -271,10 +321,12 @@ tasks.register<Jar>("makeJar") {
tasks.withType<KotlinJvmCompile> {
compilerOptions {
jvmTarget.set(javaTarget)
freeCompilerArgs.addAll(
"-Xjvm-default=all-compatibility",
"-Xannotation-default-target=param-property",
"-opt-in=com.lagradost.cloudstream3.Prerelease"
jvmDefault.set(JvmDefaultMode.ENABLE)
freeCompilerArgs.add("-Xannotation-default-target=param-property")
optIn.addAll(
"com.lagradost.cloudstream3.InternalAPI",
"com.lagradost.cloudstream3.Prerelease",
"kotlin.uuid.ExperimentalUuidApi",
)
}
}
@ -282,8 +334,10 @@ tasks.withType<KotlinJvmCompile> {
dokka {
moduleName = "App"
dokkaSourceSets {
main {
configureEach {
suppress = name != "prereleaseDebug"
analysisPlatform = KotlinPlatform.JVM
displayName = "JVM"
documentedVisibilities(
VisibilityModifier.Public,
VisibilityModifier.Protected

13
app/lint.xml Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- ByteOrderMark has errors in values-b+ja/strings.xml, but it's handled by weblate so we don't really care. -->
<issue id="ByteOrderMark" severity="ignore" />
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
<issue id="MissingTranslation" severity="ignore" />
<!-- We only care about the source language here. -->
<issue id="StringFormatInvalid">
<ignore path="**/res/values-*/**" />
</issue>
</lint>

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

@ -2,8 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="go.torrServer.gojni" /> <!-- torrServer has a different api level -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads -->
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
@ -24,6 +22,47 @@
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<queries>
<!--
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
-->
<!-- For external video players -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/x-mpegURL" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="magnet" />
</intent>
<!-- Common players supported in actions/temp -->
<package android:name="org.videolan.vlc" />
<package android:name="org.videolan.vlc.debug" />
<package android:name="is.xyz.mpv" />
<package android:name="is.xyz.mpv.ytdl" />
<package android:name="app.marlboroadvance.mpvex" />
<package android:name="live.mehiz.mpvkt" />
<package android:name="live.mehiz.mpvkt.preview" />
<package android:name="com.brouken.player" />
<package android:name="dev.anilbeesetti.nextplayer" />
<package android:name="com.instantbits.cast.webvideo" />
<package android:name="com.gianlu.aria2android" />
<!-- Torrent clients -->
<package android:name="org.proninyaroslav.libretorrent" />
<package android:name="com.biglybt.android.client" />
</queries>
<!-- Fixes android tv fuckery -->
<uses-feature
android:name="android.hardware.touchscreen"
@ -35,9 +74,8 @@
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
<application
android:name=".AcraApplication"
android:name=".CloudStreamApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:appCategory="video"
android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor"
@ -45,11 +83,12 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:pageSizeCompat="enabled"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:targetApi="35">
tools:targetApi="${target_sdk_version}">
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
@ -110,14 +149,31 @@
android:launchMode="singleTask"
is a bit experimental, it makes loading repositories from browser still stay on the same page
no idea about side effects
Not exported to prevent bypassing the AccountSelectActivity
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="true"
android:exported="false"
android:launchMode="singleTask"
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 -->
<intent-filter>
@ -175,7 +231,7 @@
<data android:scheme="cloudstreamcontinuewatching" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -188,21 +244,6 @@
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<receiver
android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false"
@ -218,6 +259,12 @@
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".services.DownloadQueueService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View file

@ -1,28 +0,0 @@
#include <jni.h>
#include <csignal>
#include <android/log.h>
#define TAG "CloudStream Crash Handler"
volatile sig_atomic_t gSignalStatus = 0;
void handleNativeCrash(int signal) {
gSignalStatus = signal;
}
extern "C" JNIEXPORT void JNICALL
Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
#define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
REGISTER_SIGNAL(SIGSEGV)
#undef REGISTER_SIGNAL
}
//extern "C" JNIEXPORT void JNICALL
//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
// int *p = nullptr;
// *p = 0;
//}
extern "C" JNIEXPORT int JNICALL
Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
//__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
return gSignalStatus;
}

View file

@ -1,233 +1,78 @@
package com.lagradost.cloudstream3
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import coil3.PlatformContext
import coil3.SingletonImageLoader
import com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeAsync
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.ImageLoader
import kotlinx.coroutines.runBlocking
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.CoreConfiguration
import org.acra.data.CrashReportData
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderFactory
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.system.exitProcess
class CustomReportSender : ReportSender {
// Sends all your crashes to google forms
override fun send(context: Context, errorContent: CrashReportData) {
/*println("Sending report")
val url =
"https://docs.google.com/forms/d/e/$id/formResponse"
val data = mapOf(
"entry.$entry" to errorContent.toJSON()
)
thread { // to not run it on main thread
runBlocking {
safeAsync {
app.post(url, data = data)
//println("Report response: $post")
}
}
}
runOnMainThread { // to run it on main looper
safe {
Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
}
}*/
}
}
class CustomSenderFactory : ReportSenderFactory {
override fun create(context: Context, config: CoreConfiguration): ReportSender {
return CustomReportSender()
}
override fun enabled(config: CoreConfiguration): Boolean {
return true
}
}
class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, error: Throwable) {
ACRA.errorReporter.handleException(error)
try {
PrintStream(errorFile).use { ps ->
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
error.printStackTrace(ps)
}
} catch (ignored: FileNotFoundException) {
}
try {
onError.invoke()
} catch (ignored: Exception) {
}
exitProcess(1)
}
}
class AcraApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// if we want to initialise coil at earliest
// (maybe when loading an image or gif using in splash screen activity)
//ImageLoader.buildImageLoader(applicationContext)
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
}.also {
exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it)
}
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
context = base
initAcra {
//core configuration:
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
)
// removed this due to bug when starting the app, moved it to when it actually crashes
//each plugin you chose above can be configured in a block like this:
/*toast {
text = getString(R.string.acra_report_toast)
//opening this block automatically enables the plugin.
}*/
}
}
override fun newImageLoader(context: PlatformContext): coil3.ImageLoader {
// Coil Module will be initialized & setSafe globally when first loadImage() is invoked
return ImageLoader.buildImageLoader(applicationContext)
}
companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
}
private var _context: WeakReference<Context>? = null
var context
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
}
fun <T> setKey(folder: String, path: String, value: T) {
context?.setKey(folder, path, value)
}
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
}
inline fun <reified T : Any> getKey(path: String): T? {
return context?.getKey(path)
}
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
}
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
return context?.getKey(folder, path, defVal)
}
fun getKeys(folder: String): List<String>? {
return context?.getKeys(folder)
}
fun removeKey(folder: String, path: String) {
context?.removeKey(folder, path)
}
fun removeKey(path: String) {
context?.removeKey(path)
}
/**
* If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
* */
fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
context?.openBrowser(url, fallbackWebview, fragment)
}
/** Will fallback to webview if in TV layout */
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
* Use CloudStreamApp instead.
*/
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
level = DeprecationLevel.WARNING
)
}
class AcraApplication {
companion object {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
level = DeprecationLevel.WARNING
)
val context get() = CloudStreamApp.context
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
level = DeprecationLevel.WARNING
)
fun removeKeys(folder: String): Int? =
CloudStreamApp.removeKeys(folder)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
level = DeprecationLevel.WARNING
)
fun <T> setKey(path: String, value: T) =
CloudStreamApp.setKey(path, value)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
level = DeprecationLevel.WARNING
)
fun <T> setKey(folder: String, path: String, value: T) =
CloudStreamApp.setKey(folder, path, value)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
CloudStreamApp.getKey(path, defVal)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(path: String): T? =
CloudStreamApp.getKey(path)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
CloudStreamApp.getKey(folder, path)
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
level = DeprecationLevel.WARNING
)
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
CloudStreamApp.getKey(folder, path, defVal)
}
}

View file

@ -0,0 +1,181 @@
package com.lagradost.cloudstream3
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Build
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import com.lagradost.api.setContext
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeAsync
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.AppDebug
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader
import kotlinx.coroutines.runBlocking
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.system.exitProcess
class ExceptionHandler(
val errorFile: File,
val onError: (() -> Unit)
) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, error: Throwable) {
try {
val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
thread.threadId()
} else {
@Suppress("DEPRECATION")
thread.id
}
PrintStream(errorFile).use { ps ->
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} ($threadId)")
error.printStackTrace(ps)
}
} catch (_: FileNotFoundException) {
}
try {
onError()
} catch (_: Exception) {
}
exitProcess(1)
}
}
class CloudStreamApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// If we want to initialize Coil as early as possible, maybe when
// loading an image or GIF in a splash screen activity.
// buildImageLoader(applicationContext)
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
}.also {
exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it)
}
AppDebug.isDebug = BuildConfig.DEBUG
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
context = base
}
override fun newImageLoader(context: PlatformContext): ImageLoader {
// Coil module will be initialized globally when first loadImage() is invoked.
return buildImageLoader(applicationContext)
}
companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get Activity from Context. */
tailrec fun Context.getActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
}
private var _context: WeakReference<Context>? = null
var context
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
}
fun <T> setKey(folder: String, path: String, value: T) {
context?.setKey(folder, path, value)
}
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
}
inline fun <reified T : Any> getKey(path: String): T? {
return context?.getKey(path)
}
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
}
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
return context?.getKey(folder, path, defVal)
}
fun getKeys(folder: String): List<String>? {
return context?.getKeys(folder)
}
fun removeKey(folder: String, path: String) {
context?.removeKey(folder, path)
}
fun removeKey(path: String) {
context?.removeKey(path)
}
/** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */
fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) {
context?.openBrowser(url, fallbackWebView, fragment)
}
/** Will fall back to WebView if in TV or emulator layout. */
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.lagradost.cloudstream3
import android.annotation.SuppressLint
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
@ -8,6 +9,8 @@ import android.content.res.Configuration
import android.content.res.Resources
import android.Manifest
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.DisplayMetrics
import android.util.Log
import android.view.Gravity
@ -24,28 +27,34 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.core.view.isNotEmpty
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
import com.lagradost.cloudstream3.ui.player.Torrent
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter
import com.lagradost.cloudstream3.ui.result.ImageAdapter
import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UiText
import java.lang.ref.WeakReference
@ -101,14 +110,12 @@ object CommonActivity {
return displayMetrics.heightPixels
}
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
var isPipDesired: Boolean = false
var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event<Pair<Int, Int>>()
val onDialogDismissedEvent = Event<Int>()
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var appliedTheme: Int = 0
var appliedColor: Int = 0
@ -184,6 +191,16 @@ object CommonActivity {
currentToast = toast
toast.show()
val handler = Handler(Looper.getMainLooper())
val ref = WeakReference(toast)
/* Clean up activity leak */
handler.postDelayed({
if (ref.get() == currentToast) {
currentToast = null
}
}, 10_000)
} catch (e: Exception) {
logError(e)
}
@ -227,16 +244,8 @@ object CommonActivity {
fun init(act: Activity) {
setActivityInstance(act)
ioSafe { Torrent.deleteAllFiles() }
val componentActivity = activity as? ComponentActivity ?: return
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
//https://developer.android.com/guide/topics/ui/picture-in-picture
canShowPipMode =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
componentActivity.updateLocale()
componentActivity.updateTv()
AccountManager.initMainAPI()
@ -252,7 +261,7 @@ object CommonActivity {
?: return@registerForActivityResult
action.onResultSafe(act, result.data)
removeKey("last_click_action")
removeKey("last_opened_id")
removeKey("last_opened")
}
}
@ -274,13 +283,15 @@ object CommonActivity {
}
}
/** Enters pip mode if it is both possible and desired to do so*/
private fun Activity.enterPIPMode() {
if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
if (!isPipDesired || !this.isPIPPossible()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
} catch (e: Exception) {
} catch (_: Exception) {
// Use fallback just in case
@Suppress("DEPRECATION")
enterPictureInPictureMode()
@ -296,10 +307,10 @@ object CommonActivity {
}
}
fun onUserLeaveHint(act: Activity?) {
if (canEnterPipMode && canShowPipMode) {
act?.enterPIPMode()
}
fun onUserLeaveHint(act: Activity) {
// On Android 12 and later we use setAutoEnterEnabled() instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
act.enterPIPMode()
}
fun updateTheme(act: Activity) {
@ -338,8 +349,10 @@ object CommonActivity {
"AmoledLight" -> R.style.AmoledModeLight
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme
"Dracula" -> R.style.DraculaMode
"Lavender" -> R.style.LavenderMode
"SilentBlue" -> R.style.SilentBlueMode
else -> R.style.AppTheme
}
@ -410,8 +423,7 @@ object CommonActivity {
private fun View.hasContent(): Boolean {
return isShown && when (this) {
//is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
is ViewGroup -> this.isNotEmpty()
else -> true
}
}
@ -441,7 +453,7 @@ object CommonActivity {
// if cant focus but visible then break and let android decide
// the exception if is the view is a parent and has children that wants focus
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty()
} ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
@ -520,87 +532,7 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
// 149 keycode_numpad 5
val playerEvent = when (keyCode) {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward
}
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack
}
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
PlayerEventType.NextEpisode
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
PlayerEventType.PrevEpisode
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause
}
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play
}
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock
}
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide
}
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute
}
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors
}
// OpenSubtitles shortcut
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline
}
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed
}
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize
}
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp
}
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
PlayerEventType.SkipCurrentChapter
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
PlayerEventType.PlayPauseToggle
}
else -> return null
}
val listener = playerEventListener
if (listener != null) {
listener.invoke(playerEvent)
return true
}
return null
//when (keyCode) {
// KeyEvent.KEYCODE_DPAD_CENTER -> {
// println("DPAD PRESSED")
// }
//}
}
/** overrides focus and custom key events */
@ -637,6 +569,7 @@ object CommonActivity {
else -> null
}
// println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
@ -644,10 +577,15 @@ object CommonActivity {
return true
}
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
// TODO: Figure out why removing the check for SearchAutoComplete seems
// to break focus on TV as it shouldn't need to be used.
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
@SuppressLint("RestrictedApi")
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) {
UIHelper.showInputMethod(act.currentFocus?.findFocus())
showInputMethod(act.currentFocus?.findFocus())
}
//println("Keycode: $keyCode")
@ -656,7 +594,6 @@ object CommonActivity {
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
}
// if someone else want to override the focus then don't handle the event as it is already

View file

@ -1,11 +0,0 @@
package com.lagradost.cloudstream3
import android.view.LayoutInflater
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.ui.HeaderViewDecoration
fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
view.addItemDecoration(HeaderViewDecoration(headerView))
}

View file

@ -9,7 +9,6 @@ import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
@ -24,14 +23,14 @@ import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.view.children
import androidx.core.view.get
import androidx.core.view.isGone
@ -65,9 +64,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.initAll
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
@ -120,6 +119,7 @@ import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
@ -157,17 +157,20 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
@ -185,14 +188,9 @@ import java.nio.charset.Charset
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.system.exitProcess
import androidx.core.net.toUri
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.TvContractCompat
import android.content.ComponentName
import android.content.ContentUris
import com.lagradost.cloudstream3.ui.home.HomeFragment
import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object {
@ -202,6 +200,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null
/** Update lastError variable based on error file, to check if app crashed.
* Can be called multiple times without changing the lastError variable changing.
**/
fun setLastError(context: Context) {
if (lastError != null) return
val errorFile = context.filesDir.resolve("last_error")
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
} else {
lastError = null
}
}
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
@ -263,7 +276,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* @return true if the str has launched an app task (be it successful or not)
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
* */
@Suppress("DEPRECATION_ERROR")
fun handleAppIntentUrl(
activity: FragmentActivity?,
str: String?,
@ -340,7 +352,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
val uri = Uri.parse(str)
val uri = str.toUri()
val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8")
@ -350,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
LinkGenerator(
listOf(BasicLink(url, name)),
extract = true,
)
id = url.hashCode()
), 0
)
)
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
@ -395,17 +408,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
return true
}
synchronized(apis) {
for (api in apis) {
if (str.startsWith(api.mainUrl)) {
loadResult(str, api.name, "")
val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
if (matchedApi != null) {
loadResult(str, matchedApi.name, "")
return true
}
}
}
}
}
}
return false
}
@ -430,6 +440,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
var lastPopup: SearchResponse? = null
var lastPopupJob: Job? = null
fun loadPopup(result: SearchResponse, load: Boolean = true) {
lastPopup = result
val syncName = syncViewModel.syncName(result.apiName)
@ -445,7 +456,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
syncViewModel.clear()
}
if (load) {
lastPopupJob?.cancel()
lastPopupJob = if (load) {
viewModel.load(
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
.contains(DubStatus.Dubbed)
@ -492,6 +504,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
R.id.navigation_downloads,
R.id.navigation_settings,
R.id.navigation_download_child,
R.id.navigation_download_queue,
R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player,
@ -539,25 +552,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
layoutParams = params
}*/
val landscape = when (resources.configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
true
}
Configuration.ORIENTATION_PORTRAIT -> {
isLayout(TV or EMULATOR)
}
else -> {
false
}
}
binding?.apply {
navRailView.isVisible = isNavVisible && landscape
navView.isVisible = isNavVisible && !landscape
navHostFragment.layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
marginStart = if (isNavVisible && landscape && isLayout(TV or EMULATOR)) 62.toPx else 0
navRailView.isVisible = isNavVisible && isLandscape()
navView.isVisible = isNavVisible && !isLandscape()
navHostFragment.apply {
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
layoutParams =
(navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
marginStart =
if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
}
}
/**
@ -566,7 +570,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* highlight the wrong one in UI.
*/
when (destination.id) {
in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
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
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
}
@ -688,7 +696,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
.setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
.setPositiveButton(R.string.yes) { _, _ ->
if (dontShowAgainCheck.isChecked) {
settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit()
settingsManager.edit(commit = true) {
putInt(getString(R.string.confirm_exit_key), 1)
}
}
// finish() causes a bug on some TVs where player
// may keep playing after closing the app.
@ -713,10 +723,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
this.sendBroadcast(broadcastIntent)
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
detachBackPressedCallback("MainActivityDefault")
super.onDestroy()
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
handleAppIntent(intent)
super.onNewIntent(intent)
}
@ -795,12 +806,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
}
private val pluginsLock = Mutex()
private fun onAllPluginsLoaded(success: Boolean = false) {
ioSafe {
pluginsLock.withLock {
synchronized(allProviders) {
allProviders.withLock {
// Load cloned sites after plugins have been loaded since clones depend on plugins.
try {
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
@ -846,6 +856,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
private fun hidePreviewPopupDialog() {
bottomPreviewPopup.dismissSafe(this)
lastPopupJob?.cancel()
lastPopupJob = null
bottomPreviewPopup = null
bottomPreviewBinding = null
}
@ -1165,18 +1177,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
}
@Suppress("DEPRECATION_ERROR")
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 errorFile = filesDir.resolve("last_error")
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
} else {
lastError = null
}
setLastError(this)
val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult =
@ -1185,6 +1193,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
MainAPI.settingsForProvider = settingsForProvider
loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
updateLocale()
super.onCreate(savedInstanceState)
try {
@ -1205,6 +1215,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
if (appVer != lastAppAutoBackup) {
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
if (lastAppAutoBackup.isEmpty()) return@safe
safe {
backup(this)
}
@ -1268,6 +1280,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
null
}
binding?.apply {
fixSystemBarsPadding(
navView,
heightResId = R.dimen.nav_view_height,
padTop = false,
overlayCutout = false
)
fixSystemBarsPadding(
navRailView,
widthResId = R.dimen.nav_rail_view_width,
padRight = false,
padTop = false
)
}
// overscan
val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
binding?.homeRoot?.setPadding(padding, padding, padding, padding)
@ -1625,9 +1653,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
ioSafe {
initAll()
// No duplicates (which can happen by registerMainAPI)
apis = synchronized(allProviders) {
allProviders.distinctBy { it }
}
apis = allProviders.distinctBy { it }
}
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
@ -1650,10 +1676,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback("MainActivity") {
showConfirmExitDialog(settingsManager)
@Suppress("DEPRECATION")
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
} else detachBackPressedCallback("MainActivity")
}
@ -1913,7 +1935,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
fun buildMediaQueueItem(video: String): MediaQueueItem {
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
//movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream")
val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString())
val mediaInfo = MediaInfo.Builder(video.toUri().toString())
.setStreamType(MediaInfo.STREAM_TYPE_NONE)
.setContentType(MimeTypes.IMAGE_JPEG)
// .setMetadata(movieMetadata).build()
@ -1939,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (BuildConfig.DEBUG) {
var providersAndroidManifestString = "Current androidmanifest should be:\n"
synchronized(allProviders) {
allProviders.withLock {
for (api in allProviders) {
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
api.mainUrl.removePrefix(
@ -2017,25 +2039,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
// }
// }
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
@Suppress("DEPRECATION")
window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
attachBackPressedCallback("MainActivityDefault") {
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
updateLocale()
// If we don't disable we end up in a loop with default behavior calling
// this callback as well, so we disable it, run default behavior,
// then re-enable this callback so it can be used for next back press.
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
runDefault()
}
}
)
// Start the download queue
DownloadQueueManager.init(this)
}
/** Biometric stuff **/

View file

@ -6,8 +6,8 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@ -21,7 +21,8 @@ import java.io.File
fun updateDurationAndPosition(position: Long, duration: Long) {
if (position <= 0 || duration <= 0) return
DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
val episode = getKey<ResultEpisode>("last_opened") ?: return
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
ResultFragment.updateUI()
}
@ -98,7 +99,7 @@ abstract class OpenInAppAction(
intent.component = ComponentName(packageName, intentClass)
}
putExtra(context, intent, video, result, index)
setKey("last_opened_id", video.id)
setKey("last_opened", video)
launchResult(intent)
}

View file

@ -16,11 +16,14 @@ import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
import com.lagradost.cloudstream3.actions.temp.MpvExPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
import com.lagradost.cloudstream3.actions.temp.MpvPackage
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
@ -31,8 +34,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UiText
import kotlinx.coroutines.Dispatchers
@ -42,7 +45,7 @@ import java.util.concurrent.FutureTask
import kotlin.reflect.jvm.jvmName
object VideoClickActionHolder {
val allVideoClickActions = threadSafeListOf(
val allVideoClickActions = atomicListOf(
// Default
PlayInBrowserAction(),
CopyClipboardAction(),
@ -51,6 +54,7 @@ object VideoClickActionHolder {
// main support external apps
VlcPackage(),
MpvPackage(),
MpvExPackage(),
NextPlayerPackage(),
JustPlayerPackage(),
FcastAction(),
@ -62,6 +66,8 @@ object VideoClickActionHolder {
MpvYTDLPackage(),
MpvKtPackage(),
MpvKtPreviewPackage(),
OnlyPlayer(),
MpvRxPackage(),
// Always Ask option
AlwaysAskAction(),
// added by plugins

View file

@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
@ -45,7 +44,7 @@ open class MpvKtPackage(
intent.apply {
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
setDataAndType(Uri.parse(link.url), "video/*")
setDataAndType(link.url.toUri(), "video/*")
// m3u8 plays, but changing sources feature is not available
// makeTempM3U8Intent(activity, this, result)

View file

@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction
@ -18,6 +17,9 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
// https://mpv-android.github.io/mpv-android/intent.html
//https://github.com/marlboro-advance/mpvEx
class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity")
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
override val sourceTypes = setOf(
ExtractorLinkType.VIDEO,
@ -26,10 +28,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
)
}
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction(
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction(
txt(appName),
packageName,
"is.xyz.mpv.MPVActivity"
intentClass
) {
override val oneSource = true // mpv has poor playlist support on TV
override suspend fun putExtra(
@ -44,7 +46,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
putExtra("title", video.name)
if (index != null) {
setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*")
setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*")
} else {
makeTempM3U8Intent(context, this, result)
}

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

@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() {
) {
val link = result.links.getOrNull(index ?: 0) ?: return
val i = Intent(Intent.ACTION_VIEW)
i.data = Uri.parse(link.url)
i.data = link.url.toUri()
launch(i)
}
}

View file

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

View file

@ -6,7 +6,7 @@ import android.content.Intent
import android.os.Build
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.actions.updateDurationAndPosition

View file

@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.core.net.toUri
import com.lagradost.cloudstream3.USER_AGENT
@ -38,7 +37,7 @@ class WebVideoCastPackage: OpenInAppAction(
val link = result.links[index ?: 0]
intent.apply {
setDataAndType(Uri.parse(link.url), "video/*")
setDataAndType(link.url.toUri(), "video/*")
val title = video.name ?: video.headerName

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.actions.temp.fcast
import android.content.Context
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.actions.VideoClickAction

View file

@ -1,16 +1,68 @@
package com.lagradost.cloudstream3.mvvm
import android.view.View
import androidx.activity.ComponentActivity
import androidx.core.view.doOnAttach
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.ui.BaseFragment
/** NOTE: Only one observer at a time per value */
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
liveData.removeObservers(this)
liveData.observe(this) { it?.let { t -> action(t) } }
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
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 androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.Prerelease
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.mvvm.safe
@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt
import java.io.File
import java.security.Security
// Backwards compatible constructor, mark as deprecated later
fun Requests.initClient(context: Context) {
this.baseClient = buildDefaultClient(context)
}
/** Only use ignoreSSL if you know what you are doing*/
@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 {
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) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient {
val baseClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.ignoreAllSSLErrors()
.apply {
if (ignoreSSL) {
ignoreAllSSLErrors()
}
}
.cache(
// Note that you need to add a ResponseInterceptor to make this 100% active.
// The server response dictates if and when stuff should be cached.
@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient {
return baseClient
}
//val Request.cookies: Map<String, String>
// get() {
// return this.headers.getCookies("Cookie")
// }
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 kotlin.Throws
abstract class Plugin : BasePlugin() {
/**
* Called when your Plugin is loaded
@ -26,10 +25,8 @@ abstract class Plugin : BasePlugin() {
fun registerVideoClickAction(element: VideoClickAction) {
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
element.sourcePlugin = this.filename
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.add(element)
}
}
/**
* This will contain your resources if you specified requiresResources in gradle

View file

@ -13,6 +13,7 @@ import android.os.Build
import android.os.Environment
import android.util.Log
import android.widget.Toast
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -20,15 +21,17 @@ import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.AutoDownloadMode
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.InternalAPI
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
import com.lagradost.cloudstream3.R
@ -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.downloadPluginToFile
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@ -51,7 +55,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader
@ -76,6 +80,7 @@ data class PluginData(
@JsonProperty("filePath") val filePath: String,
@JsonProperty("version") val version: Int,
) {
@WorkerThread
fun toSitePlugin(): SitePlugin {
return SitePlugin(
this.filePath,
@ -90,7 +95,9 @@ data class PluginData(
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.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName", "DEPRECATION_ERROR")
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
assertNonRecursiveCallstack()
@ -304,6 +307,7 @@ object PluginManager {
downloadPlugin(
activity,
pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
File(pluginData.savedData.filePath),
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.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName", "DEPRECATION_ERROR")
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
activity: Activity,
@ -419,6 +419,7 @@ object PluginManager {
downloadPlugin(
activity,
pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
pluginData.onlineData.first,
!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.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName", "DEPRECATION_ERROR")
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
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.
* 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
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
assertNonRecursiveCallstack()
@ -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.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/
@Suppress("FunctionName", "DEPRECATION_ERROR")
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Suppress("FunctionName")
@InternalAPI
@Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
assertNonRecursiveCallstack()
@ -572,6 +561,11 @@ object PluginManager {
afterPluginsLoadedEvent.invoke(forceReload)
}
/** @return true if safe mode is enabled in any possible way. */
fun isSafeMode(): Boolean {
return checkSafeModeFile() || lastError != null
}
/**
* This can be used to override any extension loading to fix crashes!
* @return true if safe mode file is present
@ -616,7 +610,7 @@ object PluginManager {
return false
}
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
)
}
synchronized(plugins) {
plugins[filePath] = pluginInstance
}
synchronized(classLoaders) {
classLoaders[loader] = pluginInstance
}
synchronized(urlPlugins) {
urlPlugins[data.url ?: filePath] = pluginInstance
}
if (pluginInstance is Plugin) {
pluginInstance.load(context)
} else {
@ -695,26 +695,34 @@ object PluginManager {
}
// remove all registered apis
synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
APIHolder.allProviders.withLock {
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
}
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
extractorApis.withLock {
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
}
VideoClickActionHolder.allVideoClickActions.withLock {
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
}
synchronized(classLoaders) {
classLoaders.values.removeIf { v -> v == plugin }
}
synchronized(plugins) {
plugins.remove(absolutePath)
}
synchronized(urlPlugins) {
urlPlugins.values.removeIf { v -> v == plugin }
}
}
/**
* Spits out a unique and safe filename based on name.
@ -743,25 +751,27 @@ object PluginManager {
suspend fun downloadPlugin(
activity: Activity,
pluginUrl: String,
pluginHash: String?,
internalName: String,
repositoryUrl: String,
loadPlugin: Boolean
): Boolean {
val file = getPluginPath(activity, internalName, repositoryUrl)
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
}
suspend fun downloadPlugin(
activity: Activity,
pluginUrl: String,
pluginHash: String?,
internalName: String,
file: File,
loadPlugin: Boolean
loadPlugin: Boolean,
): Boolean {
try {
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
val data = PluginData(
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.
* 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
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
assertNonRecursiveCallstack()
@ -853,6 +859,7 @@ object PluginManager {
if (downloadPlugin(
activity,
pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName,
existingFile,
true

View file

@ -1,10 +1,11 @@
package com.lagradost.cloudstream3.plugins
import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
@ -18,10 +19,12 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.AtomicMoveNotSupportedException
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.
@ -67,6 +70,7 @@ data class SitePlugin(
@JsonProperty("iconUrl") val iconUrl: String?,
// Automatically generated by the gradle plugin
@JsonProperty("fileSize") val fileSize: Long?,
@JsonProperty("fileHash") val fileHash: String?,
)
@ -75,7 +79,26 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
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 */
fun convertRawGitUrl(url: String): String {
@ -140,21 +163,52 @@ object RepositoryManager {
}.flatten()
}
suspend fun downloadPluginToFile(
context: Context,
pluginUrl: String,
file: File
file: File,
expectedFileHash: String?
): File? {
return safeAsync {
file.mkdirs()
val parentDir = file.parentFile ?: return@safeAsync null
parentDir.mkdirs()
// Overwrite if exists
if (file.exists()) {
file.delete()
}
file.createNewFile()
// Prevent corrupting the plugin file if the operation fails
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
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
}
}
@ -202,13 +256,4 @@ object RepositoryManager {
PluginManager.deleteRepositoryData(file.absolutePath)
}
private fun write(stream: InputStream, output: OutputStream) {
val input = BufferedInputStream(stream)
val dataBuffer = ByteArray(512)
var readBytes: Int
while (input.read(dataBuffer).also { readBytes = it } != -1) {
output.write(dataBuffer, 0, readBytes)
}
}
}

View file

@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins
import android.util.Log
import android.widget.Toast
import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import java.security.MessageDigest
import com.lagradost.cloudstream3.app
@ -12,52 +12,37 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object VotingApi { // please do not cheat the votes lol
object 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 = // dont touch or all votes get reset
private fun transformUrl(url: String): String =
MessageDigest
.getInstance("SHA-256")
.digest("${url}#funny-salt".toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
suspend fun SitePlugin.getVotes(): Int {
return getVotes(url)
}
suspend fun SitePlugin.getVotes(): Int = getVotes(url)
fun SitePlugin.hasVoted(): Boolean = hasVoted(url)
suspend fun SitePlugin.vote(): Int = vote(url)
fun SitePlugin.canVote(): Boolean = canVote(this.url)
fun SitePlugin.hasVoted(): Boolean {
return hasVoted(url)
}
suspend fun SitePlugin.vote(): Int {
return vote(url)
}
fun SitePlugin.canVote(): Boolean {
return canVote(this.url)
}
// Plugin url to Int
private val votesCache = mutableMapOf<String, Int>()
private fun getRepository(pluginUrl: String) = pluginUrl
.split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int {
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value ?: 0
val id = transformUrl(pluginUrl)
val url = "$API_DOMAIN/get-total/$id"
Log.d(LOGKEY, "Requesting GET: $url")
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
}
private suspend fun writeVote(pluginUrl: String): Boolean {
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value != null
val id = transformUrl(pluginUrl)
val url = "$API_DOMAIN/increment/$id"
Log.d(LOGKEY, "Requesting POST: $url")
return app.post(url, emptyMap<String, String>())
.parsedSafe<CountifyResult>()?.count != null
}
suspend fun getVotes(pluginUrl: String): Int =
@ -68,31 +53,35 @@ object VotingApi { // please do not cheat the votes lol
fun hasVoted(pluginUrl: String) =
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
fun canVote(pluginUrl: String): Boolean {
return PluginManager.urlPlugins.contains(pluginUrl)
}
fun canVote(pluginUrl: String): Boolean =
PluginManager.urlPlugins.contains(pluginUrl)
private val voteLock = Mutex()
suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time.
voteLock.withLock {
if (!canVote(pluginUrl)) {
main {
Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
.show()
Toast.makeText(
context,
R.string.extension_install_first,
Toast.LENGTH_SHORT
).show()
}
return getVotes(pluginUrl)
}
if (hasVoted(pluginUrl)) {
main {
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
.show()
Toast.makeText(
context,
R.string.already_voted,
Toast.LENGTH_SHORT
).show()
}
return getVotes(pluginUrl)
}
if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol
}
}
private data class Result(
val value: Int?
private data class CountifyResult(
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.getDub
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 java.util.concurrent.TimeUnit
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
.build()
)
}
@Suppress("DEPRECATION_ERROR")
override suspend fun doWork(): Result {
try {
// println("Update subscriptions!")

View file

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

View file

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

View file

@ -1,49 +1,14 @@
package com.lagradost.cloudstream3.syncproviders
import android.util.Base64
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.NextAiring
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SearchQuality
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.base64Encode
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.txt
import me.xdrop.fuzzywuzzy.FuzzySearch
import java.net.URL
import java.net.URI
import java.security.SecureRandom
import java.util.Date
import java.util.concurrent.TimeUnit
data class AuthLoginPage(
/** The website to open to authenticate */
@ -80,10 +45,10 @@ data class AuthToken(
val payload: String? = null,
) {
fun isAccessTokenExpired(marginSec: Long = 10L) =
accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime
accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime
fun isRefreshTokenExpired(marginSec: Long = 10L) =
refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime
refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime
}
data class AuthUser(
@ -178,16 +143,33 @@ abstract class AuthAPI {
open val inAppLoginRequirement: AuthLoginRequirement? = null
companion object {
@Deprecated(
message = "Use APIHolder.unixTime instead",
replaceWith = ReplaceWith(
expression = "APIHolder.unixTime",
imports = ["com.lagradost.cloudstream3.APIHolder"]
),
level = DeprecationLevel.WARNING,
)
val unixTime: Long
get() = System.currentTimeMillis() / 1000L
get() = APIHolder.unixTime
@Deprecated(
message = "Use APIHolder.unixTimeMS instead",
replaceWith = ReplaceWith(
expression = "unixTimeMS",
imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"]
),
level = DeprecationLevel.WARNING,
)
val unixTimeMs: Long
get() = System.currentTimeMillis()
get() = unixTimeMS
fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
return splitQuery(
URL(
URI(
redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
)
).toURL()
)
}
@ -197,9 +179,8 @@ abstract class AuthAPI {
val secureRandom = SecureRandom()
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
secureRandom.nextBytes(codeVerifierBytes)
return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=')
.replace("+", "-")
.replace("/", "_").replace("\n", "")
return base64Encode(codeVerifierBytes).trimEnd('=')
.replace("+", "-").replace("/", "_").replace("\n", "")
}
}
@ -247,14 +228,15 @@ abstract class AuthAPI {
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
@Throws
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
fun toRepo(): AuthRepo = when (this) {
is SubtitleAPI -> SubtitleRepo(this)
is SyncAPI -> SyncRepo(this)
else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
}
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
@Suppress("DEPRECATION_ERROR")
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
fun loginInfo(): LoginInfo? {
return this.toRepo().authUser()?.let { user ->
LoginInfo(
@ -265,19 +247,16 @@ abstract class AuthAPI {
}
}
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
@Suppress("DEPRECATION_ERROR")
return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow()
}
@Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
class LoginInfo(
val profilePicture: String? = null,
val name: String?,
val accountIndex: Int,
)
}

View file

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

View file

@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
/** Stateless safe abstraction of SubtitleAPI */
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
private val searchCache = threadSafeListOf<SavedSearchResponse>()
private val searchCache = atomicListOf<SavedSearchResponse>()
private var searchCacheIndex: Int = 0
private val resourceCache = threadSafeListOf<SavedResourceResponse>()
private val resourceCache = atomicListOf<SavedResourceResponse>()
private var resourceCacheIndex: Int = 0
const val CACHE_SIZE = 20
}
@WorkerThread
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
synchronized(resourceCache) {
val cached = resourceCache.withLock {
var found: SubtitleResource? = null
for (item in resourceCache) {
// 20 min save
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)
synchronized(resourceCache) {
resourceCache.withLock {
val add = SavedResourceResponse(unixTime, returnValue, data)
if (resourceCache.size > CACHE_SIZE) {
resourceCache[resourceCacheIndex] = add // rolling cache
@ -58,22 +62,25 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
@WorkerThread
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
return runCatching {
synchronized(searchCache) {
val cached = searchCache.withLock {
var found: List<SubtitleEntity>? = null
for (item in searchCache) {
// 120 min save
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
return@runCatching item.response
found = item.response
break
}
}
found
}
val returnValue =
api.search(freshAuth(), query) ?: emptyList()
if (cached != null) return@runCatching cached
val returnValue = api.search(freshAuth(), query) ?: emptyList()
// only cache valid return values
if (returnValue.isNotEmpty()) {
val add = SavedSearchResponse(unixTime, returnValue, query)
synchronized(searchCache) {
searchCache.withLock {
if (searchCache.size > CACHE_SIZE) {
searchCache[searchCacheIndex] = add // rolling cache
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.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.Levenshtein
import com.lagradost.cloudstream3.utils.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch
import java.util.Date
/**
@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() {
ListSorting.Query ->
if (query != null) {
items.sortedBy {
-FuzzySearch.partialRatio(
-Levenshtein.partialRatio(
query.lowercase(), it.name.lowercase()
)
}

View file

@ -86,7 +86,7 @@ class Addic7ed : SubtitleAPI() {
newSubtitleEntity(
displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(),
link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(),
isHearingImpaired = node.select("td:eq(6)")!!.text().isNotEmpty()
isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty()
)
else null
}

View file

@ -2,11 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.Actor
import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.ActorRole
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.NextAiring
import com.lagradost.cloudstream3.R
@ -35,7 +37,7 @@ class AniListApi : SyncAPI() {
override var name = "AniList"
override val idPrefix = "anilist"
val key = "6871"
private val key = BuildConfig.ANILIST_KEY
override val redirectUrlIdentifier = "anilistlogin"
override var requireLibraryRefresh = true
override val hasOAuth2 = true
@ -50,9 +52,10 @@ class AniListApi : SyncAPI() {
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
val sanitizer = splitRedirectUrl(redirectUrl)
val token = AuthToken(
accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"),
accessToken = sanitizer["access_token"]
?: throw ErrorLoadingException("No access token"),
//refreshToken = sanitizer["refresh_token"],
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(),
)
return token
}
@ -84,7 +87,7 @@ class AniListApi : SyncAPI() {
}
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 {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,
@ -106,7 +109,7 @@ class AniListApi : SyncAPI() {
nextAiring = season.nextAiringEpisode?.let {
NextAiring(
it.episode ?: return@let null,
(it.timeUntilAiring ?: return@let null) + unixTime
(it.timeUntilAiring ?: return@let null) + APIHolder.unixTime
)
},
title = season.title?.userPreferred,

View file

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

View file

@ -2,8 +2,10 @@ package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.BuildConfig
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
@ -34,7 +36,7 @@ class MALApi : SyncAPI() {
override var name = "MAL"
override val idPrefix = "mal"
val key = "1714d6f2f4f7cc19644384f8c4629910"
private val key = BuildConfig.MAL_KEY
private val apiUrl = "https://api.myanimelist.net"
override val hasOAuth2 = true
override val redirectUrlIdentifier: String? = "mallogin"
@ -78,7 +80,7 @@ class MALApi : SyncAPI() {
)
).parsed<ResponseToken>()
return AuthToken(
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken,
accessToken = token.accessToken
)
@ -100,7 +102,7 @@ class MALApi : SyncAPI() {
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
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(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
@ -366,7 +368,7 @@ class MALApi : SyncAPI() {
return AuthToken(
accessToken = res.accessToken,
refreshToken = res.refreshToken,
accessTokenLifetime = unixTime + res.expiresIn.toLong()
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong()
)
}

View file

@ -2,9 +2,11 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
@ -43,17 +45,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
}
private fun canDoRequest(): Boolean {
return unixTimeMs > currentCoolDown
return unixTimeMS > currentCoolDown
}
private fun throwIfCantDoRequest() {
if (!canDoRequest()) {
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s")
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s")
}
}
private fun throwGotTooManyRequests() {
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
currentCoolDown = unixTimeMS + COOLDOWN_DURATION
throw ErrorLoadingException("Too many requests")
}
@ -89,7 +91,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
accessToken = response.token
?: throw ErrorLoadingException("Invalid password or username"),
/// JWT token is valid 24 hours after successfully authentication of user
accessTokenLifetime = unixTime + 60 * 60 * 24,
accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24,
payload = form.toJson()
)
}

View file

@ -4,19 +4,19 @@ import androidx.annotation.StringRes
import androidx.core.net.toUri
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.CloudStreamApp
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mapper
import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
@ -30,6 +30,7 @@ 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.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import com.lagradost.cloudstream3.utils.txt
import java.math.BigInteger
@ -77,15 +78,15 @@ class SimklApi : SyncAPI() {
private class SimklCacheWrapper<T>(
@JsonProperty("obj") val obj: T?,
@JsonProperty("validUntil") val validUntil: Long,
@JsonProperty("cacheTime") val cacheTime: Long = unixTime,
@JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime,
) {
/** Returns true if cache is newer than cacheDays */
fun isFresh(): Boolean {
return validUntil > unixTime
return validUntil > APIHolder.unixTime
}
fun remainingTime(): Duration {
val unixTime = unixTime
val unixTime = APIHolder.unixTime
return if (validUntil > unixTime) {
(validUntil - unixTime).toDuration(DurationUnit.SECONDS)
} else {
@ -96,7 +97,7 @@ class SimklApi : SyncAPI() {
fun cleanOldCache() {
getKeys(SIMKL_CACHE_KEY)?.forEach {
val isOld = AcraApplication.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
val isOld = CloudStreamApp.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
if (isOld) {
removeKey(it)
}
@ -109,7 +110,7 @@ class SimklApi : SyncAPI() {
SIMKL_CACHE_KEY,
path,
// Storing as plain sting is required to make generics work.
SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson()
SimklCacheWrapper(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson()
)
}
@ -117,13 +118,8 @@ class SimklApi : SyncAPI() {
* Gets cached object, if object is not fresh returns null and removes it from cache
*/
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 {
mapper.readValue<SimklCacheWrapper<T>>(it, type)
tryParseJson<SimklCacheWrapper<T>>(it)
}
return if (cache?.isFresh() == true) {
@ -423,7 +419,7 @@ class SimklApi : SyncAPI() {
}
suspend fun execute(): Boolean {
val time = getDateTime(unixTime)
val time = getDateTime(APIHolder.unixTime)
val headers = this.headers ?: emptyMap()
return if (this.status == SimklListStatusType.None.value) {
app.post(
@ -573,7 +569,7 @@ class SimklApi : SyncAPI() {
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@JsonProperty("rating") val rating: Int,
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime)
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime)
) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -582,7 +578,7 @@ class SimklApi : SyncAPI() {
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@JsonProperty("to") val to: String,
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime)
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime)
) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -867,7 +863,7 @@ class SimklApi : SyncAPI() {
newStatus: AbstractSyncStatus
): Boolean {
val parsedId = readIdFromString(id)
lastScoreTime = unixTime
lastScoreTime = APIHolder.unixTime
val simklStatus = newStatus as? SimklSyncStatus
val builder = SimklScoreBuilder.Builder()
@ -916,7 +912,7 @@ class SimklApi : SyncAPI() {
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
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() }
}

View file

@ -29,7 +29,7 @@ class SubSourceApi : SubtitleAPI() {
//Only supports Imdb Id search for now
if (query.imdbId == null) return null
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
val searchRes = app.post(

View file

@ -12,6 +12,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class SubDlApi : SubtitleAPI() {
override val name = "SubDL"
@ -24,7 +26,7 @@ class SubDlApi : SubtitleAPI() {
override val createAccountUrl = "https://subdl.com/panel/register"
companion object {
const val APIURL = "https://apiold.subdl.com"
const val APIURL = "https://api.subdl.com"
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
}
@ -122,72 +124,80 @@ class SubDlApi : SubtitleAPI() {
}
}
@Serializable
data class SubtitleOAuthEntity(
@JsonProperty("userEmail") var userEmail: String,
@JsonProperty("pass") var pass: String,
@JsonProperty("name") var name: String? = null,
@JsonProperty("accessToken") var accessToken: String? = null,
@JsonProperty("apiKey") var apiKey: String? = null,
@JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String,
@JsonProperty("pass") @SerialName("pass") var pass: String,
@JsonProperty("name") @SerialName("name") var name: String? = null,
@JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null,
@JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null,
)
@Serializable
data class OAuthTokenResponse(
@JsonProperty("token") val token: String,
@JsonProperty("userData") val userData: UserData? = null,
@JsonProperty("status") val status: Boolean? = null,
@JsonProperty("message") val message: String? = null,
@JsonProperty("token") @SerialName("token") val token: String,
@JsonProperty("userData") @SerialName("userData") val userData: UserData? = null,
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
@JsonProperty("message") @SerialName("message") val message: String? = null,
)
@Serializable
data class UserData(
@JsonProperty("email") val email: String,
@JsonProperty("name") val name: String,
@JsonProperty("country") val country: String,
@JsonProperty("scStepCode") val scStepCode: String,
@JsonProperty("scVerified") val scVerified: Boolean,
@JsonProperty("username") val username: String? = null,
@JsonProperty("scUsername") val scUsername: String,
@JsonProperty("email") @SerialName("email") val email: String,
@JsonProperty("name") @SerialName("name") val name: String,
@JsonProperty("country") @SerialName("country") val country: String,
@JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String,
@JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean,
@JsonProperty("username") @SerialName("username") val username: String? = null,
@JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String,
)
@Serializable
data class ApiKeyResponse(
@JsonProperty("ok") val ok: Boolean? = false,
@JsonProperty("api_key") val apiKey: String,
@JsonProperty("usage") val usage: Usage? = null,
@JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false,
@JsonProperty("api_key") @SerialName("api_key") val apiKey: String,
@JsonProperty("usage") @SerialName("usage") val usage: Usage? = null,
)
@Serializable
data class Usage(
@JsonProperty("total") val total: Long? = 0,
@JsonProperty("today") val today: Long? = 0,
@JsonProperty("total") @SerialName("total") val total: Long? = 0,
@JsonProperty("today") @SerialName("today") val today: Long? = 0,
)
@Serializable
data class ApiResponse(
@JsonProperty("status") val status: Boolean? = null,
@JsonProperty("results") val results: List<Result>? = null,
@JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
@JsonProperty("status") @SerialName("status") val status: Boolean? = null,
@JsonProperty("results") @SerialName("results") val results: List<Result>? = null,
@JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List<Subtitle>? = null,
)
@Serializable
data class Result(
@JsonProperty("sd_id") val sdId: Int? = null,
@JsonProperty("type") val type: String? = null,
@JsonProperty("name") val name: String? = null,
@JsonProperty("imdb_id") val imdbId: String? = null,
@JsonProperty("tmdb_id") val tmdbId: Long? = null,
@JsonProperty("first_air_date") val firstAirDate: String? = null,
@JsonProperty("year") val year: Int? = null,
@JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null,
@JsonProperty("type") @SerialName("type") val type: String? = null,
@JsonProperty("name") @SerialName("name") val name: String? = null,
@JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null,
@JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null,
@JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null,
@JsonProperty("year") @SerialName("year") val year: Int? = null,
)
@Serializable
data class Subtitle(
@JsonProperty("release_name") val releaseName: String,
@JsonProperty("name") val name: String,
@JsonProperty("lang") val lang: String, // subdl language code
@JsonProperty("author") val author: String? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("subtitlePage") val subtitlePage: String? = null,
@JsonProperty("season") val season: Int? = null,
@JsonProperty("episode") val episode: Int? = null,
@JsonProperty("language") val language: String? = null, // full language name
@JsonProperty("hi") val hearingImpaired: Boolean? = null,
@JsonProperty("release_name") @SerialName("release_name") val releaseName: String,
@JsonProperty("name") @SerialName("name") val name: String,
@JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code
@JsonProperty("author") @SerialName("author") val author: String? = null,
@JsonProperty("url") @SerialName("url") val url: String? = null,
@JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null,
@JsonProperty("season") @SerialName("season") val season: Int? = null,
@JsonProperty("episode") @SerialName("episode") val episode: Int? = null,
@JsonProperty("language") @SerialName("language") val language: String? = null, // full language name
@JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null,
)
// https://subdl.com/api-files/language_list.json
// https://subdl.com/api-files/language_list.json
// most of it is IETF BPC 47 conformant tag
// but there are some exceptions
private val langTagIETF2subdl = mapOf(
@ -197,63 +207,63 @@ class SubDlApi : SubtitleAPI() {
"en-nl" to "NL_EN", // "Dutch_English"
"pt-br" to "BR_PT", // "Brazillian Portuguese"
"zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?)
// "ar" to "AR", // "Arabic"
// "az" to "AZ", // "Azerbaijani"
// "be" to "BE", // "Belarusian"
// "bg" to "BG", // "Bulgarian"
// "bn" to "BN", // "Bengali"
// "bs" to "BS", // "Bosnian"
// "ca" to "CA", // "Catalan"
// "cs" to "CS", // "Czech"
// "da" to "DA", // "Danish"
// "de" to "DE", // "German"
// "el" to "EL", // "Greek"
// "en" to "EN", // "English"
// "eo" to "EO", // "Esperanto"
// "es" to "ES", // "Spanish"
// "et" to "ET", // "Estonian"
// "fa" to "FA", // "Farsi_Persian"
// "fi" to "FI", // "Finnish"
// "fr" to "FR", // "French"
// "he" to "HE", // "Hebrew"
// "hi" to "HI", // "Hindi"
// "hr" to "HR", // "Croatian"
// "hu" to "HU", // "Hungarian"
// "id" to "ID", // "Indonesian"
// "is" to "IS", // "Icelandic"
// "it" to "IT", // "Italian"
// "ja" to "JA", // "Japanese"
// "ka" to "KA", // "Georgian"
// "kl" to "KL", // "Greenlandic"
// "ko" to "KO", // "Korean"
// "ku" to "KU", // "Kurdish"
// "lt" to "LT", // "Lithuanian"
// "lv" to "LV", // "Latvian"
// "mk" to "MK", // "Macedonian"
// "ml" to "ML", // "Malayalam"
// "mni" to "MNI", // "Manipuri"
// "ms" to "MS", // "Malay"
// "my" to "MY", // "Burmese"
// "nl" to "NL", // "Dutch"
// "no" to "NO", // "Norwegian"
// "pl" to "PL", // "Polish"
// "pt" to "PT", // "Portuguese"
// "ro" to "RO", // "Romanian"
// "ru" to "RU", // "Russian"
// "si" to "SI", // "Sinhala"
// "sk" to "SK", // "Slovak"
// "sl" to "SL", // "Slovenian"
// "sq" to "SQ", // "Albanian"
// "sr" to "SR", // "Serbian"
// "sv" to "SV", // "Swedish"
// "ta" to "TA", // "Tamil"
// "te" to "TE", // "Telugu"
// "th" to "TH", // "Thai"
// "tl" to "TL", // "Tagalog"
// "tr" to "TR", // "Turkish"
// "uk" to "UK", // "Ukranian"
// "ur" to "UR", // "Urdu"
// "vi" to "VI", // "Vietnamese"
// "zh" to "ZH", // "Chinese BG code"
// "ar" to "AR", // "Arabic"
// "az" to "AZ", // "Azerbaijani"
// "be" to "BE", // "Belarusian"
// "bg" to "BG", // "Bulgarian"
// "bn" to "BN", // "Bengali"
// "bs" to "BS", // "Bosnian"
// "ca" to "CA", // "Catalan"
// "cs" to "CS", // "Czech"
// "da" to "DA", // "Danish"
// "de" to "DE", // "German"
// "el" to "EL", // "Greek"
// "en" to "EN", // "English"
// "eo" to "EO", // "Esperanto"
// "es" to "ES", // "Spanish"
// "et" to "ET", // "Estonian"
// "fa" to "FA", // "Farsi_Persian"
// "fi" to "FI", // "Finnish"
// "fr" to "FR", // "French"
// "he" to "HE", // "Hebrew"
// "hi" to "HI", // "Hindi"
// "hr" to "HR", // "Croatian"
// "hu" to "HU", // "Hungarian"
// "id" to "ID", // "Indonesian"
// "is" to "IS", // "Icelandic"
// "it" to "IT", // "Italian"
// "ja" to "JA", // "Japanese"
// "ka" to "KA", // "Georgian"
// "kl" to "KL", // "Greenlandic"
// "ko" to "KO", // "Korean"
// "ku" to "KU", // "Kurdish"
// "lt" to "LT", // "Lithuanian"
// "lv" to "LV", // "Latvian"
// "mk" to "MK", // "Macedonian"
// "ml" to "ML", // "Malayalam"
// "mni" to "MNI", // "Manipuri"
// "ms" to "MS", // "Malay"
// "my" to "MY", // "Burmese"
// "nl" to "NL", // "Dutch"
// "no" to "NO", // "Norwegian"
// "pl" to "PL", // "Polish"
// "pt" to "PT", // "Portuguese"
// "ro" to "RO", // "Romanian"
// "ru" to "RU", // "Russian"
// "si" to "SI", // "Sinhala"
// "sk" to "SK", // "Slovak"
// "sl" to "SL", // "Slovenian"
// "sq" to "SQ", // "Albanian"
// "sr" to "SR", // "Serbian"
// "sv" to "SV", // "Swedish"
// "ta" to "TA", // "Tamil"
// "te" to "TE", // "Telugu"
// "th" to "TH", // "Thai"
// "tl" to "TL", // "Tagalog"
// "tr" to "TR", // "Turkish"
// "uk" to "UK", // "Ukranian"
// "ur" to "UR", // "Urdu"
// "vi" to "VI", // "Vietnamese"
// "zh" to "ZH", // "Chinese BG code"
)
}

View file

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

View file

@ -1,34 +1,55 @@
package com.lagradost.cloudstream3.ui
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewbinding.ViewBinding
import coil3.dispose
import java.util.WeakHashMap
import java.util.concurrent.CopyOnWriteArrayList
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
open fun save(): T? = null
open fun restore(state: T) = Unit
open fun onViewAttachedToWindow() = Unit
open fun onViewDetachedFromWindow() = Unit
open fun onViewRecycled() = Unit
}
abstract class NoStateAdapter<T : Any>(
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : BaseAdapter<T, Any>(0, diffCallback)
// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154
class StateViewModel : ViewModel() {
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
/** 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)
})
}
}
abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
/** 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.
@ -49,13 +70,14 @@ abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>
abstract class BaseAdapter<
T : Any,
S : Any>(
fragment: Fragment,
val id: Int = 0,
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : RecyclerView.Adapter<ViewHolderState<S>>() {
open val footers: Int = 0
open val headers: Int = 0
val immutableCurrentList: List<T> get() = mDiffer.currentList
fun getItem(position: Int): T {
return mDiffer.currentList[position]
}
@ -93,17 +115,25 @@ abstract class BaseAdapter<
*
* Use `submitList` for general use, as that can reuse old views.
* */
open fun submitIncomparableList(list: List<T>?) {
open fun submitIncomparableList(list: List<T>?, commitCallback : Runnable? = null) {
// This leverages a quirk in the submitList function that has a fast case for null arrays
// What this implies is that as long as we do a double submit we can ensure no pop-ins,
// as the changes are the entire list instead of calculating deltas
submitList(null)
submitList(list)
submitList(list, commitCallback)
}
open fun submitList(list: List<T>?) {
/**
* @param commitCallback Optional runnable that is executed when the List is committed, if it is committed.
* This is needed for some tasks as submitList will use a background thread for diff
* */
open fun submitList(list: Collection<T>?, commitCallback : Runnable? = null) {
// deep copy at least the top list, because otherwise adapter can go crazy
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
if (list.isNullOrEmpty()) {
mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList()
} else {
mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback)
}
}
override fun getItemCount(): Int {
@ -117,16 +147,25 @@ abstract class BaseAdapter<
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomContent(
parent: ViewGroup,
viewType: Int
) = onCreateContent(parent)
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomFooter(
parent: ViewGroup,
viewType: Int
) = onCreateFooter(parent)
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomHeader(
parent: ViewGroup,
viewType: Int
) = onCreateHeader(parent)
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
holder.onViewAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
holder.onViewDetachedFromWindow()
}
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {}
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {}
@Suppress("UNCHECKED_CAST")
fun save(recyclerView: RecyclerView) {
@ -137,21 +176,20 @@ abstract class BaseAdapter<
}
}
fun clear() {
stateViewModel.layoutManagerStates[id]?.clear()
fun clearState() {
layoutManagerStates[id]?.clear()
}
@Suppress("UNCHECKED_CAST")
private fun getState(holder: ViewHolderState<S>): S? =
stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
private fun setState(holder: ViewHolderState<S>) {
if (id == 0) return
if (!stateViewModel.layoutManagerStates.contains(id)) {
stateViewModel.layoutManagerStates[id] = HashMap()
if (!layoutManagerStates.contains(id)) {
layoutManagerStates[id] = HashMap()
}
stateViewModel.layoutManagerStates[id]?.let { map ->
layoutManagerStates[id]?.let { map ->
map[holder.absoluteAdapterPosition] = holder.save()
}
}
@ -174,30 +212,40 @@ abstract class BaseAdapter<
super.onDetachedFromRecyclerView(recyclerView)
}
open fun customContentViewType(item: T): Int = 0
open fun customFooterViewType(): Int = 0
open fun customHeaderViewType(): Int = 0
final override fun getItemViewType(position: Int): Int {
if (position < headers) {
return HEADER
return HEADER or customHeaderViewType()
}
if (position - headers >= mDiffer.currentList.size) {
return FOOTER
val realPosition = position - headers
if (realPosition >= mDiffer.currentList.size) {
return FOOTER or customFooterViewType()
}
return CONTENT
return CONTENT or customContentViewType(getItem(realPosition))
}
private val stateViewModel: StateViewModel by fragment.viewModels()
final override fun onViewRecycled(holder: ViewHolderState<S>) {
setState(holder)
holder.onViewRecycled()
onClearView(holder)
super.onViewRecycled(holder)
}
/** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data.
*
* If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.
*
* Use this with `clearImage`
* */
open fun onClearView(holder: ViewHolderState<S>) {}
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
return when (viewType) {
CONTENT -> onCreateContent(parent)
HEADER -> onCreateHeader(parent)
FOOTER -> onCreateFooter(parent)
return when (viewType and TYPE_MASK) {
CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK)
HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK)
FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK)
else -> throw NotImplementedError()
}
}
@ -212,7 +260,7 @@ abstract class BaseAdapter<
super.onBindViewHolder(holder, position, payloads)
return
}
when (getItemViewType(position)) {
when (getItemViewType(position) and TYPE_MASK) {
CONTENT -> {
val realPosition = position - headers
val item = getItem(realPosition)
@ -230,7 +278,7 @@ abstract class BaseAdapter<
}
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
when (getItemViewType(position)) {
when (getItemViewType(position) and TYPE_MASK) {
CONTENT -> {
val realPosition = position - headers
val item = getItem(realPosition)
@ -252,9 +300,20 @@ abstract class BaseAdapter<
}
companion object {
private const val HEADER: Int = 1
private const val FOOTER: Int = 2
private const val CONTENT: Int = 0
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
fun clearImage(image: ImageView?) {
image?.dispose()
}
// Use the lowermost MASK_SIZE bits for the custom content,
// use the uppermost 32 - MASK_SIZE to the type
private const val MASK_SIZE = 28
private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1
private const val TYPE_MASK = CUSTOM_MASK.inv()
const val HEADER: Int = 3 shl MASK_SIZE
const val FOOTER: Int = 2 shl MASK_SIZE
/** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */
const val CONTENT: Int = 1 shl MASK_SIZE
}
}
@ -264,5 +323,5 @@ class BaseDiffCallback<T : Any>(
) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
override fun getChangePayload(oldItem: T, newItem: T): Any? = Any()
}

View file

@ -0,0 +1,278 @@
package com.lagradost.cloudstream3.ui
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding
import com.lagradost.cloudstream3.utils.txt
/**
* A base Fragment class that simplifies ViewBinding usage and handles view inflation safely.
*
* This class allows two modes of creating ViewBinding:
* 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes.
* 2. Bind: Using `bind()` on an existing root view.
*
* It also provides hooks for:
* - Safe initialization of the binding (`onBindingCreated`)
* - Automatic padding adjustment for system bars (`fixPadding`)
* - Optional layout resource selection via `pickLayout()`
*
* @param T The type of ViewBinding for this Fragment.
* @param bindingCreator The strategy used to create the binding instance.
*/
private interface BaseFragmentHelper<T : ViewBinding> {
val bindingCreator: BaseFragment.BindingCreator<T>
var _binding: T?
val binding: T? get() = _binding
fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val layoutId = pickLayout()
val root: View? = layoutId?.let { inflater.inflate(it, container, false) }
_binding = try {
when (val creator = bindingCreator) {
is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false)
is BaseFragment.BindingCreator.Bind -> {
if (root != null) creator.fn(root)
else throw IllegalStateException("Root view is null for bind()")
}
}
} catch (t: Throwable) {
showToast(
txt(R.string.unable_to_inflate, t.message ?: ""),
Toast.LENGTH_LONG
)
logError(t)
null
}
return _binding?.root ?: root
}
/**
* Called after the fragment's view has been created.
*
* This method is `final` to ensure that the binding is properly initialized and
* system bar padding adjustments are applied before any subclass logic runs.
* Subclasses should use [onBindingCreated] instead of overriding this method directly.
*/
fun onViewReady(view: View, savedInstanceState: Bundle?) {
fixLayout(view)
binding?.let { onBindingCreated(it, savedInstanceState) }
}
/**
* Called when the binding is safely created and view is ready.
* Can be overridden to provide fragment-specific initialization.
*
* @param binding The safely created ViewBinding.
* @param savedInstanceState Saved state bundle or null.
*/
fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
onBindingCreated(binding)
}
/**
* Called when the binding is safely created and view is ready.
* Overload without savedInstanceState for convenience.
*
* @param binding The safely created ViewBinding.
*/
fun onBindingCreated(binding: T) {}
/**
* Pick a layout resource ID for the fragment.
*
* Return `null` by default. Override to provide a layout resource when using
* `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`.
*
* @return Layout resource ID or null.
*/
@LayoutRes
fun pickLayout(): Int? = null
/**
* Ensures the layout of the root view is correctly adjusted for the current configuration.
*
* This may include applying padding for system bars, adjusting insets, or performing other
* layout updates. `fixLayout` should remain idempotent, as it can be called multiple
* times on the same view, such as during configuration changes (e.g. device rotation) or when
* the view is recreated.
*
* @param view The root view to adjust.
*/
fun fixLayout(view: View)
}
abstract class BaseFragment<T : ViewBinding>(
override val bindingCreator: BindingCreator<T>
) : Fragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
/** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */
fun dispatchBackPressed() {
try {
activity?.onBackPressedDispatcher?.onBackPressed()
} catch (_: IllegalStateException) {
// FragmentManager is already executing transactions, so try again
delayedDispatchBackPressed(5)
} catch (t: Throwable) {
logError(t)
}
}
/** Recursive back press when available */
private fun delayedDispatchBackPressed(remaining: Int) {
if (remaining <= 0) return
binding?.root?.postDelayed({
try {
activity?.onBackPressedDispatcher?.onBackPressed()
} catch (_: IllegalStateException) {
// FragmentManager is already executing transactions, so try again
delayedDispatchBackPressed(remaining - 1)
} catch (t: Throwable) {
logError(t)
}
}, 200)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/**
* Called when the device configuration changes (e.g., orientation).
* Re-applies system bar padding fixes to the root view to ensure it
* readjusts for orientation changes.
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Sealed class representing the two strategies for creating a ViewBinding instance.
*/
sealed class BindingCreator<T : ViewBinding> {
/**
* Use the standard inflate() method for creating the binding.
*
* @param fn Lambda that inflates the binding.
*/
class Inflate<T : ViewBinding>(
val fn: (LayoutInflater, ViewGroup?, Boolean) -> T
) : BindingCreator<T>()
/**
* Use bind() on an existing root view to create the binding. This should
* be used if you are differing per device layouts, such as different
* layouts for TV and Phone.
*
* @param fn Lambda that binds the root view.
*/
class Bind<T : ViewBinding>(
val fn: (View) -> T
) : BindingCreator<T>()
}
}
abstract class BaseDialogFragment<T : ViewBinding>(
override val bindingCreator: BaseFragment.BindingCreator<T>
) : DialogFragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class BaseBottomSheetDialogFragment<T : ViewBinding>(
override val bindingCreator: BaseFragment.BindingCreator<T>
) : BottomSheetDialogFragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setSystemBarsPadding()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setSystemBarsPadding()
}
}

View file

@ -12,9 +12,6 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
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.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions
@ -105,9 +102,6 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init {
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
view.setOnClickListener {
@ -245,7 +239,12 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
.setPlayPosition(startAt)
.setAutoplay(true)
.build()
awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
awaitLinks(
remoteMediaClient?.load(
mediaItem,
mediaLoadOptions
)
) {
loadMirror(index + 1)
}
}
@ -299,7 +298,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val currentDuration = remoteMediaClient?.streamDuration
val currentPosition = remoteMediaClient?.approximateStreamPosition
if (currentDuration != null && currentPosition != null)
DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
DataStoreHelper.setViewPosAndResume(
epData.id,
currentPosition,
currentDuration,
epData,
meta.episodes.getOrNull(index + 1)
)
} catch (t: Throwable) {
logError(t)
}
@ -323,7 +328,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
}, subtitleCallback = {
currentSubs.add(it)
},
isCasting = true)
offset = 0,
isCasting = true
)
}
val sortedLinks = sortUrls(currentLinks)

View file

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

View file

@ -4,17 +4,13 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding
@ -26,10 +22,9 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.random.Random
class EasterEggMonkeFragment : Fragment() {
private var _binding: FragmentEasterEggMonkeBinding? = null
private val binding get() = _binding!!
class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate)
) {
// planet of monks
private val monkeys: List<Int> = listOf(
@ -51,27 +46,20 @@ class EasterEggMonkeFragment : Fragment() {
private val activeMonkeys = mutableListOf<ImageView>()
private var spawningJob: Job? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentEasterEggMonkeBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun fixLayout(view: View) = Unit
override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) {
activity?.hideSystemUI()
spawningJob = lifecycleScope.launch {
delay(1000)
while (isActive) {
spawnMonkey()
spawnMonkey(binding)
delay(500)
}
}
}
private fun spawnMonkey() {
private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) {
val newMonkey = ImageView(context ?: return).apply {
setImageResource(monkeys.random())
isVisible = true
@ -102,12 +90,12 @@ class EasterEggMonkeFragment : Fragment() {
}
@SuppressLint("ClickableViewAccessibility")
newMonkey.setOnTouchListener { view, event -> handleTouch(view, event) }
newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) }
startFloatingAnimation(newMonkey)
startFloatingAnimation(newMonkey, binding)
}
private fun startFloatingAnimation(monkey: ImageView) {
private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) {
val floatUpAnimator = ObjectAnimator.ofFloat(
monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat()
).apply {
@ -117,19 +105,20 @@ class EasterEggMonkeFragment : Fragment() {
floatUpAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
// necessary check because binding becomes null but monkes are still moving until onDestroy()
if (_binding != null) {
binding.frame.removeView(monkey)
activeMonkeys.remove(monkey)
}
}
})
floatUpAnimator.start()
monkey.tag = floatUpAnimator
}
private fun handleTouch(view: View, event: MotionEvent): Boolean {
private fun handleTouch(
view: View,
event: MotionEvent,
binding: FragmentEasterEggMonkeBinding
): Boolean {
val monkey = view as ImageView
when (event.action) {
MotionEvent.ACTION_DOWN -> {
@ -143,17 +132,17 @@ class EasterEggMonkeFragment : Fragment() {
monkey.y = event.rawY - monkey.height / 2
// Check if monkey touches the screen edge
if (isTouchingEdge(monkey)) {
removeMonkey(monkey)
if (isTouchingEdge(monkey, binding)) {
removeMonkey(monkey, binding)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (isTouchingEdge(monkey)) {
removeMonkey(monkey)
if (isTouchingEdge(monkey, binding)) {
removeMonkey(monkey, binding)
} else {
startFloatingAnimation(monkey)
startFloatingAnimation(monkey, binding)
}
return true
}
@ -161,12 +150,12 @@ class EasterEggMonkeFragment : Fragment() {
return false
}
private fun isTouchingEdge(monkey: ImageView): Boolean {
private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean {
return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width ||
monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height
}
private fun removeMonkey(monkey: ImageView) {
private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) {
// Fade out and remove the monkey
ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply {
duration = 300
@ -184,6 +173,5 @@ class EasterEggMonkeFragment : Fragment() {
super.onDestroyView()
activity?.showSystemUI()
spawningJob?.cancel()
_binding = null
}
}

View file

@ -1,42 +0,0 @@
package com.lagradost.cloudstream3.ui
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
customView.layout(parent.left, 0, parent.right, customView.measuredHeight)
for (i in 0 until parent.childCount) {
val view = parent.getChildAt(i)
if (parent.getChildAdapterPosition(view) == 0) {
c.save()
val height = customView.measuredHeight
val top = view.top - height
c.translate(0f, top.toFloat())
customView.draw(c)
c.restore()
break
}
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (parent.getChildAdapterPosition(view) == 0) {
customView.measure(
View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST)
)
outRect.set(0, customView.measuredHeight, 0, 0)
} else {
outRect.setEmpty()
}
}
}

View file

@ -7,12 +7,12 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.RelativeLayout
import androidx.core.content.withStyledAttributes
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import java.lang.ref.WeakReference
class MyMiniControllerFragment : MiniControllerFragment() {
@ -25,28 +25,17 @@ class MyMiniControllerFragment : MiniControllerFragment() {
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
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) {
WeakReference(
context.obtainStyledAttributes(
attributeSet,
R.styleable.CustomCast
)
).apply {
if (get()
?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true
) {
currentColor =
get()
?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0
context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) {
if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) {
currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0)
}
get()?.recycle()
}.clear()
}
}
super.onInflate(context, attributeSet, bundle)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View file

@ -1,17 +1,12 @@
package com.lagradost.cloudstream3.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.USER_AGENT
@ -19,19 +14,18 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate)
) {
class WebviewFragment : Fragment() {
override fun fixLayout(view: View) = Unit
var binding: FragmentWebviewBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun onBindingCreated(binding: FragmentWebviewBinding) {
val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
findNavController().popBackStack()
}
binding?.webView?.webViewClient = object : WebViewClient() {
@OptIn(UnstableApi::class)
binding.webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
@ -46,28 +40,17 @@ class WebviewFragment : Fragment() {
return super.shouldOverrideUrlLoading(view, request)
}
}
binding?.webView?.apply {
binding.webView.apply {
WebViewResolver.webViewUserAgent = settings.userAgentString
addJavascriptInterface(RepoApi(activity), "RepoApi")
settings.javaScriptEnabled = true
settings.userAgentString = USER_AGENT
settings.domStorageEnabled = true
// WebView.setWebContentsDebuggingEnabled(true)
loadUrl(url)
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
binding = localBinding
// Inflate the layout for this fragment
return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false)
}
companion object {

View file

@ -1,16 +1,17 @@
package com.lagradost.cloudstream3.ui.account
import android.os.Build
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil3.transform.RoundedCornersTransformation
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
import com.lagradost.cloudstream3.databinding.AccountListItemBinding
import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -19,34 +20,39 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
class AccountAdapter(
private val accounts: List<DataStoreHelper.Account>,
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
) : RecyclerView.Adapter<AccountAdapter.AccountViewHolder>() {
) : NoStateAdapter<DataStoreHelper.Account>() {
companion object {
const val VIEW_TYPE_SELECT_ACCOUNT = 0
const val VIEW_TYPE_ADD_ACCOUNT = 1
const val VIEW_TYPE_EDIT_ACCOUNT = 2
}
inner class AccountViewHolder(private val binding: ViewBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: DataStoreHelper.Account?) {
when (binding) {
override val footers: Int = 1
var viewType = VIEW_TYPE_SELECT_ACCOUNT
override fun customContentViewType(item: DataStoreHelper.Account): Int {
return viewType
}
override fun onBindContent(
holder: ViewHolderState<Any>,
item: DataStoreHelper.Account,
position: Int
) {
when (val binding = holder.view) {
is AccountListItemBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = account.name
accountImage.loadImage(account.image)
lockIcon.isVisible = account.lockPin != null
accountName.text = item.name
accountImage.loadImage(item.image)
lockIcon.isVisible = item.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
if (isTv) {
@ -56,18 +62,28 @@ class AccountAdapter(
root.requestFocus()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
root.foreground = ContextCompat.getDrawable(
root.context,
R.drawable.outline_drawable
)
}
} else {
root.setOnLongClickListener {
showAccountEditDialog(
context = root.context,
account = account,
account = item,
isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
accountEditCallback = { account ->
accountEditCallback.invoke(
account
)
},
accountDeleteCallback = { account ->
accountDeleteCallback.invoke(
account
)
}
)
true
@ -75,22 +91,20 @@ class AccountAdapter(
}
root.setOnClickListener {
accountSelectCallback.invoke(account)
accountSelectCallback.invoke(item)
}
}
is AccountListItemEditBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = account.name
accountImage.loadImage(account.image) {
accountName.text = item.name
accountImage.loadImage(item.image) {
RoundedCornersTransformation(10f)
}
lockIcon.isVisible = account.lockPin != null
lockIcon.isVisible = item.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
if (isTv) {
@ -100,31 +114,47 @@ class AccountAdapter(
root.requestFocus()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
root.foreground = ContextCompat.getDrawable(
root.context,
R.drawable.outline_drawable
)
}
}
root.setOnClickListener {
showAccountEditDialog(
context = root.context,
account = account,
account = item,
isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
accountDeleteCallback = { account ->
accountDeleteCallback.invoke(
account
)
}
)
}
}
}
}
is AccountListItemAddBinding -> binding.apply {
override fun onBindFooter(holder: ViewHolderState<Any>) {
val binding = holder.view as? AccountListItemAddBinding ?: return
binding.apply {
root.setOnClickListener {
val accounts = this@AccountAdapter.immutableCurrentList
val remainingImages =
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }
.toSet()
val image =
DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
DataStoreHelper.profileImages.indexOf(
remainingImages.randomOrNull()
?: DataStoreHelper.profileImages.random()
)
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
val accountName = root.context.getString(R.string.account)
@ -144,12 +174,20 @@ class AccountAdapter(
}
}
}
}
override fun onCreateFooter(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
AccountViewHolder(
binding = when (viewType) {
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
when (viewType) {
VIEW_TYPE_SELECT_ACCOUNT -> {
AccountListItemBinding.inflate(
LayoutInflater.from(parent.context),
@ -157,13 +195,7 @@ class AccountAdapter(
false
)
}
VIEW_TYPE_ADD_ACCOUNT -> {
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_EDIT_ACCOUNT -> {
AccountListItemEditBinding.inflate(
LayoutInflater.from(parent.context),
@ -171,28 +203,9 @@ class AccountAdapter(
false
)
}
else -> throw IllegalArgumentException("Invalid view type")
}
)
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
holder.bind(accounts.getOrNull(position))
}
var viewType = 0
override fun getItemViewType(position: Int): Int {
if (viewType != 0 && position != accounts.count()) {
return viewType
}
return when (position) {
accounts.count() -> VIEW_TYPE_ADD_ACCOUNT
else -> VIEW_TYPE_SELECT_ACCOUNT
}
}
override fun getItemCount(): Int {
return accounts.count() + 1
}
}

View file

@ -21,7 +21,7 @@ import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
@ -392,7 +392,6 @@ object AccountHelper {
activity.observe(viewModel.accounts) { liveAccounts ->
recyclerView.adapter = AccountAdapter(
liveAccounts,
accountSelectCallback = { account ->
viewModel.handleAccountSelect(account, activity)
builder.dismissSafe()
@ -400,7 +399,9 @@ object AccountHelper {
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
)
).apply {
submitList(liveAccounts)
}
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
// Scroll to current account (which is focused by default)

View file

@ -31,20 +31,22 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut
import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.openActivity
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
companion object {
var hasLoggedIn: Boolean = false
}
val accountViewModel: AccountViewModel by viewModels()
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loadThemes(this)
@Suppress("DEPRECATION")
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
// Are we editing and coming from MainActivity?
val isEditingFromMainActivity = intent.getBooleanExtra(
@ -52,8 +54,22 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
false
)
// Sometimes we start this activity when we have already logged in
// For example when using cloudstreamsearch://
// In those cases we want to just go to the main activity instantly
if (hasLoggedIn && !isEditingFromMainActivity) {
navigateToMainActivity()
return
}
loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
val skipStartup = settingsManager.getBoolean(
getString(R.string.skip_startup_account_select_key), false
) || accounts.count() <= 1
fun askBiometricAuth() {
@ -89,10 +105,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
accountViewModel.handleAccountSelect(currentAccount, this, true)
} else {
if (accounts.count() > 1) {
showToast(this, getString(
showToast(
this, getString(
R.string.logged_account,
currentAccount?.name
))
)
)
}
navigateToMainActivity()
@ -105,12 +123,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
setContentView(binding.root)
fixSystemBarsPadding(binding.root, padTop = false)
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
observe(accountViewModel.accounts) { liveAccounts ->
val adapter = AccountAdapter(
liveAccounts,
// Handle the selected account
accountSelectCallback = {
accountViewModel.handleAccountSelect(it, this)
@ -118,7 +136,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) },
accountEditCallback = {
accountViewModel.handleAccountUpdate(it, this)
// We came from MainActivity, return there
// and switch to the edited account
if (isEditingFromMainActivity) {
@ -127,7 +144,9 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
}
},
accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) }
)
).apply {
submitList(liveAccounts)
}
recyclerView.adapter = adapter
@ -182,8 +201,11 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
askBiometricAuth()
}
@SuppressLint("UnsafeIntentLaunch")
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
}

View file

@ -4,8 +4,8 @@ import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
import com.lagradost.cloudstream3.utils.DataStoreHelper

View file

@ -1,5 +1,6 @@
package com.lagradost.cloudstream3.ui.download
import android.annotation.SuppressLint
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.ViewGroup
@ -7,19 +8,18 @@ import android.widget.CheckBox
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
const val DOWNLOAD_ACTION_PLAY_FILE = 0
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_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5
const val DOWNLOAD_ACTION_CANCEL_PENDING = 6
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
sealed class VisualDownloadCached {
abstract val currentBytes: Long
abstract val totalBytes: Long
abstract val data: VideoDownloadHelper.DownloadCached
abstract val data: DownloadObjects.DownloadCached
abstract var isSelected: Boolean
data class Child(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadEpisodeCached,
override val data: DownloadObjects.DownloadEpisodeCached,
override var isSelected: Boolean,
) : VisualDownloadCached()
data class Header(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached,
override val data: DownloadObjects.DownloadHeaderCached,
override var isSelected: Boolean,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
val child: DownloadObjects.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
val totalDownloads: Int,
) : VisualDownloadCached()
@ -57,19 +58,19 @@ sealed class VisualDownloadCached {
data class DownloadClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadEpisodeCached
val data: DownloadObjects.DownloadEpisodeCached
)
data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
val data: DownloadObjects.DownloadHeaderCached
)
class DownloadAdapter(
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
) : NoStateAdapter<VisualDownloadCached>(DiffCallback()) {
private var isMultiDeleteState: Boolean = false
@ -78,18 +79,8 @@ class DownloadAdapter(
private const val VIEW_TYPE_CHILD = 1
}
inner class DownloadViewHolder(
private val binding: ViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(card: VisualDownloadCached?) {
when (binding) {
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
}
}
private fun bindHeader(card: VisualDownloadCached.Header?) {
private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) {
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
val data = card.data
@ -99,12 +90,16 @@ class DownloadAdapter(
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
} else {
setOnLongClickListener {
onItemSelectionChanged.invoke(data.id, true)
true
}
}
}
downloadHeaderPoster.apply {
@ -130,7 +125,7 @@ class DownloadAdapter(
}
}
downloadHeaderTitle.text = data.name
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes)
if (card.child != null) {
handleChildDownload(card, formattedSize)
@ -157,15 +152,26 @@ class DownloadAdapter(
downloadHeaderGotoChild.isVisible = false
val posDur = getViewPos(card.data.id)
watchProgressContainer.isVisible = true
downloadHeaderEpisodeProgress.apply {
isVisible = posDur != null
posDur?.let {
val visualPos = it.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
val max = (it.duration / 1000).toInt()
val progress = (it.position / 1000).toInt()
if (max > 0 && progress >= (0.95 * max).toInt()) {
playIcon.setImageResource(R.drawable.ic_baseline_check_24)
isVisible = false
} else {
playIcon.setImageResource(R.drawable.netflix_play)
this.max = max
this.progress = progress
isVisible = true
}
}
}
downloadButton.resetView()
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
@ -183,7 +189,6 @@ class DownloadAdapter(
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
@ -195,6 +200,7 @@ class DownloadAdapter(
)
}
downloadHeaderInfo.isVisible = true
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
downloadButton.isVisible = !isMultiDeleteState
@ -214,11 +220,14 @@ class DownloadAdapter(
card: VisualDownloadCached.Header,
formattedSize: String
) {
downloadButton.resetViewData()
watchProgressContainer.isVisible = false
downloadButton.isVisible = false
downloadHeaderEpisodeProgress.isVisible = false
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
try {
downloadHeaderInfo.isVisible = true
downloadHeaderInfo.text =
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads,
@ -245,7 +254,7 @@ class DownloadAdapter(
}
}
private fun bindChild(card: VisualDownloadCached.Child?) {
private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) {
if (binding !is DownloadChildEpisodeBinding || card == null) return
val data = card.data
@ -254,12 +263,22 @@ class DownloadAdapter(
downloadChildEpisodeProgress.apply {
isVisible = posDur != null
posDur?.let {
val visualPos = it.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
val max = (it.duration / 1000).toInt()
val progress = (it.position / 1000).toInt()
if (max > 0 && progress >= (0.95 * max).toInt()) {
downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24)
isVisible = false
} else {
downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent)
this.max = max
this.progress = progress
isVisible = true
}
}
}
downloadButton.resetView()
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
@ -278,7 +297,6 @@ class DownloadAdapter(
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
@ -312,6 +330,10 @@ class DownloadAdapter(
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
else -> {
@ -323,14 +345,14 @@ class DownloadAdapter(
)
)
}
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
onItemSelectionChanged.invoke(data.id, true)
true
}
}
}
}
if (isMultiDeleteState) {
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
@ -344,50 +366,47 @@ class DownloadAdapter(
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState<Any> {
val inflater = LayoutInflater.from(parent.context)
val binding = when (viewType) {
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Invalid view type")
}
return DownloadViewHolder(binding)
return ViewHolderState(binding)
}
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
holder.bind(getItem(position))
override fun onBindContent(
holder: ViewHolderState<Any>,
item: VisualDownloadCached,
position: Int
) {
when (val binding = holder.view) {
is DownloadHeaderEpisodeBinding -> bindHeader(
binding,
item as? VisualDownloadCached.Header
)
is DownloadChildEpisodeBinding -> bindChild(
binding,
item as? VisualDownloadCached.Child
)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
override fun customContentViewType(item: VisualDownloadCached): Int {
return when (item) {
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
else -> throw IllegalArgumentException("Invalid data type at position $position")
}
}
@SuppressLint("NotifyDataSetChanged")
fun setIsMultiDeleteState(value: Boolean) {
if (isMultiDeleteState == value) return
isMultiDeleteState = value
notifyItemRangeChanged(0, itemCount)
}
fun notifyAllSelected() {
currentList.indices.forEach { index ->
if (!currentList[index].isSelected) {
notifyItemChanged(index)
}
}
}
fun notifySelectionStates() {
currentList.indices.forEach { index ->
if (currentList[index].isSelected) {
notifyItemChanged(index)
}
}
notifyDataSetChanged() // This is shit, but what can you do?
}
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {

View file

@ -4,8 +4,8 @@ import android.content.DialogInterface
import android.net.Uri
import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
@ -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.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.MainScope
object DownloadButtonSetup {
@ -82,7 +83,7 @@ object DownloadButtonSetup {
} else {
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
if (pkg != null) {
VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
DownloadQueueManager.addToQueue(pkg.toWrapper())
} else {
VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
@ -95,7 +96,7 @@ object DownloadButtonSetup {
DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act ->
val length =
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
VideoDownloadManager.getDownloadFileInfo(
act,
click.data.id
)?.fileLength
@ -110,24 +111,31 @@ object DownloadButtonSetup {
}
}
DOWNLOAD_ACTION_CANCEL_PENDING -> {
DownloadQueueManager.cancelDownload(id)
}
DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act ->
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
val parent = getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString()
) ?: return
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
?.mapNotNull {
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
getKey<DownloadObjects.DownloadEpisodeCached>(it)
}
?.filter { it.parentId == click.data.parentId }
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 {
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString()
) ?: return@forEach
@ -141,7 +149,7 @@ object DownloadButtonSetup {
uri = Uri.EMPTY,
id = it.id,
parentId = it.parentId,
name = act.getString(R.string.downloaded_file),
name = it.name ?: act.getString(R.string.downloaded_file),
season = it.season,
episode = it.episode,
headerName = parent.name,
@ -154,7 +162,8 @@ object DownloadButtonSetup {
}
act.navigate(
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

@ -1,32 +1,35 @@
package com.lagradost.cloudstream3.ui.download
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
class DownloadChildFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentChildDownloadsBinding? = null
class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate)
) {
private val downloadViewModel: DownloadViewModel by activityViewModels()
companion object {
fun newInstance(headerName: String, folder: String): Bundle {
@ -39,99 +42,104 @@ class DownloadChildFragment : Fragment() {
override fun onDestroyView() {
activity?.detachBackPressedCallback("Downloads")
binding = null
downloadViewModel.clearChildren()
super.onDestroyView()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padBottom = isLandscape(),
padLeft = isLayout(TV or EMULATOR)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
override fun onBindingCreated(binding: FragmentChildDownloadsBinding) {
val folder = arguments?.getString("folder")
val name = arguments?.getString("name")
if (folder == null) {
activity?.onBackPressedDispatcher?.onBackPressed()
dispatchBackPressed()
return
}
binding?.downloadChildToolbar?.apply {
context?.let { downloadViewModel.updateChildList(it, folder) }
binding.downloadChildToolbar.apply {
title = name
if (isLayout(PHONE or EMULATOR)) {
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener {
activity?.onBackPressedDispatcher?.onBackPressed()
dispatchBackPressed()
}
}
setAppBarNoScrollFlagsOnTV()
}
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.childCards) {
if (it.isEmpty()) {
activity?.onBackPressedDispatcher?.onBackPressed()
return@observe
observe(downloadViewModel.childCards) { cards ->
when (cards) {
is Resource.Success -> {
if (cards.value.isEmpty()) {
dispatchBackPressed()
}
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value)
}
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
}
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
activity?.detachBackPressedCallback("Downloads")
downloadsViewModel.clearSelectedItems()
binding?.downloadChildToolbar?.isVisible = true
else -> {
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null)
}
}
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
val allSelected = downloadsViewModel.isAllSelected()
binding.apply {
btnDelete.setOnClickListener { view ->
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
}
btnCancel.setOnClickListener {
downloadViewModel.cancelSelection()
}
btnToggleAll.setOnClickListener {
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllChildren()
}
}
}
observeNullable(downloadViewModel.selectedItemIds) { selection ->
val isMultiDeleteState = selection != null
val adapter = binding.downloadChildList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
binding.downloadChildToolbar.isGone = isMultiDeleteState
if (selection == null) {
activity?.detachBackPressedCallback("Downloads")
return@observeNullable
}
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.cancelSelection()
}
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
binding.btnDelete.isVisible = selection.isNotEmpty()
binding.selectItemsText.isVisible = selection.isEmpty()
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) {
binding.btnToggleAll.setText(R.string.deselect_all)
} else binding.btnToggleAll.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
@ -139,18 +147,18 @@ class DownloadChildFragment : Fragment() {
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
downloadViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
downloadViewModel.addSelected(itemId)
} else downloadViewModel.removeSelected(itemId)
}
)
binding?.downloadChildList?.apply {
binding.downloadChildList.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
@ -160,43 +168,6 @@ class DownloadChildFragment : Fragment() {
nextDown = FOCUS_SELF,
)
}
context?.let { downloadsViewModel.updateChildList(it, folder) }
fixPaddingStatusbar(binding?.downloadChildRoot)
}
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadChildToolbar?.isVisible = false
activity?.attachBackPressedCallback("Downloads") {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {

View file

@ -7,13 +7,8 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
@ -22,23 +17,28 @@ import androidx.annotation.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
@ -46,7 +46,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
@ -54,9 +54,12 @@ import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentDownloadsBinding? = null
class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate)
) {
private val downloadViewModel: DownloadViewModel by activityViewModels()
private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams(
@ -69,120 +72,135 @@ class DownloadFragment : Fragment() {
override fun onDestroyView() {
activity?.detachBackPressedCallback("Downloads")
binding = null
super.onDestroyView()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
hideKeyboard()
binding?.downloadAppbar?.setAppBarNoScrollFlagsOnTV()
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
observe(downloadsViewModel.headerCards) {
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding?.downloadLoading?.isVisible = false
binding?.textNoDownloads?.isVisible = it.isEmpty()
}
observe(downloadsViewModel.availableBytes) {
updateStorageInfo(
view.context,
it,
R.string.free_storage,
binding?.downloadFreeTxt,
binding?.downloadFree
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padBottom = isLandscape(),
padLeft = isLayout(TV or EMULATOR)
)
}
observe(downloadsViewModel.usedBytes) {
override fun onBindingCreated(binding: FragmentDownloadsBinding) {
hideKeyboard()
binding.downloadAppbar.setAppBarNoScrollFlagsOnTV()
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
observe(downloadViewModel.headerCards) { cards ->
when (cards) {
is Resource.Success -> {
(binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value)
binding.textNoDownloads.isVisible = cards.value.isEmpty()
binding.downloadLoading.isVisible = false
binding.downloadList.isVisible = true
}
is Resource.Loading -> {
binding.downloadList.isVisible = false
binding.downloadLoading.isVisible = true
}
is Resource.Failure -> {
binding.downloadList.isVisible = true
binding.downloadLoading.isVisible = false
}
}
}
observe(downloadViewModel.availableBytes) {
updateStorageInfo(
view.context,
binding.root.context,
it,
R.string.free_storage,
binding.downloadFreeTxt,
binding.downloadFree
)
}
observe(downloadViewModel.usedBytes) {
updateStorageInfo(
binding.root.context,
it,
R.string.used_storage,
binding?.downloadUsedTxt,
binding?.downloadUsed
binding.downloadUsedTxt,
binding.downloadUsed
)
val hasBytes = it > 0
if (hasBytes) {
binding?.downloadLoadingBytes?.stopShimmer()
} else {
binding?.downloadLoadingBytes?.startShimmer()
}
binding.downloadLoadingBytes.stopShimmer()
} else binding.downloadLoadingBytes.startShimmer()
binding?.downloadBytesBar?.isVisible = hasBytes
binding?.downloadLoadingBytes?.isGone = hasBytes
binding.downloadBytesBar.isVisible = hasBytes
binding.downloadLoadingBytes.isGone = hasBytes
}
observe(downloadsViewModel.downloadBytes) {
observe(downloadViewModel.downloadBytes) {
updateStorageInfo(
view.context,
binding.root.context,
it,
R.string.app_storage,
binding?.downloadAppTxt,
binding?.downloadApp
binding.downloadAppTxt,
binding.downloadApp
)
}
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
activity?.detachBackPressedCallback("Downloads")
downloadsViewModel.clearSelectedItems()
// Prevent race condition and make sure
// we don't display it early
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
binding?.downloadAppbar?.isVisible = true
observe(downloadQueueViewModel.childCards) { cards ->
val size = cards.currentDownloads.size + cards.queue.size
val context = binding.root.context
val baseText = context.getString(R.string.download_queue)
binding.downloadQueueText.text = if (size > 0) {
"$baseText (${cards.currentDownloads.size}/$size)"
} else {
baseText
}
}
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
val allSelected = downloadsViewModel.isAllSelected()
binding.apply {
btnDelete.setOnClickListener { view ->
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
}
btnCancel.setOnClickListener {
downloadViewModel.cancelSelection()
}
btnToggleAll.setOnClickListener {
val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllHeaders()
}
}
}
observeNullable(downloadViewModel.selectedItemIds) { selection ->
val isMultiDeleteState = selection != null
val adapter = binding.downloadList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
binding.downloadAppbar.isGone = isMultiDeleteState
if (selection == null) {
activity?.detachBackPressedCallback("Downloads")
return@observeNullable
}
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.cancelSelection()
}
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
binding.btnDelete.isVisible = selection.isNotEmpty()
binding.selectItemsText.isVisible = selection.isEmpty()
val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) {
binding.btnToggleAll.setText(R.string.deselect_all)
} else binding.btnToggleAll.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
@ -190,29 +208,29 @@ class DownloadFragment : Fragment() {
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
downloadViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
downloadViewModel.addSelected(itemId)
} else downloadViewModel.removeSelected(itemId)
}
)
binding?.downloadList?.apply {
binding.downloadList.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF,
nextDown = R.id.download_queue_button,
)
}
binding?.apply {
binding.apply {
openLocalVideoButton.apply {
isGone = isLayout(TV)
setOnClickListener { openLocalVideo() }
@ -222,6 +240,10 @@ class DownloadFragment : Fragment() {
setOnClickListener { showStreamInputDialog(it.context) }
}
downloadQueueButton.setOnClickListener {
activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
}
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
@ -230,13 +252,12 @@ class DownloadFragment : Fragment() {
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
handleScroll(scrollY - oldScrollY)
}
}
context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot)
context?.let { downloadViewModel.updateHeaderList(it) }
}
private fun handleItemClick(click: DownloadHeaderClickEvent) {
@ -258,40 +279,6 @@ class DownloadFragment : Fragment() {
}
}
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadAppbar?.isVisible = false
activity?.attachBackPressedCallback("Downloads") {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =
@ -362,7 +349,8 @@ class DownloadFragment : Fragment() {
listOf(BasicLink(url)),
extract = true,
refererUrl = referer,
)
id = url.hashCode()
), 0
)
)
dialog.dismissSafe(activity)
@ -393,7 +381,7 @@ class DownloadFragment : Fragment() {
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
val selectedVideoUri = result.data?.data ?: return@registerForActivityResult
playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
}
}

View file

@ -5,91 +5,119 @@ import android.content.DialogInterface
import android.os.Environment
import android.os.StatFs
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.api.Log
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.DownloadQueueService
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.ConsistentLiveData
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
import com.lagradost.cloudstream3.utils.ResourceLiveData
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() {
companion object {
const val TAG = "DownloadViewModel"
}
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
private val _headerCards =
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading())
val childCards: LiveData<Resource<List<VisualDownloadCached.Child>>> = _childCards
private val _usedBytes = MutableLiveData<Long>()
private val _usedBytes = ConsistentLiveData<Long>()
val usedBytes: LiveData<Long> = _usedBytes
private val _availableBytes = MutableLiveData<Long>()
private val _availableBytes = ConsistentLiveData<Long>()
val availableBytes: LiveData<Long> = _availableBytes
private val _downloadBytes = MutableLiveData<Long>()
private val _downloadBytes = ConsistentLiveData<Long>()
val downloadBytes: LiveData<Long> = _downloadBytes
private val _selectedBytes = MutableLiveData<Long>(0)
private val _selectedBytes = ConsistentLiveData<Long>(0)
val selectedBytes: LiveData<Long> = _selectedBytes
private val _isMultiDeleteState = MutableLiveData(false)
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
private val _selectedItemIds = ConsistentLiveData<Set<Int>?>(null)
val selectedItemIds: LiveData<Set<Int>?> = _selectedItemIds
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
private var previousVisual: List<VisualDownloadCached>? = null
fun setIsMultiDeleteState(value: Boolean) {
_isMultiDeleteState.postValue(value)
fun cancelSelection() {
updateSelectedItems { null }
}
fun addSelected(itemId: Int) {
updateSelectedItems { it.add(itemId) }
updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) }
}
fun removeSelected(itemId: Int) {
updateSelectedItems { it.remove(itemId) }
updateSelectedItems { it?.minus(itemId) ?: emptySet() }
}
fun selectAllItems() {
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
fun selectAllHeaders() {
updateSelectedItems {
_headerCards.success.orEmpty()
.map { item -> item.data.id }.toSet()
}
}
fun selectAllChildren() {
updateSelectedItems {
_childCards.success.orEmpty()
.map { item -> item.data.id }.toSet()
}
}
fun clearSelectedItems() {
// We need this to be done immediately
// so we can't use postValue
_selectedItemIds.value = mutableSetOf()
updateSelectedItems { it.clear() }
updateSelectedItems { emptySet() }
}
fun isAllSelected(): Boolean {
fun isAllChildrenSelected(): Boolean {
val currentSelected = selectedItemIds.value ?: return false
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
val children = _childCards.success.orEmpty()
return currentSelected.size == children.size && children.all { it.data.id in currentSelected }
}
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
val currentSelected = selectedItemIds.value ?: mutableSetOf()
action(currentSelected)
fun isAllHeadersSelected(): Boolean {
val currentSelected = selectedItemIds.value ?: return false
val headers = _headerCards.success.orEmpty()
return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected }
}
private fun updateSelectedItems(action: (Set<Int>?) -> Set<Int>?) {
val currentSelected = action(selectedItemIds.value)
_selectedItemIds.postValue(currentSelected)
postHeaders()
postChildren()
updateSelectedBytes()
updateSelectedCards()
}
private fun updateSelectedBytes() = viewModelScope.launchSafe {
@ -98,61 +126,173 @@ class DownloadViewModel : ViewModel() {
_selectedBytes.postValue(totalSelectedBytes)
}
private fun updateSelectedCards() = viewModelScope.launchSafe {
val currentSelected = selectedItemIds.value ?: return@launchSafe
headerCards.value?.let { headers ->
headers.forEach { header ->
header.isSelected = header.data.id in currentSelected
fun removeRedundantEpisodeKeys(context: Context, keys: List<Pair<Int, Int>>) {
val settingsManager = context.getSharedPrefs()
ioSafe {
settingsManager.edit {
keys.forEach { (parentId, childId) ->
Log.i(TAG, "Removing download episode key: ${parentId}/${childId}")
val oldPath = getFolderName(
getFolderName(
DOWNLOAD_EPISODE_CACHE,
parentId.toString()
),
childId.toString()
)
val newPath = getFolderName(
getFolderName(
DOWNLOAD_EPISODE_CACHE_BACKUP,
parentId.toString()
),
childId.toString()
)
val oldPref = settingsManager.getString(oldPath, null)
// Cowardly future backup solution in case the key removal fails in some edge case.
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
this.putString(newPath, oldPref)
this.remove(oldPath)
}
}
}
_headerCards.postValue(headers)
}
childCards.value?.let { children ->
children.forEach { child ->
child.isSelected = child.data.id in currentSelected
fun removeRedundantHeaderKeys(
context: Context,
cached: List<DownloadObjects.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
) {
val settingsManager = context.getSharedPrefs()
ioSafe {
// Do not remove headers used by resume watching
val resumeWatchingIds =
getAllResumeStateIds()?.mapNotNull { id ->
getLastWatched(id)?.parentId
}?.toSet() ?: emptySet()
settingsManager.edit {
cached.forEach { header ->
val downloads = totalDownloads[header.id] ?: 0
val bytes = totalBytesUsedByChild[header.id] ?: 0
if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) {
Log.i(TAG, "Removing download header key: ${header.id}")
val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString())
val newPath =
getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString())
val oldPref = settingsManager.getString(oldPAth, null)
// Cowardly future backup solution in case the key removal fails in some edge case.
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
this.putString(newPath, oldPref)
this.remove(oldPAth)
}
}
}
_childCards.postValue(children)
}
}
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) {
// Do not push loading as it interrupts the UI
//_headerCards.postValue(Resource.Loading())
val visual = ioWork {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.mapNotNull { context.getKey<DownloadObjects.DownloadEpisodeCached>(it) }
.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)
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(
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
context,
cached,
downloadStats.totalBytesUsedByChild,
downloadStats.currentBytesUsedByChild,
downloadStats.totalDownloads
)
}
if (visual != previousVisual) {
previousVisual = visual
updateStorageStats(visual)
_headerCards.postValue(visual)
postHeaders(visual)
}
fun postHeaders(newValue: List<VisualDownloadCached.Header>? = null) {
val newValue = newValue ?: _headerCards.success ?: return
val selection = selectedItemIds.value ?: emptySet()
_headerCards.postValue(Resource.Success(newValue.map {
it.copy(
isSelected = selection.contains(
it.data.id
)
)
}))
}
fun postChildren(newValue: List<VisualDownloadCached.Child>? = null) {
val newValue = newValue ?: _childCards.success ?: return
val selection = selectedItemIds.value ?: emptySet()
_childCards.postValue(Resource.Success(newValue.map {
it.copy(
isSelected = selection.contains(
it.data.id
)
)
}))
}
private data class DownloadStats(
val totalBytesUsedByChild: Map<Int, Long>,
val currentBytesUsedByChild: Map<Int, Long>,
val totalDownloads: Map<Int, Int>,
/** Parent ID to child ID. Keys to be removed. */
val redundantDownloads: List<Pair<Int, Int>>
)
private fun calculateDownloadStats(
context: Context,
children: List<VideoDownloadHelper.DownloadEpisodeCached>
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
children: List<DownloadObjects.DownloadEpisodeCached>
): DownloadStats {
// parentId : bytes
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount
val totalDownloads = mutableMapOf<Int, Int>()
val redundantDownloads = mutableListOf<Pair<Int, Int>>()
children.forEach { child ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
val childFile = getDownloadFileInfo(context, child.id)
if (childFile == null) {
// It may not be a redundant child if something is currently downloading.
// DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader
// leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE
if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) {
redundantDownloads.add(child.parentId to child.id)
}
return@forEach
}
if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes
@ -162,12 +302,17 @@ class DownloadViewModel : ViewModel() {
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads.merge(child.parentId, 1, Int::plus)
}
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
return DownloadStats(
totalBytesUsedByChild,
currentBytesUsedByChild,
totalDownloads,
redundantDownloads
)
}
private fun createVisualDownloadList(
context: Context,
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
cached: List<DownloadObjects.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>,
currentBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
@ -176,10 +321,14 @@ class DownloadViewModel : ViewModel() {
val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
if (bytes <= 0 || downloads <= 0) {
return@mapNotNull null
}
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
val movieEpisode =
if (it.type.isEpisodeBased()) null else context.getKey<DownloadObjects.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString())
)
@ -208,12 +357,14 @@ class DownloadViewModel : ViewModel() {
}
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
_childCards.postValue(Resource.Loading()) // always push loading
val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key ->
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
context.getKey<DownloadObjects.DownloadEpisodeCached>(key)
}.mapNotNull {
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null
VisualDownloadCached.Child(
currentBytes = info.fileLength,
totalBytes = info.totalBytes,
@ -221,24 +372,21 @@ class DownloadViewModel : ViewModel() {
data = it,
)
}
}.sortedWith(compareBy(
}.sortedWith(
compareBy(
// Sort by season first, and then by episode number,
// to ensure sorting is consistent.
{ it.data.season ?: 0 },
{ it.data.episode }
))
if (previousVisual != visual) {
previousVisual = visual
_childCards.postValue(visual)
}
postChildren(visual)
}
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
_headerCards.postValue(updatedHeaders)
_childCards.postValue(updatedChildren)
_selectedItemIds.postValue(null)
postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove })
postChildren(_childCards.success?.filter { it.data.id !in idsToRemove })
}
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
@ -292,7 +440,7 @@ class DownloadViewModel : ViewModel() {
if (item.data.type.isEpisodeBased()) {
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull {
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
context.getKey<DownloadObjects.DownloadEpisodeCached>(
it
)
}
@ -316,7 +464,7 @@ class DownloadViewModel : ViewModel() {
is VisualDownloadCached.Child -> {
ids.add(item.data.id)
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
val parent = context.getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
item.data.parentId.toString()
)
@ -345,16 +493,16 @@ class DownloadViewModel : ViewModel() {
.joinToString(separator = "\n") { "$it" }
return when {
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.ids.count() == 1 -> {
context.getString(R.string.delete_message).format(
data.names.firstOrNull()
)
}
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.parentName != null && data.names.isNotEmpty() -> {
context.getString(R.string.delete_message_series_episodes)
.format(data.parentName, formattedNames)
@ -383,7 +531,6 @@ class DownloadViewModel : ViewModel() {
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
viewModelScope.launchSafe {
setIsMultiDeleteState(false)
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
// We always remove parent because if we are deleting from here
// and we have it as non-empty, it was triggered on
@ -414,8 +561,8 @@ class DownloadViewModel : ViewModel() {
}
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
val headers = headerCards.value.orEmpty()
val children = childCards.value.orEmpty()
val headers = _headerCards.success.orEmpty()
val children = _childCards.success.orEmpty()
return selectedItemIds.value?.mapNotNull { id ->
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
@ -423,10 +570,11 @@ class DownloadViewModel : ViewModel() {
}
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
val headers = headerCards.value.orEmpty()
val children = childCards.value.orEmpty()
return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId }
}
return (headers + children).filter { it.data.id == itemId }
fun clearChildren() {
_childCards.postValue(Resource.Loading())
}
private data class DeleteData(

View file

@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
@ -62,6 +62,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
open fun resetViewData() {
// lastRequest = null
progressText = null
isZeroBytes = true
doSetProgress = true
persistentId = null
@ -75,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
currentMetaData.id = id
if (!doSetProgress) return
val appContext = context.applicationContext
ioSafe {
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
mainWork {
if (savedData != null) {
val downloadedBytes = savedData.fileLength
@ -86,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
setProgress(downloadedBytes, totalBytes)
applyMetaData(id, downloadedBytes, totalBytes)
} else run { resetView() }
}
}
}
}

View file

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

View file

@ -10,11 +10,14 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.MainThread
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
@ -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.DownloadClickEvent
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) {
@ -63,7 +67,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
open fun onInflate() {}
init {
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) {
try {
inflate(
overrideLayout ?: getResourceId(
@ -72,6 +76,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
)
)
} catch (e: Exception) {
recycle() // Manually call recycle first to avoid memory leaks
Log.e(
"PieFetchButton", "Error inflating PieFetchButton, " +
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
@ -79,11 +84,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
throw e
}
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
animateWaiting = getBoolean(
R.styleable.PieFetchButton_download_animate_waiting,
true
@ -92,16 +92,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
R.styleable.PieFetchButton_download_hide_when_icon,
true
)
waitingAnimation = getResourceId(
R.styleable.PieFetchButton_download_waiting_animation,
R.anim.rotate_around_center_point
)
activeOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
)
nonActiveOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_non_active,
R.drawable.circle_shape_dotted
@ -129,19 +126,29 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
)
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
)
}
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
recycle()
}
resetView()
// resetView()
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 fun getActivity(): Activity? {
var context = context
@ -162,16 +169,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}*/
protected fun setDefaultClickListener(
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached,
callback: (DownloadClickEvent) -> Unit
) {
this.progressText = textView
this.setPersistentId(card.id)
view.setOnClickListener {
if (isZeroBytes) {
val localQueue = queue.value
val localInstances = downloadInstances.value
val id = card.id
// If the download is already in queue or active downloads, provide an option to cancel it
if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) {
it.popupMenuNoIcons(
arrayListOf(
Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel),
)
) {
callback(DownloadClickEvent(itemId, card))
}
} else {
// Otherwise just start a download instantly
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
}
} else {
val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
@ -212,7 +234,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}
open fun setDefaultClickListener(
card: VideoDownloadHelper.DownloadEpisodeCached,
card: DownloadObjects.DownloadEpisodeCached,
textView: TextView?,
callback: (DownloadClickEvent) -> Unit
) {
@ -282,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status
// Runs on the main thread, but also instant if it already is
if (Looper.myLooper() == Looper.getMainLooper()) {
// Runs on the main thread, but also instant if it already is.
if (Looper.getMainLooper().isCurrentThread) {
try {
setStatusInternal(status)
} 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,11 +5,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import androidx.viewbinding.ViewBinding
import coil3.load
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding
@ -19,6 +16,7 @@ import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
@ -43,25 +41,14 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(vi
}
}
}
override fun onViewRecycled() {
super.onViewRecycled()
// Clear the image, idk if this saves ram or not, but I guess?
view.root.findViewById<ImageView>(R.id.imageView)?.apply {
load(null)
}
}
}
class ResumeItemAdapter(
fragment: Fragment,
nextFocusUp: Int? = null,
nextFocusDown: Int? = null,
clickCallback: (SearchClickCallback) -> Unit,
private val removeCallback: (View) -> Unit,
) : HomeChildItemAdapter(
fragment = fragment,
id = "resumeAdapter".hashCode(),
nextFocusUp = nextFocusUp,
nextFocusDown = nextFocusDown,
@ -81,6 +68,11 @@ class ResumeItemAdapter(
return HomeScrollViewHolderState(binding)
}
override fun onClearView(holder: ViewHolderState<Boolean>) {
// Clear the image, idk if this saves ram or not, but I guess?
clearImage(holder.view.root.findViewById(R.id.imageView))
}
override fun onBindFooter(holder: ViewHolderState<Boolean>) {
this.applyBinding(holder, false)
when (val binding = holder.view) {
@ -114,16 +106,15 @@ class ResumeItemAdapter(
/** Remember to set `updatePosterSize` to cache the poster size,
* otherwise the width and height is unset */
open class HomeChildItemAdapter(
fragment: Fragment,
id: Int,
var nextFocusUp: Int? = null,
var nextFocusDown: Int? = null,
var clickCallback: (SearchClickCallback) -> Unit,
) :
BaseAdapter<SearchResponse, Boolean>(
fragment, id, diffCallback = BaseDiffCallback(
id, diffCallback = BaseDiffCallback(
itemSame = { a, b ->
a.url == b.url
a.url == b.url && a.name == b.name
},
contentSame = { a, b ->
a == b
@ -168,11 +159,16 @@ open class HomeChildItemAdapter(
}
companion object {
// The vast majority of the lag comes from creating the view
// This simply shares the views between all HomeChildItemAdapter
val sharedPool =
newSharedPool { setMaxRecycledViews(CONTENT, 20) }
var minPosterSize: Int = 0
var maxPosterSize: Int = 0
fun updatePosterSize(context: Context) {
val scale = PreferenceManager.getDefaultSharedPreferences(context)
fun updatePosterSize(context: Context, value: Int? = null) {
val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context)
?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0
// Scale by +10% per step
val mul = 1.0f + scale * 0.1f

View file

@ -5,8 +5,6 @@ import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -14,14 +12,15 @@ import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
@ -46,15 +45,18 @@ import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.account.AccountViewModel
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE
import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@ -64,26 +66,30 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.EmptyEvent
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.txt
private const val TAG = "HomeFragment"
class HomeFragment : Fragment() {
class HomeFragment : BaseFragment<FragmentHomeBinding>(
BindingCreator.Bind(FragmentHomeBinding::bind)
) {
companion object {
val configEvent = Event<Int>()
// Used for configuration changed events to fix any popups that are not attached to a fragment
val configEvent = EmptyEvent()
var currentSpan = 1
val listHomepageItems = mutableListOf<SearchResponse>()
private val errorProfilePics = listOf(
R.drawable.monke_benene,
@ -112,6 +118,7 @@ class HomeFragment : Fragment() {
//}
// returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView
fun Activity.loadHomepageList(
expand: HomeViewModel.ExpandableHomepageList,
deleteCallback: (() -> Unit)? = null,
@ -193,16 +200,17 @@ class HomeFragment : Fragment() {
// Span settings
binding.homeExpandedRecycler.spanCount = currentSpan
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages)
binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool)
binding.homeExpandedRecycler.adapter =
SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback ->
handleSearchClickCallback(callback)
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
//bottomSheetDialogBuilder.dismissSafe(this)
}
}.apply {
submitList(item.list)
hasNext = expand.hasNext
}
@ -226,7 +234,7 @@ class HomeFragment : Fragment() {
expandCallback?.invoke(name)?.let { newExpand ->
(recyclerView.adapter as? SearchAdapter?)?.apply {
hasNext = newExpand.hasNext
updateList(newExpand.list.list)
submitList(newExpand.list.list)
}
}
}
@ -234,9 +242,12 @@ class HomeFragment : Fragment() {
}
})
val spanListener = { span: Int ->
binding.homeExpandedRecycler.spanCount = span
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
val spanListener = Runnable {
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages)
// We want to rebind everything to update the UI, however we also want to avoid
// any animations ect, this is the easiest way to do this, and the most correct
@SuppressLint("NotifyDataSetChanged")
binding.homeExpandedRecycler.adapter?.notifyDataSetChanged()
}
configEvent += spanListener
@ -306,7 +317,7 @@ class HomeFragment : Fragment() {
val pairList = getPairList(header)
for ((button, types) in pairList) {
button?.isChecked =
button?.isVisible == true && selectedTypes.any { types.contains(it) }
button.isVisible && selectedTypes.any { types.contains(it) }
}
}
@ -416,7 +427,7 @@ class HomeFragment : Fragment() {
val name = getItem(position)
titleText?.text = name
val isPinned =
pinnedphashset.contains(currentValidApis[position].name ?: "")
pinnedphashset.contains(currentValidApis[position].name)
pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE
return view
}
@ -547,46 +558,24 @@ class HomeFragment : Fragment() {
}
}
var binding: FragmentHomeBinding? = null
override fun pickLayout(): Int? =
if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
//homeViewModel =
// ViewModelProvider(this).get(HomeViewModel::class.java)
bottomSheetDialog?.ownShow()
val layout =
if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home
val root = inflater.inflate(layout, container, false)
binding = try {
FragmentHomeBinding.bind(root)
} catch (t: Throwable) {
showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
logError(t)
null
}
return root
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onDestroyView() {
(activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress")
bottomSheetDialog?.ownHide()
binding = null
super.onDestroyView()
}
private fun fixGrid() {
activity?.getSpanCount()?.let {
currentSpan = it
}
configEvent.invoke(currentSpan)
}
private val apiChangeClickListener = View.OnClickListener { view ->
view.context.selectHomepage(currentApiName) { api ->
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
@ -600,12 +589,6 @@ class HomeFragment : Fragment() {
}*/
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
//(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged()
fixGrid()
}
private var currentApiName: String? = null
private var toggleRandomButton = false
@ -634,14 +617,25 @@ class HomeFragment : Fragment() {
}
}
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padTop = false,
padBottom = isLandscape(),
padLeft = isLayout(TV or EMULATOR)
)
// Fix grid
configEvent.invoke()
}
@SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fixGrid()
override fun onBindingCreated(binding: FragmentHomeBinding) {
context?.let { HomeChildItemAdapter.updatePosterSize(it) }
binding?.apply {
(activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") {
handleTvBackPress(this)
}
binding.apply {
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
homeApiFab.setOnClickListener(apiChangeClickListener)
@ -656,17 +650,11 @@ class HomeFragment : Fragment() {
activity?.showAccountSelectLinear()
}
homeRandom.setOnClickListener {
if (listHomepageItems.isNotEmpty()) {
activity.loadSearchResult(listHomepageItems.random())
}
}
homeMasterAdapter = HomeParentItemAdapterPreview(
fragment = this@HomeFragment,
homeViewModel, accountViewModel
)
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
homeMasterRecycler.adapter = homeMasterAdapter
//fixPaddingStatusbar(homeLoadingStatusbar)
homeApiFab.isVisible = isLayout(PHONE)
@ -702,11 +690,11 @@ class HomeFragment : Fragment() {
// Header scrolling is only relevant to TV/Emulator
val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView
val scrollParent = binding?.homeApiHolder
val scrollParent = binding.homeApiHolder
if (view == null) {
// The first view is not visible, so we can assume we have scrolled past it
scrollParent?.isVisible = false
scrollParent.isVisible = false
} else {
// A bit weird, but this is a major limitation we are working around here
// 1. We cant have a real parent to the recyclerview as android cant layout that without lagging
@ -722,8 +710,8 @@ class HomeFragment : Fragment() {
// Hopefully getLocationInWindow acts correctly on all devices
val rect = IntArray(2)
view.getLocationInWindow(rect)
scrollParent?.isVisible = true
scrollParent?.translationY = rect[1].toFloat() - 60.toPx
scrollParent.isVisible = true
scrollParent.translationY = rect[1].toFloat() - 60.toPx
}
}
super.onScrolled(recyclerView, dx, dy)
@ -739,13 +727,14 @@ class HomeFragment : Fragment() {
settingsManager.getBoolean(
getString(R.string.random_button_key),
false
) && isLayout(PHONE)
binding?.homeRandom?.visibility = View.GONE
)
binding.homeRandom.visibility = View.GONE
binding.homeRandomButtonTv.visibility = View.GONE
}
observe(homeViewModel.apiName) { apiName ->
currentApiName = apiName
binding?.apply {
binding.apply {
homeApiFab.text = apiName
homeChangeApi.text = apiName
homePreviewReloadProvider.isGone = (apiName == noneApi.name)
@ -754,38 +743,40 @@ class HomeFragment : Fragment() {
}
observe(homeViewModel.page) { data ->
binding?.apply {
binding.apply {
when (data) {
is Resource.Success -> {
homeLoadingShimmer.stopShimmer()
val d = data.value
saveHomepageToTV(d)
val mutableListOfResponse = mutableListOf<SearchResponse>()
listHomepageItems.clear()
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
it.copy(
list = it.list.copy(list = it.list.list.toMutableList())
)
}.toMutableList())
})
saveHomepageToTV(d)
homeLoading.isVisible = false
homeLoadingError.isVisible = false
homeMasterRecycler.isVisible = true
homeLoadingShimmer.stopShimmer()
//home_loaded?.isVisible = true
if (toggleRandomButton) {
//Flatten list
d.values.forEach { dlist ->
mutableListOfResponse.addAll(dlist.list.list)
val distinct = d.values
.flatMap { it.list.list }
.distinctBy { it.url }
val hasItems = distinct.isNotEmpty()
val isPhone = isLayout(PHONE)
val randomClickListener = View.OnClickListener {
distinct.randomOrNull()?.let { activity.loadSearchResult(it) }
}
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 {
homeRandom.isGone = true
homeRandomButtonTv.isGone = true
}
}
@ -803,7 +794,7 @@ class HomeFragment : Fragment() {
}) {
try {
val i = Intent(Intent.ACTION_VIEW)
i.data = Uri.parse(validAPIs[itemId].mainUrl)
i.data = validAPIs[itemId].mainUrl.toUri()
startActivity(i)
} catch (e: Exception) {
logError(e)
@ -813,7 +804,7 @@ class HomeFragment : Fragment() {
homeLoading.isVisible = false
homeLoadingError.isVisible = true
homeMasterRecycler.isVisible = false
homeMasterRecycler.isInvisible = true
// Based on https://github.com/recloudstream/cloudstream/pull/1438
val hasNoNetworkConnection = context?.isNetworkAvailable() == false
@ -835,24 +826,28 @@ class HomeFragment : Fragment() {
homeReloadConnectionGoToDownloads.setOnClickListener {
activity.navigate(R.id.navigation_downloads)
}
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
submitList(null)
clearState()
}
}
is Resource.Loading -> {
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
homeLoadingShimmer.startShimmer()
homeLoading.isVisible = true
homeLoadingError.isVisible = false
homeMasterRecycler.isVisible = false
homeMasterRecycler.isInvisible = true
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
submitList(null)
clearState()
}
//home_loaded?.isVisible = false
}
}
}
}
//context?.fixPaddingStatusbarView(home_statusbar)
//context?.fixPaddingStatusbar(home_padding)
observeNullable(homeViewModel.popup) { item ->
if (item == null) {
bottomSheetDialog?.dismissSafe()
@ -897,4 +892,44 @@ class HomeFragment : Fragment() {
}
}*/
}
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.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
@ -17,15 +15,16 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppContextUtils.setMaxViewPoolSize
class LoadClickCallback(
val action: Int = 0,
@ -35,13 +34,11 @@ class LoadClickCallback(
)
open class ParentItemAdapter(
open val fragment: Fragment,
id: Int,
private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null,
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
fragment,
id,
diffCallback = BaseDiffCallback(
itemSame = { a, b -> a.list.name == b.list.name },
@ -50,10 +47,8 @@ open class ParentItemAdapter(
})
) {
companion object {
// The vast majority of the lag comes from creating the view
// This simply shares the views between all HomeChildItemAdapter
private val sharedPool =
RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(0, 20) }
val sharedPool =
newSharedPool { setMaxRecycledViews(CONTENT, 4) }
}
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
@ -73,8 +68,11 @@ open class ParentItemAdapter(
}
}
override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
super.submitList(list?.sortedBy { it.list.list.isEmpty() })
override fun submitList(
list: Collection<HomeViewModel.ExpandableHomepageList>?,
commitCallback: Runnable?
) {
super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback)
}
override fun onUpdateContent(
@ -100,9 +98,8 @@ open class ParentItemAdapter(
binding.apply {
val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter
if (currentAdapter == null) {
homeChildRecyclerview.setRecycledViewPool(sharedPool)
homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
homeChildRecyclerview.adapter = HomeChildItemAdapter(
fragment = fragment,
id = id + position + 100,
clickCallback = clickCallback,
nextFocusUp = homeChildRecyclerview.nextFocusUpId,
@ -188,11 +185,6 @@ open class ParentItemAdapter(
return ParentItemHolder(binding)
}
fun updateList(newList: List<HomePageList>) {
submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
.toMutableList())
}
}
@Suppress("DEPRECATION")

View file

@ -7,13 +7,12 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
@ -21,9 +20,8 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigation.NavigationBarItemView
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainActivity
@ -35,14 +33,13 @@ import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugException
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.account.AccountViewModel
import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.getId
@ -62,13 +59,14 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectSt
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
import androidx.core.graphics.toColorInt
import com.lagradost.cloudstream3.ui.setRecycledViewPool
class HomeParentItemAdapterPreview(
override val fragment: Fragment,
private val viewModel: HomeViewModel,
private val accountViewModel: AccountViewModel
) : ParentItemAdapter(
fragment, id = "HomeParentItemAdapterPreview".hashCode(),
id = "HomeParentItemAdapterPreview".hashCode(),
clickCallback = {
viewModel.click(it)
}, moreInfoClickCallback = {
@ -106,18 +104,33 @@ class HomeParentItemAdapterPreview(
)
}
return HeaderViewHolder(binding, viewModel, accountViewModel, fragment = fragment)
return HeaderViewHolder(binding, viewModel, accountViewModel)
}
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
(holder as? HeaderViewHolder)?.bind()
}
override fun onViewDetachedFromWindow(holder: ViewHolderState<Bundle>) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewDetachedFromWindow()
}
}
}
override fun onViewAttachedToWindow(holder: ViewHolderState<Bundle>) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewAttachedToWindow()
}
}
}
private class HeaderViewHolder(
val binding: ViewBinding,
val viewModel: HomeViewModel,
accountViewModel: AccountViewModel,
fragment: Fragment,
) :
ViewHolderState<Bundle>(binding) {
@ -143,14 +156,13 @@ class HomeParentItemAdapterPreview(
}
}
val previewAdapter = HomeScrollAdapter(fragment = fragment) { view, position, item ->
val previewAdapter = HomeScrollAdapter { view, position, item ->
viewModel.click(
LoadClickCallback(0, view, position, item)
)
}
private val resumeAdapter = ResumeItemAdapter(
fragment,
nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId,
removeCallback = { v ->
@ -233,7 +245,6 @@ class HomeParentItemAdapterPreview(
}
})
private val bookmarkAdapter = HomeChildItemAdapter(
fragment,
id = "bookmarkAdapter".hashCode(),
nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId
@ -328,16 +339,63 @@ class HomeParentItemAdapterPreview(
fun onSelect(item: LoadResponse, position: Int) {
(binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewDescription.isGone =
item.plot.isNullOrBlank()
homePreviewDescription.text =
item.plot?.html() ?: ""
homePreviewDescription.isGone = item.plot.isNullOrBlank()
homePreviewDescription.text = item.plot?.html() ?: ""
val scoreText = item.score?.toStringNull(0.1, 10, 1, false)
scoreText?.let { score ->
homePreviewScore.text =
homePreviewScore.context.getString(R.string.extension_rating, score)
// while it should never fail, we do this just in case
val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0
val color = when {
rating < 5.0 -> "#eb2f2f".toColorInt() // Red
rating < 8.0 -> "#eda009".toColorInt() // Yellow
else -> "#3bb33b".toColorInt() // Green
}
homePreviewScore.backgroundTintList =
android.content.res.ColorStateList.valueOf(color)
}
homePreviewScore.isGone = scoreText == null
item.year?.let { year ->
homePreviewYear.text = year.toString()
}
homePreviewYear.isGone = item.year == null
val duration = item.duration
duration?.let { min ->
homePreviewDuration.text =
homePreviewDuration.context.getString(R.string.duration_format, min)
}
homePreviewDuration.isGone = duration == null || duration <= 0
val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name }
if (!castText.isNullOrBlank()) {
homePreviewCast.text =
homePreviewCast.context.getString(R.string.cast_format, castText)
homePreviewCast.isVisible = true
} else {
homePreviewCast.isVisible = false
}
homePreviewText.text = item.name.html()
populateChips(
homePreviewTags,
item.tags?.take(6) ?: emptyList(),
R.style.ChipFilledSemiTransparent
R.style.ChipFilledSemiTransparent,
null
)
bindLogo(
url = item.logoUrl,
headers = item.posterHeaders,
titleView = homePreviewText,
logoView = homeBackgroundPosterWatermarkBadgeHolder
)
homePreviewTags.isGone =
@ -432,7 +490,7 @@ class HomeParentItemAdapterPreview(
}
}
override fun onViewDetachedFromWindow() {
fun onViewDetachedFromWindow() {
previewViewpager.unregisterOnPageChangeCallback(previewCallback)
}
@ -453,12 +511,14 @@ class HomeParentItemAdapterPreview(
previewViewpager.adapter = previewAdapter
resumeRecyclerView.adapter = resumeAdapter
bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
bookmarkRecyclerView.adapter = bookmarkAdapter
resumeRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF
)
bookmarkRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF
@ -482,7 +542,7 @@ class HomeParentItemAdapterPreview(
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
viewModel.currentAccount.observe(fragment.viewLifecycleOwner) { currentAccount ->
(headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
headProfilePic?.loadImage(currentAccount?.image)
alternateHeadProfilePic?.loadImage(currentAccount?.image)
}
@ -598,9 +658,7 @@ class HomeParentItemAdapterPreview(
params.height = 0
layoutParams = params
}
} else {
fixPaddingStatusbarView(homeNonePadding)
}
} else fixPaddingStatusbarView(homeNonePadding)
when (preview) {
is Resource.Success -> {
@ -627,6 +685,12 @@ class HomeParentItemAdapterPreview(
(binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewInfoBtt.isVisible = true
}
// Explicitly bind the current item to ensure instant loading
val currentPos = previewViewpager.currentItem
val item = preview.value.second.getOrNull(currentPos)
if (item != null) {
onSelect(item, currentPos)
}
}
else -> {
@ -706,10 +770,10 @@ class HomeParentItemAdapterPreview(
}
}
override fun onViewAttachedToWindow() {
fun onViewAttachedToWindow() {
previewViewpager.registerOnPageChangeCallback(previewCallback)
binding.root.findViewTreeLifecycleOwner()?.apply {
previewViewpager.apply {
observe(viewModel.preview) {
updatePreview(it)
}
@ -734,7 +798,7 @@ class HomeParentItemAdapterPreview(
}
toggleListHolder?.isGone = visible.isEmpty()
}
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
}
}
}
}

View file

@ -1,25 +1,27 @@
package com.lagradost.cloudstream3.ui.home
import android.content.res.Configuration
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.fragment.app.Fragment
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
class HomeScrollAdapter(
fragment: Fragment,
val callback: ((View, Int, LoadResponse) -> Unit)
) : NoStateAdapter<LoadResponse>(fragment) {
) : NoStateAdapter<LoadResponse>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
a.uniqueUrl == b.uniqueUrl && a.name == b.name
})) {
var hasMoreItems: Boolean = false
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
@ -33,19 +35,26 @@ class HomeScrollAdapter(
return ViewHolderState(binding)
}
override fun onClearView(holder: ViewHolderState<Any>) {
when (val binding = holder.view) {
is HomeScrollViewBinding -> {
clearImage(binding.homeScrollPreview)
}
is HomeScrollViewTvBinding -> {
clearImage(binding.homeScrollPreview)
}
}
}
override fun onBindContent(
holder: ViewHolderState<Any>,
item: LoadResponse,
position: Int,
) {
val binding = holder.view
val itemView = holder.itemView
val isHorizontal =
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val posterUrl =
if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl
?: item.backgroundPosterUrl
val posterUrl = item.backgroundPosterUrl ?: item.posterUrl
when (binding) {
is HomeScrollViewBinding -> {
@ -55,7 +64,14 @@ class HomeScrollAdapter(
isGone = item.tags.isNullOrEmpty()
maxLines = 2
}
binding.homeScrollPreviewTitle.text = item.name
binding.homeScrollPreviewTitle.text = item.name.html()
bindLogo(
url = item.logoUrl,
headers = item.posterHeaders,
titleView = binding.homeScrollPreviewTitle,
logoView = binding.homePreviewLogo
)
}
is HomeScrollViewTvBinding -> {

View file

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

View file

@ -7,22 +7,16 @@ import android.content.res.Configuration
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
import android.view.animation.AlphaAnimation
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.widget.SearchView
import androidx.core.view.allViews
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
@ -30,35 +24,33 @@ import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs
@ -84,10 +76,10 @@ data class ProviderLibraryData(
val apiName: String
)
class LibraryFragment : Fragment() {
class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
) {
companion object {
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
fun newInstance() = LibraryFragment()
/**
@ -98,35 +90,10 @@ class LibraryFragment : Fragment() {
private val libraryViewModel: LibraryViewModel by activityViewModels()
var binding: FragmentLibraryBinding? = null
private var toggleRandomButton = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
val layout =
if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library
val root = inflater.inflate(layout, container, false)
binding = try {
FragmentLibraryBinding.bind(root)
} catch (t: Throwable) {
CommonActivity.showToast(
txt(R.string.unable_to_inflate, t.message ?: ""),
Toast.LENGTH_LONG
)
logError(t)
null
}
return root
//return inflater.inflate(R.layout.fragment_library, container, false)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
override fun pickLayout(): Int? =
if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv
override fun onSaveInstanceState(outState: Bundle) {
binding?.viewpager?.currentItem?.let { currentItem ->
@ -135,48 +102,52 @@ class LibraryFragment : Fragment() {
super.onSaveInstanceState(outState)
}
private fun updateRandom() {
private fun updateRandomVisibility(binding: FragmentLibraryBinding) {
if (!toggleRandomButton) {
binding.libraryRandom.isGone = true
binding.libraryRandomButtonTv.isGone = true
return
}
val position = libraryViewModel.currentPage.value ?: 0
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
if (toggleRandomButton) {
listLibraryItems.clear()
listLibraryItems.addAll(pages[position].items)
binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty()
} else {
binding?.libraryRandom?.isGone = true
val hasItems = pages[position].items.isNotEmpty()
val isPhone = isLayout(PHONE)
binding.libraryRandom.isVisible = isPhone && hasItems
binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems
}
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padBottom = isLandscape(),
padLeft = !isLayout(PHONE)
)
}
@SuppressLint("ResourceType", "CutPasteId")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fixPaddingStatusbar(binding?.searchStatusBarPadding)
override fun onBindingCreated(
binding: FragmentLibraryBinding,
savedInstanceState: Bundle?
) {
binding.sortFab.setOnClickListener(sortChangeClickListener)
binding.librarySort.setOnClickListener(sortChangeClickListener)
binding?.sortFab?.setOnClickListener(sortChangeClickListener)
binding?.librarySort?.setOnClickListener(sortChangeClickListener)
binding?.libraryRoot?.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)?.apply {
binding.libraryRoot.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)
?.apply {
tag = "tv_no_focus_tag"
// Expand the Appbar when search bar is focused, fixing scroll up issue
setOnFocusChangeListener { _, _ ->
binding?.searchBar?.setExpanded(true)
binding.searchBar.setExpanded(true)
}
}
// Set the color for the search exit icon to the correct theme text color
val searchExitIcon =
binding?.mainSearch?.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
val searchExitIconColor = TypedValue()
activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true)
searchExitIcon?.setColorFilter(searchExitIconColor.data)
val searchCallback = Runnable {
val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable
val newText = binding.mainSearch.query.toString()
libraryViewModel.sort(ListSorting.Query, newText)
}
binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
libraryViewModel.sort(ListSorting.Query, query)
return true
@ -192,11 +163,11 @@ class LibraryFragment : Fragment() {
return true
}
binding?.mainSearch?.removeCallbacks(searchCallback)
binding.mainSearch.removeCallbacks(searchCallback)
// Delay the execution of the search operation by 1 second (adjust as needed)
// this prevents running search when the user is typing
binding?.mainSearch?.postDelayed(searchCallback, 1000)
binding.mainSearch.postDelayed(searchCallback, 1000)
return true
}
@ -204,11 +175,12 @@ class LibraryFragment : Fragment() {
libraryViewModel.reloadPages(false)
binding?.listSelector?.setOnClickListener {
binding.listSelector.setOnClickListener {
val items = libraryViewModel.availableApiNames
val currentItem = libraryViewModel.currentApiName.value
activity?.showBottomDialog(items,
activity?.showBottomDialog(
items,
items.indexOf(currentItem),
txt(R.string.select_library).asString(it.context),
false,
@ -225,17 +197,9 @@ class LibraryFragment : Fragment() {
settingsManager.getBoolean(
getString(R.string.random_button_key),
false
) && isLayout(PHONE)
binding?.libraryRandom?.visibility = View.GONE
}
binding?.libraryRandom?.setOnClickListener {
if (listLibraryItems.isNotEmpty()) {
val listLibraryItem = listLibraryItems.random()
libraryViewModel.currentSyncApi?.syncIdName?.let {
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
}
}
)
binding.libraryRandom.visibility = View.GONE
binding.libraryRandomButtonTv.visibility = View.GONE
}
/**
@ -246,14 +210,13 @@ class LibraryFragment : Fragment() {
syncId: SyncIdName,
apiName: String? = null,
) {
val availableProviders = synchronized(allProviders) {
allProviders.filter {
val availableProviders = allProviders.filter {
it.supportedSyncNames.contains(syncId)
}.map { it.name } +
// Add the api if it exists
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
?: emptyList())
}
val baseOptions = listOf(
LibraryOpenerType.Default,
LibraryOpenerType.None,
@ -305,22 +268,21 @@ class LibraryFragment : Fragment() {
}
}
binding?.providerSelector?.setOnClickListener {
binding.providerSelector.setOnClickListener {
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
activity?.showPluginSelectionDialog(syncName.name, syncName)
}
binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
binding.viewpager.setPageTransformer(LibraryScrollTransformer())
binding?.viewpager?.adapter = ViewpagerAdapter(
fragment = this,
binding.viewpager.adapter = ViewpagerAdapter(
{ isScrollingDown: Boolean ->
if (isScrollingDown) {
binding?.sortFab?.shrink()
binding?.libraryRandom?.shrink()
binding.sortFab.shrink()
binding.libraryRandom.shrink()
} else {
binding?.sortFab?.extend()
binding?.libraryRandom?.extend()
binding.sortFab.extend()
binding.libraryRandom.extend()
}
}) callback@{ searchClickCallback ->
// To prevent future accidents
@ -353,15 +315,15 @@ class LibraryFragment : Fragment() {
}
}
binding?.apply {
binding.apply {
viewpager.offscreenPageLimit = 2
viewpager.reduceDragSensitivity()
searchBar.setExpanded(true)
}
val startLoading = Runnable {
binding?.apply {
gridview.numColumns = context?.getSpanCount() ?: 3
binding.apply {
gridview.numColumns = root.context.getSpanCount()
gridview.adapter =
context?.let { LoadingPosterAdapter(it, 6 * 3) }
libraryLoadingOverlay.isVisible = true
@ -371,7 +333,7 @@ class LibraryFragment : Fragment() {
}
val stopLoading = Runnable {
binding?.apply {
binding.apply {
gridview.adapter = null
libraryLoadingOverlay.isVisible = false
libraryLoadingShimmer.stopShimmer()
@ -387,7 +349,7 @@ class LibraryFragment : Fragment() {
val pages = resource.value
val showNotice = pages.all { it.items.isEmpty() }
binding?.apply {
binding.apply {
emptyListTextview.isVisible = showNotice
if (showNotice) {
if (libraryViewModel.availableApiNames.size > 1) {
@ -415,11 +377,23 @@ class LibraryFragment : Fragment() {
)*/
libraryViewModel.currentPage.value?.let { page ->
binding?.viewpager?.setCurrentItem(page, false)
binding?.searchBar?.setExpanded(true)
binding.viewpager.setCurrentItem(page, false)
binding.searchBar.setExpanded(true)
}
updateRandom()
// 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
// Without this there would be a flashing effect:
@ -460,21 +434,20 @@ class LibraryFragment : Fragment() {
tab.view.nextFocusDownId = R.id.search_result_root
tab.view.setOnClickListener {
val currentItem =
binding?.viewpager?.currentItem ?: return@setOnClickListener
val currentItem = binding.viewpager.currentItem
val distance = abs(position - currentItem)
hideViewpager(distance)
}
//Expand the appBar on tab focus
tab.view.setOnFocusChangeListener { _, _ ->
binding?.searchBar?.setExpanded(true)
binding.searchBar.setExpanded(true)
}
}.attach()
binding?.libraryTabLayout?.addOnTabSelectedListener(object :
binding.libraryTabLayout.addOnTabSelectedListener(object :
TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
binding?.libraryTabLayout?.selectedTabPosition?.let { page ->
binding.libraryTabLayout.selectedTabPosition.let { page ->
libraryViewModel.switchPage(page)
}
}
@ -499,11 +472,11 @@ class LibraryFragment : Fragment() {
}
observe(libraryViewModel.currentPage) { position ->
updateRandom()
val all = binding?.viewpager?.allViews?.toList()
?.filterIsInstance<AutofitRecyclerView>()
updateRandomVisibility(binding)
val all = binding.viewpager.allViews.toList()
.filterIsInstance<AutofitRecyclerView>()
all?.forEach { view ->
all.forEach { view ->
view.isVisible = view.tag == position
view.isFocusable = view.tag == position
@ -513,14 +486,6 @@ class LibraryFragment : Fragment() {
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
}
}
/*binding?.viewpager?.registerOnPageChangeCallback(object :
ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
}
})*/
}
private fun loadLibraryItem(
@ -579,10 +544,10 @@ class LibraryFragment : Fragment() {
}
@SuppressLint("NotifyDataSetChanged")
override fun onConfigurationChanged(newConfig: Configuration) {
binding?.viewpager?.adapter?.notifyDataSetChanged()
super.onConfigurationChanged(newConfig)
val adapter = binding?.viewpager?.adapter ?: return
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
private val sortChangeClickListener = View.OnClickListener { view ->
@ -590,7 +555,8 @@ class LibraryFragment : Fragment() {
txt(it.stringRes).asString(view.context)
}
activity?.showBottomDialog(methods,
activity?.showBottomDialog(
methods,
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
txt(R.string.sort_by).asString(view.context),
false,

View file

@ -4,8 +4,8 @@ import androidx.annotation.StringRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource

View file

@ -1,31 +1,34 @@
package com.lagradost.cloudstream3.ui.library
import android.graphics.Color
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.graphics.ColorUtils
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlin.math.roundToInt
class PageAdapter(
override val items: MutableList<SyncAPI.LibraryItem>,
private val resView: AutofitRecyclerView,
val clickCallback: (SearchClickCallback) -> Unit
) :
AppContextUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
NoStateAdapter<SyncAPI.LibraryItem>(diffCallback = BaseDiffCallback(itemSame = { a, b ->
if (a.id != null || b.id != null) {
a.id == b.id
} else {
a.name == b.name && a.url == b.url
}
})) {
private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return LibraryItemViewHolder(
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
SearchResultGridExpandedBinding.inflate(
LayoutInflater.from(parent.context),
parent,
@ -34,86 +37,45 @@ class PageAdapter(
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is LibraryItemViewHolder -> {
holder.bind(items[position], position)
override fun onClearView(holder: ViewHolderState<Any>) {
when (val binding = holder.view) {
is SearchResultGridExpandedBinding -> {
clearImage(binding.imageView)
}
}
}
private fun isDark(color: Int): Boolean {
return ColorUtils.calculateLuminance(color) < 0.5
}
override fun onBindContent(
holder: ViewHolderState<Any>,
item: SyncAPI.LibraryItem,
position: Int
) {
val binding = holder.view as? SearchResultGridExpandedBinding ?: return
fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int {
return if (isDark(color)) {
ColorUtils.blendARGB(color, Color.WHITE, ratio)
} else {
ColorUtils.blendARGB(color, Color.BLACK, ratio)
}
}
inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) :
RecyclerView.ViewHolder(binding.root) {
private val compactView = false//itemView.context.getGridIsCompact()
private val coverHeight: Int =
if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt()
fun bind(item: SyncAPI.LibraryItem, position: Int) {
/** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */
SearchResultBuilder.bind(
this@PageAdapter.clickCallback,
item,
position,
itemView,
/*colorCallback = { palette ->
AcraApplication.context?.let { ctx ->
val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg)
var bg = palette.getDarkVibrantColor(defColor)
if (bg == defColor) {
bg = palette.getDarkMutedColor(defColor)
}
if (bg == defColor) {
bg = palette.getVibrantColor(defColor)
}
val fg =
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
binding.textRating.apply {
setTextColor(ColorStateList.valueOf(fg))
}
binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg)
binding.textRating.backgroundTintList = ColorStateList.valueOf(bg)
binding.watchProgress.apply {
progressTintList = ColorStateList.valueOf(fg)
progressBackgroundTintList = ColorStateList.valueOf(bg)
}
}
}
*/
holder.itemView,
)
// See searchAdaptor for this, it basically fixes the height
if (!compactView) {
binding.imageView.apply {
layoutParams = FrameLayout.LayoutParams(
val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
coverHeight
)
}
if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) {
binding.imageView.layoutParams = params
}
val showProgress = item.episodesCompleted != null && item.episodesTotal != null
val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null
binding.watchProgress.isVisible = showProgress
if (showProgress) {
binding.watchProgress.max = item.episodesTotal!!
binding.watchProgress.progress = item.episodesCompleted!!
binding.watchProgress.max = item.episodesTotal
binding.watchProgress.progress = item.episodesCompleted
}
binding.imageText.text = item.name
}
}
}

View file

@ -40,10 +40,9 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding)
}
class ViewpagerAdapter(
fragment: Fragment,
val scrollCallback: (isScrollingDown: Boolean) -> Unit,
val clickCallback: (SearchClickCallback) -> Unit
) : BaseAdapter<SyncAPI.Page, Bundle>(fragment,
) : BaseAdapter<SyncAPI.Page, Bundle>(
id = "ViewpagerAdapter".hashCode(),
diffCallback = BaseDiffCallback(
itemSame = { a, b ->
@ -53,11 +52,13 @@ class ViewpagerAdapter(
a.items == b.items && a.title == b.title
}
)) {
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Bundle> {
return ViewpagerAdapterViewHolderState(
LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onUpdateContent(
holder: ViewHolderState<Bundle>,
item: SyncAPI.Page,
@ -65,7 +66,7 @@ class ViewpagerAdapter(
) {
val binding = holder.view
if (binding !is LibraryViewpagerPageBinding) return
(binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items)
(binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items)
binding.pageRecyclerview.scrollToPosition(0)
}
@ -75,21 +76,21 @@ class ViewpagerAdapter(
binding.pageRecyclerview.tag = position
binding.pageRecyclerview.apply {
spanCount =
binding.root.context.getSpanCount() ?: 3
spanCount = binding.root.context.getSpanCount()
if (adapter == null) { // || rebind
// Only add the items after it has been attached since the items rely on ItemWidth
// Which is only determined after the recyclerview is attached.
// If this fails then item height becomes 0 when there is only one item
doOnAttach {
adapter = PageAdapter(
item.items.toMutableList(),
this,
clickCallback
)
).apply {
submitList(item.items)
}
}
} else {
(adapter as? PageAdapter)?.updateList(item.items)
(adapter as? PageAdapter)?.submitList(item.items)
// scrollToPosition(0)
}
@ -100,7 +101,7 @@ class ViewpagerAdapter(
//Expand the top Appbar based on scroll direction up/down, simulate phone behavior
if (isLayout(TV or EMULATOR)) {
binding.root.rootView.findViewById<AppBarLayout>(R.id.search_bar)
.apply {
?.apply {
if (diff <= 0)
setExpanded(true)
else

View file

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

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.pgs.PgsParser
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.tx3g.Tx3gParser
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
@ -35,8 +34,8 @@ import java.nio.charset.Charset
/**
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
* enough to identify the subtitle format.
**/
@UnstableApi
*/
@OptIn(UnstableApi::class)
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
companion object {
fun updateForcedEncoding(context: Context) {
@ -53,15 +52,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
}
private const val DEFAULT_MARGIN: Float = 0.05f
private const val SSA_ALIGNMENT_BOTTOM_LEFT = 1
private const val SSA_ALIGNMENT_BOTTOM_CENTER = 2
private const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3
private const val SSA_ALIGNMENT_MIDDLE_LEFT = 4
private const val SSA_ALIGNMENT_MIDDLE_CENTER = 5
private const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6
private const val SSA_ALIGNMENT_TOP_LEFT = 7
private const val SSA_ALIGNMENT_TOP_CENTER = 8
private const val SSA_ALIGNMENT_TOP_RIGHT = 9
const val SSA_ALIGNMENT_BOTTOM_LEFT = 1
const val SSA_ALIGNMENT_BOTTOM_CENTER = 2
const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3
const val SSA_ALIGNMENT_MIDDLE_LEFT = 4
const val SSA_ALIGNMENT_MIDDLE_CENTER = 5
const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6
const val SSA_ALIGNMENT_TOP_LEFT = 7
const val SSA_ALIGNMENT_TOP_CENTER = 8
const val SSA_ALIGNMENT_TOP_RIGHT = 9
/** Subtitle offset in milliseconds */
var subtitleOffset: Long = 0
@ -148,6 +147,17 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
// exoplayer can already parse this, however for eg webvtt it fails
locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment ->
// toLineAnchor
this.setSubtitleAlignment(alignment)
}
// remove all matches, so we do not display \anx
trimmed = trimmed.replace(locationRegex, "")
setText(trimmed)
return this
}
fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder {
if (alignment == null) return this
when (alignment) {
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END
SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE
@ -179,11 +189,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
}?.let { anchor ->
setTextAlignment(anchor)
}
}
// remove all matches, so we do not display \anx
trimmed = trimmed.replace(locationRegex, "")
setText(trimmed)
return this
}
}
@ -245,14 +250,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
ignoreCase = true
)) -> SsaParser(fallbackFormat?.initializationData)
trimmedText.startsWith("1", ignoreCase = true) -> SubripParser()
trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser()
fallbackFormat != null -> {
when (val mimeType = fallbackFormat.sampleMimeType) {
when (fallbackFormat.sampleMimeType) {
MimeTypes.TEXT_VTT -> WebvttParser()
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
MimeTypes.APPLICATION_TTML -> TtmlParser()
MimeTypes.APPLICATION_SUBRIP -> SubripParser()
MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser()
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
// These decoders are not converted to parsers yet
// TODO
@ -386,7 +391,7 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
/**
* Decoders created here persists across reset()
* Do not save state in the decoder which you want to reset (e.g subtitle offset)
**/
*/
override fun createDecoder(format: Format): SubtitleDecoder {
val parser = CustomDecoder(format)
// Allow garbage collection if player releases the decoder
@ -398,8 +403,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
}
}
@OptIn(UnstableApi::class)
/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */
@OptIn(UnstableApi::class)
class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) :
SimpleSubtitleDecoder(name) {

View file

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

View file

@ -11,9 +11,12 @@ import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
class DownloadedPlayerActivity : AppCompatActivity() {
private val dTAG = "DownloadedPlayerAct"
companion object {
const val TAG = "DownloadedPlayerActivity"
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
@ -26,48 +29,79 @@ class DownloadedPlayerActivity : AppCompatActivity() {
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?) {
super.onCreate(savedInstanceState)
CommonActivity.loadThemes(this)
CommonActivity.init(this)
enableEdgeToEdgeCompat()
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
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
return
}
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) {
val extraText = safe { // I dont trust android
intent.getStringExtra(Intent.EXTRA_TEXT)
}
if (
intent.action == Intent.ACTION_SEND ||
intent.action == Intent.ACTION_OPEN_DOCUMENT ||
intent.action == Intent.ACTION_VIEW
) {
val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
val cd = intent.clipData
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
val url = item?.text?.toString()
// idk what I am doing, just hope any of these work
if (item?.uri != null)
playUri(this, item.uri)
else if (url != null)
playLink(this, url)
else if (data != null)
playUri(this, data)
else if (extraText != null)
playLink(this, extraText)
else {
finish()
return
when {
item?.uri != null -> playUri(this, item.uri)
url != null -> playLink(this, url)
data != null -> playUri(this, data)
extraText != null -> playLink(this, extraText)
else -> finishAndRemoveTask()
}
} else if (data?.scheme == "content") {
playUri(this, data)
} else {
finish()
return
}
attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
} else finishAndRemoveTask()
}
override fun onResume() {

View file

@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
class ExtractorLinkGenerator(
private val links: List<ExtractorLink>,
private val subtitles: List<SubtitleData>,
) : NoVideoGenerator() {
) : NoVideoGenerator(null) {
override suspend fun generateLinks(
clearCache: Boolean,
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
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import kotlin.math.max
import kotlin.math.min
val LOADTYPE_INAPP = setOf(
ExtractorLinkType.VIDEO,
@ -28,71 +25,27 @@ val LOADTYPE_CHROMECAST = setOf(
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 canSkipLoading = false
override fun getId(index: Int): Int? = id
}
abstract class VideoGenerator<T : Any>(val videos: List<T>, var videoIndex: Int = 0) :
IGenerator {
abstract class VideoGenerator<T : Any>(val videos: List<T>) {
abstract val hasCache: Boolean
abstract val canSkipLoading: Boolean
abstract fun getId(index : Int) : Int?
override fun hasNext(): Boolean = videoIndex < videos.lastIndex
override fun hasPrev(): 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
}
}
fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex
fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0
override fun prev() {
if (hasPrev()) {
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(
@Throws
abstract suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset: Int = 0,
isCasting: Boolean = false
offset: Int,
isCasting: Boolean
): Boolean
}

View file

@ -3,30 +3,11 @@ package com.lagradost.cloudstream3.ui.player
import android.content.Context
import android.graphics.Bitmap
import android.util.Rational
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink
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),
}
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
enum class CSPlayerEvent(val value: Int) {
Pause(0),
@ -47,6 +28,7 @@ enum class CSPlayerLoading {
IsPaused,
IsPlaying,
IsBuffering,
IsEnded,
}
enum class PlayerEventSource {
@ -85,13 +67,13 @@ data class ErrorEvent(
/** Event when timestamps appear, null when it should disappear */
data class TimestampInvokedEvent(
val timestamp: EpisodeSkip.SkipStamp,
val timestamp: VideoSkipStamp,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */
data class TimestampSkippedEvent(
val timestamp: EpisodeSkip.SkipStamp,
val timestamp: VideoSkipStamp,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
@ -181,6 +163,7 @@ interface Track {
val id: String?
val label: String?
val language: String?
val sampleMimeType : String?
}
data class VideoTrack(
@ -189,19 +172,23 @@ data class VideoTrack(
override val language: String?,
val width: Int?,
val height: Int?,
override val sampleMimeType: String?,
) : Track
data class AudioTrack(
override val id: String?,
override val label: String?,
override val language: String?,
override val sampleMimeType: String?,
val channelCount: Int?,
val formatIndex: Int?,
) : Track
data class TextTrack(
override val id: String?,
override val label: String?,
override val language: String?,
val mimeType: String?,
override val sampleMimeType: String?,
) : Track
@ -214,8 +201,6 @@ data class CurrentTracks(
val allTextTracks: List<TextTrack>,
)
class InvalidFileException(msg: String) : Exception(msg)
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
const val ACTION_MEDIA_CONTROL = "media_control"
const val EXTRA_CONTROL_TYPE = "control_type"
@ -237,8 +222,9 @@ interface IPlayer {
fun getSubtitleOffset(): Long // in ms
fun setSubtitleOffset(offset: Long) // in ms
@AnyThread
fun initCallbacks(
eventHandler: ((PlayerEvent) -> Unit),
@MainThread eventHandler: ((PlayerEvent) -> Unit),
/** this is used to request when the player should report back view percentage */
requestedListeningPercentages: List<Int>? = null,
)
@ -248,7 +234,7 @@ interface IPlayer {
fun updateSubtitleStyle(style: SaveCaptionStyle)
fun saveData()
fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>)
fun addTimeStamps(timeStamps: List<VideoSkipStamp>)
fun loadPlayer(
context: Context,
@ -301,7 +287,7 @@ interface IPlayer {
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. */
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 */
fun getSubtitleCues(): List<SubtitleCue>

View file

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

View file

@ -1,10 +1,10 @@
package com.lagradost.cloudstream3.ui.player
import android.app.Activity
import android.content.ContentUris
import android.content.Intent
import android.net.Uri
import androidx.core.content.ContextCompat.getString
import androidx.navigation.NavOptions
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
@ -13,15 +13,25 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.safefile.SafeFile
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) {
activity.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
LinkGenerator(
listOf(
BasicLink(url)
)
)
)
), id = url.hashCode()
), 0
),
replacePlayerNavOptions
)
}
@ -52,8 +62,9 @@ object OfflinePlaybackHelper {
links,
subs,
if (id != -1) id else null,
)
)
), 0
),
replacePlayerNavOptions
)
return true
}
@ -73,12 +84,12 @@ object OfflinePlaybackHelper {
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
// play downloaded files and save the location
id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()
?.hashCode()
)
)
id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode()
)
)
), 0
),
replacePlayerNavOptions
)
}
}

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