Compare commits

...

395 commits

Author SHA1 Message Date
Gian-Fr
4d3ab40093
Updated SuperVideo extractor url from supervideo.tv to supervideo.cc (#1265) 2024-08-12 04:04:14 +02:00
int3debug
c4ccc5d351
feat(ui): settings for thumbnail on seekbar (#1256) 2024-08-09 00:34:26 +02:00
Phisher98
fcac19737c
Update VidSrcTo.kt Domain Changed (#1257)
Vidsrc is changed from to to cc
2024-08-07 15:47:15 +02:00
Luna712
77dc9f7484
Add support for progress on header downloads (#1238) 2024-08-05 20:57:51 +02:00
Luna712
f6a65f38db
Add support for Next Episode in downloads (#1228) 2024-08-05 20:49:04 +02:00
CranberrySoup
4d9a080341
Create jitpack.yml (#1248) 2024-08-04 15:59:57 +02:00
CranberrySoup
7936ccf5d3
Update FcastManager.kt (#1244) 2024-08-02 12:30:37 +02:00
recloudstream[bot]
15b5013e28 chore(locales): fix locale issues 2024-08-02 09:40:37 +00:00
Hosted Weblate
6f522828a4 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Amharic)

Currently translated at 28.9% (213 of 737 strings)

Translated using Weblate (Filipino)

Currently translated at 14.6% (108 of 737 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 60.2% (444 of 737 strings)

Translated using Weblate (Malay)

Currently translated at 21.9% (162 of 737 strings)

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

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.2% (702 of 737 strings)

Translated using Weblate (Tamil)

Currently translated at 94.0% (693 of 737 strings)

Co-authored-by: Beabfekad Zikie <beabfekadz@gmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/am/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ars/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ms/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/qt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/
Translation: Cloudstream/App
2024-08-02 11:40:24 +02:00
Cloudburst
ad727b96cf
[skip ci] match weblate xml style 2024-08-02 11:40:03 +02:00
recloudstream[bot]
67e278b2b7 chore(locales): fix locale issues 2024-08-02 09:20:56 +00:00
Hosted Weblate
7f1cba99e4 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (German)

Currently translated at 98.6% (727 of 737 strings)

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

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (737 of 737 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (737 of 737 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (737 of 737 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Russian)

Currently translated at 96.9% (705 of 727 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (727 of 727 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 (Italian)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Arabic (Levantine))

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 (German)

Currently translated at 99.7% (723 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (725 of 725 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 99.7% (723 of 725 strings)

Translated using Weblate (German)

Currently translated at 96.8% (702 of 725 strings)

Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Assamese)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Assamese)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Maltese)

Currently translated at 31.5% (229 of 725 strings)

Translated using Weblate (Maltese)

Currently translated at 31.5% (229 of 725 strings)

Translated using Weblate (Nepali)

Currently translated at 32.0% (232 of 725 strings)

Translated using Weblate (Nepali)

Currently translated at 32.0% (232 of 725 strings)

Translated using Weblate (Nepali)

Currently translated at 32.0% (232 of 725 strings)

Translated using Weblate (Nepali)

Currently translated at 32.0% (232 of 725 strings)

Translated using Weblate (Afrikaans)

Currently translated at 29.1% (211 of 725 strings)

Translated using Weblate (Afrikaans)

Currently translated at 29.1% (211 of 725 strings)

Translated using Weblate (Afrikaans)

Currently translated at 29.1% (211 of 725 strings)

Translated using Weblate (Lithuanian)

Currently translated at 45.1% (327 of 725 strings)

Translated using Weblate (Lithuanian)

Currently translated at 45.1% (327 of 725 strings)

Translated using Weblate (Lithuanian)

Currently translated at 45.1% (327 of 725 strings)

Translated using Weblate (Lithuanian)

Currently translated at 45.1% (327 of 725 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Amharic)

Currently translated at 29.2% (212 of 725 strings)

Translated using Weblate (Amharic)

Currently translated at 29.2% (212 of 725 strings)

Translated using Weblate (Amharic)

Currently translated at 29.2% (212 of 725 strings)

Translated using Weblate (Amharic)

Currently translated at 29.2% (212 of 725 strings)

Translated using Weblate (Amharic)

Currently translated at 29.2% (212 of 725 strings)

Translated using Weblate (Amharic)

Currently translated at 29.2% (212 of 725 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.1% (110 of 725 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.1% (110 of 725 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.1% (110 of 725 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.1% (110 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 14.7% (107 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 14.7% (107 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 14.7% (107 of 725 strings)

Translated using Weblate (Filipino)

Currently translated at 14.7% (107 of 725 strings)

Translated using Weblate (Burmese)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Burmese)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Burmese)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Burmese)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Galician)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Galician)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Galician)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Galician)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Galician)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Odia)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Odia)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Odia)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Odia)

Currently translated at 36.4% (264 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 97.1% (704 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 97.1% (704 of 725 strings)

Translated using Weblate (Korean)

Currently translated at 97.1% (704 of 725 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 61.1% (443 of 725 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 61.1% (443 of 725 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 61.1% (443 of 725 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 61.1% (443 of 725 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 61.1% (443 of 725 strings)

Translated using Weblate (Latvian)

Currently translated at 83.8% (608 of 725 strings)

Translated using Weblate (Latvian)

Currently translated at 83.8% (608 of 725 strings)

Translated using Weblate (Latvian)

Currently translated at 83.8% (608 of 725 strings)

Translated using Weblate (Latvian)

Currently translated at 83.8% (608 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Malay)

Currently translated at 22.6% (164 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 46.3% (336 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 46.3% (336 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 46.3% (336 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 46.3% (336 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 46.3% (336 of 725 strings)

Translated using Weblate (Japanese)

Currently translated at 46.3% (336 of 725 strings)

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

Currently translated at 47.0% (341 of 725 strings)

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

Currently translated at 47.0% (341 of 725 strings)

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

Currently translated at 47.0% (341 of 725 strings)

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

Currently translated at 47.0% (341 of 725 strings)

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

Currently translated at 47.0% (341 of 725 strings)

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

Currently translated at 47.0% (341 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 64.2% (466 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 64.2% (466 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 64.2% (466 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 64.2% (466 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 64.2% (466 of 725 strings)

Translated using Weblate (Slovak)

Currently translated at 64.2% (466 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Somali)

Currently translated at 77.5% (562 of 725 strings)

Translated using Weblate (Somali)

Currently translated at 77.5% (562 of 725 strings)

Translated using Weblate (Somali)

Currently translated at 77.5% (562 of 725 strings)

Translated using Weblate (Somali)

Currently translated at 77.5% (562 of 725 strings)

Translated using Weblate (Somali)

Currently translated at 77.5% (562 of 725 strings)

Translated using Weblate (Somali)

Currently translated at 77.5% (562 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 40.5% (294 of 725 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Esperanto)

Currently translated at 31.4% (228 of 725 strings)

Translated using Weblate (Persian)

Currently translated at 34.0% (247 of 725 strings)

Translated using Weblate (Persian)

Currently translated at 34.0% (247 of 725 strings)

Translated using Weblate (Persian)

Currently translated at 34.0% (247 of 725 strings)

Translated using Weblate (Persian)

Currently translated at 34.0% (247 of 725 strings)

Translated using Weblate (Persian)

Currently translated at 34.0% (247 of 725 strings)

Translated using Weblate (Persian)

Currently translated at 34.0% (247 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 88.6% (643 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 88.6% (643 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 88.6% (643 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 88.6% (643 of 725 strings)

Translated using Weblate (Hungarian)

Currently translated at 88.6% (643 of 725 strings)

Translated using Weblate (German)

Currently translated at 96.6% (701 of 725 strings)

Translated using Weblate (German)

Currently translated at 96.6% (701 of 725 strings)

Translated using Weblate (German)

Currently translated at 96.6% (701 of 725 strings)

Translated using Weblate (German)

Currently translated at 96.6% (701 of 725 strings)

Translated using Weblate (German)

Currently translated at 96.6% (701 of 725 strings)

Translated using Weblate (Spanish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Spanish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Spanish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (705 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (705 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (705 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (705 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (705 of 725 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (705 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Kannada)

Currently translated at 32.4% (235 of 725 strings)

Translated using Weblate (Urdu)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Urdu)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Urdu)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Urdu)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Urdu)

Currently translated at 96.8% (702 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Tamil)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Hebrew)

Currently translated at 86.2% (625 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Bengali)

Currently translated at 63.5% (461 of 725 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.6% (715 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.6% (715 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.6% (715 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.6% (715 of 725 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.6% (715 of 725 strings)

Translated using Weblate (Turkish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Turkish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Turkish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Turkish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Turkish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Tagalog)

Currently translated at 48.4% (351 of 725 strings)

Translated using Weblate (Tagalog)

Currently translated at 48.4% (351 of 725 strings)

Translated using Weblate (Tagalog)

Currently translated at 48.4% (351 of 725 strings)

Translated using Weblate (Tagalog)

Currently translated at 48.4% (351 of 725 strings)

Translated using Weblate (Tagalog)

Currently translated at 48.4% (351 of 725 strings)

Translated using Weblate (Tagalog)

Currently translated at 48.4% (351 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Swedish)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Romanian)

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Romanian)

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Romanian)

Currently translated at 97.5% (707 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Polish)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 82.3% (597 of 725 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 82.3% (597 of 725 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 82.3% (597 of 725 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 82.3% (597 of 725 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 82.3% (597 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 90.3% (655 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 90.3% (655 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 90.3% (655 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 90.3% (655 of 725 strings)

Translated using Weblate (Dutch)

Currently translated at 90.3% (655 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Malayalam)

Currently translated at 47.1% (342 of 725 strings)

Translated using Weblate (Macedonian)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Macedonian)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Macedonian)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Macedonian)

Currently translated at 97.3% (706 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.7% (709 of 725 strings)

Translated using Weblate (Croatian)

Currently translated at 98.0% (711 of 725 strings)

Translated using Weblate (Croatian)

Currently translated at 98.0% (711 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (304 of 725 strings)

Translated using Weblate (French)

Currently translated at 96.9% (703 of 725 strings)

Translated using Weblate (French)

Currently translated at 96.9% (703 of 725 strings)

Translated using Weblate (French)

Currently translated at 96.9% (703 of 725 strings)

Translated using Weblate (French)

Currently translated at 96.9% (703 of 725 strings)

Translated using Weblate (French)

Currently translated at 96.9% (703 of 725 strings)

Translated using Weblate (French)

Currently translated at 96.9% (703 of 725 strings)

Translated using Weblate (Greek)

Currently translated at 97.6% (708 of 725 strings)

Translated using Weblate (Greek)

Currently translated at 97.6% (708 of 725 strings)

Translated using Weblate (Greek)

Currently translated at 97.6% (708 of 725 strings)

Translated using Weblate (Greek)

Currently translated at 97.6% (708 of 725 strings)

Translated using Weblate (Czech)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Czech)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 93.7% (680 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 93.7% (680 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 93.7% (680 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 93.7% (680 of 725 strings)

Translated using Weblate (Bulgarian)

Currently translated at 93.7% (680 of 725 strings)

Translated using Weblate (Arabic)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Arabic)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Arabic)

Currently translated at 99.1% (719 of 725 strings)

Translated using Weblate (Arabic)

Currently translated at 99.1% (719 of 725 strings)

Co-authored-by: --//-- <htetoh2006@outlook.com>
Co-authored-by: Aayush Shah <shahaayush999@gmail.com>
Co-authored-by: Abinanthankv <abinanthankv@protonmail.com>
Co-authored-by: Ahmed seif al-nasr <ahmdsyfalnsr2@gmail.com>
Co-authored-by: Alessandro Burzio <alebu3007@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: Anonymous <noreply@weblate.org>
Co-authored-by: Astrid <github@astrid.exposed>
Co-authored-by: Aydın <mtahaydn@gmail.com>
Co-authored-by: Beabfekad Zikie <beabfekadz@gmail.com>
Co-authored-by: Cait Martin Newnham <85128509+helloiamcait@users.noreply.github.com>
Co-authored-by: Carlos Luiz <ecarlos-luiz@hotmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Colgrave <hanqixu.blogs@simplelogin.co>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: DarkOrbFX <darkorbfx@gmail.com>
Co-authored-by: Deleted User <Skrripy@users.noreply.hosted.weblate.org>
Co-authored-by: Deleted User <noreply+53776@weblate.org>
Co-authored-by: Don Apis <apisapisapis@gmail.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: FastAct <alex.rijckaert@gmail.com>
Co-authored-by: Fedorov Alexei <aleksejfedorov963@gmail.com>
Co-authored-by: Felipe Nogueira <contato.fnog@gmail.com>
Co-authored-by: Filip Drogrishki <alekfilip425@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fqwe1 <Fqwe1@users.noreply.hosted.weblate.org>
Co-authored-by: Giuseppe Terrana <terranagiuseppe03@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Itsmechinmoy <gituborah280@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Joel Brink <joel.brink.handy@gmail.com>
Co-authored-by: Jose Delvani <jsdelvani@users.noreply.hosted.weblate.org>
Co-authored-by: Julian <hello@apollo.moe>
Co-authored-by: Kaan Çetin <cetinkaan895@hotmail.com>
Co-authored-by: Kai <rafahdamin@gmail.com>
Co-authored-by: Kardi Demha <kardi.demha@gmail.com>
Co-authored-by: LagradOst <46196380+Blatzar@users.noreply.github.com>
Co-authored-by: LiJu09 <lisojuraj@gmail.com>
Co-authored-by: Ma Ue <MattiaU59@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: Muhammad Fahad Khan <itxmfahadkhan@gmail.com>
Co-authored-by: Márkó <gost1336@gmail.com>
Co-authored-by: Osten <11805592+LagradOst@users.noreply.github.com>
Co-authored-by: Overmet15 <overmet15@gmail.com>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Prathap Rathod <prathap0144@gmail.com>
Co-authored-by: Radoslav Vasilev Vasilev <fifata@gmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: Rudy Tantono <rudzlong@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: Sarlay <raphmd0@gmail.com>
Co-authored-by: SeMih Budur <zaaf10@hotmail.de>
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: Subham Jena <subhamjena8465@gmail.com>
Co-authored-by: Sufyan Zahoor Jutt <sufyanpahore@gmail.com>
Co-authored-by: TZVS <akyasan@tuta.io>
Co-authored-by: The Initiator <eithansten@gmail.com>
Co-authored-by: TubaApollo <86665265+TubaApollo@users.noreply.github.com>
Co-authored-by: Veselin Ivanov <slavitransbg@gmail.com>
Co-authored-by: Vrwi <jurgisbums@gmail.com>
Co-authored-by: Walter H <walter75@gmail.com>
Co-authored-by: XblateX <blate@users.noreply.hosted.weblate.org>
Co-authored-by: ZsoltiHUB <zsoltizsolti043@gmail.com>
Co-authored-by: akku vijay <akkuvijay@duck.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: duckling <salmanfc.bd@gmail.com>
Co-authored-by: edgolron <edgolron@tutanota.com>
Co-authored-by: eightyy8 <oliver.kha@pm.me>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: george kitsoukakis <norhorn@gmail.com>
Co-authored-by: jinu147 <nesqea20@gmail.com>
Co-authored-by: kaajjo <claymanoff@gmail.com>
Co-authored-by: l <thisuserooo@gmail.com>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: phlostically <phlostically@mailinator.com>
Co-authored-by: sonacore <sonacore@gmail.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: tuan041 <30403510+tuan041@users.noreply.github.com>
Co-authored-by: tuan041 <tuananh163025ttt@gmail.com>
Co-authored-by: Ömer Faruk Sancak <keyiflerolsun@gmail.com>
Co-authored-by: Влад Николаев <vladnic1990@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
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/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/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/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
2024-08-02 11:20:40 +02:00
recloudstream[bot]
ff29fe6ee6 chore(locales): fix locale issues 2024-08-01 22:14:37 +00:00
Luna712
aac2311722
Fix TV focus issues for add repo input (#1239) 2024-08-02 00:14:23 +02:00
Ömer Faruk Sancak
60e3c48aca
Helper Added: CryptoJS (#1237) 2024-08-02 00:13:54 +02:00
CranberrySoup
14dd418652
Update build.gradle.kts (#1240) 2024-08-01 03:28:47 +02:00
Cloudburst
5012821216
[skip ci] add trailing nline to strings to be consistent with weblate 2024-07-31 10:44:25 +02:00
Luna712
ab379ab31c
Support for multi deleting downloads and other major improvements/fixes (#1177) 2024-07-30 20:54:54 +02:00
Luna712
8fcb3e3121
Fix cast recycler scrolling (#1221) 2024-07-30 20:45:25 +02:00
Ömer Faruk Sancak
30adb1cd9d
fixed: Test Search & VidMoxy, RapidVid extractors (#1219) 2024-07-30 20:38:51 +02:00
KingLucius
63e27c2ea5
Fix Trailers on API<33 (#1226)
Recent NewPipeExtractor updates pushed minimum sdk to 33 which needs desugar_jdk_libs_nio
2024-07-30 20:16:11 +02:00
epireyn
b2f08847e1
Add system dark theme (#1208) 2024-07-29 01:01:45 +02:00
epireyn
150ad5fc9f
Add sorting by release date (#1206) 2024-07-29 01:00:44 +02:00
firelight
82f8ab489e
Fix prerelease test function 2024-07-29 00:58:35 +02:00
epireyn
04dda008c4
Clean up and mark questionable code issues (#1209) 2024-07-29 00:39:04 +02:00
Luna712
0aa48f335a
Fix subscription icon displaying for movie types in result previews (#1222) 2024-07-29 00:26:22 +02:00
Luna712
a28ee41368
Fix for navigation UI bug (#1220) 2024-07-28 23:59:37 +02:00
Osten
2fc279f4ae
Bump 4.4.0 2024-07-25 20:26:21 +02:00
epireyn
15d2d21631
Add the option to hide video controls (#1210) 2024-07-25 20:25:17 +02:00
KingLucius
e3ff1cf455
feat(UI): Show Episode Runtime (#1207) 2024-07-25 20:23:49 +02:00
KingLucius
dfd127265a
Trailers Fix (#1213) 2024-07-25 20:23:31 +02:00
firelight
c8a863e332
Fixed ExampleInstrumentedTest 2024-07-24 22:38:16 +02:00
RowdyRushya
0c418fdf9b
Updated VidSrc encryption methods (#1205) 2024-07-21 00:06:04 +02:00
firelight
4c7379c766
Revert #979 Episode download cache 2024-07-20 19:14:11 +02:00
KingLucius
bb8144a52e
feat(TV UI): Player's Top controls redesign (#1203) 2024-07-19 19:35:29 +02:00
firelight
073af50f5f
fixed html plot in preview 2024-07-19 18:28:36 +02:00
firelight
63465ed7a9
fix autohide 2024-07-19 18:24:06 +02:00
RowdyRushya
12de924559
updating vidplay encryption method (#1202) 2024-07-19 18:10:34 +02:00
firelight
627dd45309
0bytes downloads fix 2024-07-18 02:02:35 +02:00
KingLucius
a157115cfa
feat(Subtitles): SubSource subtitles provider (#1199) 2024-07-15 17:15:59 +02:00
IndusAryan
694193fa3e
refactor(fix): result sync, fix slider theme and trailer fix (#1187) 2024-07-15 17:10:41 +02:00
RowdyRushya
febb843424
Fix VidSrcTo extractor (#1198) 2024-07-15 17:06:20 +02:00
firelight
8be8e54746
Fixed log 2024-07-08 23:17:25 +02:00
Ömer Faruk Sancak
e86c926c30
Extractor: added Pichive & Sobreatsesuyp (#1184) 2024-07-08 22:59:02 +02:00
KingLucius
145c42f1c8
feat(UI): Use same Episode holder size (#1180) 2024-07-05 18:10:58 +02:00
KingLucius
9b1ac5fc28
feat(Trakt): Skip specials season for next airing (#1181) 2024-07-05 18:05:32 +02:00
KingLucius
699a6979a5
feat(TV UI): Fix clone site focus (#1179) 2024-07-05 18:04:32 +02:00
firelight
e1d4a46309
bugfix on lib startup 2024-07-05 15:26:44 +02:00
Luna712
c1b5f5c128
Fix download button display bug in adapter (#1175) 2024-07-04 23:51:07 +02:00
firelight
e5c9e96c83
fix filesystem 2024-07-04 22:33:21 +02:00
CranberrySoup
02b956940a
Port large parts of the API to crossplatform (#1163) 2024-07-04 20:07:01 +02:00
Luna712
03b8b6e637
Major performance and bug fixes to downloads (#1164) 2024-07-04 19:37:08 +02:00
Luna712
29ec554334
Fix an IllegalStateException crash (#1171) 2024-07-04 02:39:26 +02:00
CranberrySoup
5f64e40a7e
Fix debug exceptions in releases (#1168) 2024-07-03 03:42:10 +02:00
Cloudburst
d17111c1c1
[skip ci] update issue templates 2024-07-02 22:40:36 +02:00
IndusAryan
a5582a7a67
feat(ui): ability to play any local video from files using file chooser (#1158) 2024-07-01 23:34:36 +02:00
CranberrySoup
1a05651510
Fix nsfw visibility (#1162) 2024-06-30 17:08:06 +02:00
IndusAryan
ad27eb3b0e
feat(ui): show currently syncing api logo on navigation bar and rail (#1146) 2024-06-30 16:17:30 +02:00
Ömer Faruk Sancak
6b93af5803
Extractor: VideoSeyred » Referer Fix (#1159) 2024-06-28 17:24:57 +02:00
Phisher98
55a0eb66cb
MyCloud New Domain Adding Extractor for it (#1157) 2024-06-26 21:06:46 +02:00
recloudstream[bot]
b776642775 chore(locales): fix locale issues 2024-06-24 19:10:15 +00:00
Cloudburst
09fe9873cf
fix locales.py 2024-06-24 21:09:41 +02:00
Weblate (bot)
0d40b5ebe3
Translations update from Hosted Weblate (#1042)
Co-authored-by: Aaditya Bhandari <bhandariaaditya4@gmail.com>
Co-authored-by: Adrian Hermida <adrian.hermida.baloira@gmail.com>
Co-authored-by: Akhlak Ur Rahman <akhlak.pro.red@gmail.com>
Co-authored-by: Alexander Svärd <genc.demiri@hotmail.com>
Co-authored-by: Anarchydr <patrikpik879@gmail.com>
Co-authored-by: Andre Costa <andrecaeu@gmail.com>
Co-authored-by: Antonio N <antonioenpidev@gmail.com>
Co-authored-by: Azgar <azgaresncf@gmail.com>
Co-authored-by: Colgrave <hanqixu.blogs@simplelogin.co>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Eji-san <ejierubani@gmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Evgeniy Khramov <65224669+thejenja@users.noreply.github.com>
Co-authored-by: FUTURE <alwaysoutsmartyou@gmail.com>
Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Huzaifah Asif <huzaifahasif3@gmail.com>
Co-authored-by: Itsmechinmoy <gituborah280@gmail.com>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Konstantinos Tranoudis <kontranpro@gmail.com>
Co-authored-by: Krisna A. Prayoga <krisnaadiprayoga@gmail.com>
Co-authored-by: Luna712 <142361265+Luna712@users.noreply.github.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: Milo Ivir <mail@milotype.de>
Co-authored-by: Mæve Rey <mrey@users.noreply.hosted.weblate.org>
Co-authored-by: Naga <yz2000.pro@gmail.com>
Co-authored-by: Nicoara Alex <alex.nicoara@yahoo.com>
Co-authored-by: Nuno Ferreira <nuno.f.gamer@gmail.com>
Co-authored-by: Only1337 <ymurathanusta@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Putra Iskandar <piskndar@gmail.com>
Co-authored-by: Qareen Skoll <qareen101@protonmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: SeMih Budur <zaaf10@hotmail.de>
Co-authored-by: Semih <semihbrn10@gmail.com>
Co-authored-by: Sufyan Zahoor Jutt <sufyanpahore@gmail.com>
Co-authored-by: Waheed Nazir <mwaheednazir8@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: amir <amirasyraf32@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: hugoalh <hugoalh@users.noreply.hosted.weblate.org>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: programutox <programutox@disroot.org>
Co-authored-by: rwi <isaac.royallll@gmail.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: streaming s <fsrmllll1111@gmail.com>
Co-authored-by: tuan041 <tuananh163025ttt@gmail.com>
Co-authored-by: user0020 <855309c256@gmail.com>
Co-authored-by: ΣΤΑΥΡΟΣ ΔΑΛΙΑΚΟΠΟΥΛΟΣ <stavros.daliakopoulos@gmail.com>
Co-authored-by: Сергей (MrSabin) <sabin.21011986@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Co-authored-by: 电棍老板 <qwertyuiop9296@outlook.com>
Co-authored-by: 구병우 <dodamby@ajou.ac.kr>
2024-06-24 21:03:09 +02:00
CranberrySoup
9ca1d02bdc
Improve tests (#1142) 2024-06-24 20:05:34 +02:00
Luna712
b06d9f224d
Downloads: performance improvements and merge adapters (#1145) 2024-06-24 20:04:45 +02:00
imgbot[bot]
b9746c2b17
[ImgBot] Optimize images (#1144)
/app/src/main/res/drawable/example_qr.png -- 45.27kb -> 1.28kb (97.17%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-06-19 19:03:55 +02:00
IndusAryan
c71d5d8add
feat(ui): new dialog on adding repository and auto redirection (#1025) 2024-06-19 16:36:40 +02:00
KingLucius
afa178a63a
feat(TV UI): Accounts PIN login support (#1123) 2024-06-19 16:06:08 +02:00
Misael Jiménez
b702b7b1ec
Fix DoodExtractor. (#1134)
Fix StreamWishExtractor
2024-06-19 15:40:23 +02:00
KingLucius
bda6673cfd
feat(Extensions): Consider time zone in Trakt durations (#1140) 2024-06-18 23:24:35 +02:00
KingLucius
7a0cd07dc1
feat(TV UI): Press Right to focus save on Logcat (#1136) 2024-06-18 05:02:32 +02:00
KingLucius
30d223cfe3
feat(UI): Reorganize Settings (#1137)
- Accounts Section & Remove "account" from title.
- Security Section for Biometric that is hidden on TV.
- Move "send logs" to "Action" section.
2024-06-17 03:01:14 +02:00
Stormunblessed
4c061edd7c
goodstream (#1133) 2024-06-15 23:47:30 +02:00
KingLucius
4c95610238
feat(UI): Hide Platform's not related settings (#1128) 2024-06-09 16:38:08 +02:00
RowdyRushya
3345326cb2
Extractor: VidSrcTo: better handling of runtime errors (#1121) 2024-06-08 21:19:29 +02:00
KingLucius
607a4510b6
feat(Extensions): Trakt season names remove (#1124) 2024-06-08 21:08:35 +02:00
KingLucius
f775c1725d
feat(TV UI): Subtitles Filter button focus fix (#1125) 2024-06-08 21:07:33 +02:00
firelight
7eec0eff02
Revert "chore: refactor gradlelocalproperties and update gradle plugin (#957)" (#1120)
This reverts commit 358a20eb77.
2024-06-05 23:41:06 +02:00
IndusAryan
358a20eb77
chore: refactor gradlelocalproperties and update gradle plugin (#957) 2024-06-05 23:18:33 +02:00
int3debug
0391a3b89c
feature(ui): added wikipedia to links (#1119) 2024-06-05 23:09:05 +02:00
int3debug
9bebfe4590
feature(ui): hide NSFW plugins (#1117)
Hide NSFW plugins if Settings / Providers NSFW is disabled
2024-06-05 23:07:54 +02:00
KingLucius
b3e3dadc72
Remove IndexSubtitles provider (#1111) 2024-06-01 18:17:41 +02:00
KingLucius
b87fdfbf85
feat(TV UI): Account switch focus fix (#1112) 2024-06-01 18:16:42 +02:00
KingLucius
dff56026de
SubDL Account login support (#1101) 2024-05-29 22:39:55 +02:00
IndusAryan
5502e478c4
chore: update material,kotlin compiler,newpipe extractor,rhino-js,guava,corektx (#1091) 2024-05-27 16:05:56 +02:00
CranberrySoup
960f8449b7
Update ResultViewModel2.kt (#1102) 2024-05-27 15:54:51 +02:00
Ömer Faruk Sancak
d0852449a5
Extractor: Added FourPichive (#1103)
🕊
2024-05-27 15:54:25 +02:00
KingLucius
e697bf7554
Next Airing episode support in Trakt meta provider (#1072) 2024-05-21 22:06:28 +02:00
Luna712
db2bf5e7be
Remove subscene (#1096)
subscene.com just shows a "Subscene is closed" message now.
2024-05-19 12:43:46 +02:00
KingLucius
469a71236b
SubDL subtitles provider (#1082) 2024-05-18 18:15:23 +02:00
CranberrySoup
4d5cd288ab
Ported more files for multiplatform (#1056) 2024-05-18 13:47:12 +02:00
KingLucius
af828de8d5
feat(TV UI: Fix online subtitles dialog focus (#1085) 2024-05-18 13:41:37 +02:00
CranberrySoup
ee4d1dedc5
Add basic fcast support (#1084) 2024-05-09 21:46:54 +02:00
KingLucius
f1cc4db89c
Show Season number for next airing episode (#1071) 2024-05-09 17:08:18 +02:00
b4byhuey
3874cb9f9d
Update Dailymotion Extractor (#1081) 2024-05-09 17:06:33 +02:00
phisher98
0a5399d9b6
Updates and Chillx Extractor Updated (#1065) 2024-05-05 01:00:42 +02:00
KingLucius
71bd48f493
feat(ui): Hide Downloads & Settings Back button on TV (#1074) 2024-05-04 13:17:52 +02:00
KingLucius
83c473d9f8
More external Ids in Trakt meta provider (#1075) 2024-05-04 13:16:09 +02:00
RowdyRushya
c28a3cb987
Extractor: new VidSrcTo extractor (#1044) 2024-05-04 13:15:34 +02:00
int3debug
d3828eeafe
refact: rename logcat file (#1061)
Rename logcat file to prevent override
2024-05-02 23:59:05 +02:00
int3debug
c07e6d3222
hotfix: Remove resume information (#1063) 2024-05-02 23:58:32 +02:00
KingLucius
949b5830b6
feat(ui): Fix downloads focus on TV (#1066) 2024-05-01 19:29:49 +02:00
b4byhuey
ff1ffbeb83
Update Voe.kt (#1062) 2024-04-28 21:42:38 +02:00
Luna712
138e1a1f0e
Don't check year when checking duplicates if year is empty (#1060)
Some sources don't use year which makes this not match when it really should match
2024-04-27 22:40:15 +02:00
KingLucius
004c481a5e
feat(ui): Episode Air date & Upcoming countdown (#1058) 2024-04-27 18:11:22 +02:00
b4byhuey
e2946cad6b
Added Vidguard Extractor (#1053) 2024-04-27 18:00:40 +02:00
int3debug
e6b9d621f9
feat(ui): added option to reset sub delay (#1041) 2024-04-22 17:00:27 +02:00
KingLucius
0019f85501
Trakt meta provider for extensions (#1026) 2024-04-22 16:59:14 +02:00
IndusAryan
0744189020
feat(ui): show account name and image on main settings page (#1001) 2024-04-22 16:48:54 +02:00
Ömer Faruk Sancak
4399a612df
Update Vidmoly.kt (#1051) 2024-04-22 01:14:36 +02:00
KingLucius
e01ff4d843
Fix NewPipeExtractor lib path (#1050) 2024-04-22 01:13:55 +02:00
KingLucius
6cef9f7ea2
Filtering first unwatched episode respects watched state (#1049) 2024-04-20 22:18:49 +02:00
int3debug
9a18ef6411
bugfix: fixing regex special chars break it (#1047) 2024-04-17 23:48:33 +02:00
CranberrySoup
6df3ef14f6
First steps for multiplatform API (#1003)
* First steps for multiplatform api

* Buildconfig testing

* Fix publishing and classes.jar

* Update build.gradle.kts
2024-04-16 23:07:28 +02:00
int3debug
5db541d7cc
feat(ui): added reset button to subtitle delay (#1040) 2024-04-14 02:13:12 +02:00
CranberrySoup
aa8972870c
Show download size on videos (#1038) 2024-04-14 00:45:58 +02:00
Rushikesh Chavan
afdc4988ac
Extractor: Update Vidplay Extractor (#1036) 2024-04-13 19:52:08 +02:00
KingLucius
e6c111532d
Defaults Play button to first unwatched Episode (#1035) 2024-04-13 19:51:39 +02:00
recloudstream[bot]
ffa7b0248a chore(locales): fix locale issues 2024-04-10 15:26:36 +00:00
firelight
c13d290377
Merge pull request #1012 from recloudstream/weblate
Translations update from Hosted Weblate
2024-04-10 17:26:17 +02:00
Hosted Weblate
1bf7e14eab
Merge remote-tracking branch 'origin/master' 2024-04-10 17:24:19 +02:00
phisher98
145ceea50f
Created vtbe and EPlay Extractor (#1014) 2024-04-10 17:24:15 +02:00
Hosted Weblate
2fad760426
Merge remote-tracking branch 'origin/master' 2024-04-10 17:16:09 +02:00
KingLucius
ff0dea3fbb
Fix focus for Tracks selection on TV (#1030) 2024-04-10 17:16:04 +02:00
Hosted Weblate
44e5b86176
Merge remote-tracking branch 'origin/master' 2024-04-10 17:14:51 +02:00
KingLucius
d8f89df163
Show player controls on pressing Pad Down (#1031) 2024-04-10 17:14:47 +02:00
Hosted Weblate
a74563d003
Translated using Weblate (Russian)
Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Vietnamese)

Currently translated at 75.0% (3 of 4 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.7% (688 of 697 strings)

Translated using Weblate (French)

Currently translated at 96.8% (675 of 697 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Persian)

Currently translated at 34.7% (242 of 697 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.8% (689 of 697 strings)

Translated using Weblate (Russian)

Currently translated at 97.1% (677 of 697 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Malayalam)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Malayalam)

Currently translated at 48.4% (338 of 697 strings)

Translated using Weblate (Indonesian)

Currently translated at 99.8% (696 of 697 strings)

Translated using Weblate (Maltese)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Maltese)

Currently translated at 32.1% (224 of 697 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (697 of 697 strings)

Added translation using Weblate (Maltese)

Translated using Weblate (Spanish)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.1% (684 of 697 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (697 of 697 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (697 of 697 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Spanish)

Currently translated at 99.4% (690 of 694 strings)

Translated using Weblate (Odia)

Currently translated at 37.5% (258 of 688 strings)

Co-authored-by: Andre Costa <andrecaeu@gmail.com>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Argo Carpathians <chrisarabagas@gmail.com>
Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Co-authored-by: Colgrave <hanqixu.blogs@simplelogin.co>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fqwe1 <Fqwe1@users.noreply.hosted.weblate.org>
Co-authored-by: Gnkalk <github.fngyb@slmail.me>
Co-authored-by: Herderson Riker <herdersonriker@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joshua Joseph <joshuasaju2@gmail.com>
Co-authored-by: Long Kim <kimlong01102000@icloud.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: Pizza Party <paol.m@proton.me>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Thanh <pancakes21f@gmail.com>
Co-authored-by: aleksej0R <omolice@hotmail.fr>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: kaajjo <claymanoff@gmail.com>
Co-authored-by: maxim <maximtested@gmail.com>
Co-authored-by: samwiaba <sambastianc@gmail.com>
Co-authored-by: Ömer Faruk Sancak <keyiflerolsun@gmail.com>
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/cs/
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/fr/
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/ml/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mt/
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/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/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ml/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/mt/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/vi/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2024-04-08 04:02:02 +02:00
firelight
0a24661e4c
fix latest commit 2024-03-25 01:48:23 +01:00
firelight
ed2bdf44fb
New TvTypes + General fixes 2024-03-25 01:38:39 +01:00
IndusAryan
51d91bf9a7
feat(ui): add ignore battery optimisation dialog for uniterrupted downloads and notifications (#915) 2024-03-25 01:18:26 +01:00
firelight
fb89fd60b8
Merge pull request #996 from KingLucius/playFirstUnwatched
Set play button to first unwatched Episode on TV
2024-03-25 01:05:41 +01:00
recloudstream[bot]
7db7742c73 chore(locales): fix locale issues 2024-03-25 00:04:49 +00:00
firelight
b246d80861
Merge pull request #890 from recloudstream/weblate
Translations update from Hosted Weblate
2024-03-25 01:04:36 +01:00
Hosted Weblate
d321aba3a7
Merge remote-tracking branch 'origin/master' 2024-03-25 01:03:08 +01:00
IndusAryan
22937424fa
feat(ui): authenticate first when enabling security settings (#991) 2024-03-25 01:03:04 +01:00
Hosted Weblate
7f0034e872
Merge remote-tracking branch 'origin/master' 2024-03-25 00:59:59 +01:00
IndusAryan
35e38a53ad
refactor: format build date and time and make it copyable (#1002) 2024-03-25 00:59:55 +01:00
Hosted Weblate
6d8a31809d
Merge remote-tracking branch 'origin/master' 2024-03-25 00:55:53 +01:00
KingLucius
650c7583af
Fix Alert Dialog width on TV (#1010)
* Fix Alert Dialog width on TV

* Fix width for AlertDialogCustom on TV
2024-03-25 00:55:37 +01:00
Hosted Weblate
34af3a4b2f
Merge remote-tracking branch 'origin/master' 2024-03-25 00:47:29 +01:00
int3debug
9ef1f1cc41
fix: extension activity interruption (#1005)
fixed interruption with only local plugins

Co-authored-by: int3debug <gh.ditch236@passinbox.com>
2024-03-25 00:47:26 +01:00
Hosted Weblate
6ede44d85f
Merge remote-tracking branch 'origin/master' 2024-03-25 00:42:23 +01:00
KingLucius
2f03ca7de9
Extenstions' Github & Rate buttons are now focusable on TV (#1008)
- Disables useless focus for (Description, Author .. etc.) buttons one the left.
- Make GitHub & Rate focusable on TV.
2024-03-25 00:42:18 +01:00
Hosted Weblate
7ce2dfc4aa
Merge remote-tracking branch 'origin/master' 2024-03-25 00:41:08 +01:00
int3debug
16510923d2
fix: No access rights after restore from backup (#1009)
Co-authored-by: int3debug <gh.ditch236@passinbox.com>
2024-03-25 00:41:04 +01:00
Hosted Weblate
a9c2c0644a
Translated using Weblate (Indonesian)
Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (677 of 688 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (French)

Currently translated at 98.1% (675 of 688 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (687 of 688 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Croatian)

Currently translated at 99.2% (683 of 688 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (German)

Currently translated at 99.7% (686 of 688 strings)

Translated using Weblate (English)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (688 of 688 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (688 of 688 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Afrikaans)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Afrikaans)

Currently translated at 29.7% (205 of 688 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.7% (686 of 688 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Hindi)

Currently translated at 40.9% (282 of 688 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (688 of 688 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (688 of 688 strings)

Merge remote-tracking branch 'origin/master'

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Levantine))

Currently translated at 99.7% (684 of 686 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.7% (684 of 686 strings)

Translated using Weblate (German)

Currently translated at 99.8% (685 of 686 strings)

Translated using Weblate (Malayalam)

Currently translated at 44.0% (302 of 686 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (686 of 686 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (686 of 686 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Levantine))

Currently translated at 98.6% (678 of 687 strings)

Translated using Weblate (German)

Currently translated at 98.9% (680 of 687 strings)

Translated using Weblate (German)

Currently translated at 98.9% (680 of 687 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Swedish)

Currently translated at 99.8% (686 of 687 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.6% (678 of 687 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Swedish)

Currently translated at 99.8% (683 of 684 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (684 of 684 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Persian)

Currently translated at 33.7% (228 of 675 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Hungarian)

Currently translated at 95.1% (642 of 675 strings)

Translated using Weblate (Romanian)

Currently translated at 94.2% (636 of 675 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Hungarian)

Currently translated at 86.3% (583 of 675 strings)

Translated using Weblate (Hindi)

Currently translated at 41.6% (281 of 675 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Russian)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (German)

Currently translated at 98.0% (662 of 675 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.4% (651 of 675 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (French)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.3% (657 of 675 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (675 of 675 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Italian)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (675 of 675 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 99.7% (672 of 674 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Vietnamese)

Currently translated at 98.6% (665 of 674 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Bulgarian)

Currently translated at 98.9% (667 of 674 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (Nepali)

Currently translated at 26.8% (181 of 674 strings)

Translated using Weblate (Afrikaans)

Currently translated at 29.9% (202 of 674 strings)

Translated using Weblate (Amharic)

Currently translated at 29.6% (200 of 674 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 42.8% (289 of 674 strings)

Translated using Weblate (Kannada)

Currently translated at 33.8% (228 of 674 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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 (Macedonian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Dutch)

Currently translated at 99.5% (666 of 669 strings)

Translated using Weblate (Macedonian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.2% (664 of 669 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.2% (664 of 669 strings)

Translated using Weblate (Portuguese)

Currently translated at 97.0% (649 of 669 strings)

Translated using Weblate (Portuguese)

Currently translated at 97.0% (649 of 669 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.9% (642 of 669 strings)

Translated using Weblate (French)

Currently translated at 99.4% (665 of 669 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Esperanto)

Currently translated at 33.7% (226 of 669 strings)

Translated using Weblate (Urdu)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.8% (668 of 669 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (French)

Currently translated at 99.4% (665 of 669 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.9% (642 of 669 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (669 of 669 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 (Japanese)

Currently translated at 50.2% (336 of 669 strings)

Translated using Weblate (Hungarian)

Currently translated at 82.8% (554 of 669 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (669 of 669 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Russian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (669 of 669 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 (Croatian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Russian)

Currently translated at 94.7% (634 of 669 strings)

Translated using Weblate (Croatian)

Currently translated at 75.0% (3 of 4 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (669 of 669 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (German)

Currently translated at 99.1% (663 of 669 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 90.2% (603 of 668 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Japanese)

Currently translated at 49.8% (333 of 668 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 96.5% (645 of 668 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (668 of 668 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 (Swedish)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Japanese)

Currently translated at 47.3% (316 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 99.4% (664 of 668 strings)

Translated using Weblate (Nepali)

Currently translated at 26.9% (180 of 668 strings)

Translated using Weblate (German)

Currently translated at 99.1% (662 of 668 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Odia)

Currently translated at 38.3% (256 of 668 strings)

Translated using Weblate (Croatian)

Currently translated at 99.7% (666 of 668 strings)

Translated using Weblate (Nepali)

Currently translated at 16.9% (113 of 668 strings)

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 99.7% (666 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 45.2% (302 of 668 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 (Arabic)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 27.6% (185 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 27.6% (185 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 36.8% (246 of 668 strings)

Translated using Weblate (Tagalog)

Currently translated at 51.9% (347 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 78.8% (527 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 39.0% (261 of 668 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Polish)

Currently translated at 98.6% (659 of 668 strings)

Co-authored-by: Aayush Shah <shahaayush999@gmail.com>
Co-authored-by: Ahmed Abd El-Fattah <a.aelfattah5@gmail.com>
Co-authored-by: Alexander Svärd <genc.demiri@hotmail.com>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Amir <amearb@duck.com>
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: AppsTool <appstool03@gmail.com>
Co-authored-by: Argo Carpathians <chrisarabagas@gmail.com>
Co-authored-by: Bálint László <blaszlobors@gmail.com>
Co-authored-by: CPavRou <mag@cleparo.fr>
Co-authored-by: CakesTwix <cakestwix1@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: Colgrave <hanqixu.blogs@simplelogin.co>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: DarkOrbFX <darkorbfx@gmail.com>
Co-authored-by: Davi Silveira <davilego10@gmail.com>
Co-authored-by: Davide Marcoli <davide.marcoli13@gmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Fqwe1 <Fqwe1@users.noreply.hosted.weblate.org>
Co-authored-by: Friso de Boer <collorfrisie@hotmail.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: Hubert Naciasta <hubert.naciasta@skiff.com>
Co-authored-by: IamNotNickerson <IamNickerson@users.noreply.hosted.weblate.org>
Co-authored-by: Jean-Michel <arsene_lupin_57@hotmail.fr>
Co-authored-by: Joana Trashlieva <j.trashlieva@gmail.com>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Julia Sugawara <jm.sugawara@gmail.com>
Co-authored-by: Just Rocket (just) <phatal1988@gmail.com>
Co-authored-by: Kjev <77635620+Kjev666@users.noreply.github.com>
Co-authored-by: Levent SD <leventsd@gmail.com>
Co-authored-by: Luna712 <142361265+Luna712@users.noreply.github.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Mc wolmarans <marthinuswolmarans61@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Naga <yz2000.pro@gmail.com>
Co-authored-by: NamelessGO <66227691+NameLessGO@users.noreply.github.com>
Co-authored-by: Only1337 <ymurathanusta@gmail.com>
Co-authored-by: Ovi329 <avijitb129@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Sanjeev Dhawan <smstranscom@gmail.com>
Co-authored-by: Semih <semihbrn10@gmail.com>
Co-authored-by: Slawa <slawa@slawagurevich.com>
Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Co-authored-by: Sufyan Zahoor Jutt <sufyanpahore@gmail.com>
Co-authored-by: T1z3n <info@njbraun.de>
Co-authored-by: TecnoLAZ <luispaulodias89@gmail.com>
Co-authored-by: Yosra Boussaid <yosra.boussaid@users.noreply.hosted.weblate.org>
Co-authored-by: Zhenye Dong <dongzhenye@gmail.com>
Co-authored-by: arcopnt <arcopnt@posteo.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: delvani <inavleb@users.noreply.hosted.weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: konatu saino <shironyann.tdbr@gmail.com>
Co-authored-by: lucasmz <github@lucasmz.dev>
Co-authored-by: maxim <maximtested@gmail.com>
Co-authored-by: oops-wtf <cert.potatoes@gmail.com>
Co-authored-by: simmon <simmon@nplob.com>
Co-authored-by: stojkovskistefan <stefanstojkovski@gmail.com>
Co-authored-by: tuan041 <tuananh163025ttt@gmail.com>
Co-authored-by: v1s7 <v1s7@users.noreply.hosted.weblate.org>
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: Сергій <sergiy.goncharuk.1@gmail.com>
Co-authored-by: सौम्य भाटी <saumyabhati2006@gmail.com>
Co-authored-by: 电棍老板 <qwertyuiop9296@outlook.com>
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/bg/
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/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/fr/
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/mk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ml/
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/ro/
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/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/fastlane/af/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/apc/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/hi/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/hr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/hu/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/id/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ko/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/mk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/sv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/zh_Hans/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2024-03-24 20:01:55 +01:00
firelight
4468ce3d80
Revert "make cloudstream very superfast boi, fast app startup and navigation …" (#1007)
This reverts commit faeb71da2c.
2024-03-22 23:06:05 +01:00
IndusAryan
faeb71da2c
make cloudstream very superfast boi, fast app startup and navigation (#965) 2024-03-22 22:56:05 +01:00
int3debug
86bc0b8345
fixed first setup extension activity interruption (#1000)
Co-authored-by: int3debug <gh.ditch236@passinbox.com>
2024-03-22 22:31:01 +01:00
int3debug
1ff0b5dccd
add build-date to main_settings (#995) 2024-03-20 08:33:50 +01:00
KingLucius
a2e63174be Set play button to first unwatched episode on TV 2024-03-19 17:47:36 +02:00
Osten
eb60be54ed fixed crash + fixed lib + fixed preview 2024-03-18 15:54:54 +01:00
Osten
8d5b73495d Added BaseAdapter to store internal state 2024-03-18 03:58:30 +01:00
KingLucius
a3bb853691
Extension's Settings Focus on TV (#990) 2024-03-17 17:07:18 +01:00
Osten
375b3ec46e
Update HomeParentItemAdapter.kt
Should fix last item problem
2024-03-17 16:42:31 +01:00
Osten
ad67b9ddab + Fixed ephemeral scroll
+ Fixed Unable to remove Subs
+ Fixed download 1 frame visual glitch
+ Maybe fixed worker
+ Updated layout API
+ Bump
2024-03-17 03:37:09 +01:00
KingLucius
638cc4fee9
New TV UI bug fixes (#983) 2024-03-16 03:50:49 +01:00
IndusAryan
4817b29b9c
refactor: toast view to use data binding (#919) 2024-03-14 00:12:13 +01:00
IndusAryan
040ac77b1a
refactor: add clipboard helper and improve repo url copy (#946) 2024-03-14 00:04:58 +01:00
firelight
527046766a
Fixed OOM when using restore data 2024-03-13 23:36:23 +01:00
Luna712
adc653943b
Improve synopsis/description display on phone and emulator (#967) 2024-03-13 18:29:12 +01:00
IndusAryan
81df68e137
fix(hotfix): bottom sheet appearing when turning biometrics off and remove toast. (#971)
* fix biometric regressions

* use nullable context

* aha!
2024-03-10 23:15:23 +01:00
Luna712
a01bb9e55b
Fix nonTransferableKeys in BackupUtils (#979)
* Fix nonTransferableKeys in BackupUtils

* Whoops...

* really...
2024-03-10 23:12:51 +01:00
Luna712
807bd85fa9
Move biometric_key to keys section in strings (#978) 2024-03-10 23:11:02 +01:00
KingLucius
510d11f705
New TV UI (#950) 2024-03-09 15:24:38 +01:00
firelight
bd69054f5d
Updated lib icon 2024-03-08 03:16:36 +01:00
firelight
694e7abbdf
Fixes #816 2024-03-08 03:00:00 +01:00
firelight
e3f9f255c7
Revert "feat: make cloudstream compilation and builds fast! using gradle conf…" (#968)
This reverts commit 21b341e12f.
2024-03-08 02:07:35 +01:00
IndusAryan
21b341e12f
feat: make cloudstream compilation and builds fast! using gradle configuration cache (#959) 2024-03-08 01:56:31 +01:00
IndusAryan
e3999d6e9a
feat(security): add biometric fingerprint sensor / face unlock authentication (#826) 2024-03-08 01:45:20 +01:00
Mater Yoda
f0f4ec87bc
Added lavender color (#949) 2024-03-08 01:20:49 +01:00
self-similarity
809a38507b
Update SimklApi.kt (#961) 2024-03-02 23:45:18 +01:00
KingLucius
1a380a3239
TV show airing status for phone (#953)
* TV show airing status for phone

* Bump Nicehttp & remove toImmutableList
2024-02-29 17:07:45 +01:00
Sofie
93d81ea038
WebViewResolver: added timeout (#941) 2024-02-19 21:18:36 +01:00
Sofie
e007714701
fix Rabbitstream (#936)
* fix Rabbitstream

* .
2024-02-19 21:06:55 +01:00
KingLucius
805f80b2ac
Long press Repo to copy URL (#934) 2024-02-19 16:46:02 +01:00
KingLucius
b5fb0997c4
[TV] Limit Homepage Header Description to 3 lines & 6 Tags (#938) 2024-02-19 16:44:50 +01:00
KingLucius
ca918b1581
[TV] More space around result description (#939) 2024-02-19 16:43:41 +01:00
IndusAryan
09779b4ee0
chore: add tooltips on results toolbar and rename speed mode (#925)
* add tooltips on results toolbar and better summaries, rename speed mode

* remove redundant space
2024-02-15 21:45:34 +01:00
Sofie
012d38398e
fix Acefile & Gofile (#926) 2024-02-15 21:42:47 +01:00
Ömer Faruk Sancak
d1db4c3370
Extractor: Added PlayRu (#930) 2024-02-15 21:42:11 +01:00
Sarlay
8d318ca84a
Fixed Chillx subtitles (#931)
* Fixed subtitles tracks for Chillx.kt

* Update Chillx.kt
2024-02-15 21:41:34 +01:00
KingLucius
eea6e13346
Make Rating non-focusable (Old API) (#935) 2024-02-15 21:40:44 +01:00
CranberrySoup
2b7d102716
Add SubScene (#923)
* Lower targetSdk to get all installed packages

* Update sdk version

* Let's not be too radical

* Many fixes

* Revert targetSdk

* Make account homepage persistent

* Add SubScene and change subtitle API

Co-authored-by: Aymanbest <51868001+aymanbest@users.noreply.github.com>

* Fix file deletion

---------

Co-authored-by: Aymanbest <51868001+aymanbest@users.noreply.github.com>
2024-02-06 23:27:35 +01:00
Osten
9ea7674a0f
Update MainAPI.kt 2024-02-02 22:18:54 +01:00
IndusAryan
3dcf7076d0
feat(ui): tap video duration to toggle remaining time counter (#878) 2024-01-21 20:11:51 +01:00
Sofie
8b14fcb881
added Mediafire (#906) 2024-01-21 15:35:33 +01:00
IndusAryan
01f21e0fe8
refactor: move buildconfig, bump ksp & better trailer scraping (#834) 2024-01-19 21:09:07 +01:00
coxju
bdef6524e7
feat : run custom js in webviewresolver (#888)
Co-authored-by: coxju <coxju>
2024-01-19 20:38:37 +01:00
Cloudburst
f40a8d9418
make *rotate_video_key untransatable (#896) 2024-01-19 20:12:52 +01:00
IndusAryan
03fcb106ac
new simple messages when updating app i.e, refined changelogs (#900) 2024-01-19 20:12:33 +01:00
coxju
636e157c63
fix: trailers not playing (#898)
Co-authored-by: coxju <coxju>
2024-01-19 12:03:20 +01:00
CranberrySoup
5af1b80cb7
Fix crash on plugin reload (#895)
* Update Event.kt

* Update Event.kt
2024-01-18 22:57:54 +01:00
coxju
5dfc08aabb
feat: added emturbovid extractor (#893)
Co-authored-by: coxju <coxju>
2024-01-17 23:28:17 +01:00
coxju
1676094488
feat (loadExtractor) : match mirror domains of extractor link (#877)
Co-authored-by: coxju <coxju>
2024-01-17 22:32:22 +01:00
Sir Aguacata
19145c6cc4
Screw it, Self host keys (#892) 2024-01-17 22:30:20 +01:00
coxju
ebb72d6a0c
feat : invalidate link cache after 20 mins (#875)
- additionaly clear cache if there is player errors or no links found

Co-authored-by: coxju <coxju>
2024-01-17 22:29:44 +01:00
Sir Aguacata
399b28c75b
Rest in piece old key repo (#891) 2024-01-17 00:35:23 +01:00
IndusAryan
601483e103
feat: limit genre tags on home to 2 lines and on result page, 10 tags max (#885) 2024-01-16 18:41:43 +01:00
recloudstream[bot]
9733d0b316 chore(locales): fix locale issues 2024-01-16 17:40:50 +00:00
Weblate (bot)
0cf199248a
Translated using Weblate (Croatian) (#856)
Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Russian)

Currently translated at 94.7% (634 of 669 strings)

Translated using Weblate (Croatian)

Currently translated at 75.0% (3 of 4 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (669 of 669 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (German)

Currently translated at 99.1% (663 of 669 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 90.2% (603 of 668 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Japanese)

Currently translated at 49.8% (333 of 668 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 96.5% (645 of 668 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (668 of 668 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 (Swedish)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Japanese)

Currently translated at 47.3% (316 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 99.4% (664 of 668 strings)

Translated using Weblate (Nepali)

Currently translated at 26.9% (180 of 668 strings)

Translated using Weblate (German)

Currently translated at 99.1% (662 of 668 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Odia)

Currently translated at 38.3% (256 of 668 strings)

Translated using Weblate (Croatian)

Currently translated at 99.7% (666 of 668 strings)

Translated using Weblate (Nepali)

Currently translated at 16.9% (113 of 668 strings)

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 99.7% (666 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 45.2% (302 of 668 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 (Arabic)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 27.6% (185 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 27.6% (185 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 36.8% (246 of 668 strings)

Translated using Weblate (Tagalog)

Currently translated at 51.9% (347 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 78.8% (527 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 39.0% (261 of 668 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Polish)

Currently translated at 98.6% (659 of 668 strings)























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/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/es/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fa/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ml/
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/or/
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/sv/
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/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/apc/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/hr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/sv/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane

Co-authored-by: Aayush Shah <shahaayush999@gmail.com>
Co-authored-by: Alexander Svärd <genc.demiri@hotmail.com>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Anarchydr <patrikpik879@gmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hubert Naciasta <hubert.naciasta@skiff.com>
Co-authored-by: IamNotNickerson <IamNickerson@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Ovi329 <avijitb129@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Slawa <slawa@slawagurevich.com>
Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: v1s7 <v1s7@users.noreply.hosted.weblate.org>
Co-authored-by: Ömer Faruk Sancak <keyiflerolsun@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
2024-01-16 18:40:37 +01:00
Sofie
2624947b5b
fix keys (#887)
Co-authored-by: ghost <ghost@gmail.com>
2024-01-16 18:37:31 +01:00
coxju
31c783d0b4
feat: added extractor vidhide and streamwish (#889)
Co-authored-by: coxju <coxju>
2024-01-16 18:36:02 +01:00
firelight
9f1b172f34
fix plugin downloads trash can 2024-01-14 01:06:06 +01:00
IndusAryan
93dce8682e
fix system bars not appearing properly (#879) 2024-01-13 22:45:30 +01:00
IndusAryan
723c653b07
feat(ui): long press title to copy (#872)
* new feature: hold to copy movie title

* remove title copy hint
2024-01-12 16:48:43 +01:00
coxju
0c73f5e59a
fix: library not loading in TV (#871) 2024-01-11 17:08:37 +01:00
coxju
0eb152c5db
fix: search only if selection changed (#868) 2024-01-11 15:54:28 +01:00
IndusAryan
8c5ab86714
hotfix window compat bug (#870) 2024-01-11 15:53:31 +01:00
Ömer Faruk Sancak
85a769a898
Extractor: ContentX add external subtitle (#869) 2024-01-11 15:52:34 +01:00
Sir Aguacata
96aa56209b
Revert the repo change to get keys (#867) 2024-01-11 00:47:40 +01:00
coxju
d71d3890b5
feat: show random button on library (#855) 2024-01-10 22:28:06 +01:00
IndusAryan
19b1a40cf8
use window insets compat controller (#847) 2024-01-10 22:20:43 +01:00
Yutatsu
e5f483b0b2
Fix vidplay extractor (#866) 2024-01-10 22:06:45 +01:00
coxju
6f1e0bef80
feat: show the episodic range with current selection checked (#851) 2024-01-10 22:05:56 +01:00
LagradOst
5e6272be3f fix 2024-01-10 19:10:34 +01:00
coxju
97ec98b9e2
feat : show favorite button in bottom dialog (#858)
Co-authored-by: coxju <coxju>
2024-01-10 18:55:10 +01:00
coxju
42fd0b5c76
new streamtape extractor (#857) 2024-01-08 23:46:19 +01:00
Ömer Faruk Sancak
42774f6183
Extractor: ContentX expanded and fix (#859)
* Extractor: ContentX expanded and fix
2024-01-08 23:45:53 +01:00
Sofie
f687508521
Extractors: fix acefile, pixeldrain & vidplay tracks (#854)
Co-authored-by: ghost <ghost@gmail.com>
2024-01-05 17:08:48 +01:00
recloudstream[bot]
dbba6d7f27 chore(locales): fix locale issues 2024-01-05 16:08:37 +00:00
Hosted Weblate
f4da170a57 Translated using Weblate (Polish)
Currently translated at 99.7% (666 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 45.2% (302 of 668 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 (Arabic)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 27.6% (185 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 27.6% (185 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 36.8% (246 of 668 strings)

Translated using Weblate (Tagalog)

Currently translated at 51.9% (347 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 78.8% (527 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 39.0% (261 of 668 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Polish)

Currently translated at 98.6% (659 of 668 strings)

Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hubert Naciasta <hubert.naciasta@skiff.com>
Co-authored-by: IamNotNickerson <IamNickerson@users.noreply.hosted.weblate.org>
Co-authored-by: Ovi329 <avijitb129@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bn/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fa/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ml/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/apc/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2024-01-05 17:08:20 +01:00
Cloudburst
2a1876f54c
fix build_to_archive.yml 2024-01-03 10:28:39 +01:00
wrongwrong
f1d0a8e955
Fixed to not use API that will be discontinued in the future (#843) 2024-01-03 10:24:42 +01:00
IndusAryan
1c6be2d5cb
refactor(bump): upgrade workflow versions to use node16 (#793) 2024-01-03 10:24:28 +01:00
Horis
fc802cdcdd
add extractors (#849) 2024-01-03 10:24:12 +01:00
Ömer Faruk Sancak
2cfdab5498
Extractor: added some extractors (#833)
* Extractor: added some extractors

* Extractor: fix same import

* Extractor: PeaceMakerst fix

* Extractor: source fix
2023-12-31 21:31:05 +01:00
recloudstream[bot]
4c2ee28d5a chore(locales): fix locale issues 2023-12-28 13:17:57 +00:00
firelight
657f2fbcb2
Merge pull request #824 from recloudstream/weblate
Translations update from Hosted Weblate
2023-12-28 14:17:41 +01:00
Hosted Weblate
b5ac668493
Added translation using Weblate (Nepali)
Translated using Weblate (Ukrainian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 75.0% (3 of 4 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Hindi)

Currently translated at 41.9% (280 of 668 strings)

Translated using Weblate (French)

Currently translated at 98.5% (658 of 668 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (663 of 668 strings)

Translated using Weblate (Hindi)

Currently translated at 38.1% (255 of 668 strings)

Translated using Weblate (Burmese)

Currently translated at 94.9% (634 of 668 strings)

Translated using Weblate (Galician)

Currently translated at 38.9% (260 of 668 strings)

Translated using Weblate (Odia)

Currently translated at 38.1% (255 of 668 strings)

Translated using Weblate (Latvian)

Currently translated at 92.3% (617 of 668 strings)

Translated using Weblate (Somali)

Currently translated at 85.1% (569 of 668 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 43.1% (288 of 668 strings)

Translated using Weblate (Persian)

Currently translated at 22.6% (151 of 668 strings)

Translated using Weblate (Tamil)

Currently translated at 21.1% (141 of 668 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 94.0% (628 of 668 strings)

Translated using Weblate (Tagalog)

Currently translated at 51.3% (343 of 668 strings)

Translated using Weblate (Swedish)

Currently translated at 78.7% (526 of 668 strings)

Translated using Weblate (Romanian)

Currently translated at 90.8% (607 of 668 strings)

Translated using Weblate (Polish)

Currently translated at 94.9% (634 of 668 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 89.5% (598 of 668 strings)

Translated using Weblate (French)

Currently translated at 98.5% (658 of 668 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (665 of 668 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Lithuanian)

Currently translated at 48.5% (324 of 668 strings)

Translated using Weblate (Amharic)

Currently translated at 29.6% (198 of 668 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.1% (101 of 668 strings)

Translated using Weblate (Korean)

Currently translated at 92.3% (617 of 668 strings)

Translated using Weblate (Malay)

Currently translated at 22.9% (153 of 668 strings)

Translated using Weblate (Japanese)

Currently translated at 46.5% (311 of 668 strings)

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

Currently translated at 50.5% (338 of 668 strings)

Translated using Weblate (Portuguese)

Currently translated at 94.1% (629 of 668 strings)

Translated using Weblate (Hungarian)

Currently translated at 82.0% (548 of 668 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Russian)

Currently translated at 94.9% (634 of 668 strings)

Translated using Weblate (Kannada)

Currently translated at 33.9% (227 of 668 strings)

Translated using Weblate (Urdu)

Currently translated at 94.9% (634 of 668 strings)

Translated using Weblate (Hebrew)

Currently translated at 94.9% (634 of 668 strings)

Translated using Weblate (Bengali)

Currently translated at 36.3% (243 of 668 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Malayalam)

Currently translated at 38.7% (259 of 668 strings)

Translated using Weblate (Macedonian)

Currently translated at 92.3% (617 of 668 strings)

Translated using Weblate (Greek)

Currently translated at 94.0% (628 of 668 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (668 of 668 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.9% (634 of 668 strings)

Translated using Weblate (Bulgarian)

Currently translated at 93.5% (625 of 668 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 98.8% (660 of 668 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 91.6% (612 of 668 strings)

Translated using Weblate (Macedonian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 92.1% (610 of 662 strings)

Translated using Weblate (Macedonian)

Currently translated at 92.2% (611 of 662 strings)

Translated using Weblate (German)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (German)

Currently translated at 99.6% (660 of 662 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (English)

Currently translated at 75.0% (3 of 4 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Italian)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Italian)

Currently translated at 99.8% (660 of 661 strings)

Translated using Weblate (Italian)

Currently translated at 98.7% (653 of 661 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (German)

Currently translated at 99.6% (659 of 661 strings)

Translated using Weblate (French)

Currently translated at 98.1% (649 of 661 strings)

Co-authored-by: Aayush Shah <shahaayush999@gmail.com>
Co-authored-by: Azgar <azgar4485@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Emanuele Frasca <noostale@live.it>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Filip Drogrishki <alekfilip425@gmail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Giuseppe Terrana <terranagiuseppe03@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lacey Anaya <yecakeh263@anawalls.com>
Co-authored-by: Luna712 <142361265+Luna712@users.noreply.github.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Samuele Righi <blackdestinyx145@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: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Ömer Faruk Sancak <keyiflerolsun@gmail.com>
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/bg/
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/el/
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/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/hu/
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/my/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nb_NO/
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/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/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/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/apc/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/en/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/mk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/uk/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2023-12-28 01:52:53 +01:00
Sofie
9d3b2ba3d2
vidplay/fallback : change to previous key (#836)
* change key

* fix rabbitstream's key
2023-12-23 23:55:02 +01:00
Osten
5f51a8f7bc
Update WatchType.kt 2023-12-21 00:10:20 +01:00
LagradOst
e886fde8b8 lib longhold 2023-12-21 00:07:39 +01:00
Sofie
1356a954f3
Vidplay: change for more accurate key (#831) 2023-12-20 01:18:32 +01:00
firelight
3d90af29eb
bookmark button on preview 2023-12-19 15:59:24 +01:00
recloudstream[bot]
2a4ce89452 chore(locales): fix locale issues 2023-12-19 14:22:10 +00:00
firelight
0543f1ffae
Merge pull request #799 from recloudstream/weblate
Translations update from Hosted Weblate
2023-12-19 14:21:55 +00:00
coxju
a5f7920bca
feat (player): optional rotate button in player and setting to enable auto rotate based on video orientation (#813)
Co-authored-by: coxju <coxju>
2023-12-19 15:20:58 +01:00
Hosted Weblate
e8fe2944bb
Translated using Weblate (German)
Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (German)

Currently translated at 99.6% (660 of 662 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (English)

Currently translated at 75.0% (3 of 4 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Italian)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Italian)

Currently translated at 99.8% (660 of 661 strings)

Translated using Weblate (Italian)

Currently translated at 98.7% (653 of 661 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (German)

Currently translated at 99.6% (659 of 661 strings)

Translated using Weblate (French)

Currently translated at 98.1% (649 of 661 strings)

Co-authored-by: Azgar <azgar4485@gmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Giuseppe Terrana <terranagiuseppe03@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lacey Anaya <yecakeh263@anawalls.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Samuele Righi <blackdestinyx145@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
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/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/fastlane/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/en/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2023-12-19 09:10:19 +00:00
Luna712
db91552f39
PluginManager: uncomment setReadOnly for Android 14 (#823) 2023-12-18 18:49:08 +01:00
Luna712
484c21cc1c
CS3IPlayer: stop player when releasing (#822) 2023-12-17 20:04:20 +01:00
Sofie
ff9144ef54
fix exception (#819) 2023-12-16 20:13:19 +01:00
Luna712
10a477c2bd
Show toast when reloading links (#818)
I'm not really sure if this should be done or not. But I like to know if I actually press the button or miss click as I often have to press it twice for it to do anything, which might just be another issue though.
2023-12-16 18:42:51 +01:00
firelight
6d51c59b18
fix visibility 2023-12-15 15:24:08 +00:00
Sofie
f98ce0558d
add prevent option in CloudflareKiller (#808)
* add prevent option in CloudflareKiller
2023-12-15 15:34:58 +01:00
firelight
f5e6d98cb0
refactor (library search): sort and query current page only and delay search when typing (#806)
* refactor (library): delay search when typing

* fix comment

* review fixes

---------

Co-authored-by: Funny-Pen-7005 <Funny-Pen-7005>
2023-12-15 15:32:21 +01:00
coxju
91dc83e6a3
refactor (search filter) : syncing main tytype chips and bottom dialog tytype chips (#811) 2023-12-15 15:20:37 +01:00
IndusAryan
fe30a85a1c
refactor: general and ui settings and added explicit unstable api opt ins (#787) 2023-12-13 22:18:12 +01:00
yueehaoo
6e5a52e440
Fixing Source List Displaying Empty Items (#810)
* Fixing source list displaying empty items
2023-12-13 20:49:42 +01:00
coxju
410cedc128
refactor (home) : show account icon when no provider selected (#812)
Co-authored-by: Funny-Pen-7005 <Funny-Pen-7005>
2023-12-13 20:23:30 +01:00
Luna712
3c152e04d1
Support showing content ratings for TmdbProvider (#705)
* Support showing content ratings for TmdbProvider
2023-12-09 18:54:29 +01:00
Osten
d0aed5e51a
bump 2023-12-09 18:54:09 +01:00
Funny-Pen-7005
530619c8d0
fixes (library): reverted and updated currentPage logic (#802)
Co-authored-by: Funny-Pen-7005 <Funny-Pen-7005>
2023-12-09 16:38:39 +01:00
firelight
3ef8f3030c
fix from pr (#801) 2023-12-09 15:04:26 +01:00
Funny-Pen-7005
2d87983eca
fix (library tab): save and restore tab selection (#798)
Co-authored-by: Funny-Pen-7005 <Funny-Pen-7005>
2023-12-09 14:55:13 +01:00
Sofie
6f3a8c1cd2
extractor: added vidplay and fix few extractors (#795)
* extractor: added Vidplay

* fix id

* fix Chillx

* fix Linkbox

* update m3u8helper in chillx

* fix Dailymotion

---------

Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-12-08 17:28:16 +01:00
recloudstream[bot]
dfd6ce7651 chore(locales): fix locale issues 2023-12-07 20:54:23 +00:00
firelight
88ad64b3b0
Merge pull request #790 from recloudstream/weblate
Translations update from Hosted Weblate
2023-12-07 20:54:07 +00:00
Hosted Weblate
cebdbd2199
Merge remote-tracking branch 'origin/master' 2023-12-07 20:51:46 +00:00
IndusAryan
25b042fb83
refactor(bump): update glide module and fix regressions (#789)
* upgrade glide module and glide ksp

* fix glide breaking changes and regressions
2023-12-07 21:51:42 +01:00
Hosted Weblate
fac0ef4c25
Merge remote-tracking branch 'origin/master' 2023-12-07 20:44:17 +00:00
IndusAryan
f7bc83024a
upstream kotlin symbol processing (#788) 2023-12-07 21:44:12 +01:00
Hosted Weblate
5b170c0573
Merge remote-tracking branch 'origin/master' 2023-12-07 21:43:56 +01:00
firelight
38cc121755
Update build.gradle.kts (#797) 2023-12-07 21:43:52 +01:00
Hosted Weblate
951b2110ad
Merge remote-tracking branch 'origin/master' 2023-12-07 21:42:39 +01:00
IndusAryan
d4aefc4e64
bump guava, json(tests), kgp, desugaring (#781)
* bump guava(for ksp)

* Update build.gradle.kts

* .

* Update kotlin gradle plugin
2023-12-07 21:42:32 +01:00
Hosted Weblate
c324eaf543
Translated using Weblate (Hungarian)
Currently translated at 81.5% (539 of 661 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (661 of 661 strings)

Co-authored-by: Gyuri Bajzik <bajzikgy@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: Ömer Faruk Sancak <keyiflerolsun@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/apc/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/
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/fastlane/tr/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2023-12-02 08:04:30 +01:00
recloudstream[bot]
962ff1c058 chore(locales): fix locale issues 2023-11-27 21:48:42 +00:00
firelight
7165b57268
Merge pull request #754 from recloudstream/weblate
Translations update from Hosted Weblate
2023-11-27 21:48:24 +00:00
Hosted Weblate
e80dc63381
Translated using Weblate (Russian)
Currently translated at 94.7% (626 of 661 strings)

Translated using Weblate (Russian)

Currently translated at 94.5% (625 of 661 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (661 of 661 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Portuguese)

Currently translated at 94.8% (627 of 661 strings)

Translated using Weblate (Japanese)

Currently translated at 46.4% (307 of 661 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Bengali)

Currently translated at 36.3% (240 of 661 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Slovak)

Currently translated at 68.2% (451 of 661 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.6% (659 of 661 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Najdi))

Currently translated at 66.5% (440 of 661 strings)

Translated using Weblate (Dutch)

Currently translated at 94.8% (627 of 661 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (French)

Currently translated at 96.3% (637 of 661 strings)

Translated using Weblate (French)

Currently translated at 96.3% (637 of 661 strings)

Translated using Weblate (Turkish)

Currently translated at 25.0% (1 of 4 strings)

Translated using Weblate (Afrikaans)

Currently translated at 30.4% (201 of 661 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Afrikaans)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 68.3% (452 of 661 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (661 of 661 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 56.8% (372 of 654 strings)

Translated using Weblate (Hindi)

Currently translated at 38.3% (251 of 654 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 52.4% (343 of 654 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 43.4% (284 of 654 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 42.2% (276 of 654 strings)

Translated using Weblate (Arabic (Levantine))

Currently translated at 39.6% (259 of 654 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (654 of 654 strings)

Added translation using Weblate (Afrikaans)

Translated using Weblate (German)

Currently translated at 99.5% (651 of 654 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (654 of 654 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (654 of 654 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (654 of 654 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (654 of 654 strings)

Co-authored-by: Alexthegib <traducoes@skiff.com>
Co-authored-by: Anarchydr <patrikpik879@gmail.com>
Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Fqwe1 <Fqwe1@users.noreply.hosted.weblate.org>
Co-authored-by: Guillaume THOMAS <t.guillaume319@laposte.net>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jay Jay Cabugat <cabugatjayjay@gmail.com>
Co-authored-by: Leon de Klerk <deklerkleon5@gmail.com>
Co-authored-by: LiJu09 <lisojuraj@gmail.com>
Co-authored-by: LiberiBg <matheo.ngn@gmail.com>
Co-authored-by: Luna712 <hanstavo1@gmail.com>
Co-authored-by: Murat Han <murathancw@gmail.com>
Co-authored-by: Nepx <anandabaskara@outlook.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Ranforingus <ranforingus@hotmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Co-authored-by: Samiul Islam <samiulislamsharan@gmail.com>
Co-authored-by: SehrGuterCode <philemonpfeiffer@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: maxim <maximtested@gmail.com>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: 跨性别 <github@lgbt.sh>
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/af/
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/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/es/
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/hr/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/id/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/
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/ru/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sk/
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/fastlane/af/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/tr/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane
2023-11-26 02:07:17 +01:00
firelight
fa7ebc05b3
Refix youtube
This reverts commit df0122c146.
2023-11-21 22:47:01 +01:00
IndusAryan
df0122c146
fix: bump rhino js and upgrade desugaring level (#774)
* fix and bump rhino js

* upgrade desugaring level

* uppercase run to Run
2023-11-18 14:31:41 +00:00
IndusAryan
b49368100b
upgrade gradle (#775) 2023-11-18 14:31:27 +00:00
Sofie
0077cebaa6
fixed AesHelper (#773)
Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-11-18 14:30:56 +00:00
IndusAryan
a2085202ec
refactor: remove kapt and it's annotation processor, clean gradle, upgrade build-dir deprecation (#764)
* remove kapt and its annotation processor

* cleanup

* change deprecated builddir
2023-11-15 17:31:02 +01:00
CranberrySoup
765071ebef
Fixed flag and name for Levantine Arabic and Najdi Arabic (#770) 2023-11-15 17:30:29 +01:00
self-similarity
e11d36aed8
Save selected subtitle language (#765) 2023-11-12 16:36:21 +01:00
Luna712
5bf2b4ead2
Massive changes to account flow (#741)
* Massive changes to account flow
2023-11-11 23:47:23 +01:00
Cloudburst
de61501b22
Update prerelease.yml 2023-11-11 17:45:10 +01:00
Cloudburst
685884e67f fix makeJar task 2023-11-11 17:40:47 +01:00
Luna712
6db295a799
Upgrade gradle (#726) 2023-11-11 17:30:36 +01:00
self-similarity
2b60e3a893
Fix faulty automatic subtitle selection (#760) 2023-11-10 23:49:37 +00:00
Luna712
3adf036135
Fix some deprecations and other warnings (#750)
* Fix some deprecations and other warnings
2023-11-10 23:48:53 +00:00
IndusAryan
c4aab5e5a8
feat: make cloudstream fast boi, ksp migration (#689)
* migrate from kapt to ksp

* fook codefactor
2023-11-10 17:02:51 +01:00
KingLucius
7e2908c0bb
Fix top bar in Extensions & Test settings (#753) 2023-11-10 15:36:38 +01:00
Sofie
22a0c25d83
extractor: fixed Rabbitstream (#757)
* Extractor: added Rabbitstream

* Extractor: added Rabbitstream

* fixed Rabbitstream

---------

Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-11-10 15:28:27 +01:00
self-similarity
11136fe63d
Fix selecting sources in cast (#752) 2023-11-05 22:33:11 +00:00
Luna712
a6786aaf98
Add done button for when creating new PINs (#742)
* Add done button for when creating new PINs

* Cleanup
2023-11-02 23:28:25 +00:00
Luna712
5b0cbbf09f
Use nicer grid layout for account select on TV (#737) 2023-11-02 21:14:16 +01:00
IndusAryan
6a8c251013
bump navigation lib (#749) 2023-11-02 21:07:34 +01:00
KingLucius
908f83c50e
Fix scroll for Library TV layout (#695)
* Fix scroll for Library TV layout

* Fixed without NestedScrollView
2023-11-02 21:03:00 +01:00
recloudstream[bot]
6f40d2750f chore(locales): fix locale issues 2023-11-02 19:59:11 +00:00
Weblate (bot)
199f5b3a9d
Translations update from Hosted Weblate (#681)
Co-authored-by: Ahmed Abd El-Fattah <a.aelfattah5@gmail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Fqwe1 <Fqwe1@users.noreply.hosted.weblate.org>
Co-authored-by: Giuseppe Terrana <terranagiuseppe03@gmail.com>
Co-authored-by: Luna712 <142361265+Luna712@users.noreply.github.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
2023-11-02 20:58:52 +01:00
KingLucius
6ce9f29331
Fix settings top bar on TV (#738) 2023-11-02 20:50:49 +01:00
firelight
8b73c35e43
faster account skip startup 2023-10-31 00:34:01 +01:00
Luna712
65313b4579
Add account selection activity and support profile locks (#736) 2023-10-31 00:32:01 +01:00
IndusAryan
a8fdf5e8f2
remove useless parent (#735) 2023-10-30 15:25:50 +00:00
Luna712
87c5aada8f
Bump androidx.preference:preference-ktx (#734) 2023-10-29 16:35:48 +00:00
Luna712
f0e429436f
Use TV results layout on emulator (#710)
* Use TV results layout on emulator

* Add subscription support to emulator

* Update
2023-10-29 01:20:04 +02:00
self-similarity
137d833d4a
Improved Simkl autosync and fixed syncing seasons (#722)
* Improved simkl autosync and fixed syncing seasons
2023-10-29 00:55:49 +02:00
Luna712
b2e0b7dec8
Fix "Multiple substitutions specified in non-positional format" (#727) 2023-10-29 00:52:58 +02:00
Luna712
d542febcda
Reload library when deleting bookmarks (#725) 2023-10-29 00:41:19 +02:00
Luna712
f0ebfa47c8
Bump material to 1.10.0 (#728) 2023-10-29 00:40:20 +02:00
Luna712
51a877f405
Fix some strings (#730) 2023-10-29 00:29:16 +02:00
Luna712
4b93524e57
Convert the rest of SingleSelectionHelper to ViewBinding (#724) 2023-10-26 02:15:50 +02:00
firelight
ef36bccc90
Delete old MultiAnimeProvider.kt (#717) 2023-10-26 02:10:08 +02:00
Luna712
968bd59188
Warn of potential duplicates when adding to library (#691) 2023-10-26 02:09:21 +02:00
Luna712
e4ba852007
Use bottom dialogs for synopsis (#709)
* Use bottom dialogs for synopsis

* Add bottomTextDialog
2023-10-25 16:43:29 +02:00
Luna712
504258bf15
Show confirm exit dialog on emulator layout (#704) 2023-10-23 18:42:17 +02:00
KingLucius
48053164dc
Old APIs focus fixes (#692)
* Movie button focus fix in TV layout

* Cast item focusable in old API

* Media description focus fix in old API

* Switch account button focus fix in old APi
2023-10-23 18:38:53 +02:00
Luna712
2a4468eb44
Add more info on homepage to emulator layout (#698)
* Add more info on homepage to emulator layout

* Support for continue watching and bookmarks

It does some manual changes to avoid having to duplicate the entire layout for minor changes
2023-10-23 18:33:44 +02:00
Luna712
5153a74d4f
Library/LocalList: enable subscriptions on emulator layout (#702) 2023-10-23 18:21:32 +02:00
KingLucius
138dea88c4
Poster cropped at 20% from Top (#693) 2023-10-23 18:16:48 +02:00
IndusAryan
eb58cb1184
fix crash when navigation graph is null (#706) 2023-10-23 18:11:05 +02:00
firelight
c9bffef7cb
Merge pull request #690 from Luna712/locallist-sort
Enable sorting by updated date in LocalList
2023-10-20 13:31:46 +00:00
IndusAryan
a7a6f2282a
Update build.gradle.kts (#694) 2023-10-17 19:13:29 +00:00
Luna712
8ed7418fe4 Enable sorting by updated date in LocalList 2023-10-14 10:30:45 -06:00
Luna712
3cb2196e62
Add favorites (#682)
* Add favorites
2023-10-14 01:02:12 +02:00
KingLucius
7e9d1ded7f
Larger top poster in TV loading layout (#685) 2023-10-14 00:54:34 +02:00
Luna712
b7322ffb19
Add clear search query icons (#687) 2023-10-14 00:44:51 +02:00
CranberrySoup
fd1620f3d7
Fix unresponsive settings (#688)
* Lower targetSdk to get all installed packages

* Update sdk version

* Let's not be too radical

* Many fixes

* Revert targetSdk

* Make account homepage persistent

* Update mobile_navigation.xml

* Update SettingsFragment.kt
2023-10-14 00:34:26 +02:00
KingLucius
fc8c0e809d
Focus fixes for old devices (#686)
- Fix Library on TV main buttons focus.
- Quick search back button focus.
2023-10-12 17:56:54 +00:00
self-similarity
1ccd3d732d
Reduce image cache to 100mb (#683)
* Reduce image cache to 100mb
2023-10-12 01:06:22 +02:00
LagradOst
bb6a17e23c resize images for lower mem footprint 2023-10-11 18:31:46 +02:00
recloudstream[bot]
2f2bbd7d88 chore(locales): fix locale issues 2023-10-10 20:19:06 +00:00
Sir Aguacata
749d131099
Added new primary colors for comfort (#653) 2023-10-10 22:18:34 +02:00
Weblate (bot)
de6dfec452
Translations update from Hosted Weblate (#596)
Co-authored-by: Alexandru <negrualexandru52@gmail.com>
Co-authored-by: Anarchydr <patrikpik879@gmail.com>
Co-authored-by: Antonio N <antonioenpidev@gmail.com>
Co-authored-by: Beabfekad Zikie <beabfekadz@gmail.com>
Co-authored-by: Beach Cow <Beachcow@skiff.com>
Co-authored-by: Boz Osman <nachodev@proton.me>
Co-authored-by: Cait Martin Newnham <85128509+helloiamcait@users.noreply.github.com>
Co-authored-by: Carlos Luiz <ecarlos-luiz@hotmail.com>
Co-authored-by: Chi Uma <jivanov.2048@gmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: Don Apis <apisapisapis@gmail.com>
Co-authored-by: GobinathAL <gobinathal8@gmail.com>
Co-authored-by: Gyuri Bajzik <bajzikgy@gmail.com>
Co-authored-by: Joel Brink <joel.brink.handy@gmail.com>
Co-authored-by: John <yes20866@gmail.com>
Co-authored-by: Julian <hello@apollo.moe>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Mubarek Seyd Juhar <mubareksd@gmail.com>
Co-authored-by: Muhammad Fahad Khan <itxmfahadkhan@gmail.com>
Co-authored-by: Pizza Party <paol.m@proton.me>
Co-authored-by: Radoslav Lelchev <rlelchev@abv.bg>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Roi Gabay <roigby@gmail.com>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Co-authored-by: Samiul Islam <samiulislamsharan@gmail.com>
Co-authored-by: ShareASmile <aapshergill@gmail.com>
Co-authored-by: Skrripy <Skrripy@users.noreply.hosted.weblate.org>
Co-authored-by: Skrripy <rozihrash.ya6w7@simplelogin.com>
Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Co-authored-by: esfzzddfse <esfzzddfse@proton.me>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: l <thisuserooo@gmail.com>
Co-authored-by: mbottari <mbottari@quantum-systems.com>
Co-authored-by: pedrolinharesmoreira <pedrolinhares@unifei.edu.br>
Co-authored-by: shivashranz <shivatheboss11@gmail.com>
Co-authored-by: tabtomi8 <tabtomi88@gmail.com>
Co-authored-by: ßozo Mamed <bozo-mamed-27@hotmail.com>
Co-authored-by: Влад Николаев <vladnic1990@gmail.com>
2023-10-10 22:18:21 +02:00
LagradOst
b4da93c1de revert jackson 2023-10-10 21:45:36 +02:00
LagradOst
dd45ac9e8a reverted to android 14 -> 13 2023-10-10 20:49:04 +02:00
LagradOst
b47209483a reverted to instant outline 2023-10-10 18:05:31 +02:00
self-similarity
91b195241e
Automatic backups (#592)
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
2023-10-10 17:19:27 +02:00
KingLucius
abbad1bc94
Delete Focus frame from empty Downloads list & Search TV Layout (#675)
* Delete Focus frame in search TV layout

* Delete focus frame for empty Downloads list

* Chip rounded stroke frame
2023-10-10 17:17:18 +02:00
KingLucius
b120a7bce2
Library on TV (#663)
* implementation for Library on TV
2023-10-10 17:16:35 +02:00
Luna712
5b4fd8d77d
Fix issue where DownloadedPlayerActivity interferes with MainActivity (#674)
* Fix issue where DownloadedPlayerActivity interferes with MainActivity
2023-10-10 17:05:34 +02:00
LagradOst
d277d8a9aa bump upstream 2023-10-10 16:56:30 +02:00
LagradOst
33eb3a3b29 lib fix2 2023-10-07 21:48:24 +02:00
LagradOst
f14557fe6a lib fix 2023-10-07 01:54:34 +02:00
LagradOst
77294dc68e cleanup 2023-10-07 01:39:30 +02:00
Luna712
0a327ccbda
Reload library when reloading home (#656)
So that library is reloaded when switching accounts.

Fixes #650
2023-10-06 23:50:31 +02:00
LagradOst
177b1e47f3 added extra logging 2023-10-04 11:33:55 +02:00
Luna712
3f5119525c
Make library preferences account specific (#649) 2023-10-03 22:59:26 +02:00
Luna712
b5d4c3bd27
Make player preferences account specific (#646) 2023-10-03 19:26:56 +02:00
Luna712
cc00e73e16
Make homepage preferences account specific (#647)
* Make homepage preferences account specific

* Fix accidentally removed whitespace

* Fix in setkey
2023-10-03 19:25:31 +02:00
Luna712
462073bd74
Make search prefs account specific (#640) 2023-10-03 16:56:38 +02:00
LagradOst
08060314ad preview seekbar m3u8 2023-10-03 16:50:34 +02:00
Luna712
1d90858f64
Make search history account specific (#638)
* Make search history account specific

* Update for clear history
2023-10-02 21:04:40 +02:00
LagradOst
bd05a67f26 preview seekbar 2023-10-02 17:44:06 +02:00
KingLucius
bb8cbb5167
More Amazon FireTV focus fixes (#636)
* cast item: mimic the same focus as ATV

* Source & Subtitles priority focus

* Subtitles sync focus

* Account management focus fixes
2023-10-01 01:26:07 +02:00
KingLucius
16c2290090
Amazon FireTV focus fixes (#635)
* Fix quick search button focus

* Switch profile button focus

* Cast & Recommendations focus

* Player: Profiles settings focus

* Player: Subtitles encoding settings focus

* profile selection: card item focus

* Search history item selectable & deleteable

* Search: search filter button next focus fix
2023-09-28 12:22:51 +02:00
KingLucius
194678c419
Player Source & Subs navigation change (#633) 2023-09-28 12:21:03 +02:00
Osten
0351053d80
Readded downloads to tv 2023-09-25 22:57:18 +02:00
Sofie
74060e7da3
Chillx: fix key (#628) 2023-09-25 13:48:35 +02:00
IndusAryan
1e2a11d6e4
refactor: speedostream and newpipe, tmdb update (#632)
* migrate speedostream (yomovies)

* update tmdb and newpipe
2023-09-25 13:48:05 +02:00
CranberrySoup
b8917ffa39
Fix episode layout when using RTL language (#631)
* Fix episode layout when using RTL language
2023-09-25 11:40:58 +02:00
CranberrySoup
d4fff7cee6
Add homepage search on TV (#618)
* Add search button on homepage for TV
2023-09-21 22:50:31 +02:00
LagradOst
527a6388a9 small fix 2023-09-21 22:46:23 +02:00
CranberrySoup
15333123cd
TV UI fixes (#612)
* TV UI fixes
2023-09-18 23:22:39 +02:00
LagradOst
2ae5b6cefb fixed the fucking updater 💀 2023-09-18 22:28:26 +02:00
LagradOst
0d2a19b350 bump 2023-09-17 20:38:59 +02:00
LagradOst
bff9727f96 Merge remote-tracking branch 'origin/master' 2023-09-17 20:35:11 +02:00
LagradOst
a82059cb57 fix 2023-09-17 20:35:01 +02:00
CranberrySoup
627c1bb223
Many UI fixes (#606)
* Lower targetSdk to get all installed packages

* Update sdk version

* Let's not be too radical

* Many fixes

* Revert targetSdk

* Make account homepage persistent
2023-09-16 00:30:34 +02:00
CranberrySoup
24977a8d62
Potential fix for crash loops (#608) 2023-09-15 22:47:59 +02:00
LagradOst
6957a8f95d bump 2023-09-14 20:30:44 +02:00
Sofie
2bed79b1f1
Update Gofile.kt (#600) 2023-09-14 12:53:54 +02:00
self-similarity
a5450e5da2
More robust player release (#601) 2023-09-14 12:53:35 +02:00
LagradOst
8fe34d3d2a Merge remote-tracking branch 'origin/master' 2023-09-11 23:05:47 +02:00
LagradOst
7d6ba8c7a4 tv changes for better centering + bigger text + better contrast 2023-09-11 23:05:10 +02:00
self-similarity
2baa75496e
Fix opensubtitles (#598)
* Fix OpenSubtitles
2023-09-11 20:13:42 +02:00
Sofie
01e7acdeac
getTracker: switched to anilist api (#593)
* getTracker: switched to anilist api

---------

Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-09-11 14:31:11 +02:00
LagradOst
10bc688eaf fixed tracker on dub 2023-09-11 14:29:30 +02:00
LagradOst
7f7c81828a added UI event for seekbar 2023-09-11 13:37:11 +02:00
KingLucius
f6b0ea8dfa
Stream button type switch support (#597)
* Stream button type switch support

* Hide Hls playlist switch
2023-09-10 15:31:01 +02:00
LagradOst
0afbc90cd2 fixed last fix 2023-09-09 23:57:18 +02:00
LagradOst
85c4c74222 fixed shitty external extractor code 2023-09-09 23:53:35 +02:00
LagradOst
b6e99d7358 backend change for events in player 2023-09-09 23:18:21 +02:00
self-similarity
130cc16e25
Simkl API optimizations (#581)
* Fix episode removal in simkl

* Simkl API optimizations
2023-09-09 00:13:04 +02:00
recloudstream[bot]
1629db2fc9 chore(locales): fix locale issues 2023-09-08 08:01:34 +00:00
Weblate (bot)
f05c65cf5c
Translated using Weblate (Odia) (#574)
Currently translated at 39.5% (249 of 630 strings)

Translated using Weblate (Odia)

Currently translated at 25.0% (1 of 4 strings)

Translated using Weblate (Odia)

Currently translated at 38.8% (245 of 630 strings)

Translated using Weblate (German)

Currently translated at 99.8% (629 of 630 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Tamil)

Currently translated at 20.9% (132 of 630 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Croatian)

Currently translated at 99.8% (629 of 630 strings)

Translated using Weblate (Croatian)

Currently translated at 99.3% (626 of 630 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Arabic (Najdi))

Currently translated at 54.9% (346 of 630 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (630 of 630 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (French)

Currently translated at 100.0% (630 of 630 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Hungarian)

Currently translated at 85.7% (540 of 630 strings)

Translated using Weblate (Romanian)

Currently translated at 95.7% (603 of 630 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 50.1% (316 of 630 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.1% (606 of 630 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 (Arabic (Najdi))

Currently translated at 44.9% (283 of 630 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 39.6% (250 of 630 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 39.5% (249 of 630 strings)

Translated using Weblate (German)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (German)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (German)

Currently translated at 99.8% (629 of 630 strings)

Translated using Weblate (German)

Currently translated at 99.8% (629 of 630 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 33.4% (211 of 630 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (German)

Currently translated at 99.8% (629 of 630 strings)

Translated using Weblate (German)

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

Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Arabic (Najdi))

Currently translated at 32.0% (202 of 630 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 91.5% (577 of 630 strings)

Translated using Weblate (Arabic (Saudi Arabia))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Arabic (Najdi))

Currently translated at 23.9% (151 of 630 strings)

Added translation using Weblate (Arabic (South Levantine))

Translated using Weblate (Amharic)

Currently translated at 14.9% (94 of 630 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.0% (95 of 630 strings)

Added translation using Weblate (Amharic)

Added translation using Weblate (Tigrinya)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Hungarian)

Currently translated at 85.2% (537 of 630 strings)

Translated using Weblate (Hungarian)

Currently translated at 81.4% (513 of 630 strings)


















Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/am/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ars/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/
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/hu/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/or/
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/ta/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ti/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ar_SA/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/or/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/uk/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane

Co-authored-by: Alexandru <negrualexandru52@gmail.com>
Co-authored-by: Anarchydr <patrikpik879@gmail.com>
Co-authored-by: Cait Martin Newnham <85128509+helloiamcait@users.noreply.github.com>
Co-authored-by: Carlos Luiz <ecarlos-luiz@hotmail.com>
Co-authored-by: Chi Uma <jivanov.2048@gmail.com>
Co-authored-by: GobinathAL <gobinathal8@gmail.com>
Co-authored-by: Gyuri Bajzik <bajzikgy@gmail.com>
Co-authored-by: Joel Brink <joel.brink.handy@gmail.com>
Co-authored-by: Julian <hello@apollo.moe>
Co-authored-by: Mubarek Seyd Juhar <mubareksd@gmail.com>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Co-authored-by: Skrripy <rozihrash.ya6w7@simplelogin.com>
Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Co-authored-by: mbottari <mbottari@quantum-systems.com>
Co-authored-by: pedrolinharesmoreira <pedrolinhares@unifei.edu.br>
Co-authored-by: tabtomi8 <tabtomi88@gmail.com>
2023-09-08 10:01:11 +02:00
IndusAryan
4ddd78ebb6
fook jitpack (#595) 2023-09-08 10:00:00 +02:00
LagradOst
49731cd699 changed drm API a bit 2023-09-06 22:42:22 +02:00
LagradOst
3fe247fb19 added drm player support 2023-09-06 20:53:43 +02:00
CranberrySoup
0839775172
Upgrade SDK (#590)
* Update sdk version

* Let's not be too radical
2023-09-04 22:36:36 +02:00
LagradOst
6211b02e85 switched from isM3u8 to ExtractorLinkType 2023-09-03 23:32:43 +02:00
Sofie
9c991f2abd
extractor: fix chillx (#583)
* Extractor: added Rabbitstream

* Extractor: added Rabbitstream

* extractor: fix Chillx

* comply

---------

Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-09-02 00:32:18 +02:00
LagradOst
6089cbc484 fixed subs on downloads 2023-08-30 00:52:34 +02:00
LagradOst
ce1f48978b fixed download error 2023-08-28 20:56:58 +02:00
LagradOst
f01820059b delete resume watching + delete bookmarks buttons. fixed backup crash 2023-08-27 19:07:08 +02:00
LagradOst
7d3b8c464e Merge remote-tracking branch 'origin/master' 2023-08-25 23:16:45 +02:00
LagradOst
8193e39b30 bump + refactor 2023-08-25 23:16:34 +02:00
recloudstream[bot]
557003895b chore(locales): fix locale issues 2023-08-25 08:59:37 +00:00
Weblate (bot)
d0c03321b9
Translations update from Hosted Weblate (#568)
Co-authored-by: Carlos Luiz <ecarlos-luiz@hotmail.com>
Co-authored-by: Joel Brink <joel.brink.handy@gmail.com>
Co-authored-by: Julian <hello@apollo.moe>
Co-authored-by: Mubarek Seyd Juhar <mubareksd@gmail.com>
Co-authored-by: Sam Cooper <samcooper838@gmail.com>
Co-authored-by: Skrripy <rozihrash.ya6w7@simplelogin.com>
Co-authored-by: mbottari <mbottari@quantum-systems.com>
Co-authored-by: tabtomi8 <tabtomi88@gmail.com>
2023-08-25 10:59:18 +02:00
Sofie
2d82480398
fix Rabbitstream (#573)
Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-08-25 10:58:58 +02:00
LagradOst
b38a9b1ff5 fuck android 2023-08-24 21:39:05 +02:00
LagradOst
1a4cbcaea0 small fix 2023-08-24 21:17:42 +02:00
595 changed files with 28940 additions and 11028 deletions

View file

@ -80,13 +80,13 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
- label: I am sure my issue is related to the app and **NOT some extension**.
required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
required: true
- label: If related to a provider, I have checked the site and it works, but not the app.
required: true
- label: I will fill out all of the requested information in this form.
required: true

View file

@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: Request a new provider or report bug with an existing provider
url: https://github.com/recloudstream
about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
- name: Discord
url: https://discord.gg/5Hus6fM
about: Join our discord for faster support on smaller issues.

View file

@ -27,9 +27,7 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
- label: My suggestion is **NOT** about adding a new provider
required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I will fill out all of the requested information in this form.
required: true
required: true

6
.github/locales.py vendored
View file

@ -1,6 +1,7 @@
import re
import glob
import requests
import os
import lxml.etree as ET # builtin library doesn't preserve comments
@ -53,11 +54,16 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
try:
tree = ET.parse(file)
for child in tree.getroot():
if not child.text:
continue
if child.text.startswith("\\@string/"):
print(f"[{file}] fixing {child.attrib['name']}")
child.text = child.text.replace("\\@string/", "@string/")
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

@ -19,21 +19,21 @@ jobs:
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v1
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- name: Generate access token (archive)
id: generate_archive_token
uses: tibdex/github-app-token@v1
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive"
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
@ -58,7 +58,7 @@ jobs:
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@v3
- uses: actions/checkout@v4
with:
repository: "recloudstream/cloudstream-archive"
token: ${{ steps.generate_archive_token.outputs.token }}

View file

@ -20,7 +20,7 @@ jobs:
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v1
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
@ -43,12 +43,13 @@ jobs:
rm -rf "./-cloudstream"
- name: Setup JDK 17
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'adopt'
- name: Setup Android SDK
uses: android-actions/setup-android@v2
uses: android-actions/setup-android@v3
- name: Generate Dokka
run: |

View file

@ -10,7 +10,7 @@ jobs:
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v1
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
@ -27,7 +27,7 @@ jobs:
comment-body: '${index}. ${similarity} #${number}'
- name: Label if possible duplicate
if: steps.similarity.outputs.similar-issues-found =='true'
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
@ -37,7 +37,7 @@ jobs:
repo: context.repo.repo,
labels: ["possible duplicate"]
})
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
@ -68,7 +68,7 @@ jobs:
Found provider name: `${{ steps.provider_check.outputs.name }}`
- name: Label if mentions provider
if: steps.provider_check.outputs.name != 'none'
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |

View file

@ -18,14 +18,14 @@ jobs:
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v1
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
@ -43,7 +43,8 @@ jobs:
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- name: Run Gradle
run: |
./gradlew assemblePrerelease makeJar androidSourcesJar
./gradlew assemblePrerelease build androidSourcesJar
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}

View file

@ -6,9 +6,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v2
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
@ -17,7 +17,7 @@ jobs:
- name: Run Gradle
run: ./gradlew assemblePrereleaseDebug
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"

View file

@ -18,12 +18,12 @@ jobs:
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@v1
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream"
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Install dependencies

6
.idea/gradle.xml generated
View file

@ -4,16 +4,16 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="delegatedBuild" value="true" />
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/library" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View file

@ -1,12 +1,14 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask
import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream
import java.net.URL
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
id("kotlin-android")
id("kotlin-kapt")
id("org.jetbrains.dokka")
}
@ -32,15 +34,16 @@ android {
enable = true
}
/* disable this for now
externalNativeBuild {
cmake {
path("CMakeLists.txt")
}
}
}*/
signingConfigs {
create("prerelease") {
if (prereleaseStoreFile != null) {
if (prereleaseStoreFile != null) {
create("prerelease") {
storeFile = file(prereleaseStoreFile)
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
@ -49,16 +52,16 @@ android {
}
}
compileSdk = 33
buildToolsVersion = "30.0.3"
compileSdk = 34
buildToolsVersion = "34.0.0"
defaultConfig {
applicationId = "com.lagradost.cloudstream3"
minSdk = 21
targetSdk = 29
versionCode = 59
versionName = "4.1.7"
targetSdk = 33 /* Android 14 is Fu*ked
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
versionCode = 64
versionName = "4.4.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
@ -68,9 +71,9 @@ android {
val localProperties = gradleLocalProperties(rootDir)
buildConfigField(
"String",
"BUILDDATE",
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
"long",
"BUILD_DATE",
"${System.currentTimeMillis()}"
)
buildConfigField(
"String",
@ -84,8 +87,9 @@ android {
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
kapt {
includeCompileClasspath = true
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("exportSchema", "true")
}
}
@ -108,6 +112,7 @@ android {
)
}
}
flavorDimensions.add("state")
productFlavors {
create("stable") {
@ -119,30 +124,31 @@ android {
resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
signingConfig = signingConfigs.getByName("prerelease")
if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease")
} else {
logger.warn("No prerelease signing config!")
}
versionNameSuffix = "-PRE"
versionCode = (System.currentTimeMillis() / 60000).toInt()
}
}
//toolchain {
// languageVersion.set(JavaLanguageVersion.of(17))
// }
// jvmToolchain(17)
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=compatibility")
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
buildFeatures {
buildConfig = true
}
namespace = "com.lagradost.cloudstream3"
}
@ -151,127 +157,132 @@ repositories {
}
dependencies {
implementation("com.google.android.mediahome:video:1.0.0")
implementation("androidx.test.ext:junit-ktx:1.1.5")
testImplementation("org.json:json:20180813")
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.6.1") // need target 32 for 1.5.0
// dont change this to 1.6.0 it looks ugly af
implementation("com.google.android.material:material:1.5.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.navigation:navigation-fragment-ktx:2.6.0")
implementation("androidx.navigation:navigation-ui-ktx:2.6.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test:core")
implementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
//implementation("io.karn:khttp-android:0.1.2") //okhttp instead
// implementation("org.jsoup:jsoup:1.13.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
implementation("androidx.preference:preference-ktx:1.2.0")
implementation("com.github.bumptech.glide:glide:4.13.1")
kapt("com.github.bumptech.glide:compiler:4.13.1")
implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0")
// Android Core & Lifecycle
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
// Design & UI
implementation("jp.wasabeef:glide-transformations:4.3.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
// Glide Module
ksp("com.github.bumptech.glide:ksp:4.16.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
// Media 3
implementation("androidx.media3:media3-common:1.1.1")
implementation("androidx.media3:media3-exoplayer:1.1.1")
implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
// For KSP -> Official Annotation Processors are Not Yet Supported for KSP
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("com.google.guava:guava:33.2.1-android")
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
// Media 3 (ExoPlayer)
implementation("androidx.media3:media3-ui:1.1.1")
implementation("androidx.media3:media3-session:1.1.1")
implementation("androidx.media3:media3-cast:1.1.1")
implementation("androidx.media3:media3-common:1.1.1")
implementation("androidx.media3:media3-session:1.1.1")
implementation("androidx.media3:media3-exoplayer:1.1.1")
implementation("com.google.android.mediahome:video:1.0.0")
implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
// Custom ffmpeg extension for audio codecs
implementation("com.github.recloudstream:media-ffmpeg:1.1.0")
implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
//implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
// PlayBack
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers
^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
// Bug reports
implementation("ch.acra:acra-core:5.11.0")
implementation("ch.acra:acra-toast:5.11.0")
compileOnly("com.google.auto.service:auto-service-annotations:1.0")
//either for java sources:
annotationProcessor("com.google.auto.service:auto-service:1.0")
//or for kotlin sources (requires kapt gradle plugin):
kapt("com.google.auto.service:auto-service:1.0")
// subtitle color picker
implementation("com.jaredrummler:colorpicker:1.1.0")
//run JS
// do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not
// available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown
implementation("org.mozilla:rhino:1.7.13")
// TorrentStream
//implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
// Downloading
implementation("androidx.work:work-runtime:2.8.1")
implementation("androidx.work:work-runtime-ktx:2.8.1")
// Networking
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
implementation("com.github.Blatzar:NiceHttp:0.4.3")
// To fix SSL fuckery on android 9
implementation("org.conscrypt:conscrypt-android:2.2.1")
// Util to skip the URI file fuckery 🙏
implementation("com.github.tachiyomiorg:unifile:17bec43")
// API because cba maintaining it myself
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
implementation("com.github.discord:OverlappingPanels:0.1.5")
// debugImplementation because LeakCanary should only run in debug builds.
//debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
// for shimmer when loading
implementation("com.facebook.shimmer:shimmer:0.5.0")
// Crash Reports (AcraApplication.kt)
implementation("ch.acra:acra-core:5.11.3")
implementation("ch.acra:acra-toast:5.11.3")
// UI Stuff
implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton)
implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors
implementation("androidx.tvprovider:tvprovider:1.0.0")
implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
// used for subtitle decoding https://github.com/albfernandez/juniversalchardet
implementation("com.github.albfernandez:juniversalchardet:2.4.0")
// Extensions & Other Libs
implementation("org.mozilla:rhino:1.7.15") // run JavaScript
implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser
^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
Level 25 or Less. */
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204
// this should be updated frequently to avoid trailer fu*kery
implementation("com.github.TeamNewPipe:NewPipeExtractor:1f08d28")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
// Downloading & Networking
implementation("androidx.work:work-runtime:2.9.0")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
// Library/extensions searching with Levenshtein distance
implementation("me.xdrop:fuzzywuzzy:1.4.0")
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)
}
}
// color palette for images -> colors
implementation("androidx.palette:palette-ktx:1.0.0")
this.extra.set("isDebug", isDebug)
})
}
tasks.register("androidSourcesJar", Jar::class) {
tasks.register<Jar>("androidSourcesJar") {
archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.srcDirs) //full sources
from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
}
// this is used by the gradlew plugin
tasks.register("makeJar", Copy::class) {
from("build/intermediates/compile_app_classes_jar/prereleaseDebug")
into("build")
include("classes.jar")
dependsOn("build")
tasks.register<Copy>("copyJar") {
from(
"build/intermediates/compile_app_classes_jar/prereleaseDebug",
"../library/build/libs"
)
into("build/app-classes")
include("classes.jar", "library-jvm*.jar")
// Remove the version
rename("library-jvm.*.jar", "library-jvm.jar")
}
// Merge the app classes and the library classes into classes.jar
tasks.register<Jar>("makeJar") {
// Duplicates cause hard to catch errors, better to fail at compile time.
duplicatesStrategy = DuplicatesStrategy.FAIL
dependsOn(tasks.getByName("copyJar"))
from(
zipTree("build/app-classes/classes.jar"),
zipTree("build/app-classes/library-jvm.jar")
)
destinationDirectory.set(layout.buildDirectory)
archivesName = "classes"
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all-compatibility")
}
}
tasks.withType<DokkaTask>().configureEach {
@ -284,6 +295,7 @@ tasks.withType<DokkaTask>().configureEach {
// URL showing where the source code can be accessed through the web browser
remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
// Suffix which is used to append the line number to the URL. Use #L for GitHub
remoteLineSuffix.set("#L")
}

View file

@ -9,6 +9,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
import com.lagradost.cloudstream3.databinding.FragmentResultBinding
@ -17,6 +19,7 @@ import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
@ -117,9 +120,12 @@ class ExampleInstrumentedTest {
// testAllLayouts<HomeScrollViewBinding>(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
// testAllLayouts<HomeScrollViewTvBinding>(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
testAllLayouts<HomepageParentTvBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
testAllLayouts<HomepageParentBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
testAllLayouts<HomepageParentTvBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
testAllLayouts<HomepageParentEmulatorBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
testAllLayouts<HomepageParentBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
testAllLayouts<FragmentLibraryTvBinding>(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
testAllLayouts<FragmentLibraryBinding>(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
}
}
}
@ -148,7 +154,7 @@ class ExampleInstrumentedTest {
fun providerCorrectHomepage() {
runBlocking {
getAllProviders().toList().amap { api ->
TestingUtils.testHomepage(api, ::println)
TestingUtils.testHomepage(api, TestingUtils.Logger())
}
}
println("Done providerCorrectHomepage")
@ -160,7 +166,6 @@ class ExampleInstrumentedTest {
TestingUtils.getDeferredProviderTests(
this,
getAllProviders(),
::println
) { _, _ -> }
}
}

View file

@ -6,7 +6,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- Downloads on low api devices -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <!-- Plugin API -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <!-- Plugin API -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- some dependency needs this -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
@ -14,8 +14,14 @@
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Required for getting arbitrary Aniyomi packages -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!-- Required for getting arbitrary Aniyomi packages -->
<!-- Fixes android tv fuckery -->
<uses-feature
android:name="android.hardware.touchscreen"
@ -35,9 +41,11 @@
<application
android:name=".AcraApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:appCategory="video"
android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
@ -45,7 +53,7 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:targetApi="o">
tools:targetApi="tiramisu">
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
@ -61,7 +69,9 @@
android:exported="true"
android:resizeableActivity="true"
android:screenOrientation="userLandscape"
android:supportsPictureInPicture="true">
android:supportsPictureInPicture="true"
android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -87,17 +97,11 @@
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:supportsPictureInPicture="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>
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
<intent-filter>
@ -161,6 +165,21 @@
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.EasterEggMonke"
android:exported="true" />
@ -168,13 +187,14 @@
<receiver
android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false"
android:exported="true">
<intent-filter android:exported="true">
android:exported="false">
<intent-filter android:exported="false">
<action android:name="restart_service" />
</intent-filter>
</receiver>
<service
android:foregroundServiceType="dataSync"
android:name=".services.VideoDownloadService"
android:enabled="true"
android:exported="false" />
@ -184,6 +204,7 @@
android:exported="false" />
<service
android:foregroundServiceType="dataSync"
android:name=".utils.PackageInstallerService"
android:exported="false" />

View file

@ -8,12 +8,14 @@ import android.content.Intent
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.google.auto.service.AutoService
import com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
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
@ -32,12 +34,11 @@ import org.acra.sender.ReportSenderFactory
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.Exception
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) {
@ -65,7 +66,6 @@ class CustomReportSender : ReportSender {
}
}
@AutoService(ReportSenderFactory::class)
class CustomSenderFactory : ReportSenderFactory {
override fun create(context: Context, config: CoreConfiguration): ReportSender {
return CustomReportSender()
@ -82,14 +82,8 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
ACRA.errorReporter.handleException(error)
try {
PrintStream(errorFile).use { ps ->
ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}"))
ps.println(
String.format(
"Fatal exception on thread %s (%d)",
thread.name,
thread.id
)
)
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
error.printStackTrace(ps)
}
} catch (ignored: FileNotFoundException) {
@ -107,7 +101,6 @@ class AcraApplication : Application() {
override fun onCreate() {
super.onCreate()
//NativeCrashHandler.initCrashHandler()
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
@ -153,6 +146,7 @@ class AcraApplication : Application() {
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
@ -214,7 +208,7 @@ class AcraApplication : Application() {
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
isTvSettings(),
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}

View file

@ -5,12 +5,16 @@ import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
import android.util.DisplayMetrics
import android.util.Log
import android.view.*
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.View.NO_ID
import android.widget.TextView
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
@ -26,12 +30,14 @@ 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.MainActivity.Companion.resumeApps
import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper
@ -40,7 +46,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import org.schabi.newpipe.extractor.NewPipe
import java.lang.ref.WeakReference
import java.util.*
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
enum class FocusDirection {
Start,
@ -58,11 +66,29 @@ object CommonActivity {
_activity = WeakReference(value)
}
@MainThread
fun setActivityInstance(newActivity: Activity?) {
activity = newActivity
}
@MainThread
fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession
}
val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
// screenWidth and screenHeight does always
// refer to the screen while in landscape mode
val screenWidth: Int
get() {
return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
val screenHeight: Int
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
@ -74,8 +100,7 @@ object CommonActivity {
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var currentToast: Toast? = null
private var currentToast: Toast? = null
fun showToast(@StringRes message: Int, duration: Int? = null) {
val act = activity ?: return
@ -131,25 +156,19 @@ object CommonActivity {
} catch (e: Exception) {
logError(e)
}
try {
val inflater =
act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val layout: View = inflater.inflate(
R.layout.toast,
act.findViewById<View>(R.id.toast_layout_root) as ViewGroup?
)
val text = layout.findViewById(R.id.text) as TextView
text.text = message.trim()
val binding = ToastBinding.inflate(act.layoutInflater)
binding.text.text = message.trim()
// custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11)
val toast = Toast(act)
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.duration = duration ?: Toast.LENGTH_SHORT
toast.view = layout
//https://github.com/PureWriter/ToastCompat
toast.show()
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
currentToast = toast
toast.show()
} catch (e: Exception) {
logError(e)
}
@ -183,23 +202,25 @@ object CommonActivity {
setLocale(this, localeCode)
}
fun init(act: ComponentActivity?) {
if (act == null) return
activity = act
fun init(act: Activity) {
setActivityInstance(act)
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
act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
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
act.updateLocale()
act.updateTv()
componentActivity.updateLocale()
componentActivity.updateTv()
NewPipe.init(DownloaderTestImpl.getInstance())
for (resumeApp in resumeApps) {
resumeApp.launcher =
act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
@ -216,11 +237,11 @@ object CommonActivity {
// Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
act,
componentActivity,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
val requestPermissionLauncher = act.registerForActivityResult(
val requestPermissionLauncher = componentActivity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission: $isGranted")
@ -256,12 +277,35 @@ object CommonActivity {
}
}
fun updateTheme(act: Activity) {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
if (settingsManager
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadThemes(act)
}
}
private fun mapSystemTheme(act: Activity): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val currentNightMode =
act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return when (currentNightMode) {
Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme
else -> R.style.AppTheme // Night mode is active, we're using dark theme
}
} else {
return R.style.AppTheme
}
}
fun loadThemes(act: Activity?) {
if (act == null) return
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
"System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
@ -275,12 +319,15 @@ object CommonActivity {
val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal
"DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
"Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite
"CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen
@ -289,6 +336,7 @@ object CommonActivity {
"Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink
"Lavender" -> R.style.OverlayPrimaryColorLavender
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
@ -328,6 +376,14 @@ object CommonActivity {
currentLook = currentLook.parent as? View ?: break
}*/
private fun View.hasContent(): Boolean {
return isShown && when (this) {
//is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
else -> true
}
}
/** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
fun continueGetNextFocus(
root: Any?,
@ -348,16 +404,17 @@ object CommonActivity {
} ?: return null
next = localLook(view, nextId) ?: next
val shown = next.hasContent()
// 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
} ?: false
if (!next.isFocusable && next.isShown && !hasChildrenThatWantsFocus) return null
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
// if not shown then continue because we will "skip" over views to get to a replacement
if (!next.isShown) {
if (!shown) {
// we don't want a while true loop, so we let android decide if we find a recursive view
if (next == view) return null
return getNextFocus(root, next, direction, depth + 1)
@ -431,20 +488,6 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
//println("Keycode: $keyCode")
//showToast(
// this,
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
// Tested keycodes on remote:
// KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
// KeyEvent.KEYCODE_MEDIA_REWIND
// KeyEvent.KEYCODE_MENU
// KeyEvent.KEYCODE_MEDIA_NEXT
// KeyEvent.KEYCODE_MEDIA_PREVIOUS
// KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
// 149 keycode_numpad 5
when (keyCode) {

View file

@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
private val client: OkHttpClient
private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build()
override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod()
val url: String = request.url()
@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
requestBody = RequestBody.create(null, dataToSend)
requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
}
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance
}
}
init {
client = builder.readTimeout(30, TimeUnit.SECONDS).build()
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,53 +0,0 @@
package com.lagradost.cloudstream3
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager.checkSafeModeFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
object NativeCrashHandler {
// external fun triggerNativeCrash()
private external fun initNativeCrashHandler()
private external fun getSignalStatus(): Int
private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch {
//launch {
// delay(10000)
// triggerNativeCrash()
//}
while (true) {
delay(10_000)
val signal = getSignalStatus()
// Signal is initialized to zero
if (signal == 0) continue
// Do not crash in safe mode!
if (lastError != null) continue
if (checkSafeModeFile()) continue
AcraApplication.exceptionHandler?.uncaughtException(
Thread.currentThread(),
RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n")
)
}
}
fun initCrashHandler() {
try {
System.loadLibrary("native-lib")
initNativeCrashHandler()
} catch (t: Throwable) {
// Make debug crash.
if (BuildConfig.DEBUG) throw t
logError(t)
return
}
initSignalPolling()
}
}

View file

@ -1,39 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.base64Decode
import com.lagradost.cloudstream3.utils.*
open class Acefile : ExtractorApi() {
override val name = "Acefile"
override val mainUrl = "https://acefile.co"
override val requiresReferer = false
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
val sources = mutableListOf<ExtractorLink>()
app.get(url).document.select("script").map { script ->
if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
val data = getAndUnpack(script.data())
val id = data.substringAfter("{\"id\":\"").substringBefore("\",")
val key = data.substringAfter("var nfck=\"").substringBefore("\";")
app.get("https://acefile.co/local/$id?key=$key").text.let {
base64Decode(
it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))")
).let { res ->
sources.add(
ExtractorLink(
name,
name,
res.substringAfter("\"file\":\"").substringBefore("\","),
"$mainUrl/",
Qualities.Unknown.value,
)
)
}
}
}
}
return sources
}
}

View file

@ -1,140 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
class Moviesapi : Chillx() {
override val name = "Moviesapi"
override val mainUrl = "https://w1.moviesapi.club"
}
class Bestx : Chillx() {
override val name = "Bestx"
override val mainUrl = "https://bestx.stream"
}
class Watchx : Chillx() {
override val name = "Watchx"
override val mainUrl = "https://watchx.top"
}
open class Chillx : ExtractorApi() {
override val name = "Chillx"
override val mainUrl = "https://chillx.top"
override val requiresReferer = true
companion object {
private const val KEY = "11x&W5UBrcqn\$9Yl"
}
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val master = Regex("MasterJS\\s*=\\s*'([^']+)").find(
app.get(
url,
referer = referer
).text
)?.groupValues?.get(1)
val encData = AppUtils.tryParseJson<AESData>(base64Decode(master ?: return))
val decrypt = cryptoAESHandler(encData ?: return, KEY, false)
val source = Regex(""""?file"?:\s*"([^"]+)""").find(decrypt)?.groupValues?.get(1)
val tracks = Regex("""tracks:\s*\[(.+)]""").find(decrypt)?.groupValues?.get(1)
// required
val headers = mapOf(
"Accept" to "*/*",
"Connection" to "keep-alive",
"Sec-Fetch-Dest" to "empty",
"Sec-Fetch-Mode" to "cors",
"Sec-Fetch-Site" to "cross-site",
"Origin" to mainUrl,
)
callback.invoke(
ExtractorLink(
name,
name,
source ?: return,
"$mainUrl/",
Qualities.P1080.value,
headers = headers,
isM3u8 = true
)
)
AppUtils.tryParseJson<List<Tracks>>("[$tracks]")
?.filter { it.kind == "captions" }?.map { track ->
subtitleCallback.invoke(
SubtitleFile(
track.label ?: "",
track.file ?: return@map null
)
)
}
}
private fun cryptoAESHandler(
data: AESData,
pass: String,
encrypt: Boolean = true
): String {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")
val spec = PBEKeySpec(
pass.toCharArray(),
data.salt?.hexToByteArray(),
data.iterations?.toIntOrNull() ?: 1,
256
)
val key = factory.generateSecret(spec)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
return if (!encrypt) {
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key.encoded, "AES"),
IvParameterSpec(data.iv?.hexToByteArray())
)
String(cipher.doFinal(base64DecodeArray(data.ciphertext.toString())))
} else {
cipher.init(
Cipher.ENCRYPT_MODE,
SecretKeySpec(key.encoded, "AES"),
IvParameterSpec(data.iv?.hexToByteArray())
)
base64Encode(cipher.doFinal(data.ciphertext?.toByteArray()))
}
}
private fun String.hexToByteArray(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
data class AESData(
@JsonProperty("ciphertext") val ciphertext: String? = null,
@JsonProperty("iv") val iv: String? = null,
@JsonProperty("salt") val salt: String? = null,
@JsonProperty("iterations") val iterations: String? = null,
)
data class Tracks(
@JsonProperty("file") val file: String? = null,
@JsonProperty("label") val label: String? = null,
@JsonProperty("kind") val kind: String? = null,
)
}

View file

@ -1,67 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
data class DataOptionsJson (
@JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(),
)
data class Flashvars (
@JsonProperty("metadata") var metadata : String? = null,
@JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8
)
data class MetadataOkru (
@JsonProperty("videos") var videos: ArrayList<Videos> = arrayListOf(),
)
data class Videos (
@JsonProperty("name") var name : String,
@JsonProperty("url") var url : String,
@JsonProperty("seekSchema") var seekSchema : Int? = null,
@JsonProperty("disallowed") var disallowed : Boolean? = null
)
class OkRuHttps: OkRu(){
override var mainUrl = "https://ok.ru"
}
open class OkRu : ExtractorApi() {
override var name = "Okru"
override var mainUrl = "http://ok.ru"
override val requiresReferer = false
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
val doc = app.get(url).document
val sources = ArrayList<ExtractorLink>()
val datajson = doc.select("div[data-options]").attr("data-options")
if (datajson.isNotBlank()) {
val main = parseJson<DataOptionsJson>(datajson)
val metadatajson = parseJson<MetadataOkru>(main.flashvars?.metadata!!)
val servers = metadatajson.videos
servers.forEach {
val quality = it.name.uppercase()
.replace("MOBILE","144p")
.replace("LOWEST","240p")
.replace("LOW","360p")
.replace("SD","480p")
.replace("HD","720p")
.replace("FULL","1080p")
.replace("QUAD","1440p")
.replace("ULTRA","4k")
val extractedurl = it.url.replace("\\\\u0026", "&")
sources.add(ExtractorLink(
name,
name = this.name,
extractedurl,
url,
getQualityFromName(quality),
isM3u8 = false
))
}
}
return sources
}
}

View file

@ -1,30 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
open class Pixeldrain : ExtractorApi() {
override val name = "Pixeldrain"
override val mainUrl = "https://pixeldrain.com"
override val requiresReferer = false
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val mId = Regex("/([ul]/[\\da-zA-Z\\-]+)").find(url)?.groupValues?.get(1)?.split("/")
callback.invoke(
ExtractorLink(
this.name,
this.name,
"$mainUrl/api/file/${mId?.last() ?: return}?download",
url,
Qualities.Unknown.value,
)
)
}
}

View file

@ -1,100 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.*
import kotlinx.coroutines.delay
import java.net.URI
class VidSrcExtractor2 : VidSrcExtractor() {
override val mainUrl = "https://vidsrc.me/embed"
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val newUrl = url.lowercase().replace(mainUrl, super.mainUrl)
super.getUrl(newUrl, referer, subtitleCallback, callback)
}
}
open class VidSrcExtractor : ExtractorApi() {
override val name = "VidSrc"
private val absoluteUrl = "https://v2.vidsrc.me"
override val mainUrl = "$absoluteUrl/embed"
override val requiresReferer = false
companion object {
/** Infinite function to validate the vidSrc pass */
suspend fun validatePass(url: String) {
val uri = URI(url)
val host = uri.host
// Basically turn https://tm3p.vidsrc.stream/ -> https://vidsrc.stream/
val referer = host.split(".").let {
val size = it.size
"https://" + it.subList(maxOf(0, size - 2), size).joinToString(".") + "/"
}
while (true) {
app.get(url, referer = referer)
delay(60_000)
}
}
}
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val iframedoc = app.get(url).document
val serverslist =
iframedoc.select("div#sources.button_content div#content div#list div").map {
val datahash = it.attr("data-hash")
if (datahash.isNotBlank()) {
val links = try {
app.get(
"$absoluteUrl/srcrcp/$datahash",
referer = "https://rcp.vidsrc.me/"
).url
} catch (e: Exception) {
""
}
links
} else ""
}
serverslist.amap { server ->
val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/")
if (linkfixed.contains("/prorcp")) {
val srcresponse = app.get(server, referer = absoluteUrl).text
val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)")
val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap
val passRegex = Regex("""['"](.*set_pass[^"']*)""")
val pass = passRegex.find(srcresponse)?.groupValues?.get(1)?.replace(
Regex("""^//"""), "https://"
)
callback.invoke(
ExtractorLink(
this.name,
this.name,
srcm3u8,
"https://vidsrc.stream/",
Qualities.Unknown.value,
extractorData = pass,
isM3u8 = true
)
)
} else {
loadExtractor(linkfixed, url, subtitleCallback, callback)
}
}
}
}

View file

@ -1,36 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper
class Tubeless : Voe() {
override var mainUrl = "https://tubelessceliolymph.com"
}
open class Voe : ExtractorApi() {
override val name = "Voe"
override val mainUrl = "https://voe.sx"
override val requiresReferer = true
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val res = app.get(url, referer = referer).document
val script = res.select("script").find { it.data().contains("sources =") }?.data()
val link = Regex("[\"']hls[\"']:\\s*[\"'](.*)[\"']").find(script ?: return)?.groupValues?.get(1)
M3u8Helper.generateM3u8(
name,
link ?: return,
"$mainUrl/",
headers = mapOf("Origin" to "$mainUrl/")
).forEach(callback)
}
}

View file

@ -1,73 +0,0 @@
package com.lagradost.cloudstream3.metaproviders
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
import com.lagradost.cloudstream3.utils.SyncUtil
// wont be implemented
class MultiAnimeProvider : MainAPI() {
override var name = "MultiAnime"
override var lang = "en"
override val usesWebView = true
override val supportedTypes = setOf(TvType.Anime)
private val syncApi: SyncAPI = aniListApi
private val syncUtilType by lazy {
when (syncApi) {
is AniListApi -> "anilist"
is MALApi -> "myanimelist"
else -> throw ErrorLoadingException("Invalid Api")
}
}
private val validApis
get() =
synchronized(APIHolder.apis) {
APIHolder.apis.filter {
it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
TvType.Anime
)
}
}
private fun filterName(name: String): String {
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
}
override suspend fun search(query: String): List<SearchResponse>? {
return syncApi.search(query)?.map {
AnimeSearchResponse(it.name, it.url, this.name, TvType.Anime, it.posterUrl)
}
}
override suspend fun load(url: String): LoadResponse? {
return syncApi.getResult(url)?.let { res ->
val data = SyncUtil.getUrlsFromId(res.id, syncUtilType).amap { url ->
validApis.firstOrNull { api -> url.startsWith(api.mainUrl) }?.load(url)
}.filterNotNull()
val type =
if (data.any { it.type == TvType.AnimeMovie }) TvType.AnimeMovie else TvType.Anime
newAnimeLoadResponse(
res.title ?: throw ErrorLoadingException("No Title found"),
url,
type
) {
posterUrl = res.posterUrl
plot = res.synopsis
tags = res.genres
rating = res.publicScore
addTrailer(res.trailers)
addAniListId(res.id.toIntOrNull())
recommendations = res.recommendations
}
}
}
}

View file

@ -2,15 +2,13 @@ package com.lagradost.cloudstream3.metaproviders
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
import com.lagradost.cloudstream3.syncproviders.SyncIdName
object SyncRedirector {
val syncApis = SyncApis
private val syncIds =
listOf(
SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""),
SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""")
SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""),
SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""")
)
suspend fun redirect(

View file

@ -105,6 +105,7 @@ open class TmdbProvider : MainAPI() {
this.id,
episode.episode_number,
episode.season_number,
this.name ?: this.original_name,
).toJson(),
episode.name,
episode.season_number,
@ -122,6 +123,7 @@ open class TmdbProvider : MainAPI() {
this.id,
episodeNum,
season.season_number,
this.name ?: this.original_name,
).toJson(),
season = season.season_number
)
@ -151,6 +153,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
contentRating = fetchContentRating(id, "US")
}
}
@ -193,6 +197,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
contentRating = fetchContentRating(id, "US")
}
}
@ -264,6 +270,26 @@ open class TmdbProvider : MainAPI() {
return null
}
open suspend fun fetchContentRating(id: Int?, country: String): String? {
id ?: return null
val contentRatings = tmdb.tvService().content_ratings(id).awaitResponse().body()?.results
return if (!contentRatings.isNullOrEmpty()) {
contentRatings.firstOrNull { it: ContentRating ->
it.iso_3166_1 == country
}?.rating
} else {
val releaseDates = tmdb.moviesService().releaseDates(id).awaitResponse().body()?.results
val certification = releaseDates?.firstOrNull { it: ReleaseDatesResult ->
it.iso_3166_1 == country
}?.release_dates?.firstOrNull { it: ReleaseDate ->
!it.certification.isNullOrBlank()
}?.certification
certification
}
}
// Possible to add recommendations and such here.
override suspend fun load(url: String): LoadResponse? {
// https://www.themoviedb.org/movie/7445-brothers

View file

@ -0,0 +1,471 @@
package com.lagradost.cloudstream3.metaproviders
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.Actor
import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.Episode
import com.lagradost.cloudstream3.HomePageResponse
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.NextAiring
import com.lagradost.cloudstream3.ProviderType
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.addDate
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.base64Decode
import com.lagradost.cloudstream3.mainPageOf
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.newHomePageResponse
import com.lagradost.cloudstream3.newMovieLoadResponse
import com.lagradost.cloudstream3.newMovieSearchResponse
import com.lagradost.cloudstream3.newTvSeriesLoadResponse
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.roundToInt
open class TraktProvider : MainAPI() {
override var name = "Trakt"
override val hasMainPage = true
override val providerType = ProviderType.MetaProvider
override val supportedTypes = setOf(
TvType.Movie,
TvType.TvSeries,
TvType.Anime,
)
private val traktClientId =
base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
override val mainPage = mainPageOf(
"$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now
"$traktApiUrl/movies/popular" to "Popular Movies", //The most popular movies for all time
"$traktApiUrl/shows/trending" to "Trending Shows", //Most watched Shows right now
"$traktApiUrl/shows/popular" to "Popular Shows", //The most popular Shows for all time
)
override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page")
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
element.toSearchResponse()
}
return newHomePageResponse(request.name, results)
}
private fun MediaDetails.toSearchResponse(): SearchResponse {
val media = this.media ?: this
val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
val poster = media.images?.poster?.firstOrNull()
if (mediaType == TvType.Movie) {
return newMovieSearchResponse(
name = media.title!!,
url = Data(
type = mediaType,
mediaDetails = media,
).toJson(),
type = TvType.Movie,
) {
posterUrl = fixPath(poster)
}
} else {
return newTvSeriesSearchResponse(
name = media.title!!,
url = Data(
type = mediaType,
mediaDetails = media,
).toJson(),
type = TvType.TvSeries,
) {
this.posterUrl = fixPath(poster)
}
}
}
override suspend fun search(query: String): List<SearchResponse>? {
val apiResponse =
getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query")
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
element.toSearchResponse()
}
return results
}
override suspend fun load(url: String): LoadResponse {
val data = parseJson<Data>(url)
val mediaDetails = data.mediaDetails
val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
val posterUrl = mediaDetails?.images?.poster?.firstOrNull()
val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull()
val resActor =
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full")
val actors = parseJson<People>(resActor).cast?.map {
ActorData(
Actor(
name = it.person?.name!!,
image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500")
),
roleString = it.character
)
}
val resRelated =
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20")
val relatedMedia = parseJson<List<MediaDetails>>(resRelated).map { it.toSearchResponse() }
val isCartoon =
mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
val isAnime =
isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja")
val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko")
val isBollywood = mediaDetails?.country == "in"
if (data.type == TvType.Movie) {
val linkData = LinkData(
id = mediaDetails?.ids?.tmdb,
traktId = mediaDetails?.ids?.trakt,
traktSlug = mediaDetails?.ids?.slug,
tmdbId = mediaDetails?.ids?.tmdb,
imdbId = mediaDetails?.ids?.imdb.toString(),
tvdbId = mediaDetails?.ids?.tvdb,
tvrageId = mediaDetails?.ids?.tvrage,
type = data.type.toString(),
title = mediaDetails?.title,
year = mediaDetails?.year,
orgTitle = mediaDetails?.title,
isAnime = isAnime,
//jpTitle = later if needed as it requires another network request,
airedDate = mediaDetails?.released
?: mediaDetails?.firstAired,
isAsian = isAsian,
isBollywood = isBollywood,
).toJson()
return newMovieLoadResponse(
name = mediaDetails?.title!!,
url = data.toJson(),
dataUrl = linkData.toJson(),
type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
) {
this.name = mediaDetails.title
this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
this.year = mediaDetails.year
this.plot = mediaDetails.overview
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
this.tags = mediaDetails.genres
this.duration = mediaDetails.runtime
this.recommendations = relatedMedia
this.actors = actors
this.comingSoon = isUpcoming(mediaDetails.released)
//posterHeaders
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
this.contentRating = mediaDetails.certification
addTrailer(mediaDetails.trailer)
addImdbId(mediaDetails.ids?.imdb)
addTMDbId(mediaDetails.ids?.tmdb.toString())
}
} else {
val resSeasons =
getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
val episodes = mutableListOf<Episode>()
val seasons = parseJson<List<Seasons>>(resSeasons)
var nextAir: NextAiring? = null
seasons.forEach { season ->
season.episodes?.map { episode ->
val linkData = LinkData(
id = mediaDetails?.ids?.tmdb,
traktId = mediaDetails?.ids?.trakt,
traktSlug = mediaDetails?.ids?.slug,
tmdbId = mediaDetails?.ids?.tmdb,
imdbId = mediaDetails?.ids?.imdb.toString(),
tvdbId = mediaDetails?.ids?.tvdb,
tvrageId = mediaDetails?.ids?.tvrage,
type = data.type.toString(),
season = episode.season,
episode = episode.number,
title = mediaDetails?.title,
year = mediaDetails?.year,
orgTitle = mediaDetails?.title,
isAnime = isAnime,
airedYear = mediaDetails?.year,
lastSeason = seasons.size,
epsTitle = episode.title,
//jpTitle = later if needed as it requires another network request,
date = episode.firstAired,
airedDate = episode.firstAired,
isAsian = isAsian,
isBollywood = isBollywood,
isCartoon = isCartoon
).toJson()
episodes.add(
Episode(
data = linkData.toJson(),
name = episode.title,
season = episode.season,
episode = episode.number,
posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
rating = episode.rating?.times(10)?.roundToInt(),
description = episode.overview,
runTime = episode.runtime
).apply {
this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
nextAir = NextAiring(
episode = this.episode!!,
unixTime = this.date!!.div(1000L),
season = if (this.season == 1) null else this.season,
)
}
}
)
}
}
return newTvSeriesLoadResponse(
name = mediaDetails?.title!!,
url = data.toJson(),
type = if (isAnime) TvType.Anime else TvType.TvSeries,
episodes = episodes
) {
this.name = mediaDetails.title
this.type = if (isAnime) TvType.Anime else TvType.TvSeries
this.episodes = episodes
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
this.year = mediaDetails.year
this.plot = mediaDetails.overview
this.showStatus = getStatus(mediaDetails.status)
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
this.tags = mediaDetails.genres
this.duration = mediaDetails.runtime
this.recommendations = relatedMedia
this.actors = actors
this.comingSoon = isUpcoming(mediaDetails.released)
//posterHeaders
this.nextAiring = nextAir
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
this.contentRating = mediaDetails.certification
addTrailer(mediaDetails.trailer)
addImdbId(mediaDetails.ids?.imdb)
addTMDbId(mediaDetails.ids?.tmdb.toString())
}
}
}
private suspend fun getApi(url: String): String {
return app.get(
url = url,
headers = mapOf(
"Content-Type" to "application/json",
"trakt-api-version" to "2",
"trakt-api-key" to traktClientId,
)
).toString()
}
private fun isUpcoming(dateString: String?): Boolean {
return try {
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val dateTime = dateString?.let { format.parse(it)?.time } ?: return false
unixTimeMS < dateTime
} catch (t: Throwable) {
logError(t)
false
}
}
private fun getStatus(t: String?): ShowStatus {
return when (t) {
"returning series" -> ShowStatus.Ongoing
"continuing" -> ShowStatus.Ongoing
else -> ShowStatus.Completed
}
}
private fun fixPath(url: String?): String? {
url ?: return null
return "https://$url"
}
private fun getWidthImageUrl(path: String?, width: String): String? {
if (path == null) return null
if (!path.contains("image.tmdb.org")) return fixPath(path)
val fileName = Uri.parse(path).lastPathSegment ?: return null
return "https://image.tmdb.org/t/p/${width}/${fileName}"
}
private fun getOriginalWidthImageUrl(path: String?): String? {
if (path == null) return null
if (!path.contains("image.tmdb.org")) return fixPath(path)
return getWidthImageUrl(path, "original")
}
data class Data(
val type: TvType? = null,
val mediaDetails: MediaDetails? = null,
)
data class MediaDetails(
@JsonProperty("title") val title: String? = null,
@JsonProperty("year") val year: Int? = null,
@JsonProperty("ids") val ids: Ids? = null,
@JsonProperty("tagline") val tagline: String? = null,
@JsonProperty("overview") val overview: String? = null,
@JsonProperty("released") val released: String? = null,
@JsonProperty("runtime") val runtime: Int? = null,
@JsonProperty("country") val country: String? = null,
@JsonProperty("updatedAt") val updatedAt: String? = null,
@JsonProperty("trailer") val trailer: String? = null,
@JsonProperty("homepage") val homepage: String? = null,
@JsonProperty("status") val status: String? = null,
@JsonProperty("rating") val rating: Double? = null,
@JsonProperty("votes") val votes: Long? = null,
@JsonProperty("comment_count") val commentCount: Long? = null,
@JsonProperty("language") val language: String? = null,
@JsonProperty("languages") val languages: List<String>? = null,
@JsonProperty("available_translations") val availableTranslations: List<String>? = null,
@JsonProperty("genres") val genres: List<String>? = null,
@JsonProperty("certification") val certification: String? = null,
@JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
@JsonProperty("first_aired") val firstAired: String? = null,
@JsonProperty("airs") val airs: Airs? = null,
@JsonProperty("network") val network: String? = null,
@JsonProperty("images") val images: Images? = null,
@JsonProperty("movie") @JsonAlias("show") val media: MediaDetails? = null
)
data class Airs(
@JsonProperty("day") val day: String? = null,
@JsonProperty("time") val time: String? = null,
@JsonProperty("timezone") val timezone: String? = null,
)
data class Ids(
@JsonProperty("trakt") val trakt: Int? = null,
@JsonProperty("slug") val slug: String? = null,
@JsonProperty("tvdb") val tvdb: Int? = null,
@JsonProperty("imdb") val imdb: String? = null,
@JsonProperty("tmdb") val tmdb: Int? = null,
@JsonProperty("tvrage") val tvrage: String? = null,
)
data class Images(
@JsonProperty("fanart") val fanart: List<String>? = null,
@JsonProperty("poster") val poster: List<String>? = null,
@JsonProperty("logo") val logo: List<String>? = null,
@JsonProperty("clearart") val clearart: List<String>? = null,
@JsonProperty("banner") val banner: List<String>? = null,
@JsonProperty("thumb") val thumb: List<String>? = null,
@JsonProperty("screenshot") val screenshot: List<String>? = null,
@JsonProperty("headshot") val headshot: List<String>? = null,
)
data class People(
@JsonProperty("cast") val cast: List<Cast>? = null,
)
data class Cast(
@JsonProperty("character") val character: String? = null,
@JsonProperty("characters") val characters: List<String>? = null,
@JsonProperty("episode_count") val episodeCount: Long? = null,
@JsonProperty("person") val person: Person? = null,
@JsonProperty("images") val images: Images? = null,
)
data class Person(
@JsonProperty("name") val name: String? = null,
@JsonProperty("ids") val ids: Ids? = null,
@JsonProperty("images") val images: Images? = null,
)
data class Seasons(
@JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
@JsonProperty("episode_count") val episodeCount: Int? = null,
@JsonProperty("episodes") val episodes: List<TraktEpisode>? = null,
@JsonProperty("first_aired") val firstAired: String? = null,
@JsonProperty("ids") val ids: Ids? = null,
@JsonProperty("images") val images: Images? = null,
@JsonProperty("network") val network: String? = null,
@JsonProperty("number") val number: Int? = null,
@JsonProperty("overview") val overview: String? = null,
@JsonProperty("rating") val rating: Double? = null,
@JsonProperty("title") val title: String? = null,
@JsonProperty("updated_at") val updatedAt: String? = null,
@JsonProperty("votes") val votes: Int? = null,
)
data class TraktEpisode(
@JsonProperty("available_translations") val availableTranslations: List<String>? = null,
@JsonProperty("comment_count") val commentCount: Int? = null,
@JsonProperty("episode_type") val episodeType: String? = null,
@JsonProperty("first_aired") val firstAired: String? = null,
@JsonProperty("ids") val ids: Ids? = null,
@JsonProperty("images") val images: Images? = null,
@JsonProperty("number") val number: Int? = null,
@JsonProperty("number_abs") val numberAbs: Int? = null,
@JsonProperty("overview") val overview: String? = null,
@JsonProperty("rating") val rating: Double? = null,
@JsonProperty("runtime") val runtime: Int? = null,
@JsonProperty("season") val season: Int? = null,
@JsonProperty("title") val title: String? = null,
@JsonProperty("updated_at") val updatedAt: String? = null,
@JsonProperty("votes") val votes: Int? = null,
)
data class LinkData(
val id: Int? = null,
val traktId: Int? = null,
val traktSlug: String? = null,
val tmdbId: Int? = null,
val imdbId: String? = null,
val tvdbId: Int? = null,
val tvrageId: String? = null,
val type: String? = null,
val season: Int? = null,
val episode: Int? = null,
val aniId: String? = null,
val animeId: String? = null,
val title: String? = null,
val year: Int? = null,
val orgTitle: String? = null,
val isAnime: Boolean = false,
val airedYear: Int? = null,
val lastSeason: Int? = null,
val epsTitle: String? = null,
val jpTitle: String? = null,
val date: String? = null,
val airedDate: String? = null,
val isAsian: Boolean = false,
val isBollywood: Boolean = false,
val isCartoon: Boolean = false,
)
}

View file

@ -0,0 +1,16 @@
package com.lagradost.cloudstream3.mvvm
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
/** 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) } }
}
/** NOTE: Only one observer at a time per value */
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
liveData.removeObservers(this)
liveData.observe(this) { action(it) }
}

View file

@ -9,7 +9,10 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests.Companion.await
import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.URI
@ -17,6 +20,8 @@ import java.net.URI
class CloudflareKiller : Interceptor {
companion object {
const val TAG = "CloudflareKiller"
private val ERROR_CODES = listOf(403, 503)
private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare")
fun parseCookieMap(cookie: String): Map<String, String> {
return cookie.split(";").associate {
val split = it.split("=")
@ -48,15 +53,23 @@ class CloudflareKiller : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
val request = chain.request()
val cookies = savedCookies[request.url.host]
if (cookies == null) {
bypassCloudflare(request)?.let {
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
return@runBlocking it
when (val cookies = savedCookies[request.url.host]) {
null -> {
val response = chain.proceed(request)
if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) {
return@runBlocking response
} else {
response.close()
bypassCloudflare(request)?.let {
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
return@runBlocking it
}
}
}
else -> {
return@runBlocking proceed(request, cookies)
}
} else {
return@runBlocking proceed(request, cookies)
}
debugWarning({ true }) { "Failed cloudflare at: ${request.url}" }

View file

@ -2,5 +2,4 @@ package com.lagradost.cloudstream3.plugins
@Suppress("unused")
@Target(AnnotationTarget.CLASS)
annotation class CloudstreamPlugin(
)
annotation class CloudstreamPlugin

View file

@ -34,7 +34,7 @@ abstract class Plugin {
*/
fun registerMainAPI(element: MainAPI) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
element.sourcePlugin = this.__filename
element.sourcePlugin = this.filename
// Race condition causing which would case duplicates if not for distinctBy
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.add(element)
@ -48,7 +48,7 @@ abstract class Plugin {
*/
fun registerExtractorAPI(element: ExtractorApi) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
element.sourcePlugin = this.__filename
element.sourcePlugin = this.filename
extractorApis.add(element)
}
@ -67,7 +67,12 @@ abstract class Plugin {
* This will contain your resources if you specified requiresResources in gradle
*/
var resources: Resources? = null
var __filename: String? = null
/** Full file path to the plugin. */
@Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename"))
var __filename: String?
get() = filename
set(value) {filename = value}
var filename: String? = null
/**
* This will add a button in the settings allowing you to add custom settings

View file

@ -1,24 +1,25 @@
package com.lagradost.cloudstream3.plugins
import android.Manifest
import android.app.*
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Resources
import android.os.Build
import android.os.Environment
import android.util.Log
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.Gson
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
@ -34,6 +35,7 @@ import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -137,6 +139,20 @@ object PluginManager {
}
}
/**
* Deletes all generated oat files which will force Android to recompile the dex extensions.
* This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update.
*/
fun deleteAllOatFiles(context: Context) {
File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo ->
repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file ->
val success = file.deleteRecursively()
Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success")
}
}
}
fun getPluginsOnline(): Array<PluginData> {
return getKey(PLUGINS_KEY) ?: emptyArray()
}
@ -150,7 +166,7 @@ object PluginManager {
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
public var currentlyLoading: String? = null
var currentlyLoading: String? = null
// Maps filepath to plugin
val plugins: MutableMap<String, Plugin> =
@ -326,7 +342,7 @@ object PluginManager {
//Omit non-NSFW if mode is set to NSFW only
if (mode == AutoDownloadMode.NsfwOnly) {
if (tvtypes.contains(TvType.NSFW.name) == false) {
if (!tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
}
@ -415,7 +431,6 @@ object PluginManager {
**/
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
val dir = File(LOCAL_PLUGINS_PATH)
removeKey(PLUGINS_KEY_LOCAL)
if (!dir.exists()) {
val res = dir.mkdirs()
@ -463,6 +478,14 @@ object PluginManager {
Log.i(TAG, "Loading plugin: $data")
return try {
// in case of android 14 then
try {
File(filePath).setReadOnly()
} catch (t: Throwable) {
Log.e(TAG, "Failed to set dex as readonly")
logError(t)
}
val loader = PathClassLoader(filePath, context.classLoader)
var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream ->
@ -484,10 +507,12 @@ object PluginManager {
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
Log.d(TAG, "No manifest version for ${data.internalName}")
}
@Suppress("UNCHECKED_CAST")
val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
val pluginInstance: Plugin =
pluginClass.newInstance() as Plugin
pluginClass.getDeclaredConstructor().newInstance() as Plugin
// Sets with the proper version
setPluginData(data.copy(version = version))
@ -497,14 +522,16 @@ object PluginManager {
return true
}
pluginInstance.__filename = fileName
pluginInstance.filename = file.absolutePath
if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
val assets = AssetManager::class.java.newInstance()
val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
val addAssetPath =
AssetManager::class.java.getMethod("addAssetPath", String::class.java)
addAssetPath.invoke(assets, file.absolutePath)
@Suppress("DEPRECATION")
pluginInstance.resources = Resources(
assets,
context.resources.displayMetrics,
@ -546,14 +573,14 @@ object PluginManager {
// remove all registered apis
synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
}
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename }
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
classLoaders.values.removeIf { v -> v == plugin }
@ -700,9 +727,14 @@ object PluginManager {
}
val notification = builder.build()
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define
notify((System.currentTimeMillis() / 1000).toInt(), notification)
// notificationId is a unique int for each notification that you must define
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
NotificationManagerCompat.from(context)
.notify((System.currentTimeMillis() / 1000).toInt(), notification)
}
return notification
} catch (e: Exception) {

View file

@ -73,7 +73,7 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
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_.-]+)/(.*)$")
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String {

View file

@ -15,7 +15,7 @@ import kotlinx.coroutines.sync.withLock
object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi"
private const val apiDomain = "https://counterapi.com/api"
private const val API_DOMAIN = "https://counterapi.com/api"
private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest
@ -49,13 +49,13 @@ object VotingApi { // please do not cheat the votes lol
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int {
var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value ?: 0
}
private suspend fun writeVote(pluginUrl: String): Boolean {
var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value != null
}
@ -69,8 +69,7 @@ object VotingApi { // please do not cheat the votes lol
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
fun canVote(pluginUrl: String): Boolean {
if (!PluginManager.urlPlugins.contains(pluginUrl)) return false
return true
return PluginManager.urlPlugins.contains(pluginUrl)
}
private val voteLock = Mutex()

View file

@ -0,0 +1,96 @@
package com.lagradost.cloudstream3.services
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.BackupUtils
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import java.util.concurrent.TimeUnit
const val BACKUP_CHANNEL_ID = "cloudstream3.backups"
const val BACKUP_WORK_NAME = "work_backup"
const val BACKUP_CHANNEL_NAME = "Backups"
const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups"
const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique
class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
companion object {
fun enqueuePeriodicWork(context: Context?, intervalHours: Long) {
if (context == null) return
if (intervalHours == 0L) {
WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME)
return
}
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.build()
val periodicSyncDataWork =
PeriodicWorkRequest.Builder(
BackupWorkManager::class.java,
intervalHours,
TimeUnit.HOURS
)
.addTag(BACKUP_WORK_NAME)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
BACKUP_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
periodicSyncDataWork
)
// Uncomment below for testing
// val oneTimeBackupWork =
// OneTimeWorkRequest.Builder(BackupWorkManager::class.java)
// .addTag(BACKUP_WORK_NAME)
// .setConstraints(constraints)
// .build()
//
// WorkManager.getInstance(context).enqueue(oneTimeBackupWork)
}
}
private val backupNotificationBuilder =
NotificationCompat.Builder(context, BACKUP_CHANNEL_ID)
.setColorized(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setAutoCancel(true)
.setContentTitle(context.getString(R.string.pref_category_backup))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
override suspend fun doWork(): Result {
context.createNotificationChannel(
BACKUP_CHANNEL_ID,
BACKUP_CHANNEL_NAME,
BACKUP_CHANNEL_DESCRIPTION
)
setForeground(
ForegroundInfo(
BACKUP_NOTIFICATION_ID,
backupNotificationBuilder.build()
)
)
BackupUtils.backup(context)
return Result.success()
}
}

View file

@ -1,5 +1,6 @@
package com.lagradost.cloudstream3.services
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
@ -9,13 +10,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.*
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
@ -97,128 +98,138 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
)
}
@SuppressLint("UnspecifiedImmutableFlag")
override suspend fun doWork(): Result {
try {
// println("Update subscriptions!")
context.createNotificationChannel(
SUBSCRIPTION_CHANNEL_ID,
SUBSCRIPTION_CHANNEL_NAME,
SUBSCRIPTION_CHANNEL_DESCRIPTION
)
setForeground(
ForegroundInfo(
SUBSCRIPTION_NOTIFICATION_ID,
progressNotificationBuilder.build()
context.createNotificationChannel(
SUBSCRIPTION_CHANNEL_ID,
SUBSCRIPTION_CHANNEL_NAME,
SUBSCRIPTION_CHANNEL_DESCRIPTION
)
)
val subscriptions = getAllSubscriptions()
setForeground(
ForegroundInfo(
SUBSCRIPTION_NOTIFICATION_ID,
progressNotificationBuilder.build()
)
)
if (subscriptions.isEmpty()) {
WorkManager.getInstance(context).cancelWorkById(this.id)
val subscriptions = getAllSubscriptions()
if (subscriptions.isEmpty()) {
WorkManager.getInstance(context).cancelWorkById(this.id)
return Result.success()
}
val max = subscriptions.size
var progress = 0
updateProgress(max, progress, true)
// We need all plugins loaded.
PluginManager.loadAllOnlinePlugins(context)
PluginManager.loadAllLocalPlugins(context, false)
subscriptions.apmap { savedData ->
try {
val id = savedData.id ?: return@apmap null
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
// Reasonable timeout to prevent having this worker run forever.
val response = withTimeoutOrNull(60_000) {
api.load(savedData.url) as? EpisodeResponse
} ?: return@apmap null
val dubPreference =
getDub(id) ?: if (
context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
) {
DubStatus.Dubbed
} else {
DubStatus.Subbed
}
val latestEpisodes = response.getLatestEpisodes()
val latestPreferredEpisode = latestEpisodes[dubPreference]
val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
val latestSeenEpisode =
savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
shouldUpdate to latestPreferredEpisode
} else {
val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
val latestSeenEpisode =
savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
val shouldUpdate = latestEpisode > latestSeenEpisode
shouldUpdate to latestEpisode
}
DataStoreHelper.updateSubscribedData(
id,
savedData,
response
)
if (shouldUpdate) {
val updateHeader = savedData.name
val updateDescription = txt(
R.string.subscription_episode_released,
latestEpisode,
savedData.name
).asString(context)
val intent = Intent(context, MainActivity::class.java).apply {
data = savedData.url.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getActivity(context, 0, intent, 0)
}
val poster = ioWork {
savedData.posterUrl?.let { url ->
context.getImageBitmapFromUrl(
url,
savedData.posterHeaders
)
}
}
val updateNotification =
updateNotificationBuilder.setContentTitle(updateHeader)
.setContentText(updateDescription)
.setContentIntent(pendingIntent)
.setLargeIcon(poster)
.build()
notificationManager.notify(id, updateNotification)
}
// You can probably get some issues here since this is async but it does not matter much.
updateProgress(max, ++progress, false)
} catch (t: Throwable) {
logError(t)
}
}
return Result.success()
} catch (t: Throwable) {
logError(t)
// ye, while this is not correct, but because gods know why android just crashes
// and this causes major battery usage as it retries it inf times. This is better, just
// in case android decides to be android and fuck us
return Result.success()
}
val max = subscriptions.size
var progress = 0
updateProgress(max, progress, true)
// We need all plugins loaded.
PluginManager.loadAllOnlinePlugins(context)
PluginManager.loadAllLocalPlugins(context, false)
subscriptions.apmap { savedData ->
try {
val id = savedData.id ?: return@apmap null
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
// Reasonable timeout to prevent having this worker run forever.
val response = withTimeoutOrNull(60_000) {
api.load(savedData.url) as? EpisodeResponse
} ?: return@apmap null
val dubPreference =
getDub(id) ?: if (
context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
) {
DubStatus.Dubbed
} else {
DubStatus.Subbed
}
val latestEpisodes = response.getLatestEpisodes()
val latestPreferredEpisode = latestEpisodes[dubPreference]
val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
val latestSeenEpisode =
savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
shouldUpdate to latestPreferredEpisode
} else {
val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
val latestSeenEpisode =
savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
val shouldUpdate = latestEpisode > latestSeenEpisode
shouldUpdate to latestEpisode
}
DataStoreHelper.updateSubscribedData(
id,
savedData,
response
)
if (shouldUpdate) {
val updateHeader = savedData.name
val updateDescription = txt(
R.string.subscription_episode_released,
latestEpisode,
savedData.name
).asString(context)
val intent = Intent(context, MainActivity::class.java).apply {
data = savedData.url.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getActivity(context, 0, intent, 0)
}
val poster = ioWork {
savedData.posterUrl?.let { url ->
context.getImageBitmapFromUrl(
url,
savedData.posterHeaders
)
}
}
val updateNotification =
updateNotificationBuilder.setContentTitle(updateHeader)
.setContentText(updateDescription)
.setContentIntent(pendingIntent)
.setLargeIcon(poster)
.build()
notificationManager.notify(id, updateNotification)
}
// You can probably get some issues here since this is async but it does not matter much.
updateProgress(max, ++progress, false)
} catch (_: Throwable) {
}
}
return Result.success()
}
}

View file

@ -1,11 +1,23 @@
package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
import okio.BufferedSource
import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.util.zip.ZipInputStream
interface AbstractSubProvider {
val idPrefix: String
@WorkerThread
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
throw NotImplementedError()
@ -15,6 +27,98 @@ interface AbstractSubProvider {
suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError()
}
@WorkerThread
suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
this.addUrl(load(data))
}
@WorkerThread
suspend fun getResource(data: SubtitleEntity): SubtitleResource {
return SubtitleResource().apply {
this.getResources(data)
}
}
}
/**
* A builder for subtitle files.
* @see addUrl
* @see addFile
*/
class SubtitleResource {
fun downloadFile(source: BufferedSource): File {
val file = File.createTempFile("temp-subtitle", ".tmp").apply {
deleteFileOnExit(this)
}
val sink = file.sink().buffer()
sink.writeAll(source)
sink.close()
source.close()
return file
}
private fun unzip(file: File): List<Pair<String, File>> {
val entries = mutableListOf<Pair<String, File>>()
ZipInputStream(file.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) {
val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply {
deleteFileOnExit(this)
}
entries.add(zipEntry.name to tempFile)
tempFile.sink().buffer().use { buffer ->
buffer.writeAll(zipInputStream.source())
}
zipEntry = zipInputStream.nextEntry
}
}
return entries
}
data class SingleSubtitleResource(
val name: String?,
val url: String,
val origin: SubtitleOrigin
)
private var resources: MutableList<SingleSubtitleResource> = mutableListOf()
fun getSubtitles(): List<SingleSubtitleResource> {
return resources.toList()
}
fun addUrl(url: String?, name: String? = null) {
if (url == null) return
this.resources.add(
SingleSubtitleResource(name, url, SubtitleOrigin.URL)
)
}
fun addFile(file: File, name: String? = null) {
this.resources.add(
SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE)
)
deleteFileOnExit(file)
}
suspend fun addZipUrl(
url: String,
nameGenerator: (String, File) -> String? = { _, _ -> null }
) {
val source = app.get(url).okhttpResponse.body.source()
val zip = downloadFile(source)
val realFiles = unzip(zip)
zip.deleteRecursively()
realFiles.forEach { (name, subtitleFile) ->
addFile(subtitleFile, nameGenerator(name, subtitleFile))
}
}
}
interface AbstractSubApi : AbstractSubProvider, AuthAPI

View file

@ -19,8 +19,11 @@ class AbstractSubtitleEntities {
data class SubtitleSearch(
var query: String = "",
var imdb: Long? = null,
var lang: String? = null,
var imdbId: String? = null,
var tmdbId: Int? = null,
var malId: Int? = null,
var aniListId: Int? = null,
var epNumber: Int? = null,
var seasonNumber: Int? = null,
var year: Int? = null

View file

@ -3,18 +3,26 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.*
import java.util.concurrent.TimeUnit
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
companion object {
val malApi = MALApi(0)
val aniListApi = AniListApi(0)
val malApi = MALApi(0).also { api ->
LoadResponse.Companion.malIdPrefix = api.idPrefix
}
val aniListApi = AniListApi(0).also { api ->
LoadResponse.Companion.aniListIdPrefix = api.idPrefix
}
val simklApi = SimklApi(0).also { api ->
LoadResponse.Companion.simklIdPrefix = api.idPrefix
}
val openSubtitlesApi = OpenSubtitlesApi(0)
val simklApi = SimklApi(0)
val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed()
val subDlApi = SubDlApi(0)
val localListApi = LocalList()
val subSourceApi = SubSourceApi()
// used to login via app intent
val OAuth2Apis
@ -25,7 +33,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// this needs init with context and can be accessed in settings
val accountManagers
get() = listOf(
malApi, aniListApi, openSubtitlesApi, simklApi //nginxApi
malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
)
// used for active syncing
@ -35,31 +43,35 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
)
val inAppAuths
get() = listOf(openSubtitlesApi)//, nginxApi)
get() = listOf<InAppAuthAPIManager>(
openSubtitlesApi,
subDlApi
)//, nginxApi)
val subtitleProviders
get() = listOf(
openSubtitlesApi,
indexSubtitlesApi, // they got anti scraping measures in place :(
addic7ed
addic7ed,
subDlApi,
subSourceApi
)
const val appString = "cloudstreamapp"
const val appStringRepo = "cloudstreamrepo"
const val appStringPlayer = "cloudstreamplayer"
const val APP_STRING = "cloudstreamapp"
const val APP_STRING_REPO = "cloudstreamrepo"
const val APP_STRING_PLAYER = "cloudstreamplayer"
// Instantly start the search given a query
const val appStringSearch = "cloudstreamsearch"
const val APP_STRING_SEARCH = "cloudstreamsearch"
// Instantly resume watching a show
const val appStringResumeWatching = "cloudstreamcontinuewatching"
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
val unixTime: Long
get() = System.currentTimeMillis() / 1000L
val unixTimeMs: Long
get() = System.currentTimeMillis()
const val maxStale = 60 * 10
const val MAX_STALE = 60 * 10
fun secondsToReadable(seconds: Int, completedValue: String): String {
var secondsLong = seconds.toLong()

View file

@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity
interface OAuth2API : AuthAPI {
val key: String
val redirectUrl: String
val supportDeviceAuth: Boolean
suspend fun handleRedirect(url: String) : Boolean
fun authenticate(activity: FragmentActivity?)
suspend fun getDevicePin() : PinAuthData? {
return null
}
suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean {
return false
}
data class PinAuthData(
val deviceCode: String,
val userCode: String,
val verificationUrl: String,
val expiresIn: Int,
val interval: Int,
)
}

View file

@ -1,18 +1,11 @@
package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch
enum class SyncIdName {
Anilist,
MyAnimeList,
Trakt,
Imdb,
Simkl,
LocalList,
}
import java.util.Date
interface SyncAPI : OAuth2API {
/**
@ -61,7 +54,7 @@ interface SyncAPI : OAuth2API {
) : SearchResponse
abstract class AbstractSyncStatus {
abstract var status: Int
abstract var status: SyncWatchType
/** 1-10 */
abstract var score: Int?
@ -70,8 +63,9 @@ interface SyncAPI : OAuth2API {
abstract var maxEpisodes: Int?
}
data class SyncStatus(
override var status: Int,
override var status: SyncWatchType,
/** 1-10 */
override var score: Int?,
override var watchedEpisodes: Int?,
@ -131,6 +125,8 @@ interface SyncAPI : OAuth2API {
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
else -> items
}
}
@ -165,6 +161,10 @@ interface SyncAPI : OAuth2API {
override var posterUrl: String?,
override var posterHeaders: Map<String, String>?,
override var quality: SearchQuality?,
val releaseDate: Date?,
override var id: Int? = null,
val plot : String? = null,
val rating: Int? = null,
val tags: List<String>? = null
) : SearchResponse
}

View file

@ -18,13 +18,13 @@ class Addic7ed : AbstractSubApi {
override fun logOut() {}
companion object {
const val host = "https://www.addic7ed.com"
const val HOST = "https://www.addic7ed.com"
const val TAG = "ADDIC7ED"
}
private fun fixUrl(url: String): String {
return if (url.startsWith("/")) host + url
else if (!url.startsWith("http")) "$host/$url"
return if (url.startsWith("/")) HOST + url
else if (!url.startsWith("http")) "$HOST/$url"
else url
}
@ -62,7 +62,7 @@ class Addic7ed : AbstractSubApi {
}
val title = queryText.substringBefore("(").trim()
val url = "$host/search.php?search=${title}&Submit=Search"
val url = "$HOST/search.php?search=${title}&Submit=Search"
val hostDocument = app.get(url).document
var searchResult = ""
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
@ -74,8 +74,8 @@ class Addic7ed : AbstractSubApi {
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
?.substringBefore(",")
val doc = app.get(
"$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
referer = "$host/"
"$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
referer = "$HOST/"
).document
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
if (node.selectFirst("td")?.text()
@ -97,7 +97,7 @@ class Addic7ed : AbstractSubApi {
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
val isHearingImpaired =
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired)
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
}
return results
}

View file

@ -13,17 +13,19 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
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.ui.result.txt
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import java.net.URL
import java.net.URLEncoder
import java.util.*
import java.util.Locale
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "AniList"
@ -31,6 +33,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist"
override var requireLibraryRefresh = true
override val supportDeviceAuth = false
override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon
override val requiresLogin = false
@ -61,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
val token = sanitizer["access_token"]!!
val expiresIn = sanitizer["expires_in"]!!
@ -85,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(name) ?: return null
return data.data?.Page?.media?.map {
return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,
this.name,
@ -99,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getResult(id: String): SyncAPI.SyncResult {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
val season = getSeason(internalId).data.Media
val season = getSeason(internalId).data.media
return SyncAPI.SyncResult(
season.id.toString(),
@ -165,7 +168,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
return SyncAPI.SyncStatus(
score = data.score,
watchedEpisodes = data.progress,
status = data.type?.value ?: return null,
status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
isFavorite = data.isFavourite,
maxEpisodes = data.episodes,
)
@ -174,7 +177,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId(
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status),
fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
@ -299,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
val shows = searchShows(name.replace(blackListRegex, ""))
shows?.data?.Page?.media?.find {
shows?.data?.page?.media?.find {
(malId ?: "NONE") == it.idMal.toString()
}?.let { return it }
val filtered =
shows?.data?.Page?.media?.filter {
shows?.data?.page?.media?.filter {
(((it.startDate.year ?: year.toString()) == year.toString()
|| year == null))
}
@ -494,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q, true)
val d = parseJson<GetDataRoot>(data ?: return null)
val main = d.data?.Media
val main = d.data?.media
if (main?.mediaListEntry != null) {
return AniListTitleHolder(
title = main.title,
@ -534,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
headers = mapOf(
"Authorization" to "Bearer " + (getAuth()
?: return@suspendSafeApiCall null),
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
),
cacheTime = 0,
data = mapOf(
@ -595,7 +598,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//@JsonProperty("source") val source: String,
@JsonProperty("episodes") val episodes: Int,
@JsonProperty("title") val title: Title,
//@JsonProperty("description") val description: String,
@JsonProperty("description") val description: String?,
@JsonProperty("coverImage") val coverImage: CoverImage,
@JsonProperty("synonyms") val synonyms: List<String>,
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
@ -629,7 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
?: this.media.coverImage.medium,
null,
null,
null
this.media.seasonYear.toYear(),
null,
plot = this.media.description,
)
}
}
@ -644,7 +649,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class Data(
@JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
)
private fun getAniListListCached(): Array<Lists>? {
@ -656,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
if (checkToken()) return null
return if (requireLibraryRefresh) {
val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray()
val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) {
setKey(ANILIST_CACHED_LIST, list)
}
@ -675,7 +680,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
// To fill empty lists when AniList does not return them
val baseMap =
AniListStatusType.values().filter { it.value >= 0 }.associate {
AniListStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>()
}
@ -686,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
@ -761,7 +768,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
/** Used to query a saved MediaItem on the list to get the id for removal */
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = null)
data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId(
@ -784,7 +791,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
"""
val response = postApi(idQuery)
val listId =
tryParseJson<MediaListItemRoot>(response)?.data?.MediaList?.id ?: return false
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
"""
mutation(${'$'}id: Int = $listId) {
DeleteMediaListEntry(id: ${'$'}id) {
@ -833,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q)
if (data.isNullOrBlank()) return null
val userData = parseJson<AniListRoot>(data)
val u = userData.data?.Viewer
val u = userData.data?.viewer
val user = AniListUser(
u?.id,
u?.name,
@ -855,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
suspend fun getSeasonRecursive(id: Int) {
val season = getSeason(id)
seasons.add(season)
if (season.data.Media.format?.startsWith("TV") == true) {
season.data.Media.relations?.edges?.forEach {
if (season.data.media.format?.startsWith("TV") == true) {
season.data.media.relations?.edges?.forEach {
if (it.node?.format != null) {
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
getSeasonRecursive(it.node.id)
@ -875,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class SeasonData(
@JsonProperty("Media") val Media: SeasonMedia,
@JsonProperty("Media") val media: SeasonMedia,
)
data class SeasonMedia(
@ -1047,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class AniListData(
@JsonProperty("Viewer") val Viewer: AniListViewer?,
@JsonProperty("Viewer") val viewer: AniListViewer?,
)
data class AniListRoot(
@ -1087,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class LikeData(
@JsonProperty("Viewer") val Viewer: LikeViewer?,
@JsonProperty("Viewer") val viewer: LikeViewer?,
)
data class LikeRoot(
@ -1127,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class GetDataData(
@JsonProperty("Media") val Media: GetDataMedia?,
@JsonProperty("Media") val media: GetDataMedia?,
)
data class GetDataRoot(
@ -1160,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class GetSearchPage(
@JsonProperty("Page") val Page: GetSearchData?,
@JsonProperty("Page") val page: GetSearchData?,
)
data class GetSearchData(

View file

@ -11,6 +11,7 @@ class Dropbox : OAuth2API {
override val key = "zlqsamadlwydvb2"
override val redirectUrl = "dropboxlogin"
override val requiresLogin = true
override val supportDeviceAuth = false
override val createAccountUrl: String? = null
override val icon: Int

View file

@ -1,265 +0,0 @@
package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.imdbUrlToIdNullable
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.utils.SubtitleHelper
class IndexSubtitleApi : AbstractSubApi {
override val name = "IndexSubtitle"
override val idPrefix = "indexsubtitle"
override val requiresLogin = false
override val icon: Nothing? = null
override val createAccountUrl: Nothing? = null
override fun loginInfo(): Nothing? = null
override fun logOut() {}
companion object {
const val host = "https://indexsubtitle.com"
const val TAG = "INDEXSUBS"
}
private fun fixUrl(url: String): String {
if (url.startsWith("http")) {
return url
}
if (url.isEmpty()) {
return ""
}
val startsWithNoHttp = url.startsWith("//")
if (startsWithNoHttp) {
return "https:$url"
} else {
if (url.startsWith('/')) {
return host + url
}
return "$host/$url"
}
}
private fun getOrdinal(num: Int?): String? {
return when (num) {
1 -> "First"
2 -> "Second"
3 -> "Third"
4 -> "Fourth"
5 -> "Fifth"
6 -> "Sixth"
7 -> "Seventh"
8 -> "Eighth"
9 -> "Ninth"
10 -> "Tenth"
11 -> "Eleventh"
12 -> "Twelfth"
13 -> "Thirteenth"
14 -> "Fourteenth"
15 -> "Fifteenth"
16 -> "Sixteenth"
17 -> "Seventeenth"
18 -> "Eighteenth"
19 -> "Nineteenth"
20 -> "Twentieth"
21 -> "Twenty-First"
22 -> "Twenty-Second"
23 -> "Twenty-Third"
24 -> "Twenty-Fourth"
25 -> "Twenty-Fifth"
26 -> "Twenty-Sixth"
27 -> "Twenty-Seventh"
28 -> "Twenty-Eighth"
29 -> "Twenty-Ninth"
30 -> "Thirtieth"
31 -> "Thirty-First"
32 -> "Thirty-Second"
33 -> "Thirty-Third"
34 -> "Thirty-Fourth"
35 -> "Thirty-Fifth"
else -> null
}
}
private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean {
val FILTER_EPS_REGEX =
Regex("(?i)((Chapter\\s?0?${epNum})|((Season)?\\s?0?${seasonNum}?\\s?(Episode)\\s?0?${epNum}[^0-9]))|(?i)((S?0?${seasonNum}?E0?${epNum}[^0-9])|(0?${seasonNum}[a-z]0?${epNum}[^0-9]))")
return text.contains(FILTER_EPS_REGEX)
}
private fun haveEps(text: String): Boolean {
val HAVE_EPS_REGEX =
Regex("(?i)((Chapter\\s?0?\\d)|((Season)?\\s?0?\\d?\\s?(Episode)\\s?0?\\d))|(?i)((S?0?\\d?E0?\\d)|(0?\\d[a-z]0?\\d))")
return text.contains(HAVE_EPS_REGEX)
}
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity> {
val imdbId = query.imdb ?: 0
val lang = query.lang
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
val queryText = query.query
val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0
val urlItems = ArrayList<String>()
fun cleanResources(
results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
name: String,
link: String
) {
results.add(
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = idPrefix,
name = name,
lang = queryLang.toString(),
data = link,
source = this.name,
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
epNumber = epNum,
seasonNumber = seasonNum,
year = yearNum,
)
)
}
val document = app.get("$host/?search=$queryText").document
document.select("div.my-3.p-3 div.media").map { block ->
if (seasonNum > 0) {
val name = block.select("strong.text-primary, strong.text-info").text().trim()
val season = getOrdinal(seasonNum)
if ((block.selectFirst("a")?.attr("href")
?.contains(
"$season",
ignoreCase = true
)!! || name.contains(
"$season",
ignoreCase = true
)) && name.contains(queryText, ignoreCase = true)
) {
block.select("div.media").mapNotNull {
urlItems.add(
fixUrl(
it.selectFirst("a")!!.attr("href")
)
)
}
}
} else {
if (block.selectFirst("strong")!!.text().trim()
.matches(Regex("(?i)^$queryText\$"))
) {
if (block.select("span[title=Release]").isNullOrEmpty()) {
block.select("div.media").mapNotNull {
val urlItem = fixUrl(
it.selectFirst("a")!!.attr("href")
)
val itemDoc = app.get(urlItem).document
val id = imdbUrlToIdNullable(
itemDoc.selectFirst("div.d-flex span.badge.badge-primary")?.parent()
?.attr("href")
)?.toLongOrNull()
val year = itemDoc.selectFirst("div.d-flex span.badge.badge-success")
?.ownText()
?.trim().toString()
Log.i(TAG, "id => $id \nyear => $year||$yearNum")
if (imdbId > 0) {
if (id == imdbId) {
urlItems.add(urlItem)
}
} else {
if (year.contains("$yearNum")) {
urlItems.add(urlItem)
}
}
}
} else {
if (block.select("span[title=Release]").text().trim()
.contains("$yearNum")
) {
block.select("div.media").mapNotNull {
urlItems.add(
fixUrl(
it.selectFirst("a")!!.attr("href")
)
)
}
}
}
}
}
}
Log.i(TAG, "urlItems => $urlItems")
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
urlItems.forEach { url ->
val request = app.get(url)
if (request.isSuccessful) {
request.document.select("div.my-3.p-3 div.media").map { block ->
if (block.select("span.d-block span[data-original-title=Language]").text()
.trim()
.contains("$queryLang")
) {
var name = block.select("strong.text-primary, strong.text-info").text().trim()
val link = fixUrl(block.selectFirst("a")!!.attr("href"))
if (seasonNum > 0) {
when {
isRightEps(name, seasonNum, epNum) -> {
cleanResources(results, name, link)
}
!(haveEps(name)) -> {
name = "$name (S${seasonNum}:E${epNum})"
cleanResources(results, name, link)
}
}
} else {
cleanResources(results, name, link)
}
}
}
}
}
return results
}
override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
val seasonNum = data.seasonNumber
val epNum = data.epNumber
val req = app.get(data.data)
if (req.isSuccessful) {
val document = req.document
val link = if (document.select("div.my-3.p-3 div.media").size == 1) {
fixUrl(
document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href")
)
} else {
document.select("div.my-3.p-3 div.media").firstNotNullOf { block ->
val name =
block.selectFirst("strong.d-block")?.text()?.trim().toString()
if (seasonNum!! > 0) {
if (isRightEps(name, seasonNum, epNum)) {
fixUrl(block.selectFirst("a")!!.attr("href"))
} else {
null
}
} else {
fixUrl(block.selectFirst("a")!!.attr("href"))
}
}
}
return link
}
return null
}
}

View file

@ -8,7 +8,10 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
@ -18,6 +21,7 @@ class LocalList : SyncAPI {
override val name = "Local"
override val icon: Int = R.drawable.ic_baseline_storage_24
override val requiresLogin = false
override val supportDeviceAuth = false
override val createAccountUrl: Nothing? = null
override val idPrefix = "local"
override var requireLibraryRefresh = true
@ -69,31 +73,57 @@ class LocalList : SyncAPI {
}?.distinctBy { it.first } ?: return null
val list = ioWork {
watchStatusIds.groupBy {
it.second.stringRes
}.mapValues { group ->
val isTrueTv = isLayout(TV)
val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate {
// None is not something to display
it.stringRes to emptyList<SyncAPI.LibraryItem>()
} + mapOf(
R.string.favorites_list_name to emptyList()
) + if (!isTrueTv) {
mapOf(
R.string.subscription_list_name to emptyList()
)
} else {
emptyMap()
}
val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group ->
group.value.mapNotNull {
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
}
} + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
}
val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull {
it.toLibraryItem()
})
// Don't show subscriptions on TV
val result = if (isTrueTv) {
baseMap + watchStatusMap + favoritesMap
} else {
val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
it.toLibraryItem()
})
baseMap + watchStatusMap + subscriptionsMap + favoritesMap
}
result
}
val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate {
// None is not something to display
it.stringRes to emptyList<SyncAPI.LibraryItem>()
} + mapOf(R.string.subscription_list_name to emptyList())
return SyncAPI.LibraryMetadata(
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
// ListSorting.UpdatedNew,
// ListSorting.UpdatedOld,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
// ListSorting.RatingHigh,
// ListSorting.RatingLow,
)
)
}

View file

@ -16,16 +16,22 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
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.ui.result.txt
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import java.net.URL
import java.security.SecureRandom
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
const val MAL_MAX_SEARCH_LIMIT = 25
@ -39,6 +45,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private val apiUrl = "https://api.myanimelist.net"
override val icon = R.drawable.mal_logo
override val requiresLogin = false
override val supportDeviceAuth = false
override val syncIdName = SyncIdName.MyAnimeList
override var requireLibraryRefresh = true
override val createAccountUrl = "$mainUrl/register.php"
@ -49,7 +56,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
override fun loginInfo(): AuthAPI.LoginInfo? {
//getMalUser(true)?
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
return AuthAPI.LoginInfo(
profilePicture = user.picture,
@ -82,7 +88,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
this.name,
node.id.toString(),
"$mainUrl/anime/${node.id}/",
node.main_picture?.large ?: node.main_picture?.medium
node.mainPicture?.large ?: node.mainPicture?.medium
)
}
}
@ -94,7 +100,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest(
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status),
fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
@ -176,7 +182,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private fun parseDate(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time
} catch (e: Exception) {
null
}
@ -188,7 +194,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
apiName = this.name,
syncId = node.id.toString(),
url = "$mainUrl/anime/${node.id}",
posterUrl = node.main_picture?.large
posterUrl = node.mainPicture?.large
)
}
@ -242,12 +248,12 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
val internalId = id.toIntOrNull() ?: return null
val data =
getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status")
getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status")
return SyncAPI.SyncStatus(
score = data?.score,
status = malStatusAsString.indexOf(data?.status),
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
isFavorite = null,
watchedEpisodes = data?.num_episodes_watched,
watchedEpisodes = data?.numEpisodesWatched,
)
}
@ -289,7 +295,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private fun parseDateLong(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse(
string ?: return null
)?.time?.div(1000)
} catch (e: Exception) {
@ -300,7 +306,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
val state = sanitizer["state"]!!
if (state == "RequestID$requestId") {
val currentCode = sanitizer["code"]!!
@ -349,9 +355,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
try {
if (response != "") {
val token = parseJson<ResponseToken>(response)
setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime))
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token)
setKey(accountId, MAL_TOKEN_KEY, token.access_token)
setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime))
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken)
setKey(accountId, MAL_TOKEN_KEY, token.accessToken)
requireLibraryRefresh = true
}
} catch (e: Exception) {
@ -393,55 +399,62 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class Node(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String,
@JsonProperty("main_picture") val main_picture: MainPicture?,
@JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?,
@JsonProperty("media_type") val media_type: String?,
@JsonProperty("num_episodes") val num_episodes: Int?,
@JsonProperty("main_picture") val mainPicture: MainPicture?,
@JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
@JsonProperty("media_type") val mediaType: String?,
@JsonProperty("num_episodes") val numEpisodes: Int?,
@JsonProperty("status") val status: String?,
@JsonProperty("start_date") val start_date: String?,
@JsonProperty("end_date") val end_date: String?,
@JsonProperty("average_episode_duration") val average_episode_duration: Int?,
@JsonProperty("start_date") val startDate: String?,
@JsonProperty("end_date") val endDate: String?,
@JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("mean") val mean: Double?,
@JsonProperty("genres") val genres: List<Genres>?,
@JsonProperty("rank") val rank: Int?,
@JsonProperty("popularity") val popularity: Int?,
@JsonProperty("num_list_users") val num_list_users: Int?,
@JsonProperty("num_favorites") val num_favorites: Int?,
@JsonProperty("num_scoring_users") val num_scoring_users: Int?,
@JsonProperty("start_season") val start_season: StartSeason?,
@JsonProperty("num_list_users") val numListUsers: Int?,
@JsonProperty("num_favorites") val numFavorites: Int?,
@JsonProperty("num_scoring_users") val numScoringUsers: Int?,
@JsonProperty("start_season") val startSeason: StartSeason?,
@JsonProperty("broadcast") val broadcast: Broadcast?,
@JsonProperty("nsfw") val nsfw: String?,
@JsonProperty("created_at") val created_at: String?,
@JsonProperty("updated_at") val updated_at: String?
@JsonProperty("created_at") val createdAt: String?,
@JsonProperty("updated_at") val updatedAt: String?
)
data class ListStatus(
@JsonProperty("status") val status: String?,
@JsonProperty("score") val score: Int,
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
@JsonProperty("is_rewatching") val is_rewatching: Boolean,
@JsonProperty("updated_at") val updated_at: String,
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
@JsonProperty("is_rewatching") val isRewatching: Boolean,
@JsonProperty("updated_at") val updatedAt: String,
)
data class Data(
@JsonProperty("node") val node: Node,
@JsonProperty("list_status") val list_status: ListStatus?,
@JsonProperty("list_status") val listStatus: ListStatus?,
) {
fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
this.node.title,
"https://myanimelist.net/anime/${this.node.id}/",
this.node.id.toString(),
this.list_status?.num_episodes_watched,
this.node.num_episodes,
this.list_status?.score?.times(10),
parseDateLong(this.list_status?.updated_at),
this.listStatus?.numEpisodesWatched,
this.node.numEpisodes,
this.listStatus?.score?.times(10),
parseDateLong(this.listStatus?.updatedAt),
"MAL",
TvType.Anime,
this.node.main_picture?.large ?: this.node.main_picture?.medium,
this.node.mainPicture?.large ?: this.node.mainPicture?.medium,
null,
null,
plot = this.node.synopsis,
releaseDate = if (this.node.startDate == null) null else try {Date.from(
Instant.from(
DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
.parse(this.node.startDate)
)
)} catch (_: RuntimeException) {null}
)
}
}
@ -467,8 +480,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
)
data class Broadcast(
@JsonProperty("day_of_the_week") val day_of_the_week: String?,
@JsonProperty("start_time") val start_time: String?
@JsonProperty("day_of_the_week") val dayOfTheWeek: String?,
@JsonProperty("start_time") val startTime: String?
)
private fun getMalAnimeListCached(): Array<Data>? {
@ -488,14 +501,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
val list = getMalAnimeListSmart()?.groupBy {
convertToStatus(it.list_status?.status ?: "").stringRes
convertToStatus(it.listStatus?.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when MAL does not return them
val baseMap =
MalStatusType.values().filter { it.value >= 0 }.associate {
MalStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>()
}
@ -506,6 +519,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
@ -570,7 +585,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
).text
val values = parseJson<MalRoot>(res)
val titles =
values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) }
values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) }
for (t in titles) {
allTitles[t.id] = t
}
@ -579,11 +594,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
// No time remaining if the show has already ended
try {
endDate?.let {
if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null
if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it)
?.before(Date.from(Instant.now())) != false
) return@convertJapanTimeToTimeRemaining null
}
} catch (e: ParseException) {
logError(e)
@ -600,7 +617,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
val currentYear = currentDate.get(Calendar.YEAR)
val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm")
val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault())
dateFormat.timeZone = TimeZone.getTimeZone("Japan")
val parsedDate =
dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
@ -644,13 +661,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
id: Int,
status: MalStatusType? = null,
score: Int? = null,
num_watched_episodes: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val res = setScoreRequest(
id,
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
score,
num_watched_episodes
numWatchedEpisodes
)
return if (res.isNullOrBlank()) {
@ -667,17 +684,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
id: Int,
status: String? = null,
score: Int? = null,
num_watched_episodes: Int? = null,
numWatchedEpisodes: Int? = null,
): String? {
val data = mapOf(
"status" to status,
"score" to score?.toString(),
"num_watched_episodes" to num_watched_episodes?.toString()
).filter { it.value != null } as Map<String, String>
"num_watched_episodes" to numWatchedEpisodes?.toString()
).filterValues { it != null } as Map<String, String>
return app.put(
"$apiUrl/v2/anime/$id/my_list_status",
@ -690,10 +708,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class ResponseToken(
@JsonProperty("token_type") val token_type: String,
@JsonProperty("expires_in") val expires_in: Int,
@JsonProperty("access_token") val access_token: String,
@JsonProperty("refresh_token") val refresh_token: String,
@JsonProperty("token_type") val tokenType: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("refresh_token") val refreshToken: String,
)
data class MalRoot(
@ -702,7 +720,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class MalDatum(
@JsonProperty("node") val node: MalNode,
@JsonProperty("list_status") val list_status: MalStatus,
@JsonProperty("list_status") val listStatus: MalStatus,
)
data class MalNode(
@ -719,16 +737,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class MalStatus(
@JsonProperty("status") val status: String,
@JsonProperty("score") val score: Int,
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
@JsonProperty("is_rewatching") val is_rewatching: Boolean,
@JsonProperty("updated_at") val updated_at: String,
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
@JsonProperty("is_rewatching") val isRewatching: Boolean,
@JsonProperty("updated_at") val updatedAt: String,
)
data class MalUser(
@JsonProperty("id") val id: Int,
@JsonProperty("name") val name: String,
@JsonProperty("location") val location: String,
@JsonProperty("joined_at") val joined_at: String,
@JsonProperty("joined_at") val joinedAt: String,
@JsonProperty("picture") val picture: String?,
)
@ -741,9 +759,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class SmallMalAnime(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String?,
@JsonProperty("num_episodes") val num_episodes: Int,
@JsonProperty("my_list_status") val my_list_status: MalStatus?,
@JsonProperty("main_picture") val main_picture: MalMainPicture?,
@JsonProperty("num_episodes") val numEpisodes: Int,
@JsonProperty("my_list_status") val myListStatus: MalStatus?,
@JsonProperty("main_picture") val mainPicture: MalMainPicture?,
)
data class MalSearchNode(

View file

@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.common.collect.BiMap
import com.google.common.collect.HashBiMap
import com.lagradost.cloudstream3.*
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.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
import com.lagradost.cloudstream3.utils.AppUtils
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import okhttp3.Interceptor
import okhttp3.Response
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val idPrefix = "opensubtitles"
@ -28,14 +29,31 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
companion object {
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
const val host = "https://api.opensubtitles.com/api/v1"
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
const val HOST = "https://api.opensubtitles.com/api/v1"
const val TAG = "OPENSUBS"
const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
var currentCoolDown: Long = 0L
var currentSession: SubtitleOAuthEntity? = null
}
private val headerInterceptor = OpenSubtitleInterceptor()
/** Automatically adds required api headers */
private class OpenSubtitleInterceptor : Interceptor {
/** Required user agent! */
private val userAgent = "Cloudstream3 v0.1"
override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(
chain.request().newBuilder()
.removeHeader("user-agent")
.addHeader("user-agent", userAgent)
.addHeader("Api-Key", API_KEY)
.build()
)
}
}
private fun canDoRequest(): Boolean {
return unixTimeMs > currentCoolDown
}
@ -47,7 +65,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
}
private fun throwGotTooManyRequests() {
currentCoolDown = unixTimeMs + coolDownDuration
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
throw ErrorLoadingException("Too many requests")
}
@ -96,15 +114,15 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
private suspend fun initLogin(username: String, password: String): Boolean {
//Log.i(TAG, "DATA = [$username] [$password]")
val response = app.post(
url = "$host/login",
url = "$HOST/login",
headers = mapOf(
"Api-Key" to apiKey,
"Content-Type" to "application/json"
"Content-Type" to "application/json",
),
data = mapOf(
"username" to username,
"password" to password
)
),
interceptor = headerInterceptor
)
//Log.i(TAG, "Responsecode = ${response.code}")
//Log.i(TAG, "Result => ${response.text}")
@ -115,7 +133,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
SubtitleOAuthEntity(
user = username,
pass = password,
access_token = token.token ?: run {
accessToken = token.token ?: run {
return false
})
)
@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
// "pt" to "pt-PT",
// "pt" to "pt-BR"
)
private fun fixLanguage(language: String?) : String? {
private fun fixLanguage(language: String?): String? {
return languageExceptions[language] ?: language
}
// O(n) but good enough, BiMap did not want to work properly
private fun fixLanguageReverse(language: String?) : String? {
private fun fixLanguageReverse(language: String?): String? {
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
}
@ -165,7 +185,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
throwIfCantDoRequest()
val fixedLang = fixLanguage(query.lang)
val imdbId = query.imdb ?: 0
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
val queryText = query.query
val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0
@ -176,16 +196,16 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid
true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
}
val req = app.get(
url = searchQueryUrl,
headers = mapOf(
Pair("Api-Key", apiKey),
Pair("Content-Type", "application/json")
)
),
interceptor = headerInterceptor
)
Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) {
@ -207,12 +227,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
//Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query
val lang = fixLanguageReverse(attr.language)?: ""
val lang = fixLanguageReverse(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
val isHearingImpaired = attr.hearing_impaired ?: false
val isHearingImpaired = attr.hearingImpaired ?: false
//Log.i(TAG, "Result id/name => ${item.id} / $name")
item.attributes?.files?.forEach { file ->
val resultData = file.fileId?.toString() ?: ""
@ -245,19 +265,19 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
throwIfCantDoRequest()
val req = app.post(
url = "$host/download",
url = "$HOST/download",
headers = mapOf(
Pair(
"Authorization",
"Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}"
"Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
),
Pair("Api-Key", apiKey),
Pair("Content-Type", "application/json"),
Pair("Accept", "*/*")
),
data = mapOf(
Pair("file_id", data.data)
)
),
interceptor = headerInterceptor
)
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
//Log.i(TAG, "Request headers => ${req.headers}")
@ -278,7 +298,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
data class SubtitleOAuthEntity(
var user: String,
var pass: String,
var access_token: String,
var accessToken: String,
)
data class OAuthToken(
@ -303,7 +323,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
@JsonProperty("url") var url: String? = null,
@JsonProperty("files") var files: List<ResultFiles>? = listOf(),
@JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
@JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null,
@JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null,
)
data class ResultFiles(

View file

@ -0,0 +1,159 @@
package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.SubtitleHelper
class SubSourceApi : AbstractSubProvider {
override val idPrefix = "subsource"
val name = "SubSource"
companion object {
const val APIURL = "https://api.subsource.net/api"
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
}
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
//Only supports Imdb Id search for now
if (query.imdbId == null) return null
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
val searchRes = app.post(
url = "$APIURL/searchMovie",
data = mapOf(
"query" to query.imdbId!!
)
).parsedSafe<ApiSearch>() ?: return null
val postData = if (type == TvType.TvSeries) {
mapOf(
"langs" to "[]",
"movieName" to searchRes.found.first().linkName,
"season" to "season-${query.seasonNumber}"
)
} else {
mapOf(
"langs" to "[]",
"movieName" to searchRes.found.first().linkName,
)
}
val getMovieRes = app.post(
url = "$APIURL/getMovie",
data = postData
).parsedSafe<ApiResponse>().let {
// api doesn't has episode number or lang filtering
if (type == TvType.Movie) {
it?.subs?.filter { sub ->
sub.lang == queryLang
}
} else {
it?.subs?.filter { sub ->
sub.releaseName!!.contains(
String.format(
null,
"E%02d",
query.epNumber
)
) && sub.lang == queryLang
}
}
} ?: return null
return getMovieRes.map { subtitle ->
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix,
name = subtitle.releaseName!!,
lang = subtitle.lang!!,
data = SubData(
movie = subtitle.linkName!!,
lang = subtitle.lang,
id = subtitle.subId.toString(),
).toJson(),
type = type,
source = this.name,
epNumber = query.epNumber,
seasonNumber = query.seasonNumber,
isHearingImpaired = subtitle.hi == 1,
)
}
}
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
val parsedSub = parseJson<SubData>(data.data)
val subRes = app.post(
url = "$APIURL/getSub",
data = mapOf(
"movie" to parsedSub.movie,
"lang" to data.lang,
"id" to parsedSub.id
)
).parsedSafe<SubTitleLink>() ?: return
this.addZipUrl(
"$DOWNLOADENDPOINT/${subRes.sub.downloadToken}"
) { name, _ ->
name
}
}
data class ApiSearch(
@JsonProperty("success") val success: Boolean,
@JsonProperty("found") val found: List<Found>,
)
data class Found(
@JsonProperty("id") val id: Long,
@JsonProperty("title") val title: String,
@JsonProperty("seasons") val seasons: Long,
@JsonProperty("type") val type: String,
@JsonProperty("releaseYear") val releaseYear: Long,
@JsonProperty("linkName") val linkName: String,
)
data class ApiResponse(
@JsonProperty("success") val success: Boolean,
@JsonProperty("movie") val movie: Movie,
@JsonProperty("subs") val subs: List<Sub>,
)
data class Movie(
@JsonProperty("id") val id: Long? = null,
@JsonProperty("type") val type: String? = null,
@JsonProperty("year") val year: Long? = null,
@JsonProperty("fullName") val fullName: String? = null,
)
data class Sub(
@JsonProperty("hi") val hi: Int? = null,
@JsonProperty("fullLink") val fullLink: String? = null,
@JsonProperty("linkName") val linkName: String? = null,
@JsonProperty("lang") val lang: String? = null,
@JsonProperty("releaseName") val releaseName: String? = null,
@JsonProperty("subId") val subId: Long? = null,
)
data class SubData(
@JsonProperty("movie") val movie: String,
@JsonProperty("lang") val lang: String,
@JsonProperty("id") val id: String,
)
data class SubTitleLink(
@JsonProperty("sub") val sub: SubToken,
)
data class SubToken(
@JsonProperty("downloadToken") val downloadToken: String,
)
}

View file

@ -0,0 +1,247 @@
package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty
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.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val idPrefix = "subdl"
override val name = "SubDL"
override val icon = R.drawable.subdl_logo_big
override val requiresPassword = true
override val requiresEmail = true
override val createAccountUrl = "https://subdl.com/login"
companion object {
const val APIURL = "https://api.subdl.com"
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user"
var currentSession: SubtitleOAuthEntity? = null
}
override suspend fun initialize() {
currentSession = getAuthKey()
}
override fun logOut() {
setAuthKey(null)
removeAccountKeys()
currentSession = getAuthKey()
}
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
val email = data.email ?: throw ErrorLoadingException("Requires Email")
val password = data.password ?: throw ErrorLoadingException("Requires Password")
switchToNewAccount()
try {
if (initLogin(email, password)) {
registerAccount()
return true
}
} catch (e: Exception) {
logError(e)
switchToOldAccount()
}
switchToOldAccount()
return false
}
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
val current = getAuthKey() ?: return null
return InAppAuthAPI.LoginData(
email = current.userEmail,
password = current.pass
)
}
override fun loginInfo(): LoginInfo? {
getAuthKey()?.let { user ->
return LoginInfo(
profilePicture = null,
name = user.name ?: user.userEmail,
accountIndex = accountIndex
)
}
return null
}
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
val queryText = query.query
val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0
val idQuery = when {
query.imdbId != null -> "&imdb_id=${query.imdbId}"
query.tmdbId != null -> "&tmdb_id=${query.tmdbId}"
else -> null
}
val epQuery = if (epNum > 0) "&episode_number=$epNum" else ""
val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else ""
val yearQuery = if (yearNum > 0) "&year=$yearNum" else ""
val searchQueryUrl = when (idQuery) {
//Use imdb/tmdb id to search if its valid
null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
}
val req = app.get(
url = searchQueryUrl,
headers = mapOf(
"Accept" to "application/json"
)
)
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
val resEpNum = subtitle.episode ?: query.epNumber
val resSeasonNum = subtitle.season ?: query.seasonNumber
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix,
name = subtitle.releaseName,
lang = lang,
data = "${DOWNLOADENDPOINT}${subtitle.url}",
type = type,
source = this.name,
epNumber = resEpNum,
seasonNumber = resSeasonNum,
isHearingImpaired = subtitle.hearingImpaired ?: false,
)
}
}
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
this.addZipUrl(data.data) { name, _ ->
name
}
}
private suspend fun initLogin(useremail: String, password: String): Boolean {
val tokenResponse = app.post(
url = "$APIURL/login",
data = mapOf(
"email" to useremail,
"password" to password
)
).parsedSafe<OAuthTokenResponse>()
if (tokenResponse?.token == null) return false
val apiResponse = app.get(
url = "$APIURL/user/userApi",
headers = mapOf(
"Authorization" to "Bearer ${tokenResponse.token}"
)
).parsedSafe<ApiKeyResponse>()
if (apiResponse?.ok == false) return false
setAuthKey(
SubtitleOAuthEntity(
userEmail = useremail,
pass = password,
name = tokenResponse.userData?.username ?: tokenResponse.userData?.name,
accessToken = tokenResponse.token,
apiKey = apiResponse?.apiKey
)
)
return true
}
private fun getAuthKey(): SubtitleOAuthEntity? {
return getKey(accountId, SUBDL_SUBTITLES_USER_KEY)
}
private fun setAuthKey(data: SubtitleOAuthEntity?) {
if (data == null) removeKey(
accountId,
SUBDL_SUBTITLES_USER_KEY
)
currentSession = data
setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data)
}
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,
)
data class OAuthTokenResponse(
@JsonProperty("token") val token: String? = null,
@JsonProperty("userData") val userData: UserData? = null,
@JsonProperty("status") val status: Boolean? = null,
@JsonProperty("message") val message: String? = null,
)
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,
)
data class ApiKeyResponse(
@JsonProperty("ok") val ok: Boolean? = false,
@JsonProperty("api_key") val apiKey: String? = null,
@JsonProperty("usage") val usage: Usage? = null,
)
data class Usage(
@JsonProperty("total") val total: Long? = 0,
@JsonProperty("today") val today: Long? = 0,
)
data class ApiResponse(
@JsonProperty("status") val status: Boolean? = null,
@JsonProperty("results") val results: List<Result>? = null,
@JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
)
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,
)
data class Subtitle(
@JsonProperty("release_name") val releaseName: String,
@JsonProperty("name") val name: String,
@JsonProperty("lang") val lang: String,
@JsonProperty("author") val author: String? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("subtitlePage") val subtitlePage: String? = null,
@JsonProperty("season") val season: Int? = null,
@JsonProperty("episode") val episode: Int? = null,
@JsonProperty("language") val language: String? = null,
@JsonProperty("hi") val hearingImpaired: Boolean? = null,
)
}

View file

@ -1,16 +1,24 @@
package com.lagradost.cloudstream3.ui
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.HomePageResponse
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.fixUrl
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope.coroutineContext
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
@ -42,7 +50,7 @@ class APIRepository(val api: MainAPI) {
private val cache = threadSafeListOf<SavedLoadResponse>()
private var cacheIndex: Int = 0
const val cacheSize = 20
const val CACHE_SIZE = 20
}
private fun afterPluginsLoaded(forceReload: Boolean) {
@ -86,9 +94,9 @@ class APIRepository(val api: MainAPI) {
val add = SavedLoadResponse(unixTime, response, lookingForHash)
synchronized(cache) {
if (cache.size > cacheSize) {
if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache
cacheIndex = (cacheIndex + 1) % cacheSize
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
} else {
cache.add(add)
}
@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) {
data: String,
isCasting: Boolean,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
callback: (ExtractorLink) -> Unit,
): Boolean {
if (isInvalidData(data)) return false // this makes providers cleaner
return try {

View file

@ -0,0 +1,252 @@
package com.lagradost.cloudstream3.ui
import android.view.View
import android.view.ViewGroup
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 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
}
// 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?>>()
}
abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
/**
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
* This should be used for restoring eg scroll or focus related to a view when it is recreated.
*
* Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel.
*
* diffCallback is how the view should be handled when updating, override onUpdateContent for updates
*
* NOTE:
*
* By default it should save automatically, but you can also call save(recycle)
*
* By default no state is stored, but doing an id != 0 will store
*
* By default no headers or footers exist, override footers and headers count
*/
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
fun getItem(position: Int): T {
return mDiffer.currentList[position]
}
fun getItemOrNull(position: Int): T? {
return mDiffer.currentList.getOrNull(position)
}
private val mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
object : NonFinalAdapterListUpdateCallback(this) {
override fun onMoved(fromPosition: Int, toPosition: Int) {
super.onMoved(fromPosition + headers, toPosition + headers)
}
override fun onRemoved(position: Int, count: Int) {
super.onRemoved(position + headers, count)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
super.onChanged(position + headers, count, payload)
}
override fun onInserted(position: Int, count: Int) {
super.onInserted(position + headers, count)
}
},
AsyncDifferConfig.Builder(diffCallback).build()
)
open fun submitList(list: List<T>?) {
// deep copy at least the top list, because otherwise adapter can go crazy
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
}
override fun getItemCount(): Int {
return mDiffer.currentList.size + footers + headers
}
open fun onUpdateContent(holder: ViewHolderState<S>, item: T, position: Int) =
onBindContent(holder, item, position)
open fun onBindContent(holder: ViewHolderState<S>, item: T, position: Int) = Unit
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 onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
holder.onViewAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
holder.onViewDetachedFromWindow()
}
@Suppress("UNCHECKED_CAST")
fun save(recyclerView: RecyclerView) {
for (child in recyclerView.children) {
val holder =
recyclerView.findContainingViewHolder(child) as? ViewHolderState<S> ?: continue
setState(holder)
}
}
fun clear() {
stateViewModel.layoutManagerStates[id]?.clear()
}
@Suppress("UNCHECKED_CAST")
private fun getState(holder: ViewHolderState<S>): S? =
stateViewModel.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()
}
stateViewModel.layoutManagerStates[id]?.let { map ->
map[holder.absoluteAdapterPosition] = holder.save()
}
}
private val attachListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) {
if (v !is RecyclerView) return
save(v)
}
}
final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
recyclerView.addOnAttachStateChangeListener(attachListener)
super.onAttachedToRecyclerView(recyclerView)
}
final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
recyclerView.removeOnAttachStateChangeListener(attachListener)
super.onDetachedFromRecyclerView(recyclerView)
}
final override fun getItemViewType(position: Int): Int {
if (position < headers) {
return HEADER
}
if (position - headers >= mDiffer.currentList.size) {
return FOOTER
}
return CONTENT
}
private val stateViewModel: StateViewModel by fragment.viewModels()
final override fun onViewRecycled(holder: ViewHolderState<S>) {
setState(holder)
holder.onViewRecycled()
super.onViewRecycled(holder)
}
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
return when (viewType) {
CONTENT -> onCreateContent(parent)
HEADER -> onCreateHeader(parent)
FOOTER -> onCreateFooter(parent)
else -> throw NotImplementedError()
}
}
// https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068
override fun onBindViewHolder(
holder: ViewHolderState<S>,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads)
return
}
when (getItemViewType(position)) {
CONTENT -> {
val realPosition = position - headers
val item = getItem(realPosition)
onUpdateContent(holder, item, realPosition)
}
FOOTER -> {
onBindFooter(holder)
}
HEADER -> {
onBindHeader(holder)
}
}
}
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
when (getItemViewType(position)) {
CONTENT -> {
val realPosition = position - headers
val item = getItem(realPosition)
onBindContent(holder, item, realPosition)
}
FOOTER -> {
onBindFooter(holder)
}
HEADER -> {
onBindHeader(holder)
}
}
getState(holder)?.let { state ->
holder.restore(state)
}
}
companion object {
private const val HEADER: Int = 1
private const val FOOTER: Int = 2
private const val CONTENT: Int = 0
}
}
class BaseDiffCallback<T : Any>(
val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() },
val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }
) : 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()
}

View file

@ -6,9 +6,10 @@ import android.view.Menu
import android.view.View.*
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.media3.common.util.UnstableApi
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF
@ -23,12 +24,13 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.sortSubs
import com.lagradost.cloudstream3.sortUrls
import com.lagradost.cloudstream3.ui.player.LoadType
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
@ -97,7 +99,7 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule())
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init {
@ -262,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
var isLoadingMore = false
override fun onMediaStatusUpdated() {
super.onMediaStatusUpdated()
val meta = getCurrentMetaData()
@ -294,7 +297,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val generator = RepoLinkGenerator(listOf(epData))
val isSuccessful = safeApiCall {
generator.generateLinks(clearCache = false, isCasting = true,
generator.generateLinks(
clearCache = false, type = LoadType.Chromecast,
callback = {
it.first?.let { link ->
currentLinks.add(link)

View file

@ -3,12 +3,13 @@ package com.lagradost.cloudstream3.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
class GrdLayoutManager(val context: Context, _spanCount: Int) :
GridLayoutManager(context, _spanCount) {
class GrdLayoutManager(val context: Context, spanCount: Int) :
GridLayoutManager(context, spanCount) {
override fun onFocusSearchFailed(
focused: View,
focusDirection: Int,
@ -70,8 +71,8 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
val orientation = this.orientation
// fixes arabic by inverting left and right layout focus
val correctDirection = if(this.isLayoutRTL) {
when(direction) {
val correctDirection = if (this.isLayoutRTL) {
when (direction) {
View.FOCUS_RIGHT -> View.FOCUS_LEFT
View.FOCUS_LEFT -> View.FOCUS_RIGHT
else -> direction
@ -83,12 +84,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
View.FOCUS_DOWN -> {
return spanCount
}
View.FOCUS_UP -> {
return -spanCount
}
View.FOCUS_RIGHT -> {
return 1
}
View.FOCUS_LEFT -> {
return -1
}
@ -98,12 +102,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
View.FOCUS_DOWN -> {
return 1
}
View.FOCUS_UP -> {
return -1
}
View.FOCUS_RIGHT -> {
return spanCount
}
View.FOCUS_LEFT -> {
return -spanCount
}
@ -155,4 +162,32 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
layoutManager = manager
}
}
/**
* Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes.
*/
class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) {
private var biggestObserved: Int = 0
private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation
private val isHorizontal = orientation == HORIZONTAL
private fun View.updateMaxSize() {
if (isHorizontal) {
this.minimumHeight = biggestObserved
} else {
this.minimumWidth = biggestObserved
}
}
override fun onChildAttachedToWindow(child: View) {
child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth
if (observed > biggestObserved) {
biggestObserved = observed
children.forEach { it.updateMaxSize() }
} else {
child.updateMaxSize()
}
super.onChildAttachedToWindow(child)
}
}

View file

@ -51,7 +51,7 @@ class EasterEggMonke : AppCompatActivity() {
FrameLayout.LayoutParams.WRAP_CONTENT)
binding.frame.addView(newStar)
newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX
newStar.scaleX += Math.random().toFloat() * 1.5f
newStar.scaleY = newStar.scaleX
starW *= newStar.scaleX
starH *= newStar.scaleY

View file

@ -0,0 +1,39 @@
package com.lagradost.cloudstream3.ui
import android.annotation.SuppressLint
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
/**
* ListUpdateCallback that dispatches update events to the given adapter.
*
* @see DiffUtil.DiffResult.dispatchUpdatesTo
*/
open class NonFinalAdapterListUpdateCallback
/**
* Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter.
*
* @param mAdapter The Adapter to send updates to.
*/(private var mAdapter: RecyclerView.Adapter<*>) :
ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
mAdapter.notifyItemRangeInserted(position, count)
}
override fun onRemoved(position: Int, count: Int) {
mAdapter.notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
mAdapter.notifyItemMoved(fromPosition, toPosition)
}
@SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly
override fun onChanged(position: Int, count: Int, payload: Any?) {
mAdapter.notifyItemRangeChanged(position, count, payload)
}
}

View file

@ -13,6 +13,29 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab
NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24);
companion object {
fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE
fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE
}
}
}
enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) {
/*
-1 -> None
0 -> Watching
1 -> Completed
2 -> OnHold
3 -> Dropped
4 -> PlanToWatch
5 -> ReWatching
*/
NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24),
WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24),
COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24),
ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24),
DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24),
PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24),
REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24);
companion object {
fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE
}
}

View file

@ -8,14 +8,16 @@ 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
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : Fragment() {
@ -29,6 +31,7 @@ class WebviewFragment : Fragment() {
}
binding?.webView?.webViewClient = object : WebViewClient() {
@OptIn(UnstableApi::class)
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?

View file

@ -1,106 +0,0 @@
package com.lagradost.cloudstream3.ui
import android.view.LayoutInflater
import android.view.ViewGroup
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.databinding.WhoIsWatchingAccountAddBinding
import com.lagradost.cloudstream3.databinding.WhoIsWatchingAccountBinding
import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.utils.DataStoreHelper
class WhoIsWatchingAdapter(
private val selectCallBack: (DataStoreHelper.Account) -> Unit = { },
private val editCallBack: (DataStoreHelper.Account) -> Unit = { },
private val addAccountCallback: () -> Unit = {}
) :
ListAdapter<DataStoreHelper.Account, WhoIsWatchingAdapter.WhoIsWatchingHolder>(DiffCallback()) {
companion object {
const val FOOTER = 1
const val NORMAL = 0
}
override fun getItemCount(): Int {
return currentList.size + 1
}
override fun getItemViewType(position: Int): Int = when (position) {
currentList.size -> FOOTER
else -> NORMAL
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WhoIsWatchingHolder =
WhoIsWatchingHolder(
binding = when (viewType) {
NORMAL -> WhoIsWatchingAccountBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
FOOTER -> WhoIsWatchingAccountAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
else -> throw NotImplementedError()
},
selectCallBack = selectCallBack,
addAccountCallback = addAccountCallback,
editCallBack = editCallBack,
)
override fun onBindViewHolder(holder: WhoIsWatchingHolder, position: Int) =
holder.bind(currentList.getOrNull(position))
class WhoIsWatchingHolder(
val binding: ViewBinding,
val selectCallBack: (DataStoreHelper.Account) -> Unit,
val addAccountCallback: () -> Unit,
val editCallBack: (DataStoreHelper.Account) -> Unit
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(card: DataStoreHelper.Account?) {
when (binding) {
is WhoIsWatchingAccountBinding -> binding.apply {
if(card == null) return@apply
outline.isVisible = card.keyIndex == DataStoreHelper.selectedKeyIndex
profileText.text = card.name
profileImageBackground.setImage(card.image)
root.setOnClickListener {
selectCallBack(card)
}
root.setOnLongClickListener {
editCallBack(card)
return@setOnLongClickListener true
}
}
is WhoIsWatchingAccountAddBinding -> binding.apply {
root.setOnClickListener {
addAccountCallback()
}
}
}
}
}
class DiffCallback : DiffUtil.ItemCallback<DataStoreHelper.Account>() {
override fun areItemsTheSame(
oldItem: DataStoreHelper.Account,
newItem: DataStoreHelper.Account
): Boolean = oldItem.keyIndex == newItem.keyIndex
override fun areContentsTheSame(
oldItem: DataStoreHelper.Account,
newItem: DataStoreHelper.Account
): Boolean = oldItem == newItem
}
}

View file

@ -0,0 +1,200 @@
package com.lagradost.cloudstream3.ui.account
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 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.account.AccountHelper.showAccountEditDialog
import com.lagradost.cloudstream3.ui.result.setImage
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.DataStoreHelper
import com.lagradost.cloudstream3.utils.UIHelper.setImage
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>() {
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) {
is AccountListItemBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = account.name
accountImage.setImage(account.image)
lockIcon.isVisible = account.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
if (isTv) {
// For emulator but this is fine on TV also
root.isFocusableInTouchMode = true
if (isLastUsedAccount) {
root.requestFocus()
}
root.foreground = ContextCompat.getDrawable(
root.context,
R.drawable.outline_drawable
)
} else {
root.setOnLongClickListener {
showAccountEditDialog(
context = root.context,
account = account,
isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
)
true
}
}
root.setOnClickListener {
accountSelectCallback.invoke(account)
}
}
is AccountListItemEditBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = account.name
accountImage.setImage(
account.image,
fadeIn = false,
radius = 10
)
lockIcon.isVisible = account.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount
if (isTv) {
// For emulator but this is fine on TV also
root.isFocusableInTouchMode = true
if (isLastUsedAccount) {
root.requestFocus()
}
root.foreground = ContextCompat.getDrawable(
root.context,
R.drawable.outline_drawable
)
}
root.setOnClickListener {
showAccountEditDialog(
context = root.context,
account = account,
isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
)
}
}
is AccountListItemAddBinding -> binding.apply {
root.setOnClickListener {
val remainingImages =
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
val image =
DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
val accountName = root.context.getString(R.string.account)
showAccountEditDialog(
root.context,
DataStoreHelper.Account(
keyIndex = keyIndex,
name = "$accountName $keyIndex",
customImage = null,
defaultImageIndex = image
),
isNewAccount = true,
accountEditCallback = { account -> accountCreateCallback.invoke(account) },
accountDeleteCallback = {}
)
}
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
AccountViewHolder(
binding = when (viewType) {
VIEW_TYPE_SELECT_ACCOUNT -> {
AccountListItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_ADD_ACCOUNT -> {
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_EDIT_ACCOUNT -> {
AccountListItemEditBinding.inflate(
LayoutInflater.from(parent.context),
parent,
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

@ -0,0 +1,356 @@
package com.lagradost.cloudstream3.ui.account
import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.text.Editable
import android.view.LayoutInflater
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding
import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding
import com.lagradost.cloudstream3.databinding.LockPinDialogBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
object AccountHelper {
fun showAccountEditDialog(
context: Context,
account: DataStoreHelper.Account,
isNewAccount: Boolean,
accountEditCallback: (DataStoreHelper.Account) -> Unit,
accountDeleteCallback: (DataStoreHelper.Account) -> Unit
) {
val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false)
val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom)
.setView(binding.root)
var currentEditAccount = account
val dialog = builder.show()
if (!isNewAccount) binding.title.setText(R.string.edit_account)
// Set up the dialog content
binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name)
binding.accountName.doOnTextChanged { text, _, _, _ ->
currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "")
}
binding.deleteBtt.isGone = isNewAccount
binding.deleteBtt.setOnClickListener {
val dialogClickListener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
accountDeleteCallback.invoke(account)
dialog?.dismissSafe()
}
DialogInterface.BUTTON_NEGATIVE -> {
dialog?.dismissSafe()
}
}
}
try {
AlertDialog.Builder(context).setTitle(R.string.delete).setMessage(
context.getString(R.string.delete_message).format(
currentEditAccount.name
)
)
.setPositiveButton(R.string.delete, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (t: Throwable) {
logError(t)
}
}
binding.cancelBtt.setOnClickListener {
dialog?.dismissSafe()
}
// Handle the profile picture and its interactions
binding.accountImage.setImage(account.image)
binding.accountImage.setOnClickListener {
// Roll the image forwards once
currentEditAccount =
currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size)
binding.accountImage.setImage(currentEditAccount.image)
}
// Handle applying changes
binding.applyBtt.setOnClickListener {
if (currentEditAccount.lockPin != null) {
// Ask for the current PIN
showPinInputDialog(context, currentEditAccount.lockPin, false) { pin ->
if (pin == null) return@showPinInputDialog
// PIN is correct, proceed to update the account
accountEditCallback.invoke(currentEditAccount)
dialog.dismissSafe()
}
} else {
// No lock PIN set, proceed to update the account
accountEditCallback.invoke(currentEditAccount)
dialog.dismissSafe()
}
}
// Handle setting or changing the PIN
if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) {
binding.lockProfileCheckbox.isVisible = false
if (currentEditAccount.lockPin != null) {
currentEditAccount = currentEditAccount.copy(lockPin = null)
}
}
var canSetPin = true
binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null
binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
if (canSetPin) {
showPinInputDialog(context, null, true) { pin ->
if (pin == null) {
binding.lockProfileCheckbox.isChecked = false
return@showPinInputDialog
}
currentEditAccount = currentEditAccount.copy(lockPin = pin)
}
}
} else {
if (currentEditAccount.lockPin != null) {
// Ask for the current PIN
showPinInputDialog(context, currentEditAccount.lockPin, true) { pin ->
if (pin == null || pin != currentEditAccount.lockPin) {
canSetPin = false
binding.lockProfileCheckbox.isChecked = true
} else {
currentEditAccount = currentEditAccount.copy(lockPin = null)
}
}
}
}
}
canSetPin = true
}
fun showPinInputDialog(
context: Context,
currentPin: String?,
editAccount: Boolean,
forStartup: Boolean = false,
errorText: String? = null,
callback: (String?) -> Unit
) {
fun TextView.visibleWithText(@StringRes textRes: Int) {
isVisible = true
setText(textRes)
}
fun TextView.visibleWithText(text: String?) {
isVisible = true
setText(text)
}
val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context))
val isPinSet = currentPin != null
val isNewPin = editAccount && !isPinSet
val isEditPin = editAccount && isPinSet
val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin
var isPinValid = false
val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom)
.setView(binding.root)
.setTitle(titleRes)
.setNegativeButton(R.string.cancel) { _, _ ->
callback.invoke(null)
}
.setOnCancelListener {
callback.invoke(null)
}
.setOnDismissListener {
if (!isPinValid) {
callback.invoke(null)
}
}
if (forStartup) {
val currentAccount = DataStoreHelper.accounts.firstOrNull {
it.keyIndex == DataStoreHelper.selectedKeyIndex
}
builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name))
builder.setOnDismissListener {
if (!isPinValid) {
context.getActivity()?.finish()
}
}
// So that if they don't know the PIN for the current account,
// they don't get completely locked out
builder.setNeutralButton(R.string.use_default_account) { _, _ ->
val activity = context.getActivity()
if (activity is AccountSelectActivity) {
isPinValid = true
activity.viewModel.handleAccountSelect(getDefaultAccount(context), activity)
}
}
}
if (isNewPin) {
if (errorText != null) binding.pinEditTextError.visibleWithText(errorText)
builder.setPositiveButton(R.string.setup_done) { _, _ ->
if (!isPinValid) {
// If the done button is pressed and there is an error,
// ask again, and mention the error that caused this.
showPinInputDialog(
context = binding.root.context,
currentPin = null,
editAccount = true,
errorText = binding.pinEditTextError.text.toString(),
callback = callback
)
} else {
val enteredPin = binding.pinEditText.text.toString()
callback.invoke(enteredPin)
}
}
}
val dialog = builder.create()
binding.pinEditText.doOnTextChanged { text, _, _, _ ->
val enteredPin = text.toString()
val isEnteredPinValid = enteredPin.length == 4
if (isEnteredPinValid) {
if (isPinSet) {
if (enteredPin != currentPin) {
binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect)
binding.pinEditText.text = null
isPinValid = false
} else {
binding.pinEditTextError.isVisible = false
isPinValid = true
callback.invoke(enteredPin)
dialog.dismissSafe()
}
} else {
binding.pinEditTextError.isVisible = false
isPinValid = true
}
} else if (isNewPin) {
binding.pinEditTextError.visibleWithText(R.string.pin_error_length)
isPinValid = false
}
}
// Detect IME_ACTION_DONE
binding.pinEditText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) {
val enteredPin = binding.pinEditText.text.toString()
callback.invoke(enteredPin)
dialog.dismissSafe()
}
true
}
// We don't want to accidentally have the dialog dismiss when clicking outside of it.
// That is what the cancel button is for.
dialog.setCanceledOnTouchOutside(false)
dialog.show()
// Auto focus on PIN input and show keyboard
binding.pinEditText.requestFocus()
binding.pinEditText.postDelayed({
showInputMethod(binding.pinEditText)
}, 200)
}
fun Activity?.showAccountSelectLinear() {
val activity = this as? MainActivity ?: return
val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java]
val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate(
LayoutInflater.from(activity)
)
val builder = BottomSheetDialog(activity)
builder.setContentView(binding.root)
builder.show()
binding.manageAccountsButton.setOnClickListener {
val accountSelectIntent = Intent(activity, AccountSelectActivity::class.java)
accountSelectIntent.putExtra("isEditingFromMainActivity", true)
activity.startActivity(accountSelectIntent)
builder.dismissSafe()
}
val recyclerView: RecyclerView = binding.accountRecyclerView
val itemSize = recyclerView.resources.getDimensionPixelSize(
R.dimen.account_select_linear_item_size
)
recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize))
recyclerView.setLinearListLayout(isHorizontal = true)
val currentAccount = DataStoreHelper.accounts.firstOrNull {
it.keyIndex == DataStoreHelper.selectedKeyIndex
} ?: getDefaultAccount(activity)
// We want to make sure the accounts are up-to-date
viewModel.handleAccountSelect(
currentAccount,
activity,
reloadForActivity = true
)
activity.observe(viewModel.accounts) { liveAccounts ->
recyclerView.adapter = AccountAdapter(
liveAccounts,
accountSelectCallback = { account ->
viewModel.handleAccountSelect(account, activity)
builder.dismissSafe()
},
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
)
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
// Scroll to current account (which is focused by default)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
}
}
}
}

View file

@ -0,0 +1,199 @@
package com.lagradost.cloudstream3.ui.account
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT
import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT
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.BiometricAuthenticator
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
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
class AccountSelectActivity : AppCompatActivity(), BiometricCallback {
lateinit var viewModel: AccountViewModel
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loadThemes(this)
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
// Are we editing and coming from MainActivity?
val isEditingFromMainActivity = intent.getBooleanExtra(
"isEditingFromMainActivity",
false
)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
) || accounts.count() <= 1
viewModel = ViewModelProvider(this)[AccountViewModel::class.java]
fun askBiometricAuth() {
if (isLayout(PHONE) && isAuthEnabled(this)) {
if (deviceHasPasswordPinLock(this)) {
startBiometricAuthentication(
this,
R.string.biometric_authentication_title,
false
)
promptInfo?.let { prompt ->
biometricPrompt?.authenticate(prompt)
}
}
}
}
observe(viewModel.isAllowedLogin) { isAllowedLogin ->
if (isAllowedLogin) {
// We are allowed to continue to MainActivity
navigateToMainActivity()
}
}
// Don't show account selection if there is only
// one account that exists
if (!isEditingFromMainActivity && skipStartup) {
val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex }
if (currentAccount?.lockPin != null) {
CommonActivity.init(this)
viewModel.handleAccountSelect(currentAccount, this, true)
} else {
if (accounts.count() > 1) {
showToast(this, getString(
R.string.logged_account,
currentAccount?.name
))
}
navigateToMainActivity()
}
return
}
CommonActivity.init(this)
val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
setContentView(binding.root)
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
observe(viewModel.accounts) { liveAccounts ->
val adapter = AccountAdapter(
liveAccounts,
// Handle the selected account
accountSelectCallback = {
viewModel.handleAccountSelect(it, this)
},
accountCreateCallback = { viewModel.handleAccountUpdate(it, this) },
accountEditCallback = {
viewModel.handleAccountUpdate(it, this)
// We came from MainActivity, return there
// and switch to the edited account
if (isEditingFromMainActivity) {
setAccount(it)
navigateToMainActivity()
}
},
accountDeleteCallback = { viewModel.handleAccountDelete(it,this) }
)
recyclerView.adapter = adapter
if (isLayout(TV or EMULATOR)) {
binding.editAccountButton.setBackgroundResource(
R.drawable.player_button_tv_attr_no_bg
)
}
observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
// Scroll to current account (which is focused by default)
val layoutManager = recyclerView.layoutManager as GridLayoutManager
layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
}
observe(viewModel.isEditing) { isEditing ->
if (isEditing) {
binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24)
binding.title.setText(R.string.manage_accounts)
adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT
} else {
binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24)
binding.title.setText(R.string.select_an_account)
adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT
}
adapter.notifyDataSetChanged()
}
if (isEditingFromMainActivity) {
viewModel.setIsEditing(true)
}
binding.editAccountButton.setOnClickListener {
// We came from MainActivity, return there
// and resume its state
if (isEditingFromMainActivity) {
navigateToMainActivity()
return@setOnClickListener
}
viewModel.toggleIsEditing()
}
if (isLayout(TV or EMULATOR)) {
recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) {
liveAccounts.count() + 1
} else 6
}
}
askBiometricAuth()
}
private fun navigateToMainActivity() {
val mainIntent = Intent(this, MainActivity::class.java)
startActivity(mainIntent)
finish() // Finish the account selection activity
}
override fun onAuthenticationSuccess() {
Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity")
}
override fun onAuthenticationError() {
finish()
}
}

View file

@ -0,0 +1,14 @@
package com.lagradost.cloudstream3.ui.account
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val layoutParams = view.layoutParams as RecyclerView.LayoutParams
layoutParams.width = size
layoutParams.height = size
view.layoutParams = layoutParams
}
}

View file

@ -0,0 +1,123 @@
package com.lagradost.cloudstream3.ui.account
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.ui.account.AccountHelper.showPinInputDialog
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
class AccountViewModel : ViewModel() {
private fun getAllAccounts(): List<DataStoreHelper.Account> {
return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList()
}
private val _accounts: MutableLiveData<List<DataStoreHelper.Account>> = MutableLiveData(getAllAccounts())
val accounts: LiveData<List<DataStoreHelper.Account>> = _accounts
private val _isEditing = MutableLiveData(false)
val isEditing: LiveData<Boolean> = _isEditing
private val _isAllowedLogin = MutableLiveData(false)
val isAllowedLogin: LiveData<Boolean> = _isAllowedLogin
private val _selectedKeyIndex = MutableLiveData(
getAllAccounts().indexOfFirst {
it.keyIndex == DataStoreHelper.selectedKeyIndex
}
)
val selectedKeyIndex: LiveData<Int> = _selectedKeyIndex
fun setIsEditing(value: Boolean) {
_isEditing.postValue(value)
}
fun toggleIsEditing() {
_isEditing.postValue(!(_isEditing.value ?: false))
}
fun handleAccountUpdate(
account: DataStoreHelper.Account,
context: Context
) {
val currentAccounts = getAccounts(context).toMutableList()
val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex }
if (overrideIndex != -1) {
currentAccounts[overrideIndex] = account
} else currentAccounts.add(account)
val currentHomePage = DataStoreHelper.currentHomePage
setAccount(account)
DataStoreHelper.currentHomePage = currentHomePage
DataStoreHelper.accounts = currentAccounts.toTypedArray()
_accounts.postValue(getAccounts(context))
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
}
fun handleAccountDelete(
account: DataStoreHelper.Account,
context: Context
) {
removeKeys(account.keyIndex.toString())
val currentAccounts = getAccounts(context).toMutableList()
currentAccounts.removeIf { it.keyIndex == account.keyIndex }
DataStoreHelper.accounts = currentAccounts.toTypedArray()
if (account.keyIndex == DataStoreHelper.selectedKeyIndex) {
setAccount(getDefaultAccount(context))
}
_accounts.postValue(getAccounts(context))
_selectedKeyIndex.postValue(getAllAccounts().indexOfFirst {
it.keyIndex == DataStoreHelper.selectedKeyIndex
})
}
fun handleAccountSelect(
account: DataStoreHelper.Account,
context: Context,
forStartup: Boolean = false,
reloadForActivity: Boolean = false
) {
if (reloadForActivity) {
_accounts.postValue(getAccounts(context))
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
return
}
// Check if the selected account has a lock PIN set
if (account.lockPin != null) {
// The selected account has a PIN set, prompt the user to enter the PIN
showPinInputDialog(
context,
account.lockPin,
false,
forStartup
) { pin ->
if (pin == null) return@showPinInputDialog
// Pin is correct, proceed
_isAllowedLogin.postValue(true)
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
setAccount(account)
}
} else {
// No PIN set for the selected account, proceed
_isAllowedLogin.postValue(true)
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
setAccount(account)
}
}
}

View file

@ -0,0 +1,414 @@
package com.lagradost.cloudstream3.ui.download
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.ViewGroup
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.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.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
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_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
sealed class VisualDownloadCached {
abstract val currentBytes: Long
abstract val totalBytes: Long
abstract val data: VideoDownloadHelper.DownloadCached
abstract var isSelected: Boolean
data class Child(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadEpisodeCached,
override var isSelected: Boolean,
) : VisualDownloadCached()
data class Header(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached,
override var isSelected: Boolean,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
val totalDownloads: Int,
) : VisualDownloadCached()
}
data class DownloadClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadEpisodeCached
)
data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
)
class DownloadAdapter(
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
private var isMultiDeleteState: Boolean = false
companion object {
private const val VIEW_TYPE_HEADER = 0
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?) {
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
val data = card.data
binding.apply {
episodeHolder.apply {
if (isMultiDeleteState) {
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
downloadHeaderPoster.apply {
setImage(data.poster)
if (isMultiDeleteState) {
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
} else {
setOnClickListener {
onHeaderClickEvent.invoke(
DownloadHeaderClickEvent(
DOWNLOAD_ACTION_LOAD_RESULT,
data
)
)
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
downloadHeaderTitle.text = data.name
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
if (card.child != null) {
handleChildDownload(card, formattedSize)
} else handleParentDownload(card, formattedSize)
if (isMultiDeleteState) {
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
onItemSelectionChanged.invoke(data.id, isChecked)
}
} else deleteCheckbox.setOnCheckedChangeListener(null)
deleteCheckbox.apply {
isVisible = isMultiDeleteState
isChecked = card.isSelected
}
}
}
private fun DownloadHeaderEpisodeBinding.handleChildDownload(
card: VisualDownloadCached.Header,
formattedSize: String
) {
card.child ?: return
downloadHeaderGotoChild.isVisible = false
val posDur = getViewPos(card.data.id)
downloadHeaderEpisodeProgress.apply {
isVisible = posDur != null
posDur?.let {
val visualPos = it.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
}
}
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
// so that we can use the value from the view model
// rather than extra unneeded disk operations and to prevent a
// delay in updating download icon state.
downloadButton.setProgress(card.currentBytes, card.totalBytes)
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
// We will let the view model handle this
downloadButton.doSetProgress = false
downloadButton.progressBar.progressDrawable =
downloadButton.getDrawableFromStatus(status)
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
downloadHeaderInfo.text = formattedSize
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
downloadButton.statusView.setImageDrawable(drawable)
downloadButton.progressBar.progressDrawable =
ContextCompat.getDrawable(
downloadButton.context,
downloadButton.progressDrawable
)
}
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
downloadButton.isVisible = !isMultiDeleteState
if (!isMultiDeleteState) {
episodeHolder.setOnClickListener {
onItemClickEvent.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
card.child
)
)
}
}
}
private fun DownloadHeaderEpisodeBinding.handleParentDownload(
card: VisualDownloadCached.Header,
formattedSize: String
) {
downloadButton.isVisible = false
downloadHeaderEpisodeProgress.isVisible = false
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
try {
downloadHeaderInfo.text =
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads,
downloadHeaderInfo.context.resources.getQuantityString(
R.plurals.episodes,
card.totalDownloads
),
formattedSize
)
} catch (e: Exception) {
downloadHeaderInfo.text = null
logError(e)
}
if (!isMultiDeleteState) {
episodeHolder.setOnClickListener {
onHeaderClickEvent.invoke(
DownloadHeaderClickEvent(
DOWNLOAD_ACTION_GO_TO_CHILD,
card.data
)
)
}
}
}
private fun bindChild(card: VisualDownloadCached.Child?) {
if (binding !is DownloadChildEpisodeBinding || card == null) return
val data = card.data
binding.apply {
val posDur = getViewPos(data.id)
downloadChildEpisodeProgress.apply {
isVisible = posDur != null
posDur?.let {
val visualPos = it.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
}
}
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
// so that we can use the value from the view model
// rather than extra unneeded disk operations and to prevent a
// delay in updating download icon state.
downloadButton.setProgress(card.currentBytes, card.totalBytes)
downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
// We will let the view model handle this
downloadButton.doSetProgress = false
downloadButton.progressBar.progressDrawable =
downloadButton.getDrawableFromStatus(status)
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
downloadChildEpisodeTextExtra.text =
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
downloadButton.statusView.setImageDrawable(drawable)
downloadButton.progressBar.progressDrawable =
ContextCompat.getDrawable(
downloadButton.context,
downloadButton.progressDrawable
)
}
downloadButton.setDefaultClickListener(
data,
downloadChildEpisodeTextExtra,
onItemClickEvent
)
downloadButton.isVisible = !isMultiDeleteState
downloadChildEpisodeText.apply {
text = context.getNameFull(data.name, data.episode, data.season)
isSelected = true // Needed for text repeating
}
downloadChildEpisodeHolder.setOnClickListener {
onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
}
downloadChildEpisodeHolder.apply {
when {
isMultiDeleteState -> {
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
}
else -> {
setOnClickListener {
onItemClickEvent.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
data
)
)
}
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
if (isMultiDeleteState) {
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
onItemSelectionChanged.invoke(data.id, isChecked)
}
} else deleteCheckbox.setOnCheckedChangeListener(null)
deleteCheckbox.apply {
isVisible = isMultiDeleteState
isChecked = card.isSelected
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = when (viewType) {
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Invalid view type")
}
return DownloadViewHolder(binding)
}
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
else -> throw IllegalArgumentException("Invalid data type at position $position")
}
}
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)
}
}
}
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {
val isChecked = !checkbox.isChecked
checkbox.isChecked = isChecked
onItemSelectionChanged.invoke(itemId, isChecked)
}
class DiffCallback : DiffUtil.ItemCallback<VisualDownloadCached>() {
override fun areItemsTheSame(
oldItem: VisualDownloadCached,
newItem: VisualDownloadCached
): Boolean {
return oldItem.data.id == newItem.data.id
}
override fun areContentsTheSame(
oldItem: VisualDownloadCached,
newItem: VisualDownloadCached
): Boolean {
return oldItem == newItem
}
}
}

View file

@ -1,28 +1,30 @@
package com.lagradost.cloudstream3.ui.download
import android.app.Activity
import android.content.DialogInterface
import android.widget.Toast
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.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.ExtractorUri
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 kotlinx.coroutines.MainScope
object DownloadButtonSetup {
fun handleDownloadClick(click: DownloadClickEvent) {
val id = click.data.id
if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
when (click.action) {
DOWNLOAD_ACTION_DELETE_FILE -> {
activity?.let { ctx ->
@ -31,9 +33,15 @@ object DownloadButtonSetup {
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id)
VideoDownloadManager.deleteFilesAndUpdateSettings(
ctx,
setOf(id),
MainScope()
)
}
DialogInterface.BUTTON_NEGATIVE -> {
// Do nothing on cancel
}
}
}
@ -58,11 +66,13 @@ object DownloadButtonSetup {
}
}
}
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
)
}
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
activity?.let { ctx ->
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
@ -81,6 +91,7 @@ object DownloadButtonSetup {
}
}
}
DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act ->
val length =
@ -90,64 +101,80 @@ object DownloadButtonSetup {
)?.fileLength
?: 0
if (length > 0) {
showToast(R.string.delete, Toast.LENGTH_LONG)
} else {
showToast(R.string.download, Toast.LENGTH_LONG)
showSnackbar(
act,
R.string.offline_file,
Snackbar.LENGTH_LONG
)
}
}
}
DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act ->
val info =
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
act,
click.data.id
) ?: return
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO,
click.data.id.toString()
) ?: return
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString()
) ?: return
act.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(
listOf(
ExtractorUri(
uri = info.path,
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
?.mapNotNull {
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
}
?.filter { it.parentId == click.data.parentId }
id = click.data.id,
parentId = click.data.parentId,
name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName
season = click.data.season,
episode = click.data.episode,
headerName = parent.name,
tvType = parent.type,
val currentSeason = click.data.season ?: 0
val currentEpisode = click.data.episode
basePath = keyInfo.basePath,
displayName = keyInfo.displayName,
relativePath = keyInfo.relativePath,
)
)
val items = mutableListOf<ExtractorUri>()
// Make sure we only get this episode and episodes after it,
// and that we can go to the next season if we need to.
val allRelevantEpisodes = episodes
?.sortedWith(
compareByDescending<VideoDownloadHelper.DownloadEpisodeCached> { it.id == click.data.id }
.thenBy { it.season ?: 0 }
.thenBy { it.episode }
)
?.filter {
if (it.season == null) return@filter true
val isCurrentOrLaterInSeason = it.season == currentSeason && (it.episode >= currentEpisode || it.id == click.data.id)
val isInFutureSeasons = it.season > currentSeason
isCurrentOrLaterInSeason || isInFutureSeasons
}
allRelevantEpisodes?.forEach {
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString()
) ?: return@forEach
items.add(
ExtractorUri(
// We just use a temporary placeholder for the URI,
// it will be updated in generateLinks().
// We just do this for performance since getting
// all paths at once can be quite expensive.
uri = Uri.EMPTY,
id = it.id,
parentId = it.parentId,
name = act.getString(R.string.downloaded_file),
season = it.season,
episode = it.episode,
headerName = parent.name,
tvType = parent.type,
basePath = keyInfo.basePath,
displayName = keyInfo.displayName,
relativePath = keyInfo.relativePath,
)
)
//R.id.global_to_navigation_player, PlayerFragment.newInstance(
// UriData(
// info.path.toString(),
// keyInfo.basePath,
// keyInfo.relativePath,
// keyInfo.displayName,
// click.data.parentId,
// click.data.id,
// headerName ?: "null",
// if (click.data.episode <= 0) null else click.data.episode,
// click.data.season
// ),
// getViewPos(click.data.id)?.position ?: 0
//)
}
act.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(items)
)
)
}
}

View file

@ -1,94 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
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
data class VisualDownloadChildCached(
val currentBytes: Long,
val totalBytes: Long,
val data: VideoDownloadHelper.DownloadEpisodeCached,
)
data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached)
class DownloadChildAdapter(
var cardList: List<VisualDownloadChildCached>,
private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadChildViewHolder(
DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false),
clickCallback
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DownloadChildViewHolder -> {
holder.bind(cardList[position])
}
}
}
override fun getItemCount(): Int {
return cardList.size
}
class DownloadChildViewHolder
constructor(
val binding: DownloadChildEpisodeBinding,
private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
/*private val title: TextView = itemView.download_child_episode_text
private val extraInfo: TextView = itemView.download_child_episode_text_extra
private val holder: CardView = itemView.download_child_episode_holder
private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
private val downloadImage: ImageView = itemView.download_child_episode_download*/
fun bind(card: VisualDownloadChildCached) {
val d = card.data
val posDur = getViewPos(d.id)
binding.downloadChildEpisodeProgress.apply {
if (posDur != null) {
val visualPos = posDur.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback)
binding.downloadChildEpisodeText.apply {
text = context.getNameFull(d.name, d.episode, d.season)
isSelected = true // is needed for text repeating
}
binding.downloadChildEpisodeHolder.setOnClickListener {
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
}
}
}
}

View file

@ -1,26 +1,33 @@
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.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.observe
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.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
class DownloadChildFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentChildDownloadsBinding? = null
companion object {
fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply {
@ -31,92 +38,170 @@ class DownloadChildFragment : Fragment() {
}
override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
detachBackPressedCallback()
binding = null
super.onDestroyView()
}
var binding: FragmentChildDownloadsBinding? = null
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//inflater.inflate(R.layout.fragment_child_downloads, container, false)
return localBinding.root
}
private fun updateList(folder: String) = main {
context?.let { ctx ->
val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) }
val eps = withContext(Dispatchers.IO) {
data.mapNotNull { key ->
context?.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull {
val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
?: return@mapNotNull null
VisualDownloadChildCached(info.fileLength, info.totalBytes, it)
}
}.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
if (eps.isEmpty()) {
activity?.onBackPressed()
return@main
}
(binding?.downloadChildList?.adapter as DownloadChildAdapter? ?: return@main).cardList =
eps
binding?.downloadChildList?.adapter?.notifyDataSetChanged()
}
}
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
val folder = arguments?.getString("folder")
val name = arguments?.getString("name")
if (folder == null) {
activity?.onBackPressed() // TODO FIX
activity?.onBackPressedDispatcher?.onBackPressed()
return
}
fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply {
title = name
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener {
activity?.onBackPressed()
}
}
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
DownloadChildAdapter(
ArrayList(),
) { click ->
handleDownloadClick(click)
}
downloadDeleteEventListener = { id: Int ->
val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList
if (list != null) {
if (list.any { it.data.id == id }) {
updateList(folder)
if (isLayout(PHONE or EMULATOR)) {
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener {
activity?.onBackPressedDispatcher?.onBackPressed()
}
}
setAppBarNoScrollFlagsOnTV()
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
binding?.downloadChildList?.adapter = adapter
binding?.downloadChildList?.setLinearListLayout(
isHorizontal = false,
nextDown = FOCUS_SELF,
nextRight = FOCUS_SELF
)//layoutManager = GridLayoutManager(context, 1)
observe(downloadsViewModel.childCards) {
if (it.isEmpty()) {
activity?.onBackPressedDispatcher?.onBackPressed()
return@observe
}
updateList(folder)
(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) {
detachBackPressedCallback()
downloadsViewModel.clearSelectedItems()
binding?.downloadChildToolbar?.isVisible = true
}
}
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()
val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
{},
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
}
)
binding?.downloadChildList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF,
)
}
context?.let { downloadsViewModel.updateChildList(it, folder) }
fixPaddingStatusbar(binding?.downloadChildRoot)
}
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadChildToolbar?.isVisible = false
activity?.attachBackPressedCallback {
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) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =
getString(R.string.delete_format).format(count, formattedSize)
}
}

View file

@ -1,55 +1,62 @@
package com.lagradost.cloudstream3.ui.download
import android.app.Activity
import android.app.Dialog
import android.content.ClipboardManager
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
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.result.contract.ActivityResultContracts
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.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isMovieType
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
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.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.main
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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore
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.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import android.text.format.Formatter.formatShortFileSize
import androidx.core.widget.doOnTextChanged
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentDownloadsBinding? = null
private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams(
@ -60,221 +67,325 @@ class DownloadFragment : Fragment() {
this.layoutParams = param
}
private fun setList(list: List<VisualDownloadHeaderCached>) {
main {
(binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list
binding?.downloadList?.adapter?.notifyDataSetChanged()
}
}
override fun onDestroyView() {
if (downloadDeleteEventListener != null) {
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
downloadDeleteEventListener = null
}
detachBackPressedCallback()
binding = null
super.onDestroyView()
}
var binding: FragmentDownloadsBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
downloadsViewModel =
ViewModelProvider(this)[DownloadViewModel::class.java]
): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_downloads, container, false)
return localBinding.root
}
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
hideKeyboard()
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.noDownloadsText) {
binding?.textNoDownloads?.text = it
/**
* 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) {
setList(it)
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding?.downloadLoading?.isVisible = false
binding?.textNoDownloads?.isVisible = it.isEmpty()
}
observe(downloadsViewModel.availableBytes) {
binding?.downloadFreeTxt?.text =
getString(R.string.storage_size_format).format(
getString(R.string.free_storage),
formatShortFileSize(view.context, it)
)
binding?.downloadFree?.setLayoutWidth(it)
updateStorageInfo(
view.context,
it,
R.string.free_storage,
binding?.downloadFreeTxt,
binding?.downloadFree
)
}
observe(downloadsViewModel.usedBytes) {
binding?.apply {
downloadUsedTxt.text =
getString(R.string.storage_size_format).format(
getString(R.string.used_storage),
formatShortFileSize(view.context, it)
)
downloadUsed.setLayoutWidth(it)
downloadStorageAppbar.isVisible = it > 0
}
}
observe(downloadsViewModel.downloadBytes) {
binding?.apply {
downloadAppTxt.text =
getString(R.string.storage_size_format).format(
getString(R.string.app_storage),
formatShortFileSize(view.context, it)
)
downloadApp.setLayoutWidth(it)
}
}
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
DownloadHeaderAdapter(
ArrayList(),
{ click ->
when (click.action) {
0 -> {
if (click.data.type.isMovieType()) {
//wont be called
} else {
val folder = DataStore.getFolderName(
DOWNLOAD_EPISODE_CACHE,
click.data.id.toString()
)
activity?.navigate(
R.id.action_navigation_downloads_to_navigation_download_child,
DownloadChildFragment.newInstance(click.data.name, folder)
)
}
}
1 -> {
(activity as AppCompatActivity?)?.loadResult(
click.data.url,
click.data.apiName
)
}
}
},
{ downloadClickEvent ->
if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.updateList(ctx)
}
}
}
updateStorageInfo(
view.context,
it,
R.string.used_storage,
binding?.downloadUsedTxt,
binding?.downloadUsed
)
downloadDeleteEventListener = { id ->
val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList
if (list != null) {
if (list.any { it.data.id == id }) {
context?.let { ctx ->
setList(ArrayList())
downloadsViewModel.updateList(ctx)
}
// Prevent race condition and make sure
// we don't display it early
if (
downloadsViewModel.isMultiDeleteState.value == null ||
downloadsViewModel.isMultiDeleteState.value == false
) binding?.downloadStorageAppbar?.isVisible = it > 0
}
observe(downloadsViewModel.downloadBytes) {
updateStorageInfo(
view.context,
it,
R.string.app_storage,
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) {
detachBackPressedCallback()
downloadsViewModel.clearSelectedItems()
// Prevent race condition and make sure
// we don't display it early
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
binding?.downloadStorageAppbar?.isVisible = true
}
}
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
{ click -> handleItemClick(click) },
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
}
)
binding?.downloadList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF
nextDown = FOCUS_SELF,
)
//layoutManager = GridLayoutManager(context, 1)
}
// Should be visible in emulator layout
binding?.downloadStreamButton?.isGone = isTrueTvSettings()
binding?.downloadStreamButton?.setOnClickListener {
val dialog =
Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom)
val binding = StreamInputBinding.inflate(dialog.layoutInflater)
dialog.setContentView(binding.root)
dialog.show()
// If user has clicked the switch do not interfere
var preventAutoSwitching = false
binding.hlsSwitch.setOnClickListener {
preventAutoSwitching = true
binding?.apply {
openLocalVideoButton.apply {
isGone = isLayout(TV)
setOnClickListener { openLocalVideo() }
}
fun activateSwitchOnHls(text: String?) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
downloadStreamButton.apply {
isGone = isLayout(TV)
setOnClickListener { showStreamInputDialog(it.context) }
}
}
binding.streamReferer.doOnTextChanged { text, _, _, _ ->
if (!preventAutoSwitching)
activateSwitchOnHls(text?.toString())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
handleScroll(scrollY - oldScrollY)
}
}
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt(
0
)?.text?.toString()?.let { copy ->
val fixedText = copy.trim()
binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText)
}
binding.applyBtt.setOnClickListener {
val url = binding.streamUrl.text?.toString()
if (url.isNullOrEmpty()) {
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
} else {
val referer = binding.streamReferer.text?.toString()
context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot)
}
private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) {
DOWNLOAD_ACTION_GO_TO_CHILD -> {
if (click.data.type.isEpisodeBased()) {
val folder =
getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
activity?.navigate(
R.id.global_to_navigation_player,
GeneratorPlayer.newInstance(
LinkGenerator(
listOf(BasicLink(url)),
extract = true,
referer = referer,
isM3u8 = binding.hlsSwitch.isChecked
)
)
R.id.action_navigation_downloads_to_navigation_download_child,
DownloadChildFragment.newInstance(click.data.name, folder)
)
dialog.dismissSafe(activity)
}
}
binding.cancelBtt.setOnClickListener {
DOWNLOAD_ACTION_LOAD_RESULT -> {
activity?.loadResult(click.data.url, click.data.apiName)
}
}
}
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadStorageAppbar?.isVisible = false
activity?.attachBackPressedCallback {
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 =
getString(R.string.delete_format).format(count, formattedSize)
}
private fun updateStorageInfo(
context: Context,
bytes: Long,
@StringRes stringRes: Int,
textView: TextView?,
view: View?
) {
textView?.text = getString(R.string.storage_size_format).format(
getString(stringRes),
formatShortFileSize(context, bytes)
)
view?.setLayoutWidth(bytes)
}
private fun openLocalVideo() {
val intent = Intent()
.setAction(Intent.ACTION_GET_CONTENT)
.setType("video/*")
.addCategory(Intent.CATEGORY_OPENABLE)
.addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access
normalSafeApiCall {
videoResultLauncher.launch(
Intent.createChooser(
intent,
getString(R.string.open_local_video)
)
)
}
}
private fun showStreamInputDialog(context: Context) {
val dialog = Dialog(context, R.style.AlertDialogCustom)
val binding = StreamInputBinding.inflate(dialog.layoutInflater)
dialog.setContentView(binding.root)
dialog.show()
var preventAutoSwitching = false
binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true }
binding.streamReferer.doOnTextChanged { text, _, _, _ ->
if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
}
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
0
)?.text?.toString()?.let { copy ->
val fixedText = copy.trim()
binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText, binding)
}
binding.applyBtt.setOnClickListener {
val url = binding.streamUrl.text?.toString()
if (url.isNullOrEmpty()) {
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
} else {
val referer = binding.streamReferer.text?.toString()
activity?.navigate(
R.id.global_to_navigation_player,
GeneratorPlayer.newInstance(
LinkGenerator(
listOf(BasicLink(url)),
extract = true,
referer = referer,
isM3u8 = binding.hlsSwitch.isChecked
)
)
)
dialog.dismissSafe(activity)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val dy = scrollY - oldScrollY
if (dy > 0) { //check for scroll down
binding?.downloadStreamButton?.shrink() // hide
} else if (dy < -5) {
binding?.downloadStreamButton?.extend() // show
}
}
}
downloadsViewModel.updateList(requireContext())
fixPaddingStatusbar(binding?.downloadRoot)
binding.cancelBtt.setOnClickListener {
dialog.dismissSafe(activity)
}
}
private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
}
private fun handleScroll(dy: Int) {
if (dy > 0) {
binding?.downloadStreamButton?.shrink()
} else if (dy < -5) {
binding?.downloadStreamButton?.extend()
}
}
// Open local video from files using content provider x safeFile
private val videoResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
}
}

View file

@ -1,149 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.annotation.SuppressLint
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import java.util.*
data class VisualDownloadHeaderCached(
val currentOngoingDownloads: Int,
val totalDownloads: Int,
val totalBytes: Long,
val currentBytes: Long,
val data: VideoDownloadHelper.DownloadHeaderCached,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
)
data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
)
class DownloadHeaderAdapter(
var cardList: List<VisualDownloadHeaderCached>,
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val movieClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadHeaderViewHolder(
DownloadHeaderEpisodeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
clickCallback,
movieClickCallback
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DownloadHeaderViewHolder -> {
holder.bind(cardList[position])
}
}
}
override fun getItemCount(): Int {
return cardList.size
}
class DownloadHeaderViewHolder
constructor(
val binding: DownloadHeaderEpisodeBinding,
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val movieClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
/*private val poster: ImageView? = itemView.download_header_poster
private val title: TextView = itemView.download_header_title
private val extraInfo: TextView = itemView.download_header_info
private val holder: CardView = itemView.episode_holder
private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded
private val downloadImage: ImageView = itemView.download_header_episode_download
private val normalImage: ImageView = itemView.download_header_goto_child*/
@SuppressLint("SetTextI18n")
fun bind(card: VisualDownloadHeaderCached) {
val d = card.data
binding.downloadHeaderPoster.apply {
setImage(d.poster)
setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
}
}
binding.apply {
binding.downloadHeaderTitle.text = d.name
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
//val isMovie = d.type.isMovieType()
if (card.child != null) {
//downloadHeaderProgressDownloaded.visibility = View.VISIBLE
// downloadHeaderEpisodeDownload.visibility = View.VISIBLE
binding.downloadHeaderGotoChild.visibility = View.GONE
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, movieClickCallback)
downloadButton.isVisible = true
/*setUpButton(
card.currentBytes,
card.totalBytes,
downloadBar,
downloadImage,
extraInfo,
card.child,
movieClickCallback
)*/
episodeHolder.setOnClickListener {
movieClickCallback.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
card.child
)
)
}
} else {
downloadButton.isVisible = false
// downloadHeaderProgressDownloaded.visibility = View.GONE
// downloadHeaderEpisodeDownload.visibility = View.GONE
binding.downloadHeaderGotoChild.visibility = View.VISIBLE
try {
downloadHeaderInfo.text =
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads,
if (card.totalDownloads == 1) downloadHeaderInfo.context.getString(R.string.episode) else downloadHeaderInfo.context.getString(
R.string.episodes
),
mbString
)
} catch (t: Throwable) {
// you probably formatted incorrectly
downloadHeaderInfo.text = "Error"
logError(t)
}
episodeHolder.setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(0, d))
}
}
}
}
}
}

View file

@ -1,122 +1,439 @@
package com.lagradost.cloudstream3.ui.download
import android.content.Context
import android.content.DialogInterface
import android.os.Environment
import android.os.StatFs
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.isMovieType
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
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
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() {
private val _noDownloadsText = MutableLiveData<String>().apply {
value = ""
}
val noDownloadsText: LiveData<String> = _noDownloadsText
private val _headerCards =
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() }
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
private val _usedBytes = MutableLiveData<Long>()
private val _availableBytes = MutableLiveData<Long>()
private val _downloadBytes = MutableLiveData<Long>()
val usedBytes: LiveData<Long> = _usedBytes
private val _availableBytes = MutableLiveData<Long>()
val availableBytes: LiveData<Long> = _availableBytes
private val _downloadBytes = MutableLiveData<Long>()
val downloadBytes: LiveData<Long> = _downloadBytes
fun updateList(context: Context) = viewModelScope.launchSafe {
val children = withContext(Dispatchers.IO) {
val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE)
headers.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates
}
private val _selectedBytes = MutableLiveData<Long>(0)
val selectedBytes: LiveData<Long> = _selectedBytes
// parentId : bytes
val totalBytesUsedByChild = HashMap<Int, Long>()
// parentId : bytes
val currentBytesUsedByChild = HashMap<Int, Long>()
// parentId : downloadsCount
val totalDownloads = HashMap<Int, Int>()
private val _isMultiDeleteState = MutableLiveData(false)
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
// Gets all children downloads
withContext(Dispatchers.IO) {
for (c in children) {
val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue
private var previousVisual: List<VisualDownloadCached>? = null
if (childFile.fileLength <= 1) continue
val len = childFile.totalBytes
val flen = childFile.fileLength
fun setIsMultiDeleteState(value: Boolean) {
_isMultiDeleteState.postValue(value)
}
totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len
currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen
totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1
fun addSelected(itemId: Int) {
updateSelectedItems { it.add(itemId) }
}
fun removeSelected(itemId: Int) {
updateSelectedItems { it.remove(itemId) }
}
fun selectAllItems() {
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
}
fun clearSelectedItems() {
// We need this to be done immediately
// so we can't use postValue
_selectedItemIds.value = mutableSetOf()
updateSelectedItems { it.clear() }
}
fun isAllSelected(): 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 }
}
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
val currentSelected = selectedItemIds.value ?: mutableSetOf()
action(currentSelected)
_selectedItemIds.postValue(currentSelected)
updateSelectedBytes()
updateSelectedCards()
}
private fun updateSelectedBytes() = viewModelScope.launchSafe {
val selectedItemsList = getSelectedItemsData() ?: return@launchSafe
val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes }
_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
}
_headerCards.postValue(headers)
}
val cached = withContext(Dispatchers.IO) { // wont fetch useless keys
totalDownloads.entries.filter { it.value > 0 }.mapNotNull {
context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
it.key.toString()
)
childCards.value?.let { children ->
children.forEach { child ->
child.isSelected = child.data.id in currentSelected
}
_childCards.postValue(children)
}
}
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) {
cached.mapNotNull { // TODO FIX
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
val movieEpisode =
if (!it.type.isMovieType()) null
else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString())
)
VisualDownloadHeaderCached(
0,
downloads,
bytes,
currentBytes,
it,
movieEpisode
)
}.sortedBy {
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0)
} // episode sorting by episode, lowest to highest
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
calculateDownloadStats(context, children)
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
createVisualDownloadList(
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
)
}
if (visual != previousVisual) {
previousVisual = visual
updateStorageStats(visual)
_headerCards.postValue(visual)
}
}
private fun calculateDownloadStats(
context: Context,
children: List<VideoDownloadHelper.DownloadEpisodeCached>
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
// parentId : bytes
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount
val totalDownloads = mutableMapOf<Int, Int>()
children.forEach { child ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes
val flen = childFile.fileLength
totalBytesUsedByChild.merge(child.parentId, len, Long::plus)
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads.merge(child.parentId, 1, Int::plus)
}
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
}
private fun createVisualDownloadList(
context: Context,
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>,
currentBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
): List<VisualDownloadCached.Header> {
return cached.mapNotNull {
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
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString())
)
VisualDownloadCached.Header(
currentBytes = currentBytes,
totalBytes = bytes,
data = it,
child = movieEpisode,
currentOngoingDownloads = 0,
totalDownloads = downloads,
isSelected = isSelected,
)
// Prevent order being almost completely random,
// making things difficult to find.
}.sortedWith(compareBy<VisualDownloadCached.Header> {
// Sort by isEpisodeBased() ascending. We put those that
// are episode based at the bottom for UI purposes and to
// make it easier to find by grouping them together.
it.data.type.isEpisodeBased()
}.thenBy {
// Then we sort alphabetically by name (case-insensitive).
// Again, we do this to make things easier to find.
it.data.name.lowercase()
})
}
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key ->
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull {
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
VisualDownloadCached.Child(
currentBytes = info.fileLength,
totalBytes = info.totalBytes,
isSelected = isSelected,
data = it,
)
}
}.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)
}
}
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)
}
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
try {
val stat = StatFs(Environment.getExternalStorageDirectory().path)
val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong
val localBytesAvailable = stat.availableBytes
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
val localDownloadedBytes = visual.sumOf { it.totalBytes }
_usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes)
val localUsedBytes = localTotalBytes - localBytesAvailable
_usedBytes.postValue(localUsedBytes)
_availableBytes.postValue(localBytesAvailable)
_downloadBytes.postValue(localDownloadedBytes)
} catch (t : Throwable) {
} catch (t: Throwable) {
_downloadBytes.postValue(0)
logError(t)
}
_headerCards.postValue(visual)
}
}
fun handleMultiDelete(context: Context) = viewModelScope.launchSafe {
val selectedItemsList = getSelectedItemsData().orEmpty()
val deleteData = processSelectedItems(context, selectedItemsList)
val message = buildDeleteMessage(context, deleteData)
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
}
fun handleSingleDelete(
context: Context,
itemId: Int
) = viewModelScope.launchSafe {
val itemData = getItemDataFromId(itemId)
val deleteData = processSelectedItems(context, itemData)
val message = buildDeleteMessage(context, deleteData)
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
}
private fun processSelectedItems(
context: Context,
selectedItemsList: List<VisualDownloadCached>
): DeleteData {
val names = mutableListOf<String>()
val seriesNames = mutableListOf<String>()
val ids = mutableSetOf<Int>()
val parentIds = mutableSetOf<Int>()
var parentName: String? = null
selectedItemsList.forEach { item ->
when (item) {
is VisualDownloadCached.Header -> {
if (item.data.type.isEpisodeBased()) {
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull {
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
it
)
}
.filter { it.parentId == item.data.id }
.map { it.id }
ids.addAll(episodes)
parentIds.add(item.data.id)
val episodeInfo = "${item.data.name} (${item.totalDownloads} ${
context.resources.getQuantityString(
R.plurals.episodes,
item.totalDownloads
).lowercase()
})"
seriesNames.add(episodeInfo)
} else {
ids.add(item.data.id)
names.add(item.data.name)
}
}
is VisualDownloadCached.Child -> {
ids.add(item.data.id)
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
item.data.parentId.toString()
)
parentName = parent?.name
names.add(
context.getNameFull(
item.data.name,
item.data.episode,
item.data.season
)
)
}
}
}
return DeleteData(ids, parentIds, seriesNames, names, parentName)
}
private fun buildDeleteMessage(
context: Context,
data: DeleteData
): String {
val formattedNames = data.names.sortedBy { it.lowercase() }
.joinToString(separator = "\n") { "$it" }
val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() }
.joinToString(separator = "\n") { "$it" }
return when {
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)
}
data.seriesNames.isNotEmpty() -> {
val seriesSection = context.getString(R.string.delete_message_series_section)
.format(formattedSeriesNames)
context.getString(R.string.delete_message_multiple)
.format(formattedNames) + "\n\n" + seriesSection
}
else -> context.getString(R.string.delete_message_multiple).format(formattedNames)
}
}
private fun showDeleteConfirmationDialog(
context: Context,
message: String,
ids: Set<Int>,
parentIds: Set<Int>
) {
val builder = AlertDialog.Builder(context)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
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
// parent header card
removeItems(successfulIds + parentIds)
}
}
}
DialogInterface.BUTTON_NEGATIVE -> {
// Do nothing on cancel
}
}
}
try {
val title = if (ids.count() == 1) {
R.string.delete_file
} else R.string.delete_files
builder.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.delete, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (e: Exception) {
logError(e)
}
}
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
val headers = headerCards.value.orEmpty()
val children = childCards.value.orEmpty()
return selectedItemIds.value?.mapNotNull { id ->
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
}
}
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
val headers = headerCards.value.orEmpty()
val children = childCards.value.orEmpty()
return (headers + children).filter { it.data.id == itemId }
}
private data class DeleteData(
val ids: Set<Int>,
val parentIds: Set<Int>,
val seriesNames: List<String>,
val names: List<String>,
val parentName: String?
)
}

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.text.format.Formatter
import android.text.format.Formatter.formatShortFileSize
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
@ -9,6 +9,8 @@ import androidx.annotation.LayoutRes
import androidx.core.view.isVisible
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
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
@ -34,7 +36,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
lateinit var progressBar: ContentLoadingProgressBar
var progressText: TextView? = null
/*val gid: String? get() = sessionIdToGid[persistentId]
/* val gid: String? get() = sessionIdToGid[persistentId]
// used for resuming data
var _lastRequestOverride: UriRequest? = null
@ -44,7 +46,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
_lastRequestOverride = value
}
var files: List<AbstractClient.JsonFile> = emptyList()*/
var files: List<AbstractClient.JsonFile> = emptyList() */
protected var isZeroBytes: Boolean = true
fun inflate(@LayoutRes layout: Int) {
@ -52,12 +54,16 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
}
init {
@Suppress("LeakingThis")
resetViewData()
}
var doSetProgress = true
open fun resetViewData() {
// lastRequest = null
isZeroBytes = true
doSetProgress = true
persistentId = null
}
@ -68,37 +74,45 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
persistentId = id
currentMetaData.id = id
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)?.let { savedData ->
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
if (!doSetProgress) return
/*lastRequest = savedData.uriRequest
files = savedData.files
ioSafe {
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
var totalBytes: Long = 0
var downloadedBytes: Long = 0
for (file in savedData.files) {
downloadedBytes += file.completedLength
totalBytes += file.length
}*/
setProgress(downloadedBytes, totalBytes)
// some extra padding for just in case
val status = VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) DownloadStatusTell.IsDone else DownloadStatusTell.IsPaused
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
mainWork {
if (savedData != null) {
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
setProgress(downloadedBytes, totalBytes)
applyMetaData(id, downloadedBytes, totalBytes)
} else run { resetView() }
}
setStatus(status)
} ?: run {
resetView()
}
}
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
// some extra padding for just in case
return VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) {
DownloadStatusTell.IsDone
} else DownloadStatusTell.IsPaused
}
fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) {
val status = getStatus(id, downloadedBytes, totalBytes)
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
}
setStatus(status)
}
open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
isZeroBytes = downloadedBytes == 0L
progressBar.post {
@ -124,13 +138,16 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
if (isZeroBytes) {
progressText?.isVisible = false
} else {
progressText?.apply {
val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes)
val totalMbString = Formatter.formatShortFileSize(context, totalBytes)
text =
//if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentMbString, totalMbString)
if (doSetProgress) {
progressText?.apply {
val currentFormattedSizeString =
formatShortFileSize(context, downloadedBytes)
val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
text =
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentFormattedSizeString, totalFormattedSizeString)
}
}
}
@ -167,8 +184,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
override fun onAttachedToWindow() {
VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent
//VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
//VideoDownloadManager.downloadEvent += ::downloadEvent
// VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
// VideoDownloadManager.downloadEvent += ::downloadEvent
VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent
val pid = persistentId
@ -182,8 +199,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
override fun onDetachedFromWindow() {
VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent
//VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
//VideoDownloadManager.downloadEvent -= ::downloadEvent
// VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
// VideoDownloadManager.downloadEvent -= ::downloadEvent
VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent
super.onDetachedFromWindow()
@ -198,5 +215,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
* Get a clean slate again, might be useful in recyclerview?
* */
abstract fun resetView()
}

View file

@ -13,7 +13,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper
class DownloadButton(context: Context, attributeSet: AttributeSet) :
PieFetchButton(context, attributeSet) {
var mainText: TextView? = null
private var mainText: TextView? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
progressText = findViewById(R.id.result_movie_download_text_precentage)

View file

@ -1,17 +1,20 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.animation.AnimationUtils
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.MainThread
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
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
@ -22,7 +25,7 @@ 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
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) {
@ -41,6 +44,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
private var iconPaused: Int = 0
private var hideWhenIcon: Boolean = true
var progressDrawable: Int = 0
var overrideLayout: Int? = null
companion object {
@ -53,7 +58,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}
private var progressBarBackground: View
private var statusView: ImageView
var statusView: ImageView
open fun onInflate() {}
@ -111,10 +116,10 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done
)
iconPaused = getResourceId(
R.styleable.PieFetchButton_download_icon_paused, 0//R.drawable.download_icon_pause
R.styleable.PieFetchButton_download_icon_paused, 0 // R.drawable.download_icon_pause
)
iconActive = getResourceId(
R.styleable.PieFetchButton_download_icon_active, 0 //R.drawable.download_icon_load
R.styleable.PieFetchButton_download_icon_active, 0 // R.drawable.download_icon_load
)
iconWaiting = getResourceId(
R.styleable.PieFetchButton_download_icon_waiting, 0
@ -125,7 +130,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
val progressDrawable = getResourceId(
progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
)
@ -164,8 +169,9 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
this.setPersistentId(card.id)
view.setOnClickListener {
if (isZeroBytes) {
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
//callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
} else {
val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
@ -192,7 +198,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
list
) {
callback(DownloadClickEvent(itemId, card))
//callback.invoke(DownloadClickEvent(itemId, data))
// callback.invoke(DownloadClickEvent(itemId, data))
}
}
}
@ -200,7 +206,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
view.setOnLongClickListener {
callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card))
//clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
// clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
return@setOnLongClickListener true
}
}
@ -213,7 +219,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
setDefaultClickListener(this, textView, card, callback)
}
/*open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List<UriRequest>) {
/* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List<UriRequest>) {
this.setOnClickListener {
when (this.currentStatus) {
null -> {
@ -239,42 +245,57 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
else -> {}
}
}
}*/
} */
@MainThread
private fun setStatusInternal(status: DownloadStatusTell?) {
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
progressBarBackground.startAnimation(animation)
} else {
progressBarBackground.clearAnimation()
}
val progressDrawable =
if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline
progressBarBackground.background =
ContextCompat.getDrawable(context, progressDrawable)
val drawable =
getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) }
statusView.setImageDrawable(drawable)
val isDrawable = drawable != null
statusView.isVisible = isDrawable
val hide = hideWhenIcon && isDrawable
if (hide) {
progressBar.clearAnimation()
progressBarBackground.clearAnimation()
}
progressBarBackground.isGone = hide
progressBar.isGone = hide
}
/** Also sets currentStatus */
override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status
//progressBar.isVisible =
// status != null && status != DownloadStatusTell.Complete && status != DownloadStatusTell.Error
//progressBarBackground.isVisible = status != null && status != DownloadStatusTell.Complete
progressBarBackground.post {
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
progressBarBackground.startAnimation(animation)
} else {
progressBarBackground.clearAnimation()
// Runs on the main thread, but also instant if it already is
if (Looper.myLooper() == Looper.getMainLooper()) {
try {
setStatusInternal(status)
} catch (t: Throwable) {
logError(t) // Just in case setStatusInternal throws because thread
progressBarBackground.post {
setStatusInternal(status)
}
}
val progressDrawable =
if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline
progressBarBackground.background =
ContextCompat.getDrawable(context, progressDrawable)
val drawable = getDrawableFromStatus(status)
statusView.setImageDrawable(drawable)
val isDrawable = drawable != null
statusView.isVisible = isDrawable
val hide = hideWhenIcon && isDrawable
if (hide) {
progressBar.clearAnimation()
progressBarBackground.clearAnimation()
} else {
progressBarBackground.post {
setStatusInternal(status)
}
progressBarBackground.isGone = hide
progressBar.isGone = hide
}
}
@ -282,6 +303,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
setStatus(null)
currentMetaData = DownloadMetadata(0, 0, 0, null)
isZeroBytes = true
doSetProgress = true
progressBar.progress = 0
}
@ -305,19 +327,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}
}
open fun getDrawableFromStatus(status: DownloadStatusTell?): Drawable? {
val drawableInt = when (status) {
DownloadStatusTell.IsPaused -> iconPaused
DownloadStatusTell.IsPending -> iconWaiting
DownloadStatusTell.IsDownloading -> iconActive
DownloadStatusTell.IsFailed -> iconError
DownloadStatusTell.IsDone -> iconComplete
DownloadStatusTell.IsStopped -> iconRemoved
null -> iconInit
}
if (drawableInt == 0) {
return null
}
return ContextCompat.getDrawable(this.context, drawableInt)
}
open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) {
DownloadStatusTell.IsPaused -> iconPaused
DownloadStatusTell.IsPending -> iconWaiting
DownloadStatusTell.IsDownloading -> iconActive
DownloadStatusTell.IsFailed -> iconError
DownloadStatusTell.IsDone -> iconComplete
DownloadStatusTell.IsStopped -> iconRemoved
else -> iconInit
}.takeIf { it != 0 }
}

View file

@ -2,32 +2,59 @@ package com.lagradost.cloudstream3.ui.home
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
import com.lagradost.cloudstream3.utils.UIHelper.toPx
class HomeChildItemAdapter(
val cardList: MutableList<SearchResponse>,
class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(view) {
/*private fun recursive(view : View) : Boolean {
if (view.isFocused) {
println("VIEW: $view | id=${view.id}")
}
return (view as? ViewGroup)?.children?.any { recursive(it) } ?: false
}*/
// very shitty that we cant store the state when the view clears,
// but this is because the focus clears before the view is removed
// so we have to manually store it
var wasFocused: Boolean = false
override fun save(): Boolean = wasFocused
override fun restore(state: Boolean) {
if (state) {
wasFocused = false
// only refocus if tv
if(isLayout(TV)) {
itemView.requestFocus()
}
}
}
}
class HomeChildItemAdapter(
fragment: Fragment,
id: Int,
private val nextFocusUp: Int? = null,
private val nextFocusDown: Int? = null,
private val clickCallback: (SearchClickCallback) -> Unit,
) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
BaseAdapter<SearchResponse, Boolean>(fragment, id) {
var isHorizontal: Boolean = false
var hasNext: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val expanded = parent.context.IsBottomLayout()
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
val expanded = parent.context.isBottomLayout()
/* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid
val root = LayoutInflater.from(parent.context).inflate(layout, parent, false)
@ -39,164 +66,77 @@ class HomeChildItemAdapter(
parent,
false
) else HomeResultGridBinding.inflate(inflater, parent, false)
return HomeScrollViewHolderState(binding)
}
override fun onBindContent(
holder: ViewHolderState<Boolean>,
item: SearchResponse,
position: Int
) {
when (val binding = holder.view) {
is HomeResultGridBinding -> {
binding.backgroundCard.apply {
val min = 114.toPx
val max = 180.toPx
return CardViewHolder(
binding,
clickCallback,
itemCount,
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
}
is HomeResultGridExpandedBinding -> {
binding.backgroundCard.apply {
val min = 114.toPx
val max = 180.toPx
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
if (position == 0) { // to fix tv
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
}
}
}
SearchResultBuilder.bind(
clickCallback = { click ->
// ok, so here we hijack the callback to fix the focus
when (click.action) {
SEARCH_ACTION_LOAD -> (holder as? HomeScrollViewHolderState)?.wasFocused = true
}
clickCallback(click)
},
item,
position,
holder.itemView,
nextFocusUp,
nextFocusDown,
isHorizontal,
parent.isRtl()
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is CardViewHolder -> {
holder.itemCount = itemCount // i know ugly af
holder.bind(cardList[position], position)
}
}
}
override fun getItemCount(): Int {
return cardList.size
}
override fun getItemId(position: Int): Long {
return (cardList[position].id ?: position).toLong()
}
fun updateList(newList: List<SearchResponse>) {
val diffResult = DiffUtil.calculateDiff(
HomeChildDiffCallback(this.cardList, newList)
nextFocusDown
)
cardList.clear()
cardList.addAll(newList)
diffResult.dispatchUpdatesTo(this)
}
class CardViewHolder
constructor(
val binding: ViewBinding,
private val clickCallback: (SearchClickCallback) -> Unit,
var itemCount: Int,
private val nextFocusUp: Int? = null,
private val nextFocusDown: Int? = null,
private val isHorizontal: Boolean = false,
private val isRtl: Boolean
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(card: SearchResponse, position: Int) {
// TV focus fixing
/*val nextFocusBehavior = when (position) {
0 -> true
itemCount - 1 -> false
else -> null
}
if (position == 0) { // to fix tv
if (isRtl) {
itemView.nextFocusRightId = R.id.nav_rail_view
itemView.nextFocusLeftId = -1
}
else {
itemView.nextFocusLeftId = R.id.nav_rail_view
itemView.nextFocusRightId = -1
}
} else {
itemView.nextFocusRightId = -1
itemView.nextFocusLeftId = -1
}*/
when (binding) {
is HomeResultGridBinding -> {
binding.backgroundCard.apply {
val min = 114.toPx
val max = 180.toPx
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
}
is HomeResultGridExpandedBinding -> {
binding.backgroundCard.apply {
val min = 114.toPx
val max = 180.toPx
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
if (position == 0) { // to fix tv
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
}
}
}
SearchResultBuilder.bind(
clickCallback,
card,
position,
itemView,
null, // nextFocusBehavior,
nextFocusUp,
nextFocusDown
)
itemView.tag = position
//val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f)
//ani.fillAfter = true
//ani.duration = 200
//itemView.startAnimation(ani)
}
holder.itemView.tag = position
}
}
class HomeChildDiffCallback(
private val oldList: List<SearchResponse>,
private val newList: List<SearchResponse>
) :
DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].name == newList[newItemPosition].name
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition] == newList[newItemPosition] && oldItemPosition < oldList.size - 1 // always update the last item
}

View file

@ -7,7 +7,6 @@ import android.content.DialogInterface
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -18,23 +17,14 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.*
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
@ -45,38 +35,31 @@ 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.WatchType
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.search.*
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppUtils.ownHide
import com.lagradost.cloudstream3.utils.AppUtils.ownShow
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
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.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
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.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import java.util.*
const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list"
const val HOME_PREF_HOMEPAGE = "home_pref_homepage"
class HomeFragment : Fragment() {
companion object {
val configEvent = Event<Int>()
@ -250,7 +233,7 @@ class HomeFragment : Fragment() {
return bottomSheetDialogBuilder
}
fun getPairList(
private fun getPairList(
anime: Chip?,
cartoons: Chip?,
tvs: Chip?,
@ -329,7 +312,7 @@ class HomeFragment : Fragment() {
button?.isVisible = isValid
button?.isChecked = isValid && selectedTypes.any { types.contains(it) }
button?.isFocusable = true
if (isTrueTvSettings()) {
if (isLayout(TV)) {
button?.isFocusableInTouchMode = true
}
@ -378,10 +361,7 @@ class HomeFragment : Fragment() {
var currentApiName = selectedApiName
var currentValidApis: MutableList<MainAPI> = mutableListOf()
val preSelectedTypes = this.getKey<List<String>>(HOME_PREF_HOMEPAGE)
?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } }
?.toMutableList()
?: mutableListOf(TvType.Movie, TvType.TvSeries)
val preSelectedTypes = DataStoreHelper.homePreference.toMutableList()
binding.cancelBtt.setOnClickListener {
dialog.dismissSafe()
@ -409,7 +389,7 @@ class HomeFragment : Fragment() {
}
fun updateList() {
this.setKey(HOME_PREF_HOMEPAGE, preSelectedTypes)
DataStoreHelper.homePreference = preSelectedTypes
arrayAdapter.clear()
currentValidApis = validAPIs.filter { api ->
@ -456,7 +436,7 @@ class HomeFragment : Fragment() {
bottomSheetDialog?.ownShow()
val layout =
if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home
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)
@ -470,6 +450,7 @@ class HomeFragment : Fragment() {
}
override fun onDestroyView() {
bottomSheetDialog?.ownHide()
binding = null
super.onDestroyView()
@ -506,6 +487,10 @@ class HomeFragment : Fragment() {
private var bottomSheetDialog: BottomSheetDialog? = null
// https://github.com/vivchar/RendererRecyclerViewAdapter/blob/185251ee9d94fb6eb3e063b00d646b745186c365/example/src/main/java/com/github/vivchar/example/pages/github/GithubFragment.kt#L32
// cry about it, but this is android we are talking about, we cant do the most simple shit without making a global variable
private var instanceState: Bundle = Bundle()
private var homeMasterAdapter: HomeParentItemAdapterPreview? = null
@SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -517,23 +502,23 @@ class HomeFragment : Fragment() {
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
homeApiFab.setOnClickListener(apiChangeClickListener)
homeChangeApi.setOnClickListener(apiChangeClickListener)
homeSwitchAccount.setOnClickListener { v ->
DataStoreHelper.showWhoIsWatching(v?.context ?: return@setOnClickListener)
homeSwitchAccount.setOnClickListener {
activity?.showAccountSelectLinear()
}
homeRandom.setOnClickListener {
if (listHomepageItems.isNotEmpty()) {
activity.loadSearchResult(listHomepageItems.random())
}
}
homeMasterRecycler.adapter =
HomeParentItemAdapterPreview(
mutableListOf(),
homeViewModel
)
homeMasterAdapter = HomeParentItemAdapterPreview(
fragment = this@HomeFragment,
homeViewModel,
)
homeMasterRecycler.adapter = homeMasterAdapter
//fixPaddingStatusbar(homeLoadingStatusbar)
homeApiFab.isVisible = !isTvSettings()
homeApiFab.isVisible = isLayout(PHONE)
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@ -541,7 +526,7 @@ class HomeFragment : Fragment() {
homeApiFab.shrink() // hide
homeRandom.shrink()
} else if (dy < -5) {
if (!isTvSettings()) {
if (isLayout(PHONE)) {
homeApiFab.extend() // show
homeRandom.extend()
}
@ -549,6 +534,7 @@ class HomeFragment : Fragment() {
super.onScrolled(recyclerView, dx, dy)
}
})
}
@ -559,7 +545,7 @@ class HomeFragment : Fragment() {
settingsManager.getBoolean(
getString(R.string.random_button_key),
false
) && !isTvSettings()
) && isLayout(PHONE)
binding?.homeRandom?.visibility = View.GONE
}
@ -579,10 +565,11 @@ class HomeFragment : Fragment() {
val mutableListOfResponse = mutableListOf<SearchResponse>()
listHomepageItems.clear()
(homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(
d.values.toMutableList(),
homeMasterRecycler
)
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
it.copy(
list = it.list.copy(list = it.list.list.toMutableList())
)
}.toMutableList())
homeLoading.isVisible = false
homeLoadingError.isVisible = false
@ -631,7 +618,7 @@ class HomeFragment : Fragment() {
}
is Resource.Loading -> {
(homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(listOf())
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
homeLoadingShimmer.startShimmer()
homeLoading.isVisible = true
homeLoadingError.isVisible = false
@ -658,16 +645,18 @@ class HomeFragment : Fragment() {
return@observeNullable
}
bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = {
val (items, delete) = item
bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = {
homeViewModel.expandAndReturn(it)
}, dismissCallback = {
homeViewModel.popup(null)
bottomSheetDialog = null
})
}, deleteCallback = delete)
}
homeViewModel.reloadStored()
homeViewModel.loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false)
homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false)
//loadHomePage(false)
// nice profile pic on homepage

View file

@ -1,22 +1,30 @@
package com.lagradost.cloudstream3.ui.home
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
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
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.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
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
class LoadClickCallback(
val action: Int = 0,
@ -26,191 +34,89 @@ class LoadClickCallback(
)
open class ParentItemAdapter(
private var items: MutableList<HomeViewModel.ExpandableHomepageList>,
//private val viewModel: HomeViewModel,
open val fragment: Fragment,
id: Int,
private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val root = LayoutInflater.from(parent.context).inflate(
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent,
parent,
false
)
val binding = HomepageParentBinding.bind(root)
return ParentViewHolder(
binding,
clickCallback,
moreInfoClickCallback,
expandCallback
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ParentViewHolder -> {
holder.bind(items[position])
}
}
}
override fun getItemCount(): Int {
return items.size
}
override fun getItemId(position: Int): Long {
return items[position].list.name.hashCode().toLong()
}
@JvmName("updateListHomePageList")
fun updateList(newList: List<HomePageList>) {
updateList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
.toMutableList())
}
@JvmName("updateListExpandableHomepageList")
fun updateList(
newList: MutableList<HomeViewModel.ExpandableHomepageList>,
recyclerView: RecyclerView? = null
) {
// this
// 1. prevents deep copy that makes this.items == newList
// 2. filters out undesirable results
// 3. moves empty results to the bottom (sortedBy is a stable sort)
val new =
newList.map { it.copy(list = it.list.copy(list = it.list.list.filterSearchResponse())) }
.sortedBy { it.list.list.isEmpty() }
val diffResult = DiffUtil.calculateDiff(
SearchDiffCallback(items, new)
)
items.clear()
items.addAll(new)
//val mAdapter = this
val delta = if (this@ParentItemAdapter is HomeParentItemAdapterPreview) {
headItems
} else {
0
}
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
//notifyItemRangeChanged(position + delta, count)
notifyItemRangeInserted(position + delta, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position + delta, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition + delta, toPosition + delta)
}
override fun onChanged(_position: Int, count: Int, payload: Any?) {
val position = _position + delta
// I know kinda messy, what this does is using the update or bind instead of onCreateViewHolder -> bind
recyclerView?.apply {
// this loops every viewHolder in the recycle view and checks the position to see if it is within the update range
val missingUpdates = (position until (position + count)).toMutableSet()
for (i in 0 until itemCount) {
val child = getChildAt(i) ?: continue
val viewHolder = getChildViewHolder(child) ?: continue
if (viewHolder !is ParentViewHolder) continue
val absolutePosition = viewHolder.bindingAdapterPosition
if (absolutePosition >= position && absolutePosition < position + count) {
val expand = items.getOrNull(absolutePosition - delta) ?: continue
missingUpdates -= absolutePosition
//println("Updating ${viewHolder.title.text} ($absolutePosition $position) -> ${expand.list.name}")
if (viewHolder.title.text == expand.list.name) {
viewHolder.update(expand)
} else {
viewHolder.bind(expand)
}
}
}
// just in case some item did not get updated
for (i in missingUpdates) {
notifyItemChanged(i, payload)
}
} ?: run {
// in case we don't have a nice
notifyItemRangeChanged(position, count, payload)
}
}
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
fragment,
id,
diffCallback = BaseDiffCallback(
itemSame = { a, b -> a.list.name == b.list.name },
contentSame = { a, b ->
a.list.list == b.list.list
})
//diffResult.dispatchUpdatesTo(this)
}
class ParentViewHolder
constructor(
val binding: HomepageParentBinding,
// val viewModel: HomeViewModel,
private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null,
) :
RecyclerView.ViewHolder(binding.root) {
val title: TextView = binding.homeChildMoreInfo
private val recyclerView: RecyclerView = binding.homeChildRecyclerview
private val startFocus = R.id.nav_rail_view
private val endFocus = FOCUS_SELF
fun update(expand: HomeViewModel.ExpandableHomepageList) {
val info = expand.list
(recyclerView.adapter as? HomeChildItemAdapter?)?.apply {
updateList(info.list.toMutableList())
hasNext = expand.hasNext
} ?: run {
recyclerView.adapter = HomeChildItemAdapter(
info.list.toMutableList(),
clickCallback = clickCallback,
nextFocusUp = recyclerView.nextFocusUpId,
nextFocusDown = recyclerView.nextFocusDownId,
).apply {
isHorizontal = info.isHorizontalImages
hasNext = expand.hasNext
}
recyclerView.setLinearListLayout(
isHorizontal = true,
nextLeft = startFocus,
nextRight = endFocus,
)
}
) {
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
override fun save(): Bundle = Bundle().apply {
val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview
putParcelable(
"value",
recyclerView?.layoutManager?.onSaveInstanceState()
)
(recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView)
}
fun bind(expand: HomeViewModel.ExpandableHomepageList) {
val info = expand.list
recyclerView.adapter = HomeChildItemAdapter(
info.list.toMutableList(),
override fun restore(state: Bundle) {
(binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
state.getSafeParcelable<Parcelable>("value")
)
}
}
override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
super.submitList(list?.sortedBy { it.list.list.isEmpty() })
}
override fun onUpdateContent(
holder: ViewHolderState<Bundle>,
item: HomeViewModel.ExpandableHomepageList,
position: Int
) {
val binding = holder.view
if (binding !is HomepageParentBinding) return
(binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list)
}
override fun onBindContent(
holder: ViewHolderState<Bundle>,
item: HomeViewModel.ExpandableHomepageList,
position: Int
) {
val startFocus = R.id.nav_rail_view
val endFocus = FOCUS_SELF
val binding = holder.view
if (binding !is HomepageParentBinding) return
val info = item.list
binding.apply {
homeChildRecyclerview.adapter = HomeChildItemAdapter(
fragment = fragment,
id = id + position + 100,
clickCallback = clickCallback,
nextFocusUp = recyclerView.nextFocusUpId,
nextFocusDown = recyclerView.nextFocusDownId,
nextFocusUp = homeChildRecyclerview.nextFocusUpId,
nextFocusDown = homeChildRecyclerview.nextFocusDownId,
).apply {
isHorizontal = info.isHorizontalImages
hasNext = expand.hasNext
hasNext = item.hasNext
submitList(item.list.list)
}
recyclerView.setLinearListLayout(
homeChildRecyclerview.setLinearListLayout(
isHorizontal = true,
nextLeft = startFocus,
nextRight = endFocus,
)
title.text = info.name
homeChildMoreInfo.text = info.name
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
homeChildRecyclerview.addOnScrollListener(object :
RecyclerView.OnScrollListener() {
var expandCount = 0
val name = expand.list.name
val name = item.list.name
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
override fun onScrollStateChanged(
recyclerView: RecyclerView,
newState: Int
) {
super.onScrollStateChanged(recyclerView, newState)
val adapter = recyclerView.adapter
@ -234,27 +140,40 @@ open class ParentItemAdapter(
})
//(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
if (!isTvSettings()) {
title.setOnClickListener {
moreInfoClickCallback.invoke(expand)
if (isLayout(PHONE)) {
homeChildMoreInfo.setOnClickListener {
moreInfoClickCallback.invoke(item)
}
}
}
}
override fun onCreateContent(parent: ViewGroup): ParentItemHolder {
val layoutResId = when {
isLayout(TV) -> R.layout.homepage_parent_tv
isLayout(EMULATOR) -> R.layout.homepage_parent_emulator
else -> R.layout.homepage_parent
}
val inflater = LayoutInflater.from(parent.context)
val binding = try {
HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false))
} catch (t: Throwable) {
logError(t)
// just in case someone forgot we don't want to crash
HomepageParentBinding.inflate(inflater)
}
return ParentItemHolder(binding)
}
fun updateList(newList: List<HomePageList>) {
submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
.toMutableList())
}
}
class SearchDiffCallback(
private val oldList: List<HomeViewModel.ExpandableHomepageList>,
private val newList: List<HomeViewModel.ExpandableHomepageList>
) :
DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].list.name == newList[newItemPosition].list.name
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldList[oldItemPosition] == newList[newItemPosition]
}
@Suppress("DEPRECATION")
inline fun <reified T> Bundle.getSafeParcelable(key: String): T? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key)
else getParcelable(key, T::class.java)

View file

@ -1,5 +1,7 @@
package com.lagradost.cloudstream3.ui.home
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -7,13 +9,13 @@ 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.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.lagradost.cloudstream3.APIHolder.getId
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList
@ -26,16 +28,21 @@ import com.lagradost.cloudstream3.databinding.FragmentHomeHeadTvBinding
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugException
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.getId
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
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.DataStoreHelper
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
@ -44,91 +51,84 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
import com.lagradost.cloudstream3.utils.UIHelper.populateChips
class HomeParentItemAdapterPreview(
items: MutableList<HomeViewModel.ExpandableHomepageList>,
override val fragment: Fragment,
private val viewModel: HomeViewModel,
) : ParentItemAdapter(items, clickCallback = {
viewModel.click(it)
}, moreInfoClickCallback = {
viewModel.popup(it)
}, expandCallback = {
viewModel.expand(it)
}) {
val headItems = 1
) : ParentItemAdapter(fragment, id = "HomeParentItemAdapterPreview".hashCode(),
clickCallback = {
viewModel.click(it)
}, moreInfoClickCallback = {
viewModel.popup(it)
}, expandCallback = {
viewModel.expand(it)
}) {
override val headers = 1
override fun onCreateHeader(parent: ViewGroup): ViewHolderState<Bundle> {
val inflater = LayoutInflater.from(parent.context)
val binding = if (isLayout(TV or EMULATOR)) FragmentHomeHeadTvBinding.inflate(
inflater,
parent,
false
) else FragmentHomeHeadBinding.inflate(inflater, parent, false)
companion object {
private const val VIEW_TYPE_HEADER = 2
private const val VIEW_TYPE_ITEM = 1
}
if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) {
binding.homeBookmarkParentItemMoreInfo.isVisible = true
override fun getItemViewType(position: Int) = when (position) {
0 -> VIEW_TYPE_HEADER
else -> VIEW_TYPE_ITEM
}
val marginInDp = 50
val density = binding.horizontalScrollChips.context.resources.displayMetrics.density
val marginInPixels = (marginInDp * density).toInt()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> {}
else -> super.onBindViewHolder(holder, position - headItems)
val params = binding.horizontalScrollChips.layoutParams as ViewGroup.MarginLayoutParams
params.marginEnd = marginInPixels
binding.horizontalScrollChips.layoutParams = params
binding.homeWatchParentItemTitle.setCompoundDrawablesWithIntrinsicBounds(
null,
null,
ContextCompat.getDrawable(
parent.context,
R.drawable.ic_baseline_arrow_forward_24
),
null
)
}
return HeaderViewHolder(binding, viewModel, fragment = fragment)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_HEADER -> {
val inflater = LayoutInflater.from(parent.context)
val binding = if (isTvSettings()) FragmentHomeHeadTvBinding.inflate(
inflater,
parent,
false
) else FragmentHomeHeadBinding.inflate(inflater, parent, false)
HeaderViewHolder(
binding,
viewModel,
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
(holder as? HeaderViewHolder)?.bind()
}
private class HeaderViewHolder(
val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment,
) :
ViewHolderState<Bundle>(binding) {
override fun save(): Bundle =
Bundle().apply {
putParcelable(
"resumeRecyclerView",
resumeRecyclerView.layoutManager?.onSaveInstanceState()
)
putParcelable(
"bookmarkRecyclerView",
bookmarkRecyclerView.layoutManager?.onSaveInstanceState()
)
//putInt("previewViewpager", previewViewpager.currentItem)
}
VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType)
else -> error("Unhandled viewType=$viewType")
}
}
override fun getItemCount(): Int {
return super.getItemCount() + headItems
}
override fun getItemId(position: Int): Long {
if (position == 0) return 0//previewData.hashCode().toLong()
return super.getItemId(position - headItems)
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewDetachedFromWindow()
override fun restore(state: Bundle) {
state.getSafeParcelable<Parcelable>("resumeRecyclerView")?.let { recycle ->
resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
}
else -> super.onViewDetachedFromWindow(holder)
}
}
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewAttachedToWindow()
state.getSafeParcelable<Parcelable>("bookmarkRecyclerView")?.let { recycle ->
bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
}
else -> super.onViewAttachedToWindow(holder)
}
}
class HeaderViewHolder
constructor(
val binding: ViewBinding,
val viewModel: HomeViewModel,
) : RecyclerView.ViewHolder(binding.root) {
private var previewAdapter: HomeScrollAdapter = HomeScrollAdapter()
private var resumeAdapter: HomeChildItemAdapter = HomeChildItemAdapter(
ArrayList(),
val previewAdapter = HomeScrollAdapter(fragment = fragment)
private val resumeAdapter = HomeChildItemAdapter(
fragment,
id = "resumeAdapter".hashCode(),
nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId
) { callback ->
@ -183,8 +183,9 @@ class HomeParentItemAdapterPreview(
}
}
}
private var bookmarkAdapter: HomeChildItemAdapter = HomeChildItemAdapter(
ArrayList(),
private val bookmarkAdapter = HomeChildItemAdapter(
fragment,
id = "bookmarkAdapter".hashCode(),
nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId
) { callback ->
@ -192,6 +193,12 @@ class HomeParentItemAdapterPreview(
viewModel.click(callback)
return@HomeChildItemAdapter
}
(callback.view.context?.getActivity() as? MainActivity)?.loadPopup(
callback.card,
load = false
)
/*
callback.view.context?.getActivity()?.showOptionSelectStringRes(
callback.view,
callback.card.posterUrl,
@ -237,44 +244,34 @@ class HomeParentItemAdapterPreview(
}
}
}
*/
}
private val previewViewpager: ViewPager2 =
itemView.findViewById(R.id.home_preview_viewpager)
private val previewViewpagerText: ViewGroup =
itemView.findViewById(R.id.home_preview_viewpager_text)
// private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview)
private var resumeHolder: View = itemView.findViewById(R.id.home_watch_holder)
private var resumeRecyclerView: RecyclerView =
// private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview)
private val resumeHolder: View = itemView.findViewById(R.id.home_watch_holder)
private val resumeRecyclerView: RecyclerView =
itemView.findViewById(R.id.home_watch_child_recyclerview)
private var bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder)
private var bookmarkRecyclerView: RecyclerView =
private val bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder)
private val bookmarkRecyclerView: RecyclerView =
itemView.findViewById(R.id.home_bookmarked_child_recyclerview)
private var homeAccount: View? =
itemView.findViewById(R.id.home_preview_switch_account)
private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account)
private val alternativeHomeAccount: View? =
itemView.findViewById(R.id.alternative_switch_account)
private var topPadding : View? = itemView.findViewById(R.id.home_padding)
private val topPadding: View? = itemView.findViewById(R.id.home_padding)
private val alternativeAccountPadding: View? =
itemView.findViewById(R.id.alternative_account_padding)
private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding)
private val previewCallback: ViewPager2.OnPageChangeCallback =
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
previewAdapter.apply {
if (position >= itemCount - 1 && hasMoreItems) {
hasMoreItems = false // don't make two requests
viewModel.loadMoreHomeScrollResponses()
}
}
val item = previewAdapter.getItem(position) ?: return
onSelect(item, position)
}
}
fun onSelect(item: LoadResponse, position: Int) {
(binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewDescription.isGone =
@ -283,7 +280,11 @@ class HomeParentItemAdapterPreview(
item.plot ?: ""
homePreviewText.text = item.name
populateChips(homePreviewTags,item.tags ?: emptyList(), R.style.ChipFilledSemiTransparent)
populateChips(
homePreviewTags,
item.tags?.take(6) ?: emptyList(),
R.style.ChipFilledSemiTransparent
)
homePreviewTags.isGone =
item.tags.isNullOrEmpty()
@ -343,66 +344,54 @@ class HomeParentItemAdapterPreview(
homePreviewBookmark.setOnClickListener { fab ->
fab.context.getActivity()?.showBottomDialog(
WatchType.values()
WatchType.entries
.map { fab.context.getString(it.stringRes) }
.toList(),
DataStoreHelper.getResultWatchState(id).ordinal,
fab.context.getString(R.string.action_add_to_bookmarks),
showApply = false,
{}) {
val newValue = WatchType.values()[it]
homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds(
null,
ContextCompat.getDrawable(
homePreviewBookmark.context,
newValue.iconRes
),
null,
null
)
homePreviewBookmark.setText(newValue.stringRes)
val newValue = WatchType.entries[it]
ResultViewModel2.updateWatchStatus(
item,
newValue
)
ResultViewModel2().updateWatchStatus(
newValue,
fab.context,
item
) { statusChanged: Boolean ->
if (!statusChanged) return@updateWatchStatus
homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds(
null,
ContextCompat.getDrawable(
homePreviewBookmark.context,
newValue.iconRes
),
null,
null
)
homePreviewBookmark.setText(newValue.stringRes)
}
}
}
}
}
fun onViewDetachedFromWindow() {
previewViewpager.unregisterOnPageChangeCallback(previewCallback)
}
fun onViewAttachedToWindow() {
previewViewpager.registerOnPageChangeCallback(previewCallback)
binding.root.findViewTreeLifecycleOwner()?.apply {
observe(viewModel.preview) {
updatePreview(it)
}
if (binding is FragmentHomeHeadTvBinding) {
observe(viewModel.apiName) { name ->
binding.homePreviewChangeApi.text = name
}
}
observe(viewModel.resumeWatching) {
updateResume(it)
}
observe(viewModel.bookmarks) {
updateBookmarks(it)
}
observe(viewModel.availableWatchStatusTypes) { (checked, visible) ->
for ((chip, watch) in toggleList) {
chip.apply {
isVisible = visible.contains(watch)
isChecked = checked.contains(watch)
private val previewCallback: ViewPager2.OnPageChangeCallback =
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
previewAdapter.apply {
if (position >= itemCount - 1 && hasMoreItems) {
hasMoreItems = false // don't make two requests
viewModel.loadMoreHomeScrollResponses()
}
}
toggleListHolder?.isGone = visible.isEmpty()
val item = previewAdapter.getItemOrNull(position) ?: return
onSelect(item, position)
}
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
}
override fun onViewDetachedFromWindow() {
previewViewpager.unregisterOnPageChangeCallback(previewCallback)
}
private val toggleList = listOf<Pair<Chip, WatchType>>(
@ -413,7 +402,9 @@ class HomeParentItemAdapterPreview(
Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH),
)
private val toggleListHolder : ChipGroup? = itemView.findViewById(R.id.home_type_holder)
private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder)
fun bind() = Unit
init {
previewViewpager.setPageTransformer(HomeScrollTransformer())
@ -422,8 +413,14 @@ class HomeParentItemAdapterPreview(
resumeRecyclerView.adapter = resumeAdapter
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)
resumeRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF
)
bookmarkRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF
)
fixPaddingStatusbarMargin(topPadding)
@ -440,8 +437,12 @@ class HomeParentItemAdapterPreview(
}
}
homeAccount?.setOnClickListener { v ->
DataStoreHelper.showWhoIsWatching(v?.context ?: return@setOnClickListener)
homeAccount?.setOnClickListener {
activity?.showAccountSelectLinear()
}
alternativeHomeAccount?.setOnClickListener {
activity?.showAccountSelectLinear()
}
(binding as? FragmentHomeHeadTvBinding)?.apply {
@ -451,6 +452,11 @@ class HomeParentItemAdapterPreview(
}
}
homePreviewSearchButton.setOnClickListener { _ ->
// Open blank screen.
viewModel.queryTextSubmit("")
}
// This makes the hidden next buttons only available when on the info button
// Otherwise you might be able to go to the next item without being at the info button
homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus ->
@ -506,7 +512,9 @@ class HomeParentItemAdapterPreview(
when (preview) {
is Resource.Success -> {
if (!previewAdapter.setItems(
previewAdapter.submitList(preview.value.second)
previewAdapter.hasMoreItems = preview.value.first
/*if (!.setItems(
preview.value.second,
preview.value.first
)
@ -518,17 +526,20 @@ class HomeParentItemAdapterPreview(
previewViewpager.fakeDragBy(1f)
previewViewpager.endFakeDrag()
previewCallback.onPageSelected(0)
previewViewpager.isVisible = true
previewViewpagerText.isVisible = true
//previewHeader.isVisible = true
}
}*/
previewViewpager.isVisible = true
previewViewpagerText.isVisible = true
alternativeAccountPadding?.isVisible = false
}
else -> {
previewAdapter.setItems(listOf(), false)
previewAdapter.submitList(listOf())
previewViewpager.setCurrentItem(0, false)
previewViewpager.isVisible = false
previewViewpagerText.isVisible = false
alternativeAccountPadding?.isVisible = true
//previewHeader.isVisible = false
}
}
@ -536,18 +547,28 @@ class HomeParentItemAdapterPreview(
private fun updateResume(resumeWatching: List<SearchResponse>) {
resumeHolder.isVisible = resumeWatching.isNotEmpty()
resumeAdapter.updateList(resumeWatching)
resumeAdapter.submitList(resumeWatching)
if (binding is FragmentHomeHeadBinding) {
binding.homeWatchParentItemTitle.setOnClickListener {
if (
binding is FragmentHomeHeadBinding ||
binding is FragmentHomeHeadTvBinding &&
isLayout(EMULATOR)
) {
val title = (binding as? FragmentHomeHeadBinding)?.homeWatchParentItemTitle
?: (binding as? FragmentHomeHeadTvBinding)?.homeWatchParentItemTitle
title?.setOnClickListener {
viewModel.popup(
HomeViewModel.ExpandableHomepageList(
HomePageList(
binding.homeWatchParentItemTitle.text.toString(),
title.text.toString(),
resumeWatching,
false
), 1, false
)
),
deleteCallback = {
viewModel.deleteResumeWatching()
}
)
}
}
@ -556,10 +577,17 @@ class HomeParentItemAdapterPreview(
private fun updateBookmarks(data: Pair<Boolean, List<SearchResponse>>) {
val (visible, list) = data
bookmarkHolder.isVisible = visible
bookmarkAdapter.updateList(list)
bookmarkAdapter.submitList(list)
if (binding is FragmentHomeHeadBinding) {
binding.homeBookmarkParentItemTitle.setOnClickListener {
if (
binding is FragmentHomeHeadBinding ||
binding is FragmentHomeHeadTvBinding &&
isLayout(EMULATOR)
) {
val title = (binding as? FragmentHomeHeadBinding)?.homeBookmarkParentItemTitle
?: (binding as? FragmentHomeHeadTvBinding)?.homeBookmarkParentItemTitle
title?.setOnClickListener {
val items = toggleList.map { it.first }.filter { it.isChecked }
if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog
val textSum = items
@ -572,10 +600,42 @@ class HomeParentItemAdapterPreview(
list,
false
), 1, false
)
), deleteCallback = {
viewModel.deleteBookmarks(list)
}
)
}
}
}
override fun onViewAttachedToWindow() {
previewViewpager.registerOnPageChangeCallback(previewCallback)
binding.root.findViewTreeLifecycleOwner()?.apply {
observe(viewModel.preview) {
updatePreview(it)
}
if (binding is FragmentHomeHeadTvBinding) {
observe(viewModel.apiName) { name ->
binding.homePreviewChangeApi.text = name
}
}
observe(viewModel.resumeWatching) {
updateResume(it)
}
observe(viewModel.bookmarks) {
updateBookmarks(it)
}
observe(viewModel.availableWatchStatusTypes) { (checked, visible) ->
for ((chip, watch) in toggleList) {
chip.apply {
isVisible = visible.contains(watch)
isChecked = checked.contains(watch)
}
}
toggleListHolder?.isGone = visible.isEmpty()
}
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
}
}
}

View file

@ -4,111 +4,61 @@ import android.content.res.Configuration
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
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.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
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.UIHelper.setImage
class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: MutableList<LoadResponse> = mutableListOf()
class HomeScrollAdapter(
fragment: Fragment
) : NoStateAdapter<LoadResponse>(fragment) {
var hasMoreItems: Boolean = false
fun getItem(position: Int): LoadResponse? {
return items.getOrNull(position)
}
fun setItems(newItems: List<LoadResponse>, hasNext: Boolean): Boolean {
val isSame = newItems.firstOrNull()?.url == items.firstOrNull()?.url
hasMoreItems = hasNext
val diffResult = DiffUtil.calculateDiff(
HomeScrollDiffCallback(this.items, newItems)
)
items.clear()
items.addAll(newItems)
diffResult.dispatchUpdatesTo(this)
return isSame
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
val inflater = LayoutInflater.from(parent.context)
val binding = if (isTvSettings()) {
val binding = if (isLayout(TV or EMULATOR)) {
HomeScrollViewTvBinding.inflate(inflater, parent, false)
} else {
HomeScrollViewBinding.inflate(inflater, parent, false)
}
return CardViewHolder(
binding,
//forceHorizontalPosters
)
return ViewHolderState(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is CardViewHolder -> {
holder.bind(items[position])
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
when (binding) {
is HomeScrollViewBinding -> {
binding.homeScrollPreview.setImage(posterUrl)
binding.homeScrollPreviewTags.apply {
text = item.tags?.joinToString("") ?: ""
isGone = item.tags.isNullOrEmpty()
maxLines = 2
}
binding.homeScrollPreviewTitle.text = item.name
}
is HomeScrollViewTvBinding -> {
binding.homeScrollPreview.setImage(posterUrl)
}
}
}
class CardViewHolder
constructor(
val binding: ViewBinding,
//private val forceHorizontalPosters: Boolean? = null
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(card: LoadResponse) {
val isHorizontal =
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val posterUrl =
if (isHorizontal) card.backgroundPosterUrl ?: card.posterUrl else card.posterUrl
?: card.backgroundPosterUrl
when (binding) {
is HomeScrollViewBinding -> {
binding.homeScrollPreview.setImage(posterUrl)
binding.homeScrollPreviewTags.apply {
text = card.tags?.joinToString("") ?: ""
isGone = card.tags.isNullOrEmpty()
}
binding.homeScrollPreviewTitle.text = card.name
}
is HomeScrollViewTvBinding -> {
binding.homeScrollPreview.setImage(posterUrl)
}
}
}
}
class HomeScrollDiffCallback(
private val oldList: List<LoadResponse>,
private val newList: List<LoadResponse>
) :
DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].url == newList[newItemPosition].url
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition] == newList[newItemPosition]
}
override fun getItemCount(): Int {
return items.size
}
}

View file

@ -6,13 +6,9 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
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.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse
@ -35,24 +31,29 @@ import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppContextUtils.filterHomePageListByFilmQuality
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality
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.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
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.USER_SELECTED_HOMEPAGE_API
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import java.util.EnumSet
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.collections.set
class HomeViewModel : ViewModel() {
@ -92,6 +93,16 @@ class HomeViewModel : ViewModel() {
}
}
fun deleteResumeWatching() {
deleteAllResumeStateIds()
loadResumeWatching()
}
fun deleteBookmarks(list: List<SearchResponse>) {
list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) }
loadStoredData()
}
var repo: APIRepository? = null
private val _apiName = MutableLiveData<String>()
@ -115,7 +126,7 @@ class HomeViewModel : ViewModel() {
private val _resumeWatching = MutableLiveData<List<SearchResponse>>()
private val _preview = MutableLiveData<Resource<Pair<Boolean, List<LoadResponse>>>>()
private val previewResponses = mutableListOf<LoadResponse>()
private val previewResponses = CopyOnWriteArrayList<LoadResponse>()
private val previewResponsesAdded = mutableSetOf<String>()
val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching
@ -123,7 +134,7 @@ class HomeViewModel : ViewModel() {
private fun loadResumeWatching() = viewModelScope.launchSafe {
val resumeWatchingResult = getResumeWatching()
if (isTrueTvSettings() && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (isLayout(TV) && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ioSafe {
// this WILL crash on non tvs, so keep this inside a try catch
activity?.addProgramsToContinueWatching(resumeWatchingResult)
@ -141,7 +152,7 @@ class HomeViewModel : ViewModel() {
}
}?.distinctBy { it.first } ?: return@launchSafe
val length = WatchType.values().size
val length = WatchType.entries.size
val currentWatchTypes = mutableSetOf<WatchType>()
for (watch in watchStatusIds) {
@ -154,10 +165,7 @@ class HomeViewModel : ViewModel() {
currentWatchTypes.remove(WatchType.NONE)
if (currentWatchTypes.size <= 0) {
setKey(
HOME_BOOKMARK_VALUE_LIST,
intArrayOf()
)
DataStoreHelper.homeBookmarkedList = intArrayOf()
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
_bookmarks.postValue(Pair(false, ArrayList()))
return@launchSafe
@ -165,16 +173,14 @@ class HomeViewModel : ViewModel() {
val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first())
//if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first())
setKey(
HOME_BOOKMARK_VALUE_LIST,
watchPrefNotNull.map { it.internalId }.toIntArray()
)
DataStoreHelper.homeBookmarkedList = watchPrefNotNull.map { it.internalId }.toIntArray()
_availableWatchStatusTypes.postValue(
Pair(
watchPrefNotNull,
currentWatchTypes,
watchPrefNotNull to
currentWatchTypes,
)
)
val list = withContext(Dispatchers.IO) {
watchStatusIds.filter { watchPrefNotNull.contains(it.second) }
@ -322,7 +328,13 @@ class HomeViewModel : ViewModel() {
val filteredList =
context?.filterHomePageListByFilmQuality(list) ?: list
expandable[list.name] =
ExpandableHomepageList(filteredList, 1, home.hasNext)
ExpandableHomepageList(
filteredList.copy(
list = CopyOnWriteArrayList(
filteredList.list
)
), 1, home.hasNext
)
}
}
@ -337,8 +349,7 @@ class HomeViewModel : ViewModel() {
val currentList =
items.shuffled().filter { it.list.isNotEmpty() }
.flatMap { it.list }
.distinctBy { it.url }
.toList()
.distinctBy { it.url }.toList()
if (currentList.isNotEmpty()) {
val randomItems =
@ -376,7 +387,9 @@ class HomeViewModel : ViewModel() {
}
is Resource.Failure -> {
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
_page.postValue(data!!)
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
_preview.postValue(data!!)
}
@ -386,19 +399,20 @@ class HomeViewModel : ViewModel() {
}
fun click(callback: SearchClickCallback) {
if (callback.action == SEARCH_ACTION_FOCUSED) {
//focusCallback(callback.card)
} else {
if (callback.action != SEARCH_ACTION_FOCUSED) {
SearchHelper.handleSearchClickCallback(callback)
}
}
private val _popup = MutableLiveData<ExpandableHomepageList?>(null)
val popup: LiveData<ExpandableHomepageList?> = _popup
private val _popup = MutableLiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?>(null)
val popup: LiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?> = _popup
fun popup(list: ExpandableHomepageList?) {
_popup.postValue(list)
fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) {
if (list == null)
_popup.postValue(null)
else
_popup.postValue(list to deleteCallback)
}
private fun bookmarksUpdated(unused: Boolean) {
@ -406,23 +420,29 @@ class HomeViewModel : ViewModel() {
}
private fun afterPluginsLoaded(forceReload: Boolean) {
loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), forceReload)
loadAndCancel(DataStoreHelper.currentHomePage, forceReload)
}
private fun afterMainPluginsLoaded(unused: Boolean = false) {
loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false)
loadAndCancel(DataStoreHelper.currentHomePage, false)
}
private fun reloadHome(unused: Boolean = false) {
loadAndCancel(DataStoreHelper.currentHomePage, true)
}
init {
MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated
MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded
MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded
MainActivity.reloadHomeEvent += ::reloadHome
}
override fun onCleared() {
MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated
MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded
MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
MainActivity.reloadHomeEvent -= ::reloadHome
super.onCleared()
}
@ -436,15 +456,19 @@ class HomeViewModel : ViewModel() {
// do nothing
}
fun reloadStored() {
loadResumeWatching()
fun loadStoredData() {
val list = EnumSet.noneOf(WatchType::class.java)
getKey<IntArray>(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let {
DataStoreHelper.homeBookmarkedList.map { WatchType.fromInternalId(it) }.let {
list.addAll(it)
}
loadStoredData(list)
}
fun reloadStored() {
loadResumeWatching()
loadStoredData()
}
fun click(load: LoadClickCallback) {
loadResult(load.response.url, load.response.apiName, load.action)
}
@ -471,7 +495,7 @@ class HomeViewModel : ViewModel() {
val api = getApiFromNameNull(preferredApiName)
if (preferredApiName == noneApi.name) {
// just set to random
if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name)
if (fromUI) DataStoreHelper.currentHomePage = noneApi.name
loadAndCancel(noneApi)
} else if (preferredApiName == randomApi.name) {
// randomize the api, if none exist like if not loaded or not installed
@ -482,7 +506,7 @@ class HomeViewModel : ViewModel() {
} else {
val apiRandom = validAPIs.random()
loadAndCancel(apiRandom)
if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name)
if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name
}
} else if (api == null) {
// API is not found aka not loaded or removed, post the loading
@ -492,11 +516,11 @@ class HomeViewModel : ViewModel() {
} else {
_page.postValue(Resource.Loading())
if (preferredApiName != null)
_apiName.postValue(preferredApiName)
_apiName.postValue(preferredApiName!!)
}
} else {
// if the api is found, then set it to it and save key
if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, api.name)
if (fromUI) DataStoreHelper.currentHomePage = api.name
loadAndCancel(api)
}
}

View file

@ -1,50 +1,73 @@
package com.lagradost.cloudstream3.ui.library
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
import android.view.animation.AlphaAnimation
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.widget.SearchView
import androidx.core.view.allViews
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import com.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.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.ui.result.txt
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity
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.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.getSpanCount
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs
const val LIBRARY_FOLDER = "library_folder"
enum class LibraryOpenerType(@StringRes val stringRes: Int) {
Default(R.string.default_subtitles), // TODO FIX AFTER MERGE
Default(R.string.action_default),
Provider(R.string.none),
Browser(R.string.browser),
Search(R.string.search),
@ -63,6 +86,8 @@ data class ProviderLibraryData(
class LibraryFragment : Fragment() {
companion object {
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
fun newInstance() = LibraryFragment()
/**
@ -74,13 +99,26 @@ 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 localBinding = FragmentLibraryBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
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)
}
@ -97,24 +135,45 @@ class LibraryFragment : Fragment() {
super.onSaveInstanceState(outState)
}
private fun updateRandom() {
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
}
}
@SuppressLint("ResourceType", "CutPasteId")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fixPaddingStatusbar(binding?.searchStatusBarPadding)
binding?.sortFab?.setOnClickListener {
val methods = libraryViewModel.sortingMethods.map {
txt(it.stringRes).asString(view.context)
}
binding?.sortFab?.setOnClickListener(sortChangeClickListener)
binding?.librarySort?.setOnClickListener(sortChangeClickListener)
activity?.showBottomDialog(methods,
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
txt(R.string.sort_by).asString(view.context),
false,
{},
{
val method = libraryViewModel.sortingMethods[it]
libraryViewModel.sort(method)
})
binding?.libraryRoot?.findViewById<TextView>(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)
}
}
// 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
libraryViewModel.sort(ListSorting.Query, newText)
}
binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
@ -133,7 +192,12 @@ class LibraryFragment : Fragment() {
return true
}
libraryViewModel.sort(ListSorting.Query, newText)
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)
return true
}
})
@ -154,6 +218,25 @@ class LibraryFragment : Fragment() {
}
}
//Load value for toggling Random button. Hide at startup
context?.let {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
toggleRandomButton =
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)
}
}
}
/**
* Shows a plugin selection dialogue and saves the response
@ -180,7 +263,7 @@ class LibraryFragment : Fragment() {
val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders
val savedSelection = getKey<LibraryOpener>(LIBRARY_FOLDER, key)
val savedSelection = getKey<LibraryOpener>("$currentAccount/$LIBRARY_FOLDER", key)
val selectedIndex =
when {
savedSelection == null -> 0
@ -215,7 +298,7 @@ class LibraryFragment : Fragment() {
}
setKey(
LIBRARY_FOLDER,
"$currentAccount/$LIBRARY_FOLDER",
key,
savedData,
)
@ -228,87 +311,52 @@ class LibraryFragment : Fragment() {
}
binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
binding?.viewpager?.adapter =
binding?.viewpager?.adapter ?: ViewpagerAdapter(
mutableListOf(),
{ isScrollingDown: Boolean ->
if (isScrollingDown) {
binding?.sortFab?.shrink()
} else {
binding?.sortFab?.extend()
}
}) callback@{ searchClickCallback ->
// To prevent future accidents
debugAssert({
searchClickCallback.card !is SyncAPI.LibraryItem
}, {
"searchClickCallback ${searchClickCallback.card} is not a LibraryItem"
})
val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId
val syncName =
libraryViewModel.currentSyncApi?.syncIdName ?: return@callback
binding?.viewpager?.adapter = ViewpagerAdapter(
fragment = this,
{ isScrollingDown: Boolean ->
if (isScrollingDown) {
binding?.sortFab?.shrink()
binding?.libraryRandom?.shrink()
} else {
binding?.sortFab?.extend()
binding?.libraryRandom?.extend()
}
}) callback@{ searchClickCallback ->
// To prevent future accidents
debugAssert({
searchClickCallback.card !is SyncAPI.LibraryItem
}, {
"searchClickCallback ${searchClickCallback.card} is not a LibraryItem"
})
when (searchClickCallback.action) {
SEARCH_ACTION_SHOW_METADATA -> {
activity?.showPluginSelectionDialog(
val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId
val syncName =
libraryViewModel.currentSyncApi?.syncIdName ?: return@callback
when (searchClickCallback.action) {
SEARCH_ACTION_SHOW_METADATA -> {
(activity as? MainActivity)?.loadPopup(
searchClickCallback.card,
load = false
)
/*activity?.showPluginSelectionDialog(
syncId,
syncName,
searchClickCallback.card.apiName
)
}
)*/
}
SEARCH_ACTION_LOAD -> {
// This basically first selects the individual opener and if that is default then
// selects the whole list opener
val savedListSelection =
getKey<LibraryOpener>(LIBRARY_FOLDER, syncName.name)
val savedSelection = getKey<LibraryOpener>(LIBRARY_FOLDER, syncId).takeIf {
it?.openType != LibraryOpenerType.Default
} ?: savedListSelection
when (savedSelection?.openType) {
null, LibraryOpenerType.Default -> {
// Prevents opening MAL/AniList as a provider
if (APIHolder.getApiFromNameNull(searchClickCallback.card.apiName) != null) {
activity?.loadSearchResult(
searchClickCallback.card
)
} else {
// Search when no provider can open
QuickSearchFragment.pushSearch(
activity,
searchClickCallback.card.name
)
}
}
LibraryOpenerType.None -> {}
LibraryOpenerType.Provider ->
savedSelection.providerData?.apiName?.let { apiName ->
activity?.loadResult(
searchClickCallback.card.url,
apiName,
)
}
LibraryOpenerType.Browser ->
openBrowser(searchClickCallback.card.url)
LibraryOpenerType.Search -> {
QuickSearchFragment.pushSearch(
activity,
searchClickCallback.card.name
)
}
}
}
SEARCH_ACTION_LOAD -> {
loadLibraryItem(syncName, syncId, searchClickCallback.card)
}
}
}
binding?.apply {
viewpager.offscreenPageLimit = 2
viewpager.reduceDragSensitivity()
searchBar.setExpanded(true)
}
val startLoading = Runnable {
@ -339,7 +387,6 @@ class LibraryFragment : Fragment() {
val pages = resource.value
val showNotice = pages.all { it.items.isEmpty() }
binding?.apply {
emptyListTextview.isVisible = showNotice
if (showNotice) {
@ -350,12 +397,28 @@ class LibraryFragment : Fragment() {
}
}
(viewpager.adapter as? ViewpagerAdapter)?.pages = pages
(viewpager.adapter as? ViewpagerAdapter)?.submitList(pages.map {
it.copy(
items = CopyOnWriteArrayList(it.items)
)
})
//fix focus on the viewpager itself
(viewpager.getChildAt(0) as RecyclerView).apply {
tag = "tv_no_focus_tag"
//isFocusable = false
}
// Using notifyItemRangeChanged keeps the animations when sorting
viewpager.adapter?.notifyItemRangeChanged(
/*viewpager.adapter?.notifyItemRangeChanged(
0,
viewpager.adapter?.itemCount ?: 0
)
)*/
libraryViewModel.currentPage.value?.let { page ->
binding?.viewpager?.setCurrentItem(page, false)
}
updateRandom()
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
// Without this there would be a flashing effect:
@ -392,13 +455,32 @@ class LibraryFragment : Fragment() {
viewpager,
) { tab, position ->
tab.text = pages.getOrNull(position)?.title?.asStringNull(context)
tab.view.tag = "tv_no_focus_tag"
tab.view.nextFocusDownId = R.id.search_result_root
tab.view.setOnClickListener {
val currentItem =
binding?.viewpager?.currentItem ?: return@setOnClickListener
val distance = abs(position - currentItem)
hideViewpager(distance)
}
//Expand the appBar on tab focus
tab.view.setOnFocusChangeListener { _, _ ->
binding?.searchBar?.setExpanded(true)
}
}.attach()
binding?.libraryTabLayout?.addOnTabSelectedListener(object :
TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
binding?.libraryTabLayout?.selectedTabPosition?.let { page ->
libraryViewModel.switchPage(page)
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
})
}
}
@ -414,16 +496,108 @@ class LibraryFragment : Fragment() {
}
}
}
observe(libraryViewModel.currentPage) { position ->
updateRandom()
val all = binding?.viewpager?.allViews?.toList()
?.filterIsInstance<AutofitRecyclerView>()
all?.forEach { view ->
view.isVisible = view.tag == position
view.isFocusable = view.tag == position
if (view.tag == position)
view.descendantFocusability = FOCUS_AFTER_DESCENDANTS
else
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
}
}
/*binding?.viewpager?.registerOnPageChangeCallback(object :
ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
}
})*/
}
private fun loadLibraryItem(
syncName: SyncIdName,
syncId: String,
card: SearchResponse
) {
// This basically first selects the individual opener and if that is default then
// selects the whole list opener
val savedListSelection =
getKey<LibraryOpener>("$currentAccount/$LIBRARY_FOLDER", syncName.name)
val savedSelection = getKey<LibraryOpener>(
"$currentAccount/$LIBRARY_FOLDER",
syncId
).takeIf {
it?.openType != LibraryOpenerType.Default
} ?: savedListSelection
when (savedSelection?.openType) {
null, LibraryOpenerType.Default -> {
// Prevents opening MAL/AniList as a provider
if (APIHolder.getApiFromNameNull(card.apiName) != null) {
activity?.loadSearchResult(
card
)
} else {
// Search when no provider can open
QuickSearchFragment.pushSearch(
activity,
card.name
)
}
}
LibraryOpenerType.None -> {}
LibraryOpenerType.Provider ->
savedSelection.providerData?.apiName?.let { apiName ->
activity?.loadResult(
card.url,
apiName,
)
}
LibraryOpenerType.Browser ->
openBrowser(card.url)
LibraryOpenerType.Search -> {
QuickSearchFragment.pushSearch(
activity,
card.name
)
}
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onConfigurationChanged(newConfig: Configuration) {
(binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind()
binding?.viewpager?.adapter?.notifyDataSetChanged()
super.onConfigurationChanged(newConfig)
}
private val sortChangeClickListener = View.OnClickListener { view ->
val methods = libraryViewModel.sortingMethods.map {
txt(it.stringRes).asString(view.context)
}
activity?.showBottomDialog(methods,
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
txt(R.string.sort_by).asString(view.context),
false,
{},
{
val method = libraryViewModel.sortingMethods[it]
libraryViewModel.sort(method)
})
}
}
class MenuSearchView(context: Context) : SearchView(context) {
override fun onActionViewCollapsed() {
super.onActionViewCollapsed()
}
}
class MenuSearchView(context: Context) : SearchView(context)

View file

@ -6,11 +6,14 @@ 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.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
enum class ListSorting(@StringRes val stringRes: Int) {
Query(R.string.none),
@ -20,11 +23,20 @@ enum class ListSorting(@StringRes val stringRes: Int) {
UpdatedOld(R.string.sort_updated_old),
AlphabeticalA(R.string.sort_alphabetical_a),
AlphabeticalZ(R.string.sort_alphabetical_z),
ReleaseDateNew(R.string.sort_release_date_new),
ReleaseDateOld(R.string.sort_release_date_old),
}
const val LAST_SYNC_API_KEY = "last_sync_api"
class LibraryViewModel : ViewModel() {
fun switchPage(page : Int) {
_currentPage.postValue(page)
}
private val _currentPage: MutableLiveData<Int> = MutableLiveData(0)
val currentPage: LiveData<Int> = _currentPage
private val _pages: MutableLiveData<Resource<List<SyncAPI.Page>>> = MutableLiveData(null)
val pages: LiveData<Resource<List<SyncAPI.Page>>> = _pages
@ -35,12 +47,12 @@ class LibraryViewModel : ViewModel() {
get() = SyncApis.filter { it.hasAccount() }
var currentSyncApi = availableSyncApis.let { allApis ->
val lastSelection = getKey<String>(LAST_SYNC_API_KEY)
val lastSelection = getKey<String>("$currentAccount/$LAST_SYNC_API_KEY")
availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull()
}
private set(value) {
field = value
setKey(LAST_SYNC_API_KEY, field?.name)
setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name)
}
val availableApiNames: List<String>
@ -58,13 +70,21 @@ class LibraryViewModel : ViewModel() {
reloadPages(true)
}
fun sort(method: ListSorting, query: String? = null) {
val currentList = pages.value ?: return
fun sort(method: ListSorting, query: String? = null) = ioSafe {
val value = _pages.value ?: return@ioSafe
if (value is Resource.Success) {
sort(method, query, value.value)
}
}
private fun sort(method: ListSorting, query: String? = null, items: List<SyncAPI.Page>) {
currentSortingMethod = method
(currentList as? Resource.Success)?.value?.forEachIndexed { _, page ->
DataStoreHelper.librarySortingMode = method.ordinal
items.forEach { page ->
page.sort(method, query)
}
_pages.postValue(currentList)
_pages.postValue(Resource.Success(items))
}
fun reloadPages(forceReload: Boolean) {
@ -85,8 +105,6 @@ class LibraryViewModel : ViewModel() {
val library = (libraryResource as? Resource.Success)?.value ?: return@let
sortingMethods = library.supportedListSorting.toList()
currentSortingMethod = null
repo.requireLibraryRefresh = false
val pages = library.allLibraryLists.map {
@ -96,8 +114,24 @@ class LibraryViewModel : ViewModel() {
)
}
_pages.postValue(Resource.Success(pages))
val desiredSortingMethod =
ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode)
if (desiredSortingMethod != null && library.supportedListSorting.contains(desiredSortingMethod)) {
sort(desiredSortingMethod, null, pages)
} else {
// null query = no sorting
sort(ListSorting.Query, null, pages)
}
}
}
}
}
init {
MainActivity.reloadLibraryEvent += ::reloadPages
}
override fun onCleared() {
MainActivity.reloadLibraryEvent -= ::reloadPages
super.onCleared()
}
}

View file

@ -16,7 +16,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlin.math.roundToInt
@ -26,7 +26,7 @@ class PageAdapter(
private val resView: AutofitRecyclerView,
val clickCallback: (SearchClickCallback) -> Unit
) :
AppUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
AppContextUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return LibraryItemViewHolder(

View file

@ -1,90 +1,124 @@
package com.lagradost.cloudstream3.ui.library
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.doOnAttach
import androidx.recyclerview.widget.RecyclerView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView.OnFlingListener
import com.google.android.material.appbar.AppBarLayout
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.home.getSafeParcelable
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) :
ViewHolderState<Bundle>(binding) {
override fun save(): Bundle =
Bundle().apply {
putParcelable(
"pageRecyclerview",
binding.pageRecyclerview.layoutManager?.onSaveInstanceState()
)
}
override fun restore(state: Bundle) {
state.getSafeParcelable<Parcelable>("pageRecyclerview")?.let { recycle ->
binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle)
}
}
}
class ViewpagerAdapter(
var pages: List<SyncAPI.Page>,
fragment: Fragment,
val scrollCallback: (isScrollingDown: Boolean) -> Unit,
val clickCallback: (SearchClickCallback) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return PageViewHolder(
) : BaseAdapter<SyncAPI.Page, Bundle>(fragment,
id = "ViewpagerAdapter".hashCode(),
diffCallback = BaseDiffCallback(
itemSame = { a, b ->
a.title == b.title
},
contentSame = { a, b ->
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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is PageViewHolder -> {
holder.bind(pages[position], unbound.remove(position))
}
}
override fun onUpdateContent(
holder: ViewHolderState<Bundle>,
item: SyncAPI.Page,
position: Int
) {
val binding = holder.view
if (binding !is LibraryViewpagerPageBinding) return
(binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items)
}
private val unbound = mutableSetOf<Int>()
override fun onBindContent(holder: ViewHolderState<Bundle>, item: SyncAPI.Page, position: Int) {
val binding = holder.view
if (binding !is LibraryViewpagerPageBinding) return
/**
* Used to mark all pages for re-binding and forces all items to be refreshed
* Without this the pages will still use the same adapters
**/
fun rebind() {
unbound.addAll(0..pages.size)
this.notifyItemRangeChanged(0, pages.size)
}
inner class PageViewHolder(private val binding: LibraryViewpagerPageBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(page: SyncAPI.Page, rebind: Boolean) {
binding.pageRecyclerview.apply {
spanCount =
this@PageViewHolder.itemView.context.getSpanCount() ?: 3
if (adapter == null || rebind) {
// Only add the items after it has been attached since the items rely on ItemWidth
// Which is only determined after the recyclerview is attached.
// If this fails then item height becomes 0 when there is only one item
doOnAttach {
adapter = PageAdapter(
page.items.toMutableList(),
this,
clickCallback
)
}
} else {
(adapter as? PageAdapter)?.updateList(page.items)
scrollToPosition(0)
binding.pageRecyclerview.tag = position
binding.pageRecyclerview.apply {
spanCount =
binding.root.context.getSpanCount() ?: 3
if (adapter == null) { // || rebind
// Only add the items after it has been attached since the items rely on ItemWidth
// Which is only determined after the recyclerview is attached.
// If this fails then item height becomes 0 when there is only one item
doOnAttach {
adapter = PageAdapter(
item.items.toMutableList(),
this,
clickCallback
)
}
} else {
(adapter as? PageAdapter)?.updateList(item.items)
// scrollToPosition(0)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val diff = scrollY - oldScrollY
if (diff == 0) return@setOnScrollChangeListener
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val diff = scrollY - oldScrollY
scrollCallback.invoke(diff > 0)
//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 {
if (diff <= 0)
setExpanded(true)
else
setExpanded(false)
}
}
} else {
onFlingListener = object : OnFlingListener() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
scrollCallback.invoke(velocityY > 0)
return false
}
if (diff == 0) return@setOnScrollChangeListener
scrollCallback.invoke(diff > 0)
}
} else {
onFlingListener = object : OnFlingListener() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
scrollCallback.invoke(velocityY > 0)
return false
}
}
}
}
}
override fun getItemCount(): Int {
return pages.size
}
}

View file

@ -1,12 +1,16 @@
package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.*
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
@ -17,22 +21,26 @@ import android.widget.ProgressBar
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import androidx.media3.common.PlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
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.R
import com.lagradost.cloudstream3.mvvm.logError
@ -40,8 +48,9 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus
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
@ -74,12 +83,12 @@ abstract class AbstractPlayerFragment(
var isBuffering = true
protected open var hasPipModeSupport = true
var playerPausePlayHolderHolder : FrameLayout? = null
var playerPausePlay : ImageView? = null
var playerBuffering : ProgressBar? = null
var playerView : PlayerView? = null
var piphide : FrameLayout? = null
var subtitleHolder : FrameLayout? = null
var playerPausePlayHolderHolder: FrameLayout? = null
var playerPausePlay: ImageView? = null
var playerBuffering: ProgressBar? = null
var playerView: PlayerView? = null
var piphide: FrameLayout? = null
var subtitleHolder: FrameLayout? = null
@LayoutRes
protected open var layout: Int = R.layout.fragment_player
@ -92,11 +101,13 @@ abstract class AbstractPlayerFragment(
throw NotImplementedError()
}
open fun playerPositionChanged(posDur: Pair<Long, Long>) {
open fun playerPositionChanged(position: Long, duration: Long) {
throw NotImplementedError()
}
open fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
open fun playerStatusChanged(){}
open fun playerDimensionsLoaded(width: Int, height: Int) {
throw NotImplementedError()
}
@ -132,8 +143,10 @@ abstract class AbstractPlayerFragment(
}
}
private fun updateIsPlaying(playing: Pair<CSPlayerLoading, CSPlayerLoading>) {
val (wasPlaying, isPlaying) = playing
private fun updateIsPlaying(
wasPlaying: CSPlayerLoading,
isPlaying: CSPlayerLoading
) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
@ -181,7 +194,11 @@ abstract class AbstractPlayerFragment(
canEnterPipMode = isPlayingRightNow && hasPipModeSupport
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.let { act ->
PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow, player.getAspectRatio())
PlayerPipHelper.updatePIPModeActions(
act,
isPlayingRightNow,
player.getAspectRatio()
)
}
}
}
@ -203,10 +220,10 @@ abstract class AbstractPlayerFragment(
return
}
player.handleEvent(
CSPlayerEvent.values()[intent.getIntExtra(
CSPlayerEvent.entries[intent.getIntExtra(
EXTRA_CONTROL_TYPE,
0
)]
)], source = PlayerEventSource.UI
)
}
}
@ -216,7 +233,7 @@ abstract class AbstractPlayerFragment(
val isPlaying = player.getIsPlaying()
val isPlayingValue =
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
updateIsPlaying(Pair(isPlayingValue, isPlayingValue))
updateIsPlaying(isPlayingValue, isPlayingValue)
} else {
// Restore the full-screen UI.
piphide?.isVisible = true
@ -245,11 +262,11 @@ abstract class AbstractPlayerFragment(
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.requestLocalAudioFocus(AppUtils.getFocusRequest())
activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
}
}
open fun playerError(exception: Exception) {
open fun playerError(exception: Throwable) {
fun showToast(message: String, gotoNext: Boolean = false) {
if (gotoNext && hasNextMirror()) {
showToast(
@ -326,6 +343,7 @@ abstract class AbstractPlayerFragment(
}
}
@SuppressLint("UnsafeOptInUsageError")
private fun playerUpdated(player: Any?) {
if (player is ExoPlayer) {
context?.let { ctx ->
@ -366,39 +384,174 @@ abstract class AbstractPlayerFragment(
// }
//}
/** 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) {
Log.i(TAG, "Handle event: $event")
when (event) {
is ResizedEvent -> {
playerDimensionsLoaded(event.width, event.height)
}
@SuppressLint("SetTextI18n")
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 = getKey(RESIZE_MODE_KEY) ?: 0
resizeMode = DataStoreHelper.resizeMode
resize(resizeMode, false)
player.releaseCallbacks()
player.initCallbacks(
playerUpdated = ::playerUpdated,
updateIsPlaying = ::updateIsPlaying,
playerError = ::playerError,
requestAutoFocus = ::requestAudioFocus,
nextEpisode = ::nextEpisode,
prevEpisode = ::prevEpisode,
playerPositionChanged = ::playerPositionChanged,
playerDimensionsLoaded = ::playerDimensionsLoaded,
eventHandler = ::mainCallback,
requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE,
),
subtitlesUpdates = ::subtitlesChanged,
embeddedSubtitlesFetched = ::embeddedSubtitlesFetched,
onTracksInfoChanged = ::onTracksInfoChanged,
onTimestampInvoked = ::onTimestamp,
onTimestampSkipped = ::onTimestampSkipped
)
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
)
}
override fun onScrubMove(
previewBar: PreviewBar?,
progress: Int,
fromUser: Boolean
) {
}
override fun onScrubStop(previewBar: PreviewBar?) {
if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
}
})
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(R.id.exo_subtitles)
subStyle = SubtitlesFragment.getCurrentSavedStyle()
player.initSubtitles(subView, subtitleHolder, subStyle)
(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
@ -446,6 +599,7 @@ abstract class AbstractPlayerFragment(
canEnterPipMode = false
mMediaSession?.release()
mMediaSession = null
playerView?.player = null
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
keepScreenOn(false)
@ -453,16 +607,17 @@ abstract class AbstractPlayerFragment(
}
fun nextResize() {
resizeMode = (resizeMode + 1) % PlayerResize.values().size
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
resize(resizeMode, true)
}
fun resize(resize: Int, showToast: Boolean) {
resize(PlayerResize.values()[resize], showToast)
resize(PlayerResize.entries[resize], showToast)
}
@SuppressLint("UnsafeOptInUsageError")
fun resize(resize: PlayerResize, showToast: Boolean) {
setKey(RESIZE_MODE_KEY, resize.ordinal)
DataStoreHelper.resizeMode = resize.ordinal
val type = when (resize) {
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT

View file

@ -1,13 +1,19 @@
package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.Rational
import android.widget.FrameLayout
import androidx.media3.common.C.*
import androidx.annotation.OptIn
import androidx.media3.common.C.TIME_UNSET
import androidx.media3.common.C.TRACK_TYPE_AUDIO
import androidx.media3.common.C.TRACK_TYPE_TEXT
import androidx.media3.common.C.TRACK_TYPE_VIDEO
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
@ -17,9 +23,10 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSourceFactory
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
@ -31,6 +38,10 @@ import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.source.ClippingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
@ -44,18 +55,23 @@ import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.DrmExtractorLink
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
import java.io.File
import java.util.UUID
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSession
@ -72,14 +88,24 @@ const val toleranceBeforeUs = 300_000L
* seek position, in microseconds. Must be non-negative.
*/
const val toleranceAfterUs = 300_000L
@OptIn(UnstableApi::class)
class CS3IPlayer : IPlayer {
private var isPlaying = false
private var exoPlayer: ExoPlayer? = null
set(value) {
// If the old value is not null then the player has not been properly released.
debugAssert(
{ field != null && value != null },
{ "Previous player instance should be released!" })
field = value
}
var cacheSize = 0L
var simpleCacheSize = 0L
var videoBufferMs = 0L
val imageGenerator = IPreviewGenerator.new()
private val seekActionTime = 30000L
private var ignoreSSL: Boolean = true
@ -102,7 +128,16 @@ class CS3IPlayer : IPlayer {
* */
data class MediaItemSlice(
val mediaItem: MediaItem,
val durationUs: Long
val durationUs: Long,
val drm: DrmMetadata? = null
)
data class DrmMetadata(
val kid: String,
val key: String,
val uuid: UUID,
val kty: String,
val keyRequestParameters: HashMap<String, String>,
)
override fun getDuration(): Long? = exoPlayer?.duration
@ -116,80 +151,24 @@ class CS3IPlayer : IPlayer {
* Boolean = if it's active
* */
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
/** isPlaying */
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null
private var requestAutoFocus: (() -> Unit)? = null
private var playerError: ((Exception) -> Unit)? = null
private var subtitlesUpdates: (() -> Unit)? = null
/** width x height */
private var playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null
/** used for playerPositionChanged */
private var requestedListeningPercentages: List<Int>? = null
/** Fired when seeking the player or on requestedListeningPercentages,
* used to make things appear on que
* position, duration */
private var playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null
private var eventHandler: ((PlayerEvent) -> Unit)? = null
private var nextEpisode: (() -> Unit)? = null
private var prevEpisode: (() -> Unit)? = null
private var playerUpdated: ((Any?) -> Unit)? = null
private var embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)? = null
private var onTracksInfoChanged: (() -> Unit)? = null
private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null
private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null
fun event(event: PlayerEvent) {
eventHandler?.invoke(event)
}
override fun releaseCallbacks() {
playerUpdated = null
updateIsPlaying = null
requestAutoFocus = null
playerError = null
playerDimensionsLoaded = null
requestedListeningPercentages = null
playerPositionChanged = null
nextEpisode = null
prevEpisode = null
subtitlesUpdates = null
onTracksInfoChanged = null
onTimestampInvoked = null
requestSubtitleUpdate = null
onTimestampSkipped = null
eventHandler = null
}
override fun initCallbacks(
playerUpdated: (Any?) -> Unit,
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)?,
requestAutoFocus: (() -> Unit)?,
playerError: ((Exception) -> Unit)?,
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)?,
eventHandler: ((PlayerEvent) -> Unit),
requestedListeningPercentages: List<Int>?,
playerPositionChanged: ((Pair<Long, Long>) -> Unit)?,
nextEpisode: (() -> Unit)?,
prevEpisode: (() -> Unit)?,
subtitlesUpdates: (() -> Unit)?,
embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)?,
onTracksInfoChanged: (() -> Unit)?,
onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?,
onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?,
) {
this.playerUpdated = playerUpdated
this.updateIsPlaying = updateIsPlaying
this.requestAutoFocus = requestAutoFocus
this.playerError = playerError
this.playerDimensionsLoaded = playerDimensionsLoaded
this.requestedListeningPercentages = requestedListeningPercentages
this.playerPositionChanged = playerPositionChanged
this.nextEpisode = nextEpisode
this.prevEpisode = prevEpisode
this.subtitlesUpdates = subtitlesUpdates
this.embeddedSubtitlesFetched = embeddedSubtitlesFetched
this.onTracksInfoChanged = onTracksInfoChanged
this.onTimestampInvoked = onTimestampInvoked
this.onTimestampSkipped = onTimestampSkipped
this.eventHandler = eventHandler
}
// I know, this is not a perfect solution, however it works for fixing subs
@ -198,7 +177,7 @@ class CS3IPlayer : IPlayer {
try {
Handler(it).post {
try {
seekTime(1L)
seekTime(1L, source = PlayerEventSource.Player)
} catch (e: Exception) {
logError(e)
}
@ -213,6 +192,14 @@ class CS3IPlayer : IPlayer {
subtitleHelper.initSubtitles(subView, subHolder, style)
}
override fun getPreview(fraction: Float): Bitmap? {
return imageGenerator.getPreviewImage(fraction)
}
override fun hasPreview(): Boolean {
return imageGenerator.hasPreview()
}
override fun loadPlayer(
context: Context,
sameEpisode: Boolean,
@ -221,7 +208,8 @@ class CS3IPlayer : IPlayer {
startPosition: Long?,
subtitles: Set<SubtitleData>,
subtitle: SubtitleData?,
autoPlay: Boolean?
autoPlay: Boolean?,
preview: Boolean,
) {
Log.i(TAG, "loadPlayer")
if (sameEpisode) {
@ -240,11 +228,30 @@ class CS3IPlayer : IPlayer {
// release the current exoplayer and cache
releasePlayer()
if (link != null) {
// only video support atm
(imageGenerator as? PreviewGenerator)?.let { gen ->
if (preview) {
gen.load(link, sameEpisode)
} else {
gen.clear(sameEpisode)
}
}
loadOnlinePlayer(context, link)
} else if (data != null) {
(imageGenerator as? PreviewGenerator)?.let { gen ->
if (preview) {
gen.load(context, data, sameEpisode)
} else {
gen.clear(sameEpisode)
}
}
loadOfflinePlayer(context, data)
} else {
throw IllegalArgumentException("Requires link or uri")
}
}
override fun setActiveSubtitles(subtitles: Set<SubtitleData>) {
@ -252,7 +259,7 @@ class CS3IPlayer : IPlayer {
subtitleHelper.setAllSubtitles(subtitles)
}
var currentSubtitles: SubtitleData? = null
private var currentSubtitles: SubtitleData? = null
private fun List<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? {
if (id == null) return null
@ -444,7 +451,7 @@ class CS3IPlayer : IPlayer {
} ?: false
}
var currentSubtitleOffset: Long = 0
private var currentSubtitleOffset: Long = 0
override fun setSubtitleOffset(offset: Long) {
currentSubtitleOffset = offset
@ -452,7 +459,7 @@ class CS3IPlayer : IPlayer {
}
override fun getSubtitleOffset(): Long {
return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset
return currentSubtitleOffset
}
override fun getCurrentPreferredSubtitle(): SubtitleData? {
@ -479,7 +486,7 @@ class CS3IPlayer : IPlayer {
exoPlayer?.let { exo ->
playbackPosition = exo.currentPosition
currentWindow = exo.currentWindowIndex
currentWindow = exo.currentMediaItemIndex
isPlaying = exo.isPlaying
}
}
@ -490,7 +497,11 @@ class CS3IPlayer : IPlayer {
if (saveTime)
updatedTime()
exoPlayer?.release()
exoPlayer?.apply {
playWhenReady = false
stop()
release()
}
//simpleCache?.release()
currentTextRenderer = null
@ -502,14 +513,14 @@ class CS3IPlayer : IPlayer {
Log.i(TAG, "onStop")
saveData()
exoPlayer?.pause()
handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
//releasePlayer()
}
override fun onPause() {
Log.i(TAG, "onPause")
saveData()
exoPlayer?.pause()
handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
//releasePlayer()
}
@ -519,6 +530,7 @@ class CS3IPlayer : IPlayer {
}
override fun release() {
imageGenerator.release()
releasePlayer()
}
@ -533,12 +545,15 @@ class CS3IPlayer : IPlayer {
**/
var preferredAudioTrackLanguage: String? = null
get() {
return field ?: getKey(PREFERRED_AUDIO_LANGUAGE_KEY, field)?.also {
return field ?: getKey(
"$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY",
field
)?.also {
field = it
}
}
set(value) {
setKey(PREFERRED_AUDIO_LANGUAGE_KEY, value)
setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value)
field = value
}
@ -586,57 +601,16 @@ class CS3IPlayer : IPlayer {
}
private fun Context.createOfflineSource(): DataSource.Factory {
return DefaultDataSourceFactory(this, USER_AGENT)
return DefaultDataSource.Factory(this, DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT))
}
/*private fun getSubSources(
onlineSourceFactory: DataSource.Factory?,
offlineSourceFactory: DataSource.Factory?,
subHelper: PlayerSubtitleHelper,
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
val activeSubtitles = ArrayList<SubtitleData>()
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url))
.setMimeType(sub.mimeType)
.setLanguage("_${sub.name}")
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
when (sub.origin) {
SubtitleOrigin.DOWNLOADED_FILE -> {
if (offlineSourceFactory != null) {
activeSubtitles.add(sub)
SingleSampleMediaSource.Factory(offlineSourceFactory)
.createMediaSource(subConfig, C.TIME_UNSET)
} else {
null
}
}
SubtitleOrigin.URL -> {
if (onlineSourceFactory != null) {
activeSubtitles.add(sub)
SingleSampleMediaSource.Factory(onlineSourceFactory)
.createMediaSource(subConfig, C.TIME_UNSET)
} else {
null
}
}
SubtitleOrigin.OPEN_SUBTITLES -> {
// TODO
throw NotImplementedError()
}
}
}
println("SUBSRC: ${subSources.size} activeSubtitles : ${activeSubtitles.size} of ${subHelper.getAllSubtitles().size} ")
return Pair(subSources, activeSubtitles)
}*/
private fun getCache(context: Context, cacheSize: Long): SimpleCache? {
return try {
val databaseProvider = StandaloneDatabaseProvider(context)
SimpleCache(
File(
context.cacheDir, "exoplayer"
).also { it.deleteOnExit() }, // Ensures always fresh file
).also { deleteFileOnExit(it) }, // Ensures always fresh file
LeastRecentlyUsedCacheEvictor(cacheSize),
databaseProvider
)
@ -708,13 +682,13 @@ class CS3IPlayer : IPlayer {
metadataRendererOutput
).map {
if (it is TextRenderer) {
currentTextRenderer = CustomTextRenderer(
val currentTextRenderer = CustomTextRenderer(
subtitleOffset,
textRendererOutput,
eventHandler.looper,
CustomSubtitleDecoderFactory()
)
currentTextRenderer!!
).also { renderer -> this.currentTextRenderer = renderer }
currentTextRenderer
} else it
}.toTypedArray()
}
@ -758,15 +732,33 @@ class CS3IPlayer : IPlayer {
// If there is only one item then treat it as normal, if multiple: concatenate the items.
val videoMediaSource = if (mediaItemSlices.size == 1) {
factory.createMediaSource(mediaItemSlices.first().mediaItem)
val item = mediaItemSlices.first()
item.drm?.let { drm ->
val drmCallback =
LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray())
val manager = DefaultDrmSessionManager.Builder()
.setPlayClearSamplesWithoutKeys(true)
.setMultiSession(false)
.setKeyRequestParameters(drm.keyRequestParameters)
.setUuidAndExoMediaDrmProvider(drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
.build(drmCallback)
val manifestDataSourceFactory = DefaultHttpDataSource.Factory()
DashMediaSource.Factory(manifestDataSourceFactory)
.setDrmSessionManagerProvider { manager }
.createMediaSource(item.mediaItem)
} ?: run {
factory.createMediaSource(item.mediaItem)
}
} else {
val source = ConcatenatingMediaSource()
mediaItemSlices.map {
mediaItemSlices.map { item ->
source.addMediaSource(
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
ClippingMediaSource(
factory.createMediaSource(it.mediaItem),
it.durationUs
factory.createMediaSource(item.mediaItem),
item.durationUs
)
)
}
@ -799,43 +791,55 @@ class CS3IPlayer : IPlayer {
return null
}
fun updatedTime(writePosition: Long? = null) {
fun updatedTime(
writePosition: Long? = null,
source: PlayerEventSource = PlayerEventSource.Player
) {
val position = writePosition ?: exoPlayer?.currentPosition
getCurrentTimestamp(position)?.let { timestamp ->
onTimestampInvoked?.invoke(timestamp)
event(TimestampInvokedEvent(timestamp, source))
}
val duration = exoPlayer?.contentDuration
if (duration != null && position != null) {
playerPositionChanged?.invoke(Pair(position, duration))
event(
PositionEvent(
source,
fromMs = exoPlayer?.currentPosition ?: 0,
position,
duration
)
)
}
}
override fun seekTime(time: Long) {
exoPlayer?.seekTime(time)
override fun seekTime(time: Long, source: PlayerEventSource) {
exoPlayer?.seekTime(time, source)
}
override fun seekTo(time: Long) {
updatedTime(time)
override fun seekTo(time: Long, source: PlayerEventSource) {
updatedTime(time, source)
exoPlayer?.seekTo(time)
}
private fun ExoPlayer.seekTime(time: Long) {
updatedTime(currentPosition + time)
private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) {
updatedTime(currentPosition + time, source)
seekTo(currentPosition + time)
}
override fun handleEvent(event: CSPlayerEvent) {
override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) {
Log.i(TAG, "handleEvent ${event.name}")
try {
exoPlayer?.apply {
when (event) {
CSPlayerEvent.Play -> {
event(PlayEvent(source))
play()
}
CSPlayerEvent.Pause -> {
event(PauseEvent(source))
pause()
}
@ -852,32 +856,48 @@ class CS3IPlayer : IPlayer {
CSPlayerEvent.PlayPauseToggle -> {
if (isPlaying) {
pause()
handleEvent(CSPlayerEvent.Pause, source)
} else {
play()
handleEvent(CSPlayerEvent.Play, source)
}
}
CSPlayerEvent.SeekForward -> seekTime(seekActionTime)
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime)
CSPlayerEvent.NextEpisode -> nextEpisode?.invoke()
CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke()
CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source)
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source)
CSPlayerEvent.Restart -> seekTo(0, source)
CSPlayerEvent.NextEpisode -> event(
EpisodeSeekEvent(
offset = 1,
source = source
)
)
CSPlayerEvent.PrevEpisode -> event(
EpisodeSeekEvent(
offset = -1,
source = source
)
)
CSPlayerEvent.SkipCurrentChapter -> {
//val dur = this@CS3IPlayer.getDuration() ?: return@apply
getCurrentTimestamp()?.let { lastTimeStamp ->
if (lastTimeStamp.skipToNextEpisode) {
handleEvent(CSPlayerEvent.NextEpisode)
handleEvent(CSPlayerEvent.NextEpisode, source)
} else {
seekTo(lastTimeStamp.endMs + 1L)
}
onTimestampSkipped?.invoke(lastTimeStamp)
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "handleEvent error", e)
playerError?.invoke(e)
} catch (t: Throwable) {
Log.e(TAG, "handleEvent error", t)
event(ErrorEvent(t))
}
}
@ -916,18 +936,14 @@ class CS3IPlayer : IPlayer {
requestSubtitleUpdate = ::reloadSubs
playerUpdated?.invoke(exoPlayer)
event(PlayerAttachedEvent(exoPlayer))
exoPlayer?.prepare()
exoPlayer?.let { exo ->
updateIsPlaying?.invoke(
Pair(
CSPlayerLoading.IsBuffering,
CSPlayerLoading.IsBuffering
)
)
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
isPlaying = exo.isPlaying
}
exoPlayer?.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
normalSafeApiCall {
@ -957,22 +973,24 @@ class CS3IPlayer : IPlayer {
format.id!!,
SubtitleOrigin.EMBEDDED_IN_VIDEO,
format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
emptyMap()
emptyMap(),
format.language
)
}
embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks)
onTracksInfoChanged?.invoke()
subtitlesUpdates?.invoke()
event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
event(TracksChangedEvent())
event(SubtitlesUpdatedEvent())
}
}
//fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead.
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
exoPlayer?.let { exo ->
updateIsPlaying?.invoke(
Pair(
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused,
if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
event(
StatusEvent(
wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused,
isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
)
)
isPlaying = exo.isPlaying
@ -994,23 +1012,15 @@ class CS3IPlayer : IPlayer {
}
Player.STATE_ENDED -> {
// Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(context)
?.getBoolean(
context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key),
true
) == true
) {
handleEvent(CSPlayerEvent.NextEpisode)
}
event(VideoEndedEvent())
}
Player.STATE_BUFFERING -> {
updatedTime()
updatedTime(source = PlayerEventSource.Player)
}
Player.STATE_IDLE -> {
// IDLE
}
else -> Unit
@ -1035,7 +1045,7 @@ class CS3IPlayer : IPlayer {
}
else -> {
playerError?.invoke(error)
event(ErrorEvent(error))
}
}
@ -1049,7 +1059,7 @@ class CS3IPlayer : IPlayer {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
if (isPlaying) {
requestAutoFocus?.invoke()
event(RequestAudioFocusEvent())
onRenderFirst()
}
}
@ -1062,6 +1072,9 @@ class CS3IPlayer : IPlayer {
}
Player.STATE_ENDED -> {
// Resets subtitle delay on ended video
setSubtitleOffset(0)
// Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(context)
?.getBoolean(
@ -1069,12 +1082,15 @@ class CS3IPlayer : IPlayer {
true
) == true
) {
handleEvent(CSPlayerEvent.NextEpisode)
handleEvent(
CSPlayerEvent.NextEpisode,
source = PlayerEventSource.Player
)
}
}
Player.STATE_BUFFERING -> {
updatedTime()
updatedTime(source = PlayerEventSource.Player)
}
Player.STATE_IDLE -> {
@ -1087,27 +1103,28 @@ class CS3IPlayer : IPlayer {
override fun onVideoSizeChanged(videoSize: VideoSize) {
super.onVideoSizeChanged(videoSize)
playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height))
event(ResizedEvent(height = videoSize.height, width = videoSize.width))
}
override fun onRenderedFirstFrame() {
super.onRenderedFirstFrame()
onRenderFirst()
updatedTime()
updatedTime(source = PlayerEventSource.Player)
}
})
} catch (e: Exception) {
Log.e(TAG, "loadExo error", e)
playerError?.invoke(e)
} catch (t: Throwable) {
Log.e(TAG, "loadExo error", t)
event(ErrorEvent(t))
}
}
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
lastTimeStamps = timeStamps
timeStamps.forEach { timestamp ->
exoPlayer?.createMessage { _, _ ->
updatedTime()
updatedTime(source = PlayerEventSource.Player)
//if (payload is EpisodeSkip.SkipStamp) // this should always be true
// onTimestampInvoked?.invoke(payload)
}
@ -1117,7 +1134,7 @@ class CS3IPlayer : IPlayer {
?.setDeleteAfterDelivery(false)
?.send()
}
updatedTime()
updatedTime(source = PlayerEventSource.Player)
}
fun onRenderFirst() {
@ -1137,7 +1154,7 @@ class CS3IPlayer : IPlayer {
if (invalid) {
releasePlayer(saveTime = false)
playerError?.invoke(InvalidFileException("Too short playback"))
event(ErrorEvent(InvalidFileException("Too short playback")))
return
}
@ -1146,7 +1163,7 @@ class CS3IPlayer : IPlayer {
val width = format?.width
val height = format?.height
if (height != null && width != null) {
playerDimensionsLoaded?.invoke(Pair(width, height))
event(ResizedEvent(width = width, height = height))
updatedTime()
exoPlayer?.apply {
requestedListeningPercentages?.forEach { percentage ->
@ -1180,9 +1197,9 @@ class CS3IPlayer : IPlayer {
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources)
} catch (e: Exception) {
Log.e(TAG, "loadOfflinePlayer error", e)
playerError?.invoke(e)
} catch (t: Throwable) {
Log.e(TAG, "loadOfflinePlayer error", t)
event(ErrorEvent(t))
}
}
@ -1197,7 +1214,7 @@ class CS3IPlayer : IPlayer {
.setMimeType(sub.mimeType)
.setLanguage("_${sub.name}")
.setId(sub.getId())
.setSelectionFlags(SELECTION_FLAG_DEFAULT)
.setSelectionFlags(0)
.build()
when (sub.origin) {
SubtitleOrigin.DOWNLOADED_FILE -> {
@ -1241,6 +1258,7 @@ class CS3IPlayer : IPlayer {
return exoPlayer != null
}
@SuppressLint("UnsafeOptInUsageError")
private fun loadOnlinePlayer(context: Context, link: ExtractorLink) {
Log.i(TAG, "loadOnlinePlayer $link")
try {
@ -1257,18 +1275,37 @@ class CS3IPlayer : IPlayer {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
}
val mime = when {
link.isM3u8 -> MimeTypes.APPLICATION_M3U8
link.isDash -> MimeTypes.APPLICATION_MPD
else -> MimeTypes.VIDEO_MP4
val mime = when (link.type) {
ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8
ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD
ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4
ExtractorLinkType.TORRENT -> throw IllegalArgumentException("No torrent support")
ExtractorLinkType.MAGNET -> throw IllegalArgumentException("No magnet support")
}
val mediaItems = if (link is ExtractorLinkPlayList) {
link.playlist.map {
val mediaItems = when (link) {
is ExtractorLinkPlayList -> link.playlist.map {
MediaItemSlice(getMediaItem(mime, it.url), it.durationUs)
}
} else {
listOf(
is DrmExtractorLink -> {
listOf(
// Single sliced list with unset length
MediaItemSlice(
getMediaItem(mime, link.url), Long.MIN_VALUE,
drm = DrmMetadata(
kid = link.kid,
key = link.key,
uuid = link.uuid,
kty = link.kty,
keyRequestParameters = link.keyRequestParameters
)
)
)
}
else -> listOf(
// Single sliced list with unset length
MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE)
)
@ -1294,16 +1331,16 @@ class CS3IPlayer : IPlayer {
}
loadExo(context, mediaItems, subSources, cacheFactory)
} catch (e: Exception) {
Log.e(TAG, "loadOnlinePlayer error", e)
playerError?.invoke(e)
} catch (t: Throwable) {
Log.e(TAG, "loadOnlinePlayer error", t)
event(ErrorEvent(t))
}
}
override fun reloadPlayer(context: Context) {
Log.i(TAG, "reloadPlayer")
exoPlayer?.release()
releasePlayer(false)
currentLink?.let {
loadOnlinePlayer(context, it)
} ?: currentDownloadedFile?.let {

View file

@ -2,9 +2,11 @@ package com.lagradost.cloudstream3.ui.player
import android.content.Context
import android.util.Log
import androidx.annotation.OptIn
import androidx.preference.PreferenceManager
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.text.ExoplayerCuesDecoder
import androidx.media3.exoplayer.text.SubtitleDecoderFactory
import androidx.media3.extractor.text.SubtitleDecoder
@ -30,6 +32,7 @@ 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.
**/
@OptIn(UnstableApi::class)
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
companion object {
fun updateForcedEncoding(context: Context) {
@ -70,7 +73,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
RegexOption.IGNORE_CASE
),
)
val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\d\s]*?[])}]\s*"""))
val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\s]*?[])}]\s*"""))
//https://emptycharacter.com/
//https://www.fileformat.info/info/unicode/char/200b/index.htm
@ -260,6 +263,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
}
/** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */
@OptIn(UnstableApi::class)
class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
override fun supportsFormat(format: Format): Boolean {
// return SubtitleDecoderFactory.DEFAULT.supportsFormat(format)

View file

@ -1,9 +1,12 @@
package com.lagradost.cloudstream3.ui.player
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.text.SubtitleDecoderFactory
import androidx.media3.exoplayer.text.TextOutput
@OptIn(UnstableApi::class)
class CustomTextRenderer(
offset: Long,
output: TextOutput?,

View file

@ -1,11 +1,15 @@
package com.lagradost.cloudstream3.ui.player
import android.net.Uri
import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.VideoDownloadManager
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 kotlin.math.max
import kotlin.math.min
@ -14,6 +18,7 @@ class DownloadFileGenerator(
private var currentIndex: Int = 0
) : IGenerator {
override val hasCache = false
override val canSkipLoading = false
override fun hasNext(): Boolean {
return currentIndex < episodes.size - 1
@ -52,44 +57,50 @@ class DownloadFileGenerator(
override suspend fun generateLinks(
clearCache: Boolean,
isCasting: Boolean,
type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset: Int,
offset: Int
): Boolean {
val meta = episodes[currentIndex + offset]
callback(Pair(null, meta))
context?.let { ctx ->
val relative = meta.relativePath
val display = meta.displayName
if (display == null || relative == null) {
return@let
}
VideoDownloadManager.getFolder(ctx, relative, meta.basePath)
?.forEach { file ->
val name = display.removeSuffix(".mp4")
if (file.first != meta.displayName && file.first.startsWith(name)) {
val realName = file.first.removePrefix(name)
.removeSuffix(".vtt")
.removeSuffix(".srt")
.removeSuffix(".txt")
.trim()
.removePrefix("(")
.removeSuffix(")")
subtitleCallback(
SubtitleData(
realName.ifBlank { ctx.getString(R.string.default_subtitles) },
file.second.toString(),
SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(),
emptyMap()
)
)
}
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)
}
}
if (info != null) {
val newMeta = meta.copy(uri = info.path)
callback(null to newMeta)
} else callback(null to meta)
} else callback(null to meta)
val ctx = context ?: return true
val relative = meta.relativePath ?: return true
val display = meta.displayName ?: return true
val cleanDisplay = cleanDisplayName(display)
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
if (isMatchingSubtitle(name, display, cleanDisplay)) {
val cleanName = cleanDisplayName(name)
val realName = cleanName.removePrefix(cleanDisplay)
subtitleCallback(
SubtitleData(
realName.ifBlank { ctx.getString(R.string.default_subtitles) },
uri.toString(),
SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(),
emptyMap(),
null
)
)
}
}
return true

View file

@ -1,21 +1,21 @@
package com.lagradost.cloudstream3.ui.player
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.storage.SafeFile
const val DTAG = "PlayerActivity"
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
class DownloadedPlayerActivity : AppCompatActivity() {
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
private val dTAG = "DownloadedPlayerAct"
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
CommonActivity.dispatchKeyEvent(this, event)?.let {
return it
}
@ -33,54 +33,18 @@ class DownloadedPlayerActivity : AppCompatActivity() {
CommonActivity.onUserLeaveHint(this)
}
override fun onBackPressed() {
finish()
}
private fun playLink(url: String) {
this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
LinkGenerator(
listOf(
BasicLink(url)
)
)
)
)
}
private fun playUri(uri: Uri) {
val name = SafeFile.fromUri(this, uri)?.name()
this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(
listOf(
ExtractorUri(
uri = uri,
name = name ?: getString(R.string.downloaded_file)
)
)
)
)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(DTAG, "onCreate")
CommonActivity.loadThemes(this)
super.onCreate(savedInstanceState)
CommonActivity.loadThemes(this)
CommonActivity.init(this)
setContentView(R.layout.empty_layout)
Log.i(dTAG, "onCreate")
val data = intent.data
if (intent?.action == Intent.ACTION_SEND) {
val extraText = try { // I dont trust android
val extraText = normalSafeApiCall { // I dont trust android
intent.getStringExtra(Intent.EXTRA_TEXT)
} catch (e: Exception) {
null
}
val cd = intent.clipData
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
@ -88,22 +52,29 @@ class DownloadedPlayerActivity : AppCompatActivity() {
// idk what I am doing, just hope any of these work
if (item?.uri != null)
playUri(item.uri)
playUri(this, item.uri)
else if (url != null)
playLink(url)
playLink(this, url)
else if (data != null)
playUri(data)
playUri(this, data)
else if (extraText != null)
playLink(extraText)
playLink(this, extraText)
else {
finish()
return
}
} else if (data?.scheme == "content") {
playUri(data)
playUri(this, data)
} else {
finish()
return
}
attachBackPressedCallback { finish() }
}
override fun onResume() {
super.onResume()
CommonActivity.setActivityInstance(this)
}
}

View file

@ -1,13 +1,13 @@
package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
class ExtractorLinkGenerator(
private val links: List<ExtractorLink>,
private val subtitles: List<SubtitleData>,
) : IGenerator {
override val hasCache = false
override val canSkipLoading = true
override fun getCurrentId(): Int? {
return null
@ -37,14 +37,17 @@ class ExtractorLinkGenerator(
override suspend fun generateLinks(
clearCache: Boolean,
isCasting: Boolean,
type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset: Int
): Boolean {
subtitles.forEach(subtitleCallback)
val allowedTypes = type.toSet()
links.forEach {
callback.invoke(it to null)
if(allowedTypes.contains(it.type)) {
callback.invoke(it to null)
}
}
return true

View file

@ -7,14 +7,13 @@ import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Color
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.Editable
import android.util.DisplayMetrics
import android.text.format.DateUtils
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
@ -26,18 +25,25 @@ import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHO
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import androidx.annotation.OptIn
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.core.view.children
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.media3.common.util.UnstableApi
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.screenHeight
import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding
@ -46,7 +52,11 @@ import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvid
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper
import com.lagradost.cloudstream3.ui.result.setText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
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.isUsingMobileData
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
@ -56,9 +66,13 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UserPreferenceDelegate
import com.lagradost.cloudstream3.utils.Vector2
import kotlin.math.*
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking
const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage
@ -72,12 +86,12 @@ private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay"
// All the UI Logic for the player
open class FullScreenPlayer : AbstractPlayerFragment() {
private var isVerticalOrientation: Boolean = false
protected open var lockRotation = true
protected open var isFullScreenPlayer = true
protected open var isTv = false
protected var playerBinding: PlayerCustomLayoutBinding? = null
private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false)
// state of player UI
protected var isShowing = false
@ -108,6 +122,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var playerResizeEnabled = false
protected var doubleTapEnabled = false
protected var doubleTapPauseEnabled = true
protected var playerRotateEnabled = false
protected var autoPlayerRotateEnabled = false
private var hideControlsNames = false
protected var subtitleDelay
set(value) = try {
@ -126,19 +143,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var useTrueSystemBrightness = true
private val fullscreenNotch = true //TODO SETTING
protected val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
// screenWidth and screenHeight does always
// refer to the screen while in landscape mode
protected val screenWidth: Int
get() {
return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
protected val screenHeight: Int
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
private var statusBarHeight: Int? = null
private var navigationBarHeight: Int? = null
@ -190,7 +194,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
open fun openOnlineSubPicker(
context: Context,
imdbId: Long?,
loadResponse: LoadResponse?,
dismissCallback: (() -> Unit)
) {
throw NotImplementedError()
@ -242,6 +246,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
fadeAnimation.duration = 100
fadeAnimation.fillAfter = true
@OptIn(UnstableApi::class)
val sView = subView
val sStyle = subStyle
if (sView != null && sStyle != null) {
@ -255,7 +260,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat()
playerBinding?.apply {
playerOpenSource.let {
ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply {
@ -296,31 +300,74 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.getCurrentPreferredSubtitle() == null
}
open fun lockOrientation(activity: Activity) {
val display =
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
val rotation = display.rotation
private fun restoreOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation
var orientation = 0
when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE -> orientation =
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
val orientation = when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
//Configuration.ORIENTATION_PORTRAIT -> orientation =
// if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
Configuration.ORIENTATION_PORTRAIT ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> dynamicOrientation()
}
activity.requestedOrientation = orientation
}
private fun updateOrientation() {
private fun toggleOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation
val orientation: Int = when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
Configuration.ORIENTATION_PORTRAIT ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else -> dynamicOrientation()
}
activity.requestedOrientation = orientation
}
open fun lockOrientation(activity: Activity) {
@Suppress("DEPRECATION")
val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
else activity.display!!
val rotation = display.rotation
val currentOrientation = activity.resources.configuration.orientation
val orientation: Int
when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
orientation =
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90)
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
Configuration.ORIENTATION_PORTRAIT ->
orientation =
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
else -> orientation = dynamicOrientation()
}
activity.requestedOrientation = orientation
}
private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) {
activity?.apply {
if(lockRotation) {
if(isLocked) {
if (lockRotation) {
if (isLocked) {
lockOrientation(this)
}
else {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} else {
if (ignoreDynamicOrientation) {
// restore when lock is disabled
restoreOrientationWithSensor(this)
} else {
this.requestedOrientation = dynamicOrientation()
}
}
}
}
@ -339,7 +386,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
protected fun exitFullscreen() {
activity?.showSystemUI()
//if (lockRotation)
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
@ -351,6 +397,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
activity?.window?.attributes = lp
activity?.showSystemUI()
}
override fun onResume() {
@ -367,7 +414,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private fun setPlayBackSpeed(speed: Float) {
try {
setKey(PLAYBACK_SPEED_KEY, speed)
DataStoreHelper.playBackSpeed = speed
playerBinding?.playerSpeedBtt?.text =
getString(R.string.player_speed_text_format).format(speed)
.replace(".0x", "x")
@ -457,6 +504,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
dialog.dismissSafe(activity)
player.seekTime(1L)
}
resetBtt.setOnClickListener {
subtitleDelay = 0
dialog.dismissSafe(activity)
player.seekTime(1L)
}
cancelBtt.setOnClickListener {
subtitleDelay = beforeOffset
dialog.dismissSafe(activity)
@ -594,7 +646,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
isLocked = !isLocked
updateOrientation()
updateOrientation(true) // set true to ignore auto rotate to stay in current orientation
if (isLocked && isShowing) {
playerBinding?.playerHolder?.postDelayed({
@ -688,6 +740,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private var currentTapIndex = 0
protected fun autoHide() {
currentTapIndex++
delayHide()
}
override fun playerStatusChanged() {
super.playerStatusChanged()
delayHide()
}
private fun delayHide() {
val index = currentTapIndex
playerBinding?.playerHolder?.postDelayed({
if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) {
@ -874,7 +935,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
currentTouch
)?.let { seekTo ->
if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) {
player.seekTo(seekTo)
player.seekTo(seekTo, PlayerEventSource.UI)
}
}
}
@ -909,7 +970,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
else -> {
player.handleEvent(CSPlayerEvent.PlayPauseToggle)
player.handleEvent(
CSPlayerEvent.PlayPauseToggle,
PlayerEventSource.UI
)
}
}
} else if (doubleTapEnabled && isFullScreenPlayer) {
@ -1102,6 +1166,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
return true
}
@SuppressLint("GestureBackNavigation")
private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean {
if (hasNavigated) {
autoHide()
@ -1118,6 +1183,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
}
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_UP -> {
if (!isShowing) {
onClickChange()
@ -1166,7 +1232,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
// netflix capture back and hide ~monke
KeyEvent.KEYCODE_BACK -> {
if (isShowing && isTv) {
if (isShowing && isLayout(TV or EMULATOR)) {
onClickChange()
return true
}
@ -1184,6 +1250,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
// if nothing has loaded these buttons should not be visible
playerBinding?.apply {
playerSkipEpisode.isVisible = false
playerGoForward.isVisible = false
playerTracksBtt.isVisible = false
playerSkipOp.isVisible = false
shadowOverlay.isVisible = false
@ -1205,7 +1272,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// init variables
setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f)
setPlayBackSpeed(DataStoreHelper.playBackSpeed)
savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let {
subtitleDelay = it
}
@ -1257,6 +1324,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.handleEvent(CSPlayerEvent.SeekBack)
}
PlayerEventType.Restart -> {
player.handleEvent(CSPlayerEvent.Restart)
}
PlayerEventType.ToggleMute -> {
player.handleEvent(CSPlayerEvent.ToggleMute)
}
@ -1292,15 +1363,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} else false
}
//player_episodes_button?.setOnClickListener {
// player_episodes_button?.isGone = true
// player_episode_list?.isVisible = true
//}
//
//player_episode_list?.adapter = PlayerEpisodeAdapter { click ->
//
//}
try {
context?.let { ctx ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
@ -1336,6 +1398,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
ctx.getString(R.string.playback_speed_enabled_key),
false
)
playerRotateEnabled = settingsManager.getBoolean(
ctx.getString(R.string.rotate_video_key),
false
)
autoPlayerRotateEnabled = settingsManager.getBoolean(
ctx.getString(R.string.auto_rotate_video_key),
false
)
playerResizeEnabled =
settingsManager.getBoolean(
ctx.getString(R.string.player_resize_enabled_key),
@ -1353,6 +1423,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
false
)
hideControlsNames = settingsManager.getBoolean(ctx.getString(R.string.hide_player_control_names_key), false)
val profiles = QualityDataHelper.getProfiles()
val type = if (ctx.isUsingMobileData())
QualityDataHelper.QualityProfileType.Data
@ -1372,20 +1444,57 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
playerBinding?.apply {
playerSpeedBtt.isVisible = playBackSpeedEnabled
playerResizeBtt.isVisible = playerResizeEnabled
playerRotateBtt.isVisible = playerRotateEnabled
if (hideControlsNames) {
hideControlsNames()
}
}
} catch (e: Exception) {
logError(e)
}
playerBinding?.apply {
if (isLayout(TV or EMULATOR)) {
mapOf(
playerGoBack to playerGoBackText,
playerRestart to playerRestartText,
playerGoForward to playerGoForwardText
).forEach { (button, text) ->
button.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
text.isSelected = false
text.isVisible = false
return@setOnFocusChangeListener
}
text.isSelected = true
text.isVisible = true
}
}
}
playerPausePlay.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.PlayPauseToggle)
}
exoDuration.setOnClickListener {
setRemainingTimeCounter(true)
}
timeLeft.setOnClickListener {
setRemainingTimeCounter(false)
}
skipChapterButton.setOnClickListener {
player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
}
playerRotateBtt.setOnClickListener {
autoHide()
toggleRotate()
}
// init clicks
playerResizeBtt.setOnClickListener {
autoHide()
@ -1407,6 +1516,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.handleEvent(CSPlayerEvent.NextEpisode)
}
playerGoForward.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.NextEpisode)
}
playerRestart.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.Restart)
}
playerLock.setOnClickListener {
autoHide()
toggleLock()
@ -1461,34 +1580,74 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
return@setOnTouchListener false
}
}
// cs3 is peak media center
setRemainingTimeCounter(durationMode || isLayout(TV))
playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ ->
updateRemainingTime()
}
// init UI
try {
uiReset()
// init chromecast UI
// removed due to having no use and bugging
//activity?.let {
// if (it.isCastApiAvailable()) {
// try {
// CastButtonFactory.setUpMediaRouteButton(it, player_media_route_button)
// val castContext = CastContext.getSharedInstance(it.applicationContext)
//
// player_media_route_button?.isGone =
// castContext.castState == CastState.NO_DEVICES_AVAILABLE
// castContext.addCastStateListener { state ->
// player_media_route_button?.isGone =
// state == CastState.NO_DEVICES_AVAILABLE
// }
// } catch (e: Exception) {
// logError(e)
// }
// } else {
// // if cast is not possible hide UI
// player_media_route_button?.isGone = true
// }
//}
} catch (e: Exception) {
logError(e)
}
}
}
@SuppressLint("SourceLockedOrientationActivity")
private fun toggleRotate() {
activity?.let {
toggleOrientationWithSensor(it)
}
}
private fun PlayerCustomLayoutBinding.hideControlsNames() {
fun iterate(layout: LinearLayout) {
layout.children.forEach {
if (it is MaterialButton) {
it.textSize = 0f
it.iconPadding = 0
it.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
it.setPadding(0,0,0,0)
} else if (it is LinearLayout) {
iterate(it)
}
}
}
iterate(playerLockHolder.parent as LinearLayout)
}
override fun playerDimensionsLoaded(width: Int, height: Int) {
isVerticalOrientation = height > width
updateOrientation()
}
private fun updateRemainingTime() {
val duration = player.getDuration()
val position = player.getPosition()
if (duration != null && duration > 1 && position != null) {
val remainingTimeSeconds = (duration - position + 500) / 1000
val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}"
playerBinding?.timeLeft?.text = formattedTime
}
}
private fun setRemainingTimeCounter(showRemaining: Boolean) {
durationMode = showRemaining
playerBinding?.exoDuration?.isInvisible = showRemaining
playerBinding?.timeLeft?.isVisible = showRemaining
}
private fun dynamicOrientation(): Int {
return if (autoPlayerRotateEnabled) {
if (isVerticalOrientation) {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE // default orientation
}
}
}

View file

@ -6,6 +6,7 @@ import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@ -13,6 +14,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.core.animation.addListener
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
@ -21,16 +23,21 @@ import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.media3.common.Format.NO_VALUE
import androidx.media3.common.MimeTypes
import com.hippo.unifile.UniFile
import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId
import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage
@ -39,9 +46,14 @@ import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSub
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
import com.lagradost.cloudstream3.ui.result.*
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1
import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
@ -52,8 +64,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.storage.SafeFile
import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.Job
import java.io.Serializable
import java.util.*
import kotlin.math.abs
@ -69,7 +82,10 @@ class GeneratorPlayer : FullScreenPlayer() {
}
val subsProviders
get() = subtitleProviders.filter { !it.requiresLogin || it.loginInfo() != null }
get() = subtitleProviders.filter { provider ->
(provider as? AbstractSubApi)?.let { !it.requiresLogin || it.loginInfo() != null }
?: true
}
val subsProvidersIsActive
get() = subsProviders.isNotEmpty()
}
@ -102,10 +118,33 @@ class GeneratorPlayer : FullScreenPlayer() {
binding?.playerLoadingOverlay?.isVisible = true
}
private fun setSubtitles(sub: SubtitleData?): Boolean {
currentSelectedSubtitles = sub
//Log.i(TAG, "setSubtitles = $sub")
return player.setPreferredSubtitles(sub)
private fun setSubtitles(subtitle: SubtitleData?): Boolean {
// If subtitle is changed -> Save the language
if (subtitle != currentSelectedSubtitles) {
val subtitleLanguage639 = if (subtitle == null) {
// "" is No Subtitles
""
} else if (subtitle.languageCode != null) {
// Could be "English 4" which is why it is trimmed.
val trimmedLanguage = subtitle.languageCode.replace(Regex("\\d"), "").trim()
languages.firstOrNull { language ->
language.languageName.equals(trimmedLanguage, ignoreCase = true) ||
language.ISO_639_1 == subtitle.languageCode
}?.ISO_639_1
} else {
null
}
if (subtitleLanguage639 != null) {
setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguage639)
preferredAutoSelectSubtitles = subtitleLanguage639
}
}
currentSelectedSubtitles = subtitle
//Log.i(TAG, "setSubtitles = $subtitle")
return player.setPreferredSubtitles(subtitle)
}
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
@ -123,6 +162,13 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
override fun playerStatusChanged() {
super.playerStatusChanged()
if (player.getIsPlaying()) {
viewModel.forceClearCache = false
}
}
private fun noSubtitles(): Boolean {
return setSubtitles(null)
}
@ -136,7 +182,7 @@ class GeneratorPlayer : FullScreenPlayer() {
return durPos.position
}
var currentVerifyLink: Job? = null
private var currentVerifyLink: Job? = null
private fun loadExtractorJob(extractorLink: ExtractorLink?) {
currentVerifyLink?.cancel()
@ -182,6 +228,7 @@ class GeneratorPlayer : FullScreenPlayer() {
(if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle(
currentSubs, settings = true, downloads = true
),
preview = isFullScreenPlayer
)
}
@ -191,7 +238,7 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun closestQuality(target: Int?): Qualities {
if (target == null) return Qualities.Unknown
return Qualities.values().minBy { abs(it.value - target) }
return Qualities.entries.minBy { abs(it.value - target) }
}
private fun getLinkPriority(
@ -221,6 +268,7 @@ class GeneratorPlayer : FullScreenPlayer() {
var episode: Int? = null,
var season: Int? = null,
var name: String? = null,
var imdbId: String? = null,
)
private fun getMetaData(): TempMetaData {
@ -247,7 +295,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
override fun openOnlineSubPicker(
context: Context, imdbId: Long?, dismissCallback: (() -> Unit)
context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit)
) {
val providers = subsProviders
val isSingleProvider = subsProviders.size == 1
@ -323,8 +371,6 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE
binding.subtitleAdapter.adapter = arrayAdapter
val adapter =
binding.subtitleAdapter.adapter as? ArrayAdapter<AbstractSubtitleEntities.SubtitleEntity>
binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ ->
currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener
@ -335,11 +381,12 @@ class GeneratorPlayer : FullScreenPlayer() {
fun setSubtitlesList(list: List<AbstractSubtitleEntities.SubtitleEntity>) {
currentSubtitles = list
adapter?.clear()
adapter?.addAll(currentSubtitles)
arrayAdapter.clear()
arrayAdapter.addAll(currentSubtitles)
}
val currentTempMeta = getMetaData()
// bruh idk why it is not correct
val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent))
binding.searchLoadingBar.progressTintList = color
@ -387,7 +434,10 @@ class GeneratorPlayer : FullScreenPlayer() {
val search =
AbstractSubtitleEntities.SubtitleSearch(
query = query ?: return@ioSafe,
imdb = imdbId,
imdbId = loadResponse?.getImdbId(),
tmdbId = loadResponse?.getTMDbId()?.toInt(),
malId = loadResponse?.getMalId()?.toInt(),
aniListId = loadResponse?.getAniListId()?.toInt(),
epNumber = currentTempMeta.episode,
seasonNumber = currentTempMeta.season,
lang = currentLanguageTwoLetters.ifBlank { null },
@ -443,16 +493,21 @@ class GeneratorPlayer : FullScreenPlayer() {
currentSubtitle?.let { currentSubtitle ->
providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api ->
ioSafe {
val url = api.load(currentSubtitle) ?: return@ioSafe
val subtitle = SubtitleData(
name = getName(currentSubtitle, true),
url = url,
origin = SubtitleOrigin.URL,
mimeType = url.toSubtitleMimeType(),
headers = currentSubtitle.headers
)
runOnMainThread {
addAndSelectSubtitles(subtitle)
val subtitles =
api.getResource(currentSubtitle).getSubtitles().map { resource ->
SubtitleData(
name = resource.name ?: getName(currentSubtitle, true),
url = resource.url,
origin = resource.origin,
mimeType = resource.url.toSubtitleMimeType(),
headers = currentSubtitle.headers,
currentSubtitle.lang
)
}
if (subtitles.isNotEmpty()) {
runOnMainThread {
addAndSelectSubtitles(*subtitles.toTypedArray())
}
}
}
}
@ -469,7 +524,7 @@ class GeneratorPlayer : FullScreenPlayer() {
//TODO: Set year text from currently loaded movie on Player
//dialog.subtitles_search_year?.setText(currentTempMeta.year)
}
@OptIn(UnstableApi::class)
private fun openSubPicker() {
try {
subsPathPicker.launch(
@ -490,7 +545,11 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
private fun addAndSelectSubtitles(subtitleData: SubtitleData) {
private fun addAndSelectSubtitles(
vararg subtitleData: SubtitleData
) {
if (subtitleData.isEmpty()) return
val selectedSubtitle = subtitleData.first()
val ctx = context ?: return
val subs = currentSubs + subtitleData
@ -502,13 +561,13 @@ class GeneratorPlayer : FullScreenPlayer() {
player.saveData()
player.reloadPlayer(ctx)
setSubtitles(subtitleData)
viewModel.addSubtitles(setOf(subtitleData))
setSubtitles(selectedSubtitle)
viewModel.addSubtitles(subtitleData.toSet())
selectSourceDialog?.dismissSafe()
showToast(
String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name),
String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name),
Toast.LENGTH_LONG
)
}
@ -537,7 +596,8 @@ class GeneratorPlayer : FullScreenPlayer() {
uri.toString(),
SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(),
emptyMap()
emptyMap(),
null
)
addAndSelectSubtitles(subtitleData)
@ -553,7 +613,7 @@ class GeneratorPlayer : FullScreenPlayer() {
//println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs")
context?.let { ctx ->
val isPlaying = player.getIsPlaying()
player.handleEvent(CSPlayerEvent.Pause)
player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
val currentSubtitles = sortSubs(currentSubs)
val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
@ -586,6 +646,8 @@ class GeneratorPlayer : FullScreenPlayer() {
}
if (subsProvidersIsActive) {
val currentLoadResponse = viewModel.getLoadResponse()
val loadFromOpenSubsFooter: TextView = layoutInflater.inflate(
R.layout.sort_bottom_footer_add_choice, null
) as TextView
@ -596,7 +658,7 @@ class GeneratorPlayer : FullScreenPlayer() {
loadFromOpenSubsFooter.setOnClickListener {
shouldDismiss = false
sourceDialog.dismissSafe(activity)
openOnlineSubPicker(it.context, null) {
openOnlineSubPicker(it.context, currentLoadResponse) {
dismiss()
}
}
@ -735,7 +797,6 @@ class GeneratorPlayer : FullScreenPlayer() {
settingsManager.edit().putString(
ctx.getString(R.string.subtitles_encoding_key), prefValues[it]
).apply()
updateForcedEncoding(ctx)
dismiss()
player.seekTime(-1) // to update subtitles, a dirty trick
@ -885,12 +946,17 @@ class GeneratorPlayer : FullScreenPlayer() {
}
override fun playerError(exception: Exception) {
override fun playerError(exception: Throwable) {
Log.i(TAG, "playerError = $currentSelectedLink")
if (!hasNextMirror()) {
viewModel.forceClearCache = true
}
super.playerError(exception)
}
private fun noLinksFound() {
viewModel.forceClearCache = true
showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT)
activity?.popCurrentPage()
}
@ -947,14 +1013,13 @@ class GeneratorPlayer : FullScreenPlayer() {
var maxEpisodeSet: Int? = null
var hasRequestedStamps: Boolean = false
override fun playerPositionChanged(posDur: Pair<Long, Long>) {
override fun playerPositionChanged(position: Long, duration: Long) {
// Don't save livestream data
if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return
// Don't save NSFW data
if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return
val (position, duration) = posDur
if (duration <= 0L) return // idk how you achieved this, but div by zero crash
if (!hasRequestedStamps) {
hasRequestedStamps = true
@ -1025,7 +1090,7 @@ class GeneratorPlayer : FullScreenPlayer() {
ctx.getString(R.string.episode_sync_enabled_key), true
)
) maxEpisodeSet = meta.episode
sync.modifyMaxEpisode(meta.episode)
sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode)
}
}
@ -1034,8 +1099,15 @@ class GeneratorPlayer : FullScreenPlayer() {
}
playerBinding?.playerSkipOp?.isVisible = isOpVisible
playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true
when {
isLayout(PHONE) ->
playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true
else ->
playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true
}
if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) {
viewModel.preLoadNextLinks()
@ -1191,7 +1263,7 @@ class GeneratorPlayer : FullScreenPlayer() {
fun setPlayerDimen(widthHeight: Pair<Int, Int>?) {
val extra = if (widthHeight != null) {
val (width, height) = widthHeight
"${width}x${height}"
"- ${width}x${height}"
} else {
""
}
@ -1202,7 +1274,7 @@ class GeneratorPlayer : FullScreenPlayer() {
0 -> ""
1 -> extra
2 -> source
3 -> "$source - $extra"
3 -> "$source $extra"
else -> ""
}
playerBinding?.playerVideoTitleRez?.apply {
@ -1211,14 +1283,15 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
override fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
setPlayerDimen(widthHeight)
override fun playerDimensionsLoaded(width: Int, height: Int) {
super.playerDimensionsLoaded(width, height)
setPlayerDimen(width to height)
}
private fun unwrapBundle(savedInstanceState: Bundle?) {
Log.i(TAG, "unwrapBundle = $savedInstanceState")
savedInstanceState?.let { bundle ->
sync.addSyncs(bundle.getSerializable("syncData") as? HashMap<String, String>?)
sync.addSyncs(bundle.getSafeSerializable<HashMap<String, String>>("syncData"))
}
}
@ -1226,8 +1299,8 @@ class GeneratorPlayer : FullScreenPlayer() {
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
// this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason
isTv = isTvSettings()
layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player
layout =
if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player
viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java]
sync = ViewModelProvider(this)[SyncViewModel::class.java]
@ -1357,6 +1430,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
binding?.playerLoadingGoBack?.setOnClickListener {
exitFullscreen()
player.release()
activity?.popCurrentPage()
}
@ -1388,7 +1462,7 @@ class GeneratorPlayer : FullScreenPlayer() {
observe(viewModel.currentLinks) {
currentLinks = it
val turnVisible = it.isNotEmpty()
val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true
val wasGone = binding?.overlayLoadingSkipButton?.isGone == true
binding?.overlayLoadingSkipButton?.isVisible = turnVisible
@ -1434,3 +1508,6 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
}
@Suppress("DEPRECATION")
inline fun <reified T : Serializable> Bundle.getSafeSerializable(key: String) : T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable(key, T::class.java)

View file

@ -1,10 +1,51 @@
package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.ExtractorLinkType
enum class LoadType {
Unknown,
InApp,
InAppDownload,
ExternalApp,
Browser,
Chromecast,
Fcast
}
fun LoadType.toSet() : Set<ExtractorLinkType> {
return when(this) {
LoadType.InApp -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
LoadType.Browser -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
LoadType.InAppDownload -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.M3U8
)
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.entries.toSet()
LoadType.Chromecast -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
LoadType.Fcast -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
}
}
interface IGenerator {
val hasCache: Boolean
val canSkipLoading: Boolean
fun hasNext(): Boolean
fun hasPrev(): Boolean
@ -13,15 +54,15 @@ interface IGenerator {
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
fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
/* not safe, must use try catch */
suspend fun generateLinks(
clearCache: Boolean,
isCasting: Boolean,
type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset : Int = 0,
offset: Int = 0,
): Boolean
}

View file

@ -1,14 +1,13 @@
package com.lagradost.cloudstream3.ui.player
import android.content.Context
import android.graphics.Bitmap
import android.util.Rational
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
enum class PlayerEventType(val value: Int) {
//Stop(-1),
Pause(0),
Play(1),
SeekForward(2),
@ -26,6 +25,7 @@ enum class PlayerEventType(val value: Int) {
Resize(13),
SearchSubtitlesOnline(14),
SkipOp(15),
Restart(16),
}
enum class CSPlayerEvent(val value: Int) {
@ -39,15 +39,127 @@ enum class CSPlayerEvent(val value: Int) {
PrevEpisode(6),
PlayPauseToggle(7),
ToggleMute(8),
Restart(9),
}
enum class CSPlayerLoading {
IsPaused,
IsPlaying,
IsBuffering,
//IsDone,
}
enum class PlayerEventSource {
/** This event was invoked from the user pressing some button or selecting something */
UI,
/** This event was invoked automatically */
Player,
/** This event was invoked from a external sync tool like WatchTogether */
Sync,
}
abstract class PlayerEvent {
abstract val source: PlayerEventSource
}
/** this is used to update UI based of the current time,
* using requestedListeningPercentages as well as saving time */
data class PositionEvent(
override val source: PlayerEventSource,
val fromMs: Long,
val toMs: Long,
/** duration of the entire video */
val durationMs: Long,
) : PlayerEvent() {
/** how many ms (+-) we have skipped */
val seekMs : Long get() = toMs - fromMs
}
/** player error when rendering or misc, used to display toast or log */
data class ErrorEvent(
val error: Throwable,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** Event when timestamps appear, null when it should disappear */
data class TimestampInvokedEvent(
val timestamp: EpisodeSkip.SkipStamp,
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,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** this is used by the player to load the next or prev episode */
data class EpisodeSeekEvent(
/** -1 = prev, 1 = next, will never be 0, atm the user cant seek more than +-1 */
val offset: Int,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent() {
init {
assert(offset != 0)
}
}
/** Event when the video is resized aka changed resolution or mirror */
data class ResizedEvent(
val height: Int,
val width: Int,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** Event when the player status update, along with the previous status (for animation)*/
data class StatusEvent(
val wasPlaying: CSPlayerLoading,
val isPlaying: CSPlayerLoading,
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event when tracks are changed, used for UI changes */
data class TracksChangedEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event from player to give all embedded subtitles */
data class EmbeddedSubtitlesFetchedEvent(
val tracks: List<SubtitleData>,
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** on attach player to view */
data class PlayerAttachedEvent(
val player: Any?,
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event from player to inform that subtitles have updated in some way */
data class SubtitlesUpdatedEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** current player starts, asking for all other programs to shut the fuck up */
data class RequestAudioFocusEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Pause event, separate from StatusEvent */
data class PauseEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Play event, separate from StatusEvent */
data class PlayEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event when the player video has ended, up to the settings on what to do when that happens */
data class VideoEndedEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
interface Track {
/**
@ -87,17 +199,8 @@ data class CurrentTracks(
class InvalidFileException(msg: String) : Exception(msg)
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
const val STATE_RESUME_WINDOW = "resumeWindow"
const val STATE_RESUME_POSITION = "resumePosition"
const val STATE_PLAYER_FULLSCREEN = "playerFullscreen"
const val STATE_PLAYER_PLAYING = "playerOnPlay"
const val ACTION_MEDIA_CONTROL = "media_control"
const val EXTRA_CONTROL_TYPE = "control_type"
const val PLAYBACK_SPEED = "playback_speed"
const val RESIZE_MODE_KEY = "resize_mode" // Last used resize mode
const val PLAYBACK_SPEED_KEY = "playback_speed" // Last used playback speed
const val PREFERRED_SUBS_KEY = "preferred_subtitles" // Last used resize mode
//const val PLAYBACK_FASTFORWARD = "playback_fastforward" // Last used resize mode
/** Abstract Exoplayer logic, can be expanded to other players */
interface IPlayer {
@ -108,27 +211,16 @@ interface IPlayer {
fun getDuration(): Long?
fun getPosition(): Long?
fun seekTime(time: Long)
fun seekTo(time: Long)
fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI)
fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI)
fun getSubtitleOffset(): Long // in ms
fun setSubtitleOffset(offset: Long) // in ms
fun initCallbacks(
playerUpdated: (Any?) -> Unit, // attach player to view
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null, // (wasPlaying, isPlaying)
requestAutoFocus: (() -> Unit)? = null, // current player starts, asking for all other programs to shut the fuck up
playerError: ((Exception) -> Unit)? = null, // player error when rendering or misc, used to display toast or log
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null, // (with, height), for UI
requestedListeningPercentages: List<Int>? = null, // this is used to request when the player should report back view percentage
playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time
nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode
prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode
subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way
embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)? = null, // callback from player to give all embedded subtitles
onTracksInfoChanged: (() -> Unit)? = null, // Callback when tracks are changed, used for UI changes
onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null, // Callback when timestamps appear, null when it should disappear
onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null, // callback for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor)
eventHandler: ((PlayerEvent) -> Unit),
/** this is used to request when the player should report back view percentage */
requestedListeningPercentages: List<Int>? = null,
)
fun releaseCallbacks()
@ -146,16 +238,20 @@ interface IPlayer {
startPosition: Long? = null,
subtitles: Set<SubtitleData>,
subtitle: SubtitleData?,
autoPlay: Boolean? = true
autoPlay: Boolean? = true,
preview : Boolean = true,
)
fun reloadPlayer(context: Context)
fun getPreview(fraction : Float) : Bitmap?
fun hasPreview() : Boolean
fun setActiveSubtitles(subtitles: Set<SubtitleData>)
fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing
fun getCurrentPreferredSubtitle(): SubtitleData?
fun handleEvent(event: CSPlayerEvent)
fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI)
fun onStop()
fun onPause()

View file

@ -1,9 +1,29 @@
package com.lagradost.cloudstream3.ui.player
import android.net.Uri
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.utils.*
import java.net.URI
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.INFER_TYPE
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.loadExtractor
import com.lagradost.cloudstream3.utils.unshortenLinkSafe
data class ExtractorUri(
val uri: Uri,
val name: String,
val basePath: String? = null,
val relativePath: String? = null,
val displayName: String? = null,
val id: Int? = null,
val parentId: Int? = null,
val episode: Int? = null,
val season: Int? = null,
val headerName: String? = null,
val tvType: TvType? = null,
)
/**
* Used to open the player more easily with the LinkGenerator
@ -19,6 +39,7 @@ class LinkGenerator(
private val isM3u8: Boolean? = null
) : IGenerator {
override val hasCache = false
override val canSkipLoading = true
override fun getCurrentId(): Int? {
return null
@ -48,7 +69,7 @@ class LinkGenerator(
override suspend fun generateLinks(
clearCache: Boolean,
isCasting: Boolean,
type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset: Int
@ -67,9 +88,8 @@ class LinkGenerator(
link.name ?: link.url,
unshortenLinkSafe(link.url), // unshorten because it might be a raw link
referer ?: "",
Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall {
URI(link.url).path?.substringAfterLast(".")?.contains("m3u")
} ?: false
Qualities.Unknown.value,
type = INFER_TYPE,
) to null
)
}

View file

@ -27,13 +27,16 @@ import android.os.Looper;
import android.os.Message;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.text.Cue;
import androidx.media3.common.text.CueGroup;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.BaseRenderer;
import androidx.media3.exoplayer.FormatHolder;
@ -64,6 +67,7 @@ import java.util.stream.Collectors;
* obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s
* is delegated to a {@link TextOutput}.
*/
@OptIn(markerClass = UnstableApi.class)
public class NonFinalTextRenderer extends BaseRenderer implements Callback {
private static final String TAG = "TextRenderer";
@ -71,7 +75,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
/**
* @param trackType The track type that the renderer handles. One of the {@link C} {@code
* TRACK_TYPE_*} constants.
* @param outputHandler
* @param outputHandler todo description
*/
public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) {
super(trackType);
@ -163,13 +167,14 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
finalStreamEndPositionUs = C.TIME_UNSET;
}
@NonNull
@Override
public String getName() {
return TAG;
}
@Override
public @Capabilities int supportsFormat(Format format) {
public @Capabilities int supportsFormat(@NonNull Format format) {
if (decoderFactory.supportsFormat(format)) {
return RendererCapabilities.create(
format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM);
@ -412,13 +417,11 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
@SuppressWarnings("unchecked")
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_UPDATE_OUTPUT:
invokeUpdateOutputInternal((List<Cue>) msg.obj);
return true;
default:
throw new IllegalStateException();
if (msg.what == MSG_UPDATE_OUTPUT) {
invokeUpdateOutputInternal((List<Cue>) msg.obj);
return true;
}
throw new IllegalStateException();
}
private void invokeUpdateOutputInternal(List<Cue> cues) {
@ -437,7 +440,6 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
}
).collect(Collectors.toList());
output.onCues(fixedCues);
output.onCues(new CueGroup(fixedCues, 0L));
}

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