Compare commits

..

466 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
LagradOst
9b4701fe91 dont remove keys while this is tested 2023-08-24 18:14:54 +02:00
LagradOst
c92ac3e8b3 fixed removal of predownloaded files 2 + permission 2023-08-24 18:13:42 +02:00
LagradOst
39ff6ef8ef Merge remote-tracking branch 'origin/master' 2023-08-24 16:40:09 +02:00
LagradOst
460b1be525 fixed removal of predownloaded files 2023-08-24 16:39:50 +02:00
CranberrySoup
9a1358e295
Lower targetSdk to get all installed packages (#571) 2023-08-24 16:16:33 +02:00
LagradOst
823ffd8708 reverted low api crash handle crashing 2023-08-24 00:25:05 +02:00
LagradOst
5bad6aca35 fixed native crash handle 2023-08-23 23:57:54 +02:00
LagradOst
e2502de02c bump acra 2023-08-23 18:43:55 +02:00
LagradOst
bac2ee9805 fixed div by zero 2023-08-23 17:08:26 +02:00
LagradOst
d436171a2f removed possible duplicate download queue 2023-08-23 06:36:43 +02:00
LagradOst
3ea6b1a8d5 fixed resume download + migrated filesystem to SafeFile 2023-08-23 06:25:06 +02:00
LagradOst
afcbdeecc8 changes to downloader for stable resume 2023-08-22 04:00:05 +02:00
LagradOst
4e28e5f8cc fixed not downloading the last 20MiB on mp4 downloader + bump + mb/s notification 2023-08-20 03:58:31 +02:00
LagradOst
1901eb371e Merge remote-tracking branch 'origin/master' 2023-08-20 01:29:57 +02:00
LagradOst
c4852ce440 made HSL downloader even faster 2023-08-20 01:29:50 +02:00
self-similarity
a3009af4f5
Add Native Crash Handler (#565)
* Add NativeCrashHandler

* Safer init
2023-08-19 21:48:10 +02:00
recloudstream[bot]
6948bf8073 chore(locales): fix locale issues 2023-08-19 19:38:45 +00:00
Weblate (bot)
61ca0a56be
Translations update from Hosted Weblate (#546)
Co-authored-by: Alexandru <negrualexandru52@gmail.com>
Co-authored-by: Alexthegib <jcwkgxc@nightorb.com>
Co-authored-by: Alexthegib <traducoes@skiff.com>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Astrid <github@astrid.exposed>
Co-authored-by: Carlos Luiz <ecarlos-luiz@hotmail.com>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Danilo <danilomaiarochaw@gmail.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Htet Oo Hlaing <htetoh2006@outlook.com>
Co-authored-by: Imprevisible <imprevisible@duck.com>
Co-authored-by: Jan Haider <jan.haider@i-kunden.de>
Co-authored-by: Jimuel Mallari <jimuelmallari284@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Rudy Tantono <rudzlong@gmail.com>
Co-authored-by: Skrripy <rozihrash.ya6w7@simplelogin.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: george kitsoukakis <norhorn@gmail.com>
Co-authored-by: infoekcz <Admin@infoek.cz>
Co-authored-by: tuan041 <tuananh163025ttt@gmail.com>
2023-08-19 21:38:29 +02:00
LagradOst
98b6417140 made downloader faster with parallel downloads 2023-08-19 21:37:14 +02:00
LagradOst
10c1ea2f02 Merge remote-tracking branch 'origin/master' 2023-08-19 17:03:47 +02:00
LagradOst
b3abf1e45f fixed decryption 2023-08-19 17:03:27 +02:00
IndusAryan
f571596bbc
fix: expand resume watching sheet and ft: ripple on profile drawable when pressed (#566)
* add ripple to profile icon on home

* Update HomeParentItemAdapterPreview.kt
2023-08-19 16:04:21 +02:00
LagradOst
e20e3dcfd3 fixed some bugs caused by new download update 2023-08-19 04:46:47 +02:00
LagradOst
35e1b8b4dc bump 2023-08-19 01:38:40 +02:00
LagradOst
a05616e3e8 fix 2023-08-19 01:37:48 +02:00
LagradOst
56cb3d7181 refactored download system for better preference + bugfixes 2023-08-19 00:48:00 +02:00
IndusAryan
e95dc1db2a
fix: cast items recycler (finally) (#564)
* turn cast items visible(tools)

* prevent cast gesture listener from permanent RIP in one lifecycle
2023-08-18 17:46:03 +02:00
LagradOst
8f6e8a8e99 fixed #547
fuck inheritance
2023-08-18 01:46:29 +02:00
IndusAryan
61d63b17d8
chore: acra improvements and media3 bump (#562)
* Acra Bump

* Media3 bump
2023-08-17 23:11:59 +02:00
LagradOst
590c74111c fuck it we ball, m3u8 download is now fixed 2023-08-17 23:10:21 +02:00
LagradOst
c2b951a078 fixed #560 lock locks orientation 2023-08-17 01:19:24 +02:00
LagradOst
cbaca158fa Merge remote-tracking branch 'origin/master' 2023-08-17 01:00:51 +02:00
LagradOst
20da3807a2 fixed search query for intent 2023-08-17 01:00:43 +02:00
IndusAryan
d247640dcf
Play n Dowload button fix for NS*W results. (#557)
* Play n Dowload button fix for NS*W results.

* Revert MainAPI Changes

* Tweaked ResultViewModel
2023-08-16 16:18:15 +02:00
IndusAryan
d536dffaf5
Fix Trailers not Working (#559)
* Fix Trailers not Working

* smol tip
2023-08-16 16:15:39 +02:00
self-similarity
4e01d327c6
Fix episode removal in simkl (#555) 2023-08-15 20:37:33 +02:00
LagradOst
4d98690adb small fix to home load 2023-08-15 02:05:07 +02:00
IndusAryan
74867bed1c
Update SpeedoStream.kt (#552)
Fixes YoMovies Provider.
2023-08-13 17:37:36 +02:00
LagradOst
0eb241e6cb fixed fab expand 2023-08-12 23:54:37 +02:00
LagradOst
3ab9e11350 fixed SimklApi subscription 2023-08-12 23:41:53 +02:00
self-similarity
d2d2e41fb3
Added Simkl (#548) 2023-08-12 22:25:30 +02:00
LagradOst
dd4f4a2b78 should fix an issue with auto_download_plugins_key 2023-08-12 21:52:37 +02:00
LagradOst
e43b4808d1 phone fix 2023-08-12 21:23:43 +02:00
LagradOst
3ac462ae96 changed UI a bit for flashbang + fixed crash inf loading bug 2023-08-12 21:20:51 +02:00
self-similarity
ecd529f73b
TV UX improvements (#538)
* Update styles.xml
2023-08-12 17:44:35 +02:00
Cloudburst
2d65aefc76 fix values-in 2023-08-10 09:34:29 +02:00
recloudstream[bot]
3af0bf750c chore(locales): fix locale issues 2023-08-09 21:56:47 +00:00
Weblate (bot)
72871c18b5
Translations update from Hosted Weblate (#535)
Co-authored-by: Alexandru <negrualexandru52@gmail.com>
Co-authored-by: Alexthegib <jcwkgxc@nightorb.com>
Co-authored-by: Alexthegib <traducoes@skiff.com>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Astrid <github@astrid.exposed>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Jan Haider <jan.haider@i-kunden.de>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Rudy Tantono <rudzlong@gmail.com>
Co-authored-by: Skrripy <rozihrash.ya6w7@simplelogin.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: george kitsoukakis <norhorn@gmail.com>
Co-authored-by: infoekcz <Admin@infoek.cz>
2023-08-09 23:56:28 +02:00
Cloudburst
44a2146c12
fix voting api (#544) 2023-08-09 23:44:17 +02:00
Sofie
bbbb7c4982
Extractor: added Rabbitstream (#536)
* Extractor: added Rabbitstream

* fix all request

---------

Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-08-05 03:11:46 +02:00
self-similarity
ca6700e28d
More meaningful errors when adding repositories (#537)
* More meaningful errors when adding repositories
2023-08-04 17:21:20 +02:00
LagradOst
5103ad09dc reverted gradle bump 2023-08-04 17:20:23 +02:00
LagradOst
f5c4864a3c tv focus changes + gradle bump + pip crash fix 2023-08-04 05:37:41 +02:00
recloudstream[bot]
653982a6bd chore(locales): fix locale issues 2023-08-02 19:14:54 +00:00
Weblate (bot)
22c0022684
Translations update from Hosted Weblate (#527)
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: infoekcz <Admin@infoek.cz>
2023-08-02 21:14:37 +02:00
LagradOst
7e6a28bb99 fixed tv focus issue 2023-08-02 21:00:04 +02:00
Vu Hoan Huy
c5f6f36fc7
fix: can not switch subtitle after integrate ffmpeg decoder. (#533)
* Revert "Revert "Use ffmpeg library (#528)" (#532)"

This reverts commit 87d85429f8.

* fix: can not select subtitle
2023-08-02 19:36:05 +02:00
self-similarity
3137a68552
fix player session id (#534) 2023-08-02 19:35:41 +02:00
LagradOst
2475088f76 added local accounts to TV layout 2023-08-02 17:31:55 +02:00
Osten
87d85429f8
Revert "Use ffmpeg library (#528)" (#532)
This reverts commit 32e243ce94.
2023-08-02 17:13:50 +02:00
self-similarity
32e243ce94
Use ffmpeg library (#528) 2023-08-02 05:31:35 +02:00
LagradOst
180987e2d0 added local accounts 2023-08-02 05:30:50 +02:00
LagradOst
b06f098447 fixed android tv trailer bug 2023-08-02 02:13:30 +02:00
recloudstream[bot]
6ff4f4c1ce chore(locales): fix locale issues 2023-08-01 14:04:17 +00:00
Weblate (bot)
0afc9f15d2
Translated using Weblate (Swedish) (#522)
Currently translated at 83.1% (518 of 623 strings)

Translated using Weblate (Swedish)

Currently translated at 72.8% (454 of 623 strings)

Translated using Weblate (Croatian)

Currently translated at 99.8% (622 of 623 strings)

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Galician)

Currently translated at 16.6% (104 of 623 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (623 of 623 strings)

Added translation using Weblate (Galician)

Translated using Weblate (Czech)

Currently translated at 100.0% (623 of 623 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (623 of 623 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (623 of 623 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (623 of 623 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Bulgarian)

Currently translated at 92.5% (575 of 621 strings)

Translated using Weblate (German)

Currently translated at 25.0% (1 of 4 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 50.0% (2 of 4 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 50.0% (2 of 4 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.3% (617 of 621 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (English)

Currently translated at 100.0% (4 of 4 strings)

Translated using Weblate (Polish)

Currently translated at 50.0% (2 of 4 strings)

Translated using Weblate (Greek)

Currently translated at 94.5% (587 of 621 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (621 of 621 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Japanese)

Currently translated at 46.3% (288 of 621 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (621 of 621 strings)

Merge remote-tracking branch 'origin/master'

Translated using Weblate (Indonesian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Japanese)

Currently translated at 46.2% (287 of 621 strings)

Translated using Weblate (Bulgarian)

Currently translated at 90.6% (563 of 621 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (621 of 621 strings)























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/cs/
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/gl/
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/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/
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/de/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/en/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/pl/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/uk/
Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/zh_Hans/
Translation: Cloudstream/App
Translation: Cloudstream/Fastlane

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hristo Hristov <hristov.tanev@gmail.com>
Co-authored-by: Julian <hello@apollo.moe>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Osten <11805592+LagradOst@users.noreply.github.com>
Co-authored-by: PiterDev <piterzdev@gmail.com>
Co-authored-by: Red Star Over Earth <rs0vere@outlook.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Rudy Tantono <rudzlong@gmail.com>
Co-authored-by: Salif Mehmed <mail@salif.eu>
Co-authored-by: Skrripy <rozihrash.ya6w7@simplelogin.com>
Co-authored-by: Thanasis <thanasakis11mail@gmail.com>
Co-authored-by: dabao1955 <dabao1955@163.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: tictactoe <phandinhminh@protonmail.ch>
2023-08-01 16:03:58 +02:00
Jace
827cbbb0b5
Feature: Refactor autodownload plugin to have multiple modes. (#518) 2023-08-01 15:54:15 +02:00
Sofie
a8ed8773de
Extractor: fix Gofile and added Userscloud (#523)
* Extractor: added Pixeldrain, Wibufile and fix some extractors

* Extractor: added Moviesapi and fix some extractors

* Extractor: fix Gofile and added Userscloud

---------

Co-authored-by: Sofie99 <Sofie99@gmail.com>
2023-08-01 15:50:02 +02:00
Osten
363ffa26de
Update README.md 2023-08-01 04:11:20 +02:00
LagradOst
7c60ccdef2 fixed #521 2023-08-01 04:03:43 +02:00
LagradOst
d5316bff9b fixed #524 2023-08-01 03:12:32 +02:00
Osten
8dae4c2b0f
Self similarity/master (#525)
* Migrated to Media3

---------

Co-authored-by: self-similarity <137652432+self-similarity@users.noreply.github.com>
2023-08-01 01:25:28 +02:00
643 changed files with 33993 additions and 11764 deletions

View file

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

View file

@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: Request a new provider or report bug with an existing provider - name: Request a new provider or report bug with an existing provider
url: https://github.com/recloudstream 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 - name: Discord
url: https://discord.gg/5Hus6fM url: https://discord.gg/5Hus6fM
about: Join our discord for faster support on smaller issues. about: Join our discord for faster support on smaller issues.

View file

@ -27,9 +27,7 @@ body:
label: Acknowledgements label: Acknowledgements
description: Your issue will be closed if you haven't done these steps. description: Your issue will be closed if you haven't done these steps.
options: 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. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true 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

6
.github/locales.py vendored
View file

@ -1,6 +1,7 @@
import re import re
import glob import glob
import requests import requests
import os
import lxml.etree as ET # builtin library doesn't preserve comments import lxml.etree as ET # builtin library doesn't preserve comments
@ -53,11 +54,16 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
try: try:
tree = ET.parse(file) tree = ET.parse(file)
for child in tree.getroot(): for child in tree.getroot():
if not child.text:
continue
if child.text.startswith("\\@string/"): if child.text.startswith("\\@string/"):
print(f"[{file}] fixing {child.attrib['name']}") print(f"[{file}] fixing {child.attrib['name']}")
child.text = child.text.replace("\\@string/", "@string/") child.text = child.text.replace("\\@string/", "@string/")
with open(file, 'wb') as fp: with open(file, 'wb') as fp:
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n') fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
# Remove trailing new line to be consistent with weblate
fp.seek(-1, os.SEEK_END)
fp.truncate()
except ET.ParseError as ex: except ET.ParseError as ex:
print(f"[{file}] {ex}") print(f"[{file}] {ex}")

View file

@ -19,21 +19,21 @@ jobs:
steps: steps:
- name: Generate access token - name: Generate access token
id: generate_token id: generate_token
uses: tibdex/github-app-token@v1 uses: tibdex/github-app-token@v2
with: with:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets" repository: "recloudstream/secrets"
- name: Generate access token (archive) - name: Generate access token (archive)
id: generate_archive_token id: generate_archive_token
uses: tibdex/github-app-token@v1 uses: tibdex/github-app-token@v2
with: with:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive" repository: "recloudstream/cloudstream-archive"
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v2 uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '17'
distribution: 'adopt' distribution: 'adopt'
@ -56,7 +56,9 @@ jobs:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
- uses: actions/checkout@v3 SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- uses: actions/checkout@v4
with: with:
repository: "recloudstream/cloudstream-archive" repository: "recloudstream/cloudstream-archive"
token: ${{ steps.generate_archive_token.outputs.token }} token: ${{ steps.generate_archive_token.outputs.token }}

View file

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

View file

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

View file

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

View file

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

View file

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

6
.idea/gradle.xml generated
View file

@ -4,16 +4,16 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <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="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/library" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View file

@ -9,8 +9,9 @@
+ **AdFree**, No ads whatsoever + **AdFree**, No ads whatsoever
+ No tracking/analytics + No tracking/analytics
+ Bookmarks + Bookmarks
+ Download and stream movies, tv-shows and anime + Phone and TV support
+ Chromecast + Chromecast
+ Extension system for personal customization
### Supported languages: ### Supported languages:
<a href="https://hosted.weblate.org/engage/cloudstream/"> <a href="https://hosted.weblate.org/engage/cloudstream/">

6
app/CMakeLists.txt Normal file
View file

@ -0,0 +1,6 @@
# Set this to the minimum version your project supports.
cmake_minimum_required(VERSION 3.18)
project(CrashHandler)
find_library(log-lib log)
add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
target_link_libraries(native-lib ${log-lib})

View file

@ -1,12 +1,14 @@
import com.android.build.gradle.api.BaseVariantOutput import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask 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.io.ByteArrayOutputStream
import java.net.URL import java.net.URL
plugins { plugins {
id("com.android.application") id("com.android.application")
id("com.google.devtools.ksp")
id("kotlin-android") id("kotlin-android")
id("kotlin-kapt")
id("org.jetbrains.dokka") id("org.jetbrains.dokka")
} }
@ -32,9 +34,16 @@ android {
enable = true enable = true
} }
/* disable this for now
externalNativeBuild {
cmake {
path("CMakeLists.txt")
}
}*/
signingConfigs { signingConfigs {
create("prerelease") {
if (prereleaseStoreFile != null) { if (prereleaseStoreFile != null) {
create("prerelease") {
storeFile = file(prereleaseStoreFile) storeFile = file(prereleaseStoreFile)
storePassword = System.getenv("SIGNING_STORE_PASSWORD") storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyAlias = System.getenv("SIGNING_KEY_ALIAS")
@ -43,33 +52,44 @@ android {
} }
} }
compileSdk = 33 compileSdk = 34
buildToolsVersion = "30.0.3" buildToolsVersion = "34.0.0"
defaultConfig { defaultConfig {
applicationId = "com.lagradost.cloudstream3" applicationId = "com.lagradost.cloudstream3"
minSdk = 21 minSdk = 21
targetSdk = 33 targetSdk = 33 /* Android 14 is Fu*ked
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
versionCode = 59 versionCode = 64
versionName = "4.1.1" versionName = "4.4.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
resValue("bool", "is_prerelease", "false") resValue("bool", "is_prerelease", "false")
// Reads local.properties
val localProperties = gradleLocalProperties(rootDir)
buildConfigField(
"long",
"BUILD_DATE",
"${System.currentTimeMillis()}"
)
buildConfigField( buildConfigField(
"String", "String",
"BUILDDATE", "SIMKL_CLIENT_ID",
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
)
buildConfigField(
"String",
"SIMKL_CLIENT_SECRET",
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
) )
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
kapt { ksp {
includeCompileClasspath = true arg("room.schemaLocation", "$projectDir/schemas")
arg("exportSchema", "true")
} }
} }
@ -92,6 +112,7 @@ android {
) )
} }
} }
flavorDimensions.add("state") flavorDimensions.add("state")
productFlavors { productFlavors {
create("stable") { create("stable") {
@ -103,25 +124,31 @@ android {
resValue("bool", "is_prerelease", "true") resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true") buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease" applicationIdSuffix = ".prerelease"
if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease") signingConfig = signingConfigs.getByName("prerelease")
} else {
logger.warn("No prerelease signing config!")
}
versionNameSuffix = "-PRE" versionNameSuffix = "-PRE"
versionCode = (System.currentTimeMillis() / 60000).toInt() versionCode = (System.currentTimeMillis() / 60000).toInt()
} }
} }
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=compatibility")
}
lint { lint {
abortOnError = false abortOnError = false
checkReleaseBuilds = false checkReleaseBuilds = false
} }
buildFeatures {
buildConfig = true
}
namespace = "com.lagradost.cloudstream3" namespace = "com.lagradost.cloudstream3"
} }
@ -130,131 +157,132 @@ repositories {
} }
dependencies { dependencies {
implementation("com.google.android.mediahome:video:1.0.0") // Testing
implementation("androidx.test.ext:junit-ktx:1.1.3")
testImplementation("org.json:json:20180813")
implementation("androidx.core:core-ktx:1.8.0")
implementation("androidx.appcompat:appcompat:1.4.2") // 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.5.1")
implementation("androidx.navigation:navigation-ui-ktx:2.5.1")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3") testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
androidTestImplementation("androidx.test:core") 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 // Android Core & Lifecycle
// implementation("org.jsoup:jsoup:1.13.1") implementation("androidx.core:core-ktx:1.13.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
implementation("com.github.bumptech.glide:glide:4.13.1") implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
kapt("com.github.bumptech.glide:compiler:4.13.1")
implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0")
// Design & UI
implementation("jp.wasabeef:glide-transformations:4.3.0") 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.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")
// Exoplayer // For KSP -> Official Annotation Processors are Not Yet Supported for KSP
implementation("androidx.media3:media3-common:1.1.0") ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("androidx.media3:media3-exoplayer:1.1.0") implementation("com.google.guava:guava:33.2.1-android")
implementation("androidx.media3:media3-datasource-okhttp:1.1.0") implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("androidx.media3:media3-ui:1.1.0")
implementation("androidx.media3:media3-session:1.1.0")
implementation("androidx.media3:media3-cast:1.1.0")
implementation("androidx.media3:media3-exoplayer-hls:1.1.0")
implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
// Media 3 (ExoPlayer)
implementation("androidx.media3:media3-ui: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")
implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
// Use the Jellyfin ffmpeg extension for easy ffmpeg audio decoding in exoplayer. Thank you Jellyfin <3 // PlayBack
// implementation("org.jellyfin.exoplayer:exoplayer-ffmpeg-extension:2.18.2+1") 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
//implementation("com.google.android.exoplayer:extension-leanback:2.14.0") // Crash Reports (AcraApplication.kt)
implementation("ch.acra:acra-core:5.11.3")
// Bug reports implementation("ch.acra:acra-toast:5.11.3")
implementation("ch.acra:acra-core:5.8.4")
implementation("ch.acra:acra-toast:5.8.4")
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.0")
implementation("androidx.work:work-runtime-ktx:2.8.0")
// 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.2")
// 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.3")
// 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")
// 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("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 // Extensions & Other Libs
implementation("com.github.albfernandez:juniversalchardet:2.4.0") 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. */
// slow af yt // Downloading & Networking
//implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") 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
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204 implementation(project(":library") {
implementation("com.github.TeamNewPipe:NewPipeExtractor:8495ad619e") // There does not seem to be a good way of getting the android flavor.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") val isDebug = gradle.startParameter.taskRequests.any { task ->
task.args.any { arg ->
arg.contains("debug", true)
}
}
// Library/extensions searching with Levenshtein distance this.extra.set("isDebug", isDebug)
implementation("me.xdrop:fuzzywuzzy:1.4.0") })
// color palette for images -> colors
implementation("androidx.palette:palette-ktx:1.0.0")
} }
tasks.register("androidSourcesJar", Jar::class) { tasks.register<Jar>("androidSourcesJar") {
archiveClassifier.set("sources") 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<Copy>("copyJar") {
tasks.register("makeJar", Copy::class) { from(
from("build/intermediates/compile_app_classes_jar/prereleaseDebug") "build/intermediates/compile_app_classes_jar/prereleaseDebug",
into("build") "../library/build/libs"
include("classes.jar") )
dependsOn("build") 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 { tasks.withType<DokkaTask>().configureEach {
@ -267,6 +295,7 @@ tasks.withType<DokkaTask>().configureEach {
// URL showing where the source code can be accessed through the web browser // 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")) 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 // Suffix which is used to append the line number to the URL. Use #L for GitHub
remoteLineSuffix.set("#L") remoteLineSuffix.set("#L")
} }

View file

@ -9,6 +9,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding 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.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
import com.lagradost.cloudstream3.databinding.FragmentResultBinding 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.FragmentSearchTvBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.databinding.HomepageParentBinding
import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding 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<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<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<HomepageParentTvBinding>(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) 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() { fun providerCorrectHomepage() {
runBlocking { runBlocking {
getAllProviders().toList().amap { api -> getAllProviders().toList().amap { api ->
TestingUtils.testHomepage(api, ::println) TestingUtils.testHomepage(api, TestingUtils.Logger())
} }
} }
println("Done providerCorrectHomepage") println("Done providerCorrectHomepage")
@ -160,7 +166,6 @@ class ExampleInstrumentedTest {
TestingUtils.getDeferredProviderTests( TestingUtils.getDeferredProviderTests(
this, this,
getAllProviders(), 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.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- Downloads on low api devices --> <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.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.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update --> <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="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.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.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" />
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> &lt;!&ndash; Used for getting if vlc is installed &ndash;&gt; -->
<!-- Fixes android tv fuckery --> <!-- Fixes android tv fuckery -->
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
@ -35,9 +41,11 @@
<application <application
android:name=".AcraApplication" android:name=".AcraApplication"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:appCategory="video" android:appCategory="video"
android:banner="@mipmap/ic_banner" android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor" android:fullBackupContent="@xml/backup_descriptor"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
@ -45,7 +53,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="o"> tools:targetApi="tiramisu">
<meta-data <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
@ -61,7 +69,9 @@
android:exported="true" android:exported="true"
android:resizeableActivity="true" android:resizeableActivity="true"
android:screenOrientation="userLandscape" android:screenOrientation="userLandscape"
android:supportsPictureInPicture="true"> android:supportsPictureInPicture="true"
android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -87,17 +97,11 @@
--> -->
<activity <activity
android:name=".MainActivity" 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:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="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 --> <!-- cloudstreamplayer://encodedUrl?name=Dune -->
<intent-filter> <intent-filter>
@ -161,6 +165,21 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity <activity
android:name=".ui.EasterEggMonke" android:name=".ui.EasterEggMonke"
android:exported="true" /> android:exported="true" />
@ -168,13 +187,14 @@
<receiver <receiver
android:name=".receivers.VideoDownloadRestartReceiver" android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false" android:enabled="false"
android:exported="true"> android:exported="false">
<intent-filter android:exported="true"> <intent-filter android:exported="false">
<action android:name="restart_service" /> <action android:name="restart_service" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<service <service
android:foregroundServiceType="dataSync"
android:name=".services.VideoDownloadService" android:name=".services.VideoDownloadService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" />
@ -184,6 +204,7 @@
android:exported="false" /> android:exported="false" />
<service <service
android:foregroundServiceType="dataSync"
android:name=".utils.PackageInstallerService" android:name=".utils.PackageInstallerService"
android:exported="false" /> android:exported="false" />

View file

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

View file

@ -8,12 +8,14 @@ import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity 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.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser 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.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
@ -32,20 +34,19 @@ import org.acra.sender.ReportSenderFactory
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.PrintStream import java.io.PrintStream
import java.lang.Exception
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.system.exitProcess import kotlin.system.exitProcess
class CustomReportSender : ReportSender { class CustomReportSender : ReportSender {
// Sends all your crashes to google forms // Sends all your crashes to google forms
override fun send(context: Context, errorContent: CrashReportData) { override fun send(context: Context, errorContent: CrashReportData) {
println("Sending report") println("Sending report")
val url = val url =
"https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse" "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
val data = mapOf( val data = mapOf(
"entry.753293084" to errorContent.toJSON() "entry.1993829403" to errorContent.toJSON()
) )
thread { // to not run it on main thread thread { // to not run it on main thread
@ -65,7 +66,6 @@ class CustomReportSender : ReportSender {
} }
} }
@AutoService(ReportSenderFactory::class)
class CustomSenderFactory : ReportSenderFactory { class CustomSenderFactory : ReportSenderFactory {
override fun create(context: Context, config: CoreConfiguration): ReportSender { override fun create(context: Context, config: CoreConfiguration): ReportSender {
return CustomReportSender() return CustomReportSender()
@ -82,14 +82,8 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
ACRA.errorReporter.handleException(error) ACRA.errorReporter.handleException(error)
try { try {
PrintStream(errorFile).use { ps -> PrintStream(errorFile).use { ps ->
ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")) ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println( ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
String.format(
"Fatal exception on thread %s (%d)",
thread.name,
thread.id
)
)
error.printStackTrace(ps) error.printStackTrace(ps)
} }
} catch (ignored: FileNotFoundException) { } catch (ignored: FileNotFoundException) {
@ -104,12 +98,16 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
} }
class AcraApplication : Application() { class AcraApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) { ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component)) startActivity(Intent.makeRestartActivityTask(intent!!.component))
}) }.also {
exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it)
}
} }
override fun attachBaseContext(base: Context?) { override fun attachBaseContext(base: Context?) {
@ -121,10 +119,10 @@ class AcraApplication : Application() {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
reportContent = arrayOf( reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE ReportField.STACK_TRACE,
) )
// removed this due to bug when starting the app, moved it to when it actually crashes // removed this due to bug when starting the app, moved it to when it actually crashes
@ -137,6 +135,8 @@ class AcraApplication : Application() {
} }
companion object { companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get activity from Context */ /** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? = this as? Activity tailrec fun Context.getActivity(): Activity? = this as? Activity
?: (this as? ContextWrapper)?.baseContext?.getActivity() ?: (this as? ContextWrapper)?.baseContext?.getActivity()
@ -146,6 +146,15 @@ class AcraApplication : Application() {
get() = _context?.get() get() = _context?.get()
private set(value) { private set(value) {
_context = WeakReference(value) _context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
} }
fun removeKeys(folder: String): Int? { fun removeKeys(folder: String): Int? {
@ -199,10 +208,9 @@ class AcraApplication : Application() {
fun openBrowser(url: String, activity: FragmentActivity?) { fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser( openBrowser(
url, url,
isTvSettings(), isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull() activity?.supportFragmentManager?.fragments?.lastOrNull()
) )
} }
} }
} }

View file

@ -5,12 +5,16 @@ import android.app.Activity
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import android.util.DisplayMetrics
import android.util.Log 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.view.View.NO_ID
import android.widget.TextView import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -19,16 +23,21 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey 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.mvvm.logError
import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.utils.AppUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper
@ -37,7 +46,16 @@ import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.NewPipe
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.Locale
import kotlin.math.max
import kotlin.math.min
enum class FocusDirection {
Start,
End,
Up,
Down,
}
object CommonActivity { object CommonActivity {
@ -48,11 +66,29 @@ object CommonActivity {
_activity = WeakReference(value) _activity = WeakReference(value)
} }
@MainThread
fun setActivityInstance(newActivity: Activity?) {
activity = newActivity
}
@MainThread @MainThread
fun Activity?.getCastSession(): CastSession? { fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession 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 canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false var canShowPipMode: Boolean = false
@ -64,8 +100,7 @@ object CommonActivity {
var playerEventListener: ((PlayerEventType) -> Unit)? = null var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
private var currentToast: Toast? = null
var currentToast: Toast? = null
fun showToast(@StringRes message: Int, duration: Int? = null) { fun showToast(@StringRes message: Int, duration: Int? = null) {
val act = activity ?: return val act = activity ?: return
@ -121,25 +156,19 @@ object CommonActivity {
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
try { try {
val inflater = val binding = ToastBinding.inflate(act.layoutInflater)
act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater binding.text.text = message.trim()
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()
// custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11)
val toast = Toast(act) val toast = Toast(act)
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.duration = duration ?: Toast.LENGTH_SHORT toast.duration = duration ?: Toast.LENGTH_SHORT
toast.view = layout toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
//https://github.com/PureWriter/ToastCompat 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.
toast.show()
currentToast = toast currentToast = toast
toast.show()
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
@ -173,23 +202,25 @@ object CommonActivity {
setLocale(this, localeCode) setLocale(this, localeCode)
} }
fun init(act: ComponentActivity?) { fun init(act: Activity) {
if (act == null) return setActivityInstance(act)
activity = 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://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 //https://developer.android.com/guide/topics/ui/picture-in-picture
canShowPipMode = canShowPipMode =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT 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 componentActivity.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.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
act.updateLocale() componentActivity.updateLocale()
act.updateTv() componentActivity.updateTv()
NewPipe.init(DownloaderTestImpl.getInstance()) NewPipe.init(DownloaderTestImpl.getInstance())
for (resumeApp in resumeApps) { for (resumeApp in resumeApps) {
resumeApp.launcher = resumeApp.launcher =
act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode val resultCode = result.resultCode
val data = result.data val data = result.data
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) { if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
@ -206,11 +237,11 @@ object CommonActivity {
// Ask for notification permissions on Android 13 // Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
act, componentActivity,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED ) != PackageManager.PERMISSION_GRANTED
) { ) {
val requestPermissionLauncher = act.registerForActivityResult( val requestPermissionLauncher = componentActivity.registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { isGranted: Boolean -> ) { isGranted: Boolean ->
Log.d(TAG, "Notification permission: $isGranted") Log.d(TAG, "Notification permission: $isGranted")
@ -246,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?) { fun loadThemes(act: Activity?) {
if (act == null) return if (act == null) return
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
val currentTheme = val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) { when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
"System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme "Black" -> R.style.AppTheme
"Light" -> R.style.LightMode "Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode "Amoled" -> R.style.AmoledMode
@ -265,12 +319,15 @@ object CommonActivity {
val currentOverlayTheme = val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) { when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal "Normal" -> R.style.OverlayPrimaryColorNormal
"DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink "CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
"Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen "DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon "Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue "NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey "Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite "White" -> R.style.OverlayPrimaryColorWhite
"CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown "Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple "Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen "Green" -> R.style.OverlayPrimaryColorGreen
@ -279,6 +336,7 @@ object CommonActivity {
"Banana" -> R.style.OverlayPrimaryColorBanana "Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty "Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink "Pink" -> R.style.OverlayPrimaryColorPink
"Lavender" -> R.style.OverlayPrimaryColorLavender
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
@ -301,7 +359,8 @@ object CommonActivity {
private fun localLook(from: View, id: Int): View? { private fun localLook(from: View, id: Int): View? {
if (id == NO_ID) return null if (id == NO_ID) return null
var currentLook: View = from var currentLook: View = from
while (true) { // limit to 15 look depth
for (i in 0..15) {
currentLook.findViewById<View?>(id)?.let { return it } currentLook.findViewById<View?>(id)?.let { return it }
currentLook = (currentLook.parent as? View) ?: break currentLook = (currentLook.parent as? View) ?: break
} }
@ -317,17 +376,79 @@ object CommonActivity {
currentLook = currentLook.parent as? View ?: break 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?,
view: View,
direction: FocusDirection,
nextId: Int,
depth: Int = 0
): View? {
if (nextId == NO_ID) return null
// do an initial search for the view, in case the localLook is too deep we can use this as
// an early break and backup view
var next =
when (root) {
is Activity -> root.findViewById(nextId)
is View -> root.rootView.findViewById<View?>(nextId)
else -> null
} ?: 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 && shown && !hasChildrenThatWantsFocus) return null
// if not shown then continue because we will "skip" over views to get to a replacement
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)
}
(when (next) {
is ChipGroup -> {
next.children.firstOrNull { it.isFocusable && it.isShown }
}
is NavigationRailView -> {
next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
}
else -> null
})?.let {
return it
}
// nothing wrong with the view found, return it
return next
}
/** recursively looks for a next focus up to a depth of 10, /** recursively looks for a next focus up to a depth of 10,
* this is used to override the normal shit focus system * this is used to override the normal shit focus system
* because this application has a lot of invisible views that messes with some tv devices*/ * because this application has a lot of invisible views that messes with some tv devices*/
private fun getNextFocus( fun getNextFocus(
act: Activity?, root: Any?,
view: View?, view: View?,
direction: FocusDirection, direction: FocusDirection,
depth: Int = 0 depth: Int = 0
): View? { ): View? {
// if input is invalid let android decide + depth test to not crash if loop is found // if input is invalid let android decide + depth test to not crash if loop is found
if (view == null || depth >= 10 || act == null) { if (view == null || depth >= 10 || root == null) {
return null return null
} }
@ -359,50 +480,14 @@ object CommonActivity {
// if not specified then use forward id // if not specified then use forward id
nextId = view.nextFocusForwardId nextId = view.nextFocusForwardId
// if view is still not found to next focus then return and let android decide // if view is still not found to next focus then return and let android decide
if (nextId == NO_ID) return null if (nextId == NO_ID)
return null
}
return continueGetNextFocus(root, view, direction, nextId, depth)
} }
var next = act.findViewById<View?>(nextId) ?: return null
next = localLook(view, nextId) ?: next
var currentLook: View = view
while (currentLook.findViewById<View?>(nextId)?.also { next = it } == null) {
currentLook = (currentLook.parent as? View) ?: break
}
// if cant focus but visible then break and let android decide
if (!next.isFocusable && next.isShown) return null
// if not shown then continue because we will "skip" over views to get to a replacement
if (!next.isShown) return getNextFocus(act, next, direction, depth + 1)
// nothing wrong with the view found, return it
return next
}
private enum class FocusDirection {
Start,
End,
Up,
Down,
}
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) { 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 // 149 keycode_numpad 5
when (keyCode) { when (keyCode) {
@ -516,7 +601,7 @@ object CommonActivity {
else -> null else -> null
} }
// println("NEXT FOCUS : $nextView")
if (nextView != null) { if (nextView != null) {
nextView.requestFocus() nextView.requestFocus()
keyEventListener?.invoke(Pair(event, true)) keyEventListener?.invoke(Pair(event, true))

View file

@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.downloader.Response
@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { 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 { override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod() val httpMethod: String = request.httpMethod()
val url: String = request.url() val url: String = request.url()
@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend() val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null var requestBody: RequestBody? = null
if (dataToSend != null) { if (dataToSend != null) {
requestBody = RequestBody.create(null, dataToSend) requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
} }
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url) .method(httpMethod, requestBody).url(url)
@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
companion object { companion object {
private const val USER_AGENT = private const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
private var instance: DownloaderTestImpl? = null private var instance: DownloaderTestImpl? = null
/** /**
@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance return instance
} }
} }
init {
client = builder.readTimeout(30, TimeUnit.SECONDS).build()
}
} }

File diff suppressed because it is too large Load diff

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,34 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.getAndUnpack
open class Mp4Upload : ExtractorApi() {
override var name = "Mp4Upload"
override var mainUrl = "https://www.mp4upload.com"
private val srcRegex = Regex("""player\.src\("(.*?)"""")
override val requiresReferer = true
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
with(app.get(url)) {
getAndUnpack(this.text).let { unpackedText ->
val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
return listOf(
ExtractorLink(
name,
name,
link,
url,
quality ?: Qualities.Unknown.value,
)
)
}
}
}
return 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.MainAPI
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
object SyncRedirector { object SyncRedirector {
val syncApis = SyncApis
private val syncIds = private val syncIds =
listOf( listOf(
SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""), SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""),
SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""") SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""")
) )
suspend fun redirect( suspend fun redirect(

View file

@ -105,6 +105,7 @@ open class TmdbProvider : MainAPI() {
this.id, this.id,
episode.episode_number, episode.episode_number,
episode.season_number, episode.season_number,
this.name ?: this.original_name,
).toJson(), ).toJson(),
episode.name, episode.name,
episode.season_number, episode.season_number,
@ -122,6 +123,7 @@ open class TmdbProvider : MainAPI() {
this.id, this.id,
episodeNum, episodeNum,
season.season_number, season.season_number,
this.name ?: this.original_name,
).toJson(), ).toJson(),
season = season.season_number season = season.season_number
) )
@ -151,6 +153,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors()) addActors(credits?.cast?.toList().toActors())
contentRating = fetchContentRating(id, "US")
} }
} }
@ -193,6 +197,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors()) addActors(credits?.cast?.toList().toActors())
contentRating = fetchContentRating(id, "US")
} }
} }
@ -264,6 +270,26 @@ open class TmdbProvider : MainAPI() {
return null 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. // Possible to add recommendations and such here.
override suspend fun load(url: String): LoadResponse? { override suspend fun load(url: String): LoadResponse? {
// https://www.themoviedb.org/movie/7445-brothers // 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.Requests.Companion.await
import com.lagradost.nicehttp.cookies import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.* import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.URI import java.net.URI
@ -17,6 +20,8 @@ import java.net.URI
class CloudflareKiller : Interceptor { class CloudflareKiller : Interceptor {
companion object { companion object {
const val TAG = "CloudflareKiller" 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> { fun parseCookieMap(cookie: String): Map<String, String> {
return cookie.split(";").associate { return cookie.split(";").associate {
val split = it.split("=") val split = it.split("=")
@ -48,16 +53,24 @@ class CloudflareKiller : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response = runBlocking { override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
val request = chain.request() val request = chain.request()
val cookies = savedCookies[request.url.host]
if (cookies == null) { 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 { bypassCloudflare(request)?.let {
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
return@runBlocking it return@runBlocking it
} }
} else { }
}
else -> {
return@runBlocking proceed(request, cookies) return@runBlocking proceed(request, cookies)
} }
}
debugWarning({ true }) { "Failed cloudflare at: ${request.url}" } debugWarning({ true }) { "Failed cloudflare at: ${request.url}" }
return@runBlocking chain.proceed(request) return@runBlocking chain.proceed(request)

View file

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

View file

@ -34,7 +34,7 @@ abstract class Plugin {
*/ */
fun registerMainAPI(element: MainAPI) { fun registerMainAPI(element: MainAPI) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) 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 // Race condition causing which would case duplicates if not for distinctBy
synchronized(APIHolder.allProviders) { synchronized(APIHolder.allProviders) {
APIHolder.allProviders.add(element) APIHolder.allProviders.add(element)
@ -48,7 +48,7 @@ abstract class Plugin {
*/ */
fun registerExtractorAPI(element: ExtractorApi) { fun registerExtractorAPI(element: ExtractorApi) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
element.sourcePlugin = this.__filename element.sourcePlugin = this.filename
extractorApis.add(element) extractorApis.add(element)
} }
@ -67,7 +67,12 @@ abstract class Plugin {
* This will contain your resources if you specified requiresResources in gradle * This will contain your resources if you specified requiresResources in gradle
*/ */
var resources: Resources? = null 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 * 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 package com.lagradost.cloudstream3.plugins
import android.Manifest
import android.app.* import android.app.*
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetManager import android.content.res.AssetManager
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.Gson import com.google.gson.Gson
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey 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.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider 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.result.txt
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -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> { fun getPluginsOnline(): Array<PluginData> {
return getKey(PLUGINS_KEY) ?: emptyArray() return getKey(PLUGINS_KEY) ?: emptyArray()
} }
@ -150,7 +166,7 @@ object PluginManager {
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
public var currentlyLoading: String? = null var currentlyLoading: String? = null
// Maps filepath to plugin // Maps filepath to plugin
val plugins: MutableMap<String, Plugin> = val plugins: MutableMap<String, Plugin> =
@ -165,6 +181,9 @@ object PluginManager {
var loadedLocalPlugins = false var loadedLocalPlugins = false
private set private set
var loadedOnlinePlugins = false
private set
private val gson = Gson() private val gson = Gson()
private suspend fun maybeLoadPlugin(context: Context, file: File) { private suspend fun maybeLoadPlugin(context: Context, file: File) {
@ -278,6 +297,7 @@ object PluginManager {
} }
// ioSafe { // ioSafe {
loadedOnlinePlugins = true
afterPluginsLoadedEvent.invoke(false) afterPluginsLoadedEvent.invoke(false)
// } // }
@ -290,7 +310,7 @@ object PluginManager {
* 2. Fetch all not downloaded plugins * 2. Fetch all not downloaded plugins
* 3. Download them and reload plugins * 3. Download them and reload plugins
**/ **/
fun downloadNotExistingPluginsAndLoad(activity: Activity) { fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
val newDownloadPlugins = mutableListOf<String>() val newDownloadPlugins = mutableListOf<String>()
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY) val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES ?: emptyArray()) + PREBUILT_REPOSITORIES
@ -304,6 +324,8 @@ object PluginManager {
// Iterate online repos and returns not downloaded plugins // Iterate online repos and returns not downloaded plugins
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
val sitePlugin = onlineData.second val sitePlugin = onlineData.second
val tvtypes = sitePlugin.tvTypes ?: listOf()
//Don't include empty urls //Don't include empty urls
if (sitePlugin.url.isBlank()) { if (sitePlugin.url.isBlank()) {
return@mapNotNull null return@mapNotNull null
@ -318,22 +340,29 @@ object PluginManager {
return@mapNotNull null return@mapNotNull null
} }
//Omit non-NSFW if mode is set to NSFW only
if (mode == AutoDownloadMode.NsfwOnly) {
if (!tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
}
//Omit NSFW, if disabled
if (!settingsForProvider.enableAdult) {
if (tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
}
//Omit lang not selected on language setting //Omit lang not selected on language setting
if (mode == AutoDownloadMode.FilterByLang) {
val lang = sitePlugin.language ?: return@mapNotNull null val lang = sitePlugin.language ?: return@mapNotNull null
//If set to 'universal', don't skip any language //If set to 'universal', don't skip any language
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
return@mapNotNull null return@mapNotNull null
} }
//Log.i(TAG, "sitePlugin lang => $lang") //Log.i(TAG, "sitePlugin lang => $lang")
}
//Omit NSFW, if disabled
sitePlugin.tvTypes?.let { tvtypes ->
if (!settingsForProvider.enableAdult) {
if (tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
}
}
val savedData = PluginData( val savedData = PluginData(
url = sitePlugin.url, url = sitePlugin.url,
internalName = sitePlugin.internalName, internalName = sitePlugin.internalName,
@ -402,7 +431,6 @@ object PluginManager {
**/ **/
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) { fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
val dir = File(LOCAL_PLUGINS_PATH) val dir = File(LOCAL_PLUGINS_PATH)
removeKey(PLUGINS_KEY_LOCAL)
if (!dir.exists()) { if (!dir.exists()) {
val res = dir.mkdirs() val res = dir.mkdirs()
@ -450,6 +478,14 @@ object PluginManager {
Log.i(TAG, "Loading plugin: $data") Log.i(TAG, "Loading plugin: $data")
return try { 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) val loader = PathClassLoader(filePath, context.classLoader)
var manifest: Plugin.Manifest var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream -> loader.getResourceAsStream("manifest.json").use { stream ->
@ -471,10 +507,12 @@ object PluginManager {
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also { val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
Log.d(TAG, "No manifest version for ${data.internalName}") Log.d(TAG, "No manifest version for ${data.internalName}")
} }
@Suppress("UNCHECKED_CAST")
val pluginClass: Class<*> = val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?> loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
val pluginInstance: Plugin = val pluginInstance: Plugin =
pluginClass.newInstance() as Plugin pluginClass.getDeclaredConstructor().newInstance() as Plugin
// Sets with the proper version // Sets with the proper version
setPluginData(data.copy(version = version)) setPluginData(data.copy(version = version))
@ -484,14 +522,16 @@ object PluginManager {
return true return true
} }
pluginInstance.__filename = fileName pluginInstance.filename = file.absolutePath
if (manifest.requiresResources) { if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}") Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk // 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 = val addAssetPath =
AssetManager::class.java.getMethod("addAssetPath", String::class.java) AssetManager::class.java.getMethod("addAssetPath", String::class.java)
addAssetPath.invoke(assets, file.absolutePath) addAssetPath.invoke(assets, file.absolutePath)
@Suppress("DEPRECATION")
pluginInstance.resources = Resources( pluginInstance.resources = Resources(
assets, assets,
context.resources.displayMetrics, context.resources.displayMetrics,
@ -533,14 +573,14 @@ object PluginManager {
// remove all registered apis // remove all registered apis
synchronized(APIHolder.apis) { synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it) removePluginMapping(it)
} }
} }
synchronized(APIHolder.allProviders) { 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 } classLoaders.values.removeIf { v -> v == plugin }
@ -687,9 +727,14 @@ object PluginManager {
} }
val notification = builder.build() val notification = builder.build()
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define // notificationId is a unique int for each notification that you must define
notify((System.currentTimeMillis() / 1000).toInt(), notification) if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
NotificationManagerCompat.from(context)
.notify((System.currentTimeMillis() / 1000).toInt(), notification)
} }
return notification return notification
} catch (e: Exception) { } catch (e: Exception) {

View file

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

View file

@ -8,22 +8,14 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import java.security.MessageDigest import java.security.MessageDigest
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
object VotingApi { // please do not cheat the votes lol object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi" private const val LOGKEY = "VotingApi"
enum class VoteType(val value: Int) { private const val API_DOMAIN = "https://counterapi.com/api"
UPVOTE(1),
DOWNVOTE(-1),
NONE(0)
}
private val apiDomain = "https://api.countapi.xyz"
private fun transformUrl(url: String): String = // dont touch or all votes get reset private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest MessageDigest
@ -35,12 +27,12 @@ object VotingApi { // please do not cheat the votes lol
return getVotes(url) return getVotes(url)
} }
suspend fun SitePlugin.vote(requestType: VoteType): Int { fun SitePlugin.hasVoted(): Boolean {
return vote(url, requestType) return hasVoted(url)
} }
fun SitePlugin.getVoteType(): VoteType { suspend fun SitePlugin.vote(): Int {
return getVoteType(url) return vote(url)
} }
fun SitePlugin.canVote(): Boolean { fun SitePlugin.canVote(): Boolean {
@ -50,36 +42,38 @@ object VotingApi { // please do not cheat the votes lol
// Plugin url to Int // Plugin url to Int
private val votesCache = mutableMapOf<String, Int>() private val votesCache = mutableMapOf<String, Int>()
suspend fun getVotes(pluginUrl: String): Int { private fun getRepository(pluginUrl: String) = pluginUrl
val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}" .split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int {
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
Log.d(LOGKEY, "Requesting: $url") Log.d(LOGKEY, "Requesting: $url")
return votesCache[pluginUrl] ?: app.get(url).parsedSafe<Result>()?.value?.also { return app.get(url).parsedSafe<Result>()?.value ?: 0
}
private suspend fun writeVote(pluginUrl: String): Boolean {
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value != null
}
suspend fun getVotes(pluginUrl: String): Int =
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
votesCache[pluginUrl] = it votesCache[pluginUrl] = it
} ?: (0.also {
ioSafe {
createBucket(pluginUrl)
}
})
} }
fun getVoteType(pluginUrl: String): VoteType { fun hasVoted(pluginUrl: String) =
return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
}
private suspend fun createBucket(pluginUrl: String) {
val url =
"${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0"
Log.d(LOGKEY, "Requesting: $url")
app.get(url)
}
fun canVote(pluginUrl: String): Boolean { fun canVote(pluginUrl: String): Boolean {
if (!PluginManager.urlPlugins.contains(pluginUrl)) return false return PluginManager.urlPlugins.contains(pluginUrl)
return true
} }
private val voteLock = Mutex() private val voteLock = Mutex()
suspend fun vote(pluginUrl: String, requestType: VoteType): Int { suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time. // Prevent multiple requests at the same time.
voteLock.withLock { voteLock.withLock {
if (!canVote(pluginUrl)) { if (!canVote(pluginUrl)) {
@ -90,33 +84,21 @@ object VotingApi { // please do not cheat the votes lol
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
val savedType: VoteType = if (hasVoted(pluginUrl)) {
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE main {
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
val newType = if (requestType == savedType) VoteType.NONE else requestType .show()
val changeValue = if (requestType == savedType) {
-requestType.value
} else if (savedType == VoteType.NONE) {
requestType.value
} else if (savedType != requestType) {
-savedType.value + requestType.value
} else 0
// Pre-emptively set vote key
setKey("cs3-votes/${transformUrl(pluginUrl)}", newType)
val url =
"${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}"
Log.d(LOGKEY, "Requesting: $url")
val res = app.get(url).parsedSafe<Result>()?.value
if (res == null) {
// "Refund" key if the response is invalid
setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType)
} else {
votesCache[pluginUrl] = res
} }
return res ?: 0 return getVotes(pluginUrl)
}
if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
}
return getVotes(pluginUrl)
} }
} }

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 package com.lagradost.cloudstream3.services
import android.annotation.SuppressLint
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
@ -9,13 +10,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.* import androidx.work.*
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.R 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.plugins.PluginManager
import com.lagradost.cloudstream3.ui.result.txt 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.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
@ -97,7 +98,9 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
) )
} }
@SuppressLint("UnspecifiedImmutableFlag")
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
try {
// println("Update subscriptions!") // println("Update subscriptions!")
context.createNotificationChannel( context.createNotificationChannel(
SUBSCRIPTION_CHANNEL_ID, SUBSCRIPTION_CHANNEL_ID,
@ -215,10 +218,18 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
// You can probably get some issues here since this is async but it does not matter much. // You can probably get some issues here since this is async but it does not matter much.
updateProgress(max, ++progress, false) updateProgress(max, ++progress, false)
} catch (_: Throwable) { } catch (t: Throwable) {
logError(t)
} }
} }
return Result.success() 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()
}
} }
} }

View file

@ -1,11 +1,23 @@
package com.lagradost.cloudstream3.subtitles package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread 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.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthAPI 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 { interface AbstractSubProvider {
val idPrefix: String
@WorkerThread @WorkerThread
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? { suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
throw NotImplementedError() throw NotImplementedError()
@ -15,6 +27,98 @@ interface AbstractSubProvider {
suspend fun load(data: SubtitleEntity): String? { suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError() 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 interface AbstractSubApi : AbstractSubProvider, AuthAPI

View file

@ -19,8 +19,11 @@ class AbstractSubtitleEntities {
data class SubtitleSearch( data class SubtitleSearch(
var query: String = "", var query: String = "",
var imdb: Long? = null,
var lang: String? = 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 epNumber: Int? = null,
var seasonNumber: Int? = null, var seasonNumber: Int? = null,
var year: Int? = null var year: Int? = null

View file

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

View file

@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity
interface OAuth2API : AuthAPI { interface OAuth2API : AuthAPI {
val key: String val key: String
val redirectUrl: String val redirectUrl: String
val supportDeviceAuth: Boolean
suspend fun handleRedirect(url: String) : Boolean suspend fun handleRedirect(url: String) : Boolean
fun authenticate(activity: FragmentActivity?) 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,17 +1,11 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.UiText import com.lagradost.cloudstream3.ui.result.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import java.util.Date
enum class SyncIdName {
Anilist,
MyAnimeList,
Trakt,
Imdb,
LocalList
}
interface SyncAPI : OAuth2API { interface SyncAPI : OAuth2API {
/** /**
@ -35,9 +29,9 @@ interface SyncAPI : OAuth2API {
4 -> PlanToWatch 4 -> PlanToWatch
5 -> ReWatching 5 -> ReWatching
*/ */
suspend fun score(id: String, status: SyncStatus): Boolean suspend fun score(id: String, status: AbstractSyncStatus): Boolean
suspend fun getStatus(id: String): SyncStatus? suspend fun getStatus(id: String): AbstractSyncStatus?
suspend fun getResult(id: String): SyncResult? suspend fun getResult(id: String): SyncResult?
@ -59,14 +53,25 @@ interface SyncAPI : OAuth2API {
override var id: Int? = null, override var id: Int? = null,
) : SearchResponse ) : SearchResponse
data class SyncStatus( abstract class AbstractSyncStatus {
val status: Int, abstract var status: SyncWatchType
/** 1-10 */ /** 1-10 */
val score: Int?, abstract var score: Int?
val watchedEpisodes: Int?, abstract var watchedEpisodes: Int?
var isFavorite: Boolean? = null, abstract var isFavorite: Boolean?
var maxEpisodes: Int? = null, abstract var maxEpisodes: Int?
) }
data class SyncStatus(
override var status: SyncWatchType,
/** 1-10 */
override var score: Int?,
override var watchedEpisodes: Int?,
override var isFavorite: Boolean? = null,
override var maxEpisodes: Int? = null,
) : AbstractSyncStatus()
data class SyncResult( data class SyncResult(
/**Used to verify*/ /**Used to verify*/
@ -120,6 +125,8 @@ interface SyncAPI : OAuth2API {
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
else -> items else -> items
} }
} }
@ -154,6 +161,10 @@ interface SyncAPI : OAuth2API {
override var posterUrl: String?, override var posterUrl: String?,
override var posterHeaders: Map<String, String>?, override var posterHeaders: Map<String, String>?,
override var quality: SearchQuality?, override var quality: SearchQuality?,
val releaseDate: Date?,
override var id: Int? = null, override var id: Int? = null,
val plot : String? = null,
val rating: Int? = null,
val tags: List<String>? = null
) : SearchResponse ) : SearchResponse
} }

View file

@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
repo.requireLibraryRefresh = value repo.requireLibraryRefresh = value
} }
suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource<Boolean> { suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource<Boolean> {
return safeApiCall { repo.score(id, status) } return safeApiCall { repo.score(id, status) }
} }
suspend fun getStatus(id: String): Resource<SyncAPI.SyncStatus> { suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") } return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
} }

View file

@ -18,13 +18,13 @@ class Addic7ed : AbstractSubApi {
override fun logOut() {} override fun logOut() {}
companion object { companion object {
const val host = "https://www.addic7ed.com" const val HOST = "https://www.addic7ed.com"
const val TAG = "ADDIC7ED" const val TAG = "ADDIC7ED"
} }
private fun fixUrl(url: String): String { private fun fixUrl(url: String): String {
return if (url.startsWith("/")) host + url return if (url.startsWith("/")) HOST + url
else if (!url.startsWith("http")) "$host/$url" else if (!url.startsWith("http")) "$HOST/$url"
else url else url
} }
@ -62,7 +62,7 @@ class Addic7ed : AbstractSubApi {
} }
val title = queryText.substringBefore("(").trim() 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 val hostDocument = app.get(url).document
var searchResult = "" var searchResult = ""
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
@ -74,8 +74,8 @@ class Addic7ed : AbstractSubApi {
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
?.substringBefore(",") ?.substringBefore(",")
val doc = app.get( val doc = app.get(
"$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
referer = "$host/" referer = "$HOST/"
).document ).document
doc.select("#season tr:contains($queryLang)").mapNotNull { node -> doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
if (node.selectFirst("td")?.text() if (node.selectFirst("td")?.text()
@ -97,7 +97,7 @@ class Addic7ed : AbstractSubApi {
val link = fixUrl(node.select("a.buttonDownload").attr("href")) val link = fixUrl(node.select("a.buttonDownload").attr("href"))
val isHearingImpaired = val isHearingImpaired =
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() !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 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.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt 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.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import java.net.URL import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.util.* import java.util.Locale
class AniListApi(index: Int) : AccountManager(index), SyncAPI { class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "AniList" override var name = "AniList"
@ -31,6 +33,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override val redirectUrl = "anilistlogin" override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist" override val idPrefix = "anilist"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override val supportDeviceAuth = false
override var mainUrl = "https://anilist.co" override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon override val icon = R.drawable.ic_anilist_icon
override val requiresLogin = false override val requiresLogin = false
@ -61,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean { override suspend fun handleRedirect(url: String): Boolean {
val sanitizer = 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 token = sanitizer["access_token"]!!
val expiresIn = sanitizer["expires_in"]!! 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>? { override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(name) ?: return null val data = searchShows(name) ?: return null
return data.data?.Page?.media?.map { return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult( SyncAPI.SyncSearchResult(
it.title.romaji ?: return null, it.title.romaji ?: return null,
this.name, this.name,
@ -99,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getResult(id: String): SyncAPI.SyncResult { override suspend fun getResult(id: String): SyncAPI.SyncResult {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
val season = getSeason(internalId).data.Media val season = getSeason(internalId).data.media
return SyncAPI.SyncResult( return SyncAPI.SyncResult(
season.id.toString(), season.id.toString(),
@ -158,23 +161,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
} }
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(internalId) ?: return null val data = getDataAboutId(internalId) ?: return null
return SyncAPI.SyncStatus( return SyncAPI.SyncStatus(
score = data.score, score = data.score,
watchedEpisodes = data.progress, watchedEpisodes = data.progress,
status = data.type?.value ?: return null, status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
isFavorite = data.isFavourite, isFavorite = data.isFavourite,
maxEpisodes = data.episodes, maxEpisodes = data.episodes,
) )
} }
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId( return postDataAboutId(
id.toIntOrNull() ?: return false, id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status), fromIntToAnimeStatus(status.status.internalId),
status.score, status.score,
status.watchedEpisodes status.watchedEpisodes
).also { ).also {
@ -299,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
val shows = searchShows(name.replace(blackListRegex, "")) val shows = searchShows(name.replace(blackListRegex, ""))
shows?.data?.Page?.media?.find { shows?.data?.page?.media?.find {
(malId ?: "NONE") == it.idMal.toString() (malId ?: "NONE") == it.idMal.toString()
}?.let { return it } }?.let { return it }
val filtered = val filtered =
shows?.data?.Page?.media?.filter { shows?.data?.page?.media?.filter {
(((it.startDate.year ?: year.toString()) == year.toString() (((it.startDate.year ?: year.toString()) == year.toString()
|| year == null)) || year == null))
} }
@ -494,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q, true) val data = postApi(q, true)
val d = parseJson<GetDataRoot>(data ?: return null) val d = parseJson<GetDataRoot>(data ?: return null)
val main = d.data?.Media val main = d.data?.media
if (main?.mediaListEntry != null) { if (main?.mediaListEntry != null) {
return AniListTitleHolder( return AniListTitleHolder(
title = main.title, title = main.title,
@ -534,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
headers = mapOf( headers = mapOf(
"Authorization" to "Bearer " + (getAuth() "Authorization" to "Bearer " + (getAuth()
?: return@suspendSafeApiCall null), ?: 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, cacheTime = 0,
data = mapOf( data = mapOf(
@ -595,7 +598,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//@JsonProperty("source") val source: String, //@JsonProperty("source") val source: String,
@JsonProperty("episodes") val episodes: Int, @JsonProperty("episodes") val episodes: Int,
@JsonProperty("title") val title: Title, @JsonProperty("title") val title: Title,
//@JsonProperty("description") val description: String, @JsonProperty("description") val description: String?,
@JsonProperty("coverImage") val coverImage: CoverImage, @JsonProperty("coverImage") val coverImage: CoverImage,
@JsonProperty("synonyms") val synonyms: List<String>, @JsonProperty("synonyms") val synonyms: List<String>,
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
@ -629,7 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
?: this.media.coverImage.medium, ?: this.media.coverImage.medium,
null, null,
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( data class Data(
@JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
) )
private fun getAniListListCached(): Array<Lists>? { private fun getAniListListCached(): Array<Lists>? {
@ -656,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
if (checkToken()) return null if (checkToken()) return null
return if (requireLibraryRefresh) { return if (requireLibraryRefresh) {
val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray() val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) { if (list != null) {
setKey(ANILIST_CACHED_LIST, list) 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 // To fill empty lists when AniList does not return them
val baseMap = val baseMap =
AniListStatusType.values().filter { it.value >= 0 }.associate { AniListStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>() it.stringRes to emptyList<SyncAPI.LibraryItem>()
} }
@ -686,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ, ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew, ListSorting.UpdatedNew,
ListSorting.UpdatedOld, ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh, ListSorting.RatingHigh,
ListSorting.RatingLow, 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 */ /** 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 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) data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId( private suspend fun postDataAboutId(
@ -784,7 +791,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
""" """
val response = postApi(idQuery) val response = postApi(idQuery)
val listId = val listId =
tryParseJson<MediaListItemRoot>(response)?.data?.MediaList?.id ?: return false tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
""" """
mutation(${'$'}id: Int = $listId) { mutation(${'$'}id: Int = $listId) {
DeleteMediaListEntry(id: ${'$'}id) { DeleteMediaListEntry(id: ${'$'}id) {
@ -833,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q) val data = postApi(q)
if (data.isNullOrBlank()) return null if (data.isNullOrBlank()) return null
val userData = parseJson<AniListRoot>(data) val userData = parseJson<AniListRoot>(data)
val u = userData.data?.Viewer val u = userData.data?.viewer
val user = AniListUser( val user = AniListUser(
u?.id, u?.id,
u?.name, u?.name,
@ -855,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
suspend fun getSeasonRecursive(id: Int) { suspend fun getSeasonRecursive(id: Int) {
val season = getSeason(id) val season = getSeason(id)
seasons.add(season) seasons.add(season)
if (season.data.Media.format?.startsWith("TV") == true) { if (season.data.media.format?.startsWith("TV") == true) {
season.data.Media.relations?.edges?.forEach { season.data.media.relations?.edges?.forEach {
if (it.node?.format != null) { if (it.node?.format != null) {
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) { if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
getSeasonRecursive(it.node.id) getSeasonRecursive(it.node.id)
@ -875,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
data class SeasonData( data class SeasonData(
@JsonProperty("Media") val Media: SeasonMedia, @JsonProperty("Media") val media: SeasonMedia,
) )
data class SeasonMedia( data class SeasonMedia(
@ -1047,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
data class AniListData( data class AniListData(
@JsonProperty("Viewer") val Viewer: AniListViewer?, @JsonProperty("Viewer") val viewer: AniListViewer?,
) )
data class AniListRoot( data class AniListRoot(
@ -1087,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
data class LikeData( data class LikeData(
@JsonProperty("Viewer") val Viewer: LikeViewer?, @JsonProperty("Viewer") val viewer: LikeViewer?,
) )
data class LikeRoot( data class LikeRoot(
@ -1127,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
data class GetDataData( data class GetDataData(
@JsonProperty("Media") val Media: GetDataMedia?, @JsonProperty("Media") val media: GetDataMedia?,
) )
data class GetDataRoot( data class GetDataRoot(
@ -1160,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
data class GetSearchPage( data class GetSearchPage(
@JsonProperty("Page") val Page: GetSearchData?, @JsonProperty("Page") val page: GetSearchData?,
) )
data class GetSearchData( data class GetSearchData(

View file

@ -11,6 +11,7 @@ class Dropbox : OAuth2API {
override val key = "zlqsamadlwydvb2" override val key = "zlqsamadlwydvb2"
override val redirectUrl = "dropboxlogin" override val redirectUrl = "dropboxlogin"
override val requiresLogin = true override val requiresLogin = true
override val supportDeviceAuth = false
override val createAccountUrl: String? = null override val createAccountUrl: String? = null
override val icon: Int 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.WatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt 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.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
@ -18,6 +21,7 @@ class LocalList : SyncAPI {
override val name = "Local" override val name = "Local"
override val icon: Int = R.drawable.ic_baseline_storage_24 override val icon: Int = R.drawable.ic_baseline_storage_24
override val requiresLogin = false override val requiresLogin = false
override val supportDeviceAuth = false
override val createAccountUrl: Nothing? = null override val createAccountUrl: Nothing? = null
override val idPrefix = "local" override val idPrefix = "local"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
@ -45,11 +49,11 @@ class LocalList : SyncAPI {
override val mainUrl = "" override val mainUrl = ""
override val syncIdName = SyncIdName.LocalList override val syncIdName = SyncIdName.LocalList
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return true return true
} }
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
return null return null
} }
@ -69,31 +73,57 @@ class LocalList : SyncAPI {
}?.distinctBy { it.first } ?: return null }?.distinctBy { it.first } ?: return null
val list = ioWork { val list = ioWork {
watchStatusIds.groupBy { val isTrueTv = isLayout(TV)
it.second.stringRes
}.mapValues { group -> 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 { group.value.mapNotNull {
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString()) getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
} }
} + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
it.toLibraryItem()
})
} }
val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate { val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull {
// None is not something to display it.toLibraryItem()
it.stringRes to emptyList<SyncAPI.LibraryItem>() })
} + mapOf(R.string.subscription_list_name to emptyList())
// 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
}
return SyncAPI.LibraryMetadata( return SyncAPI.LibraryMetadata(
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
setOf( setOf(
ListSorting.AlphabeticalA, ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ, ListSorting.AlphabeticalZ,
// ListSorting.UpdatedNew, ListSorting.UpdatedNew,
// ListSorting.UpdatedOld, ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
// ListSorting.RatingHigh, // ListSorting.RatingHigh,
// ListSorting.RatingLow, // 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.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt 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.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import java.net.URL import java.net.URL
import java.security.SecureRandom import java.security.SecureRandom
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat 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 */ /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
const val MAL_MAX_SEARCH_LIMIT = 25 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" private val apiUrl = "https://api.myanimelist.net"
override val icon = R.drawable.mal_logo override val icon = R.drawable.mal_logo
override val requiresLogin = false override val requiresLogin = false
override val supportDeviceAuth = false
override val syncIdName = SyncIdName.MyAnimeList override val syncIdName = SyncIdName.MyAnimeList
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override val createAccountUrl = "$mainUrl/register.php" override val createAccountUrl = "$mainUrl/register.php"
@ -49,7 +56,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
} }
override fun loginInfo(): AuthAPI.LoginInfo? { override fun loginInfo(): AuthAPI.LoginInfo? {
//getMalUser(true)?
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user -> getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
return AuthAPI.LoginInfo( return AuthAPI.LoginInfo(
profilePicture = user.picture, profilePicture = user.picture,
@ -82,7 +88,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
this.name, this.name,
node.id.toString(), node.id.toString(),
"$mainUrl/anime/${node.id}/", "$mainUrl/anime/${node.id}/",
node.main_picture?.large ?: node.main_picture?.medium node.mainPicture?.large ?: node.mainPicture?.medium
) )
} }
} }
@ -91,10 +97,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
} }
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest( return setScoreRequest(
id.toIntOrNull() ?: return false, id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status), fromIntToAnimeStatus(status.status.internalId),
status.score, status.score,
status.watchedEpisodes status.watchedEpisodes
).also { ).also {
@ -176,7 +182,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private fun parseDate(string: String?): Long? { private fun parseDate(string: String?): Long? {
return try { 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) { } catch (e: Exception) {
null null
} }
@ -188,7 +194,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
apiName = this.name, apiName = this.name,
syncId = node.id.toString(), syncId = node.id.toString(),
url = "$mainUrl/anime/${node.id}", 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 internalId = id.toIntOrNull() ?: return null
val data = 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( return SyncAPI.SyncStatus(
score = data?.score, score = data?.score,
status = malStatusAsString.indexOf(data?.status), status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
isFavorite = null, 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? { private fun parseDateLong(string: String?): Long? {
return try { 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 string ?: return null
)?.time?.div(1000) )?.time?.div(1000)
} catch (e: Exception) { } catch (e: Exception) {
@ -300,7 +306,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean { override suspend fun handleRedirect(url: String): Boolean {
val sanitizer = 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"]!! val state = sanitizer["state"]!!
if (state == "RequestID$requestId") { if (state == "RequestID$requestId") {
val currentCode = sanitizer["code"]!! val currentCode = sanitizer["code"]!!
@ -349,9 +355,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
try { try {
if (response != "") { if (response != "") {
val token = parseJson<ResponseToken>(response) val token = parseJson<ResponseToken>(response)
setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime)) setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime))
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token) setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken)
setKey(accountId, MAL_TOKEN_KEY, token.access_token) setKey(accountId, MAL_TOKEN_KEY, token.accessToken)
requireLibraryRefresh = true requireLibraryRefresh = true
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -393,55 +399,62 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class Node( data class Node(
@JsonProperty("id") val id: Int, @JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String, @JsonProperty("title") val title: String,
@JsonProperty("main_picture") val main_picture: MainPicture?, @JsonProperty("main_picture") val mainPicture: MainPicture?,
@JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?, @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
@JsonProperty("media_type") val media_type: String?, @JsonProperty("media_type") val mediaType: String?,
@JsonProperty("num_episodes") val num_episodes: Int?, @JsonProperty("num_episodes") val numEpisodes: Int?,
@JsonProperty("status") val status: String?, @JsonProperty("status") val status: String?,
@JsonProperty("start_date") val start_date: String?, @JsonProperty("start_date") val startDate: String?,
@JsonProperty("end_date") val end_date: String?, @JsonProperty("end_date") val endDate: String?,
@JsonProperty("average_episode_duration") val average_episode_duration: Int?, @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
@JsonProperty("synopsis") val synopsis: String?, @JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("mean") val mean: Double?, @JsonProperty("mean") val mean: Double?,
@JsonProperty("genres") val genres: List<Genres>?, @JsonProperty("genres") val genres: List<Genres>?,
@JsonProperty("rank") val rank: Int?, @JsonProperty("rank") val rank: Int?,
@JsonProperty("popularity") val popularity: Int?, @JsonProperty("popularity") val popularity: Int?,
@JsonProperty("num_list_users") val num_list_users: Int?, @JsonProperty("num_list_users") val numListUsers: Int?,
@JsonProperty("num_favorites") val num_favorites: Int?, @JsonProperty("num_favorites") val numFavorites: Int?,
@JsonProperty("num_scoring_users") val num_scoring_users: Int?, @JsonProperty("num_scoring_users") val numScoringUsers: Int?,
@JsonProperty("start_season") val start_season: StartSeason?, @JsonProperty("start_season") val startSeason: StartSeason?,
@JsonProperty("broadcast") val broadcast: Broadcast?, @JsonProperty("broadcast") val broadcast: Broadcast?,
@JsonProperty("nsfw") val nsfw: String?, @JsonProperty("nsfw") val nsfw: String?,
@JsonProperty("created_at") val created_at: String?, @JsonProperty("created_at") val createdAt: String?,
@JsonProperty("updated_at") val updated_at: String? @JsonProperty("updated_at") val updatedAt: String?
) )
data class ListStatus( data class ListStatus(
@JsonProperty("status") val status: String?, @JsonProperty("status") val status: String?,
@JsonProperty("score") val score: Int, @JsonProperty("score") val score: Int,
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int, @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
@JsonProperty("is_rewatching") val is_rewatching: Boolean, @JsonProperty("is_rewatching") val isRewatching: Boolean,
@JsonProperty("updated_at") val updated_at: String, @JsonProperty("updated_at") val updatedAt: String,
) )
data class Data( data class Data(
@JsonProperty("node") val node: Node, @JsonProperty("node") val node: Node,
@JsonProperty("list_status") val list_status: ListStatus?, @JsonProperty("list_status") val listStatus: ListStatus?,
) { ) {
fun toLibraryItem(): SyncAPI.LibraryItem { fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem( return SyncAPI.LibraryItem(
this.node.title, this.node.title,
"https://myanimelist.net/anime/${this.node.id}/", "https://myanimelist.net/anime/${this.node.id}/",
this.node.id.toString(), this.node.id.toString(),
this.list_status?.num_episodes_watched, this.listStatus?.numEpisodesWatched,
this.node.num_episodes, this.node.numEpisodes,
this.list_status?.score?.times(10), this.listStatus?.score?.times(10),
parseDateLong(this.list_status?.updated_at), parseDateLong(this.listStatus?.updatedAt),
"MAL", "MAL",
TvType.Anime, TvType.Anime,
this.node.main_picture?.large ?: this.node.main_picture?.medium, this.node.mainPicture?.large ?: this.node.mainPicture?.medium,
null, null,
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( data class Broadcast(
@JsonProperty("day_of_the_week") val day_of_the_week: String?, @JsonProperty("day_of_the_week") val dayOfTheWeek: String?,
@JsonProperty("start_time") val start_time: String? @JsonProperty("start_time") val startTime: String?
) )
private fun getMalAnimeListCached(): Array<Data>? { private fun getMalAnimeListCached(): Array<Data>? {
@ -488,14 +501,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
val list = getMalAnimeListSmart()?.groupBy { val list = getMalAnimeListSmart()?.groupBy {
convertToStatus(it.list_status?.status ?: "").stringRes convertToStatus(it.listStatus?.status ?: "").stringRes
}?.mapValues { group -> }?.mapValues { group ->
group.value.map { it.toLibraryItem() } group.value.map { it.toLibraryItem() }
} ?: emptyMap() } ?: emptyMap()
// To fill empty lists when MAL does not return them // To fill empty lists when MAL does not return them
val baseMap = val baseMap =
MalStatusType.values().filter { it.value >= 0 }.associate { MalStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>() it.stringRes to emptyList<SyncAPI.LibraryItem>()
} }
@ -506,6 +519,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ, ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew, ListSorting.UpdatedNew,
ListSorting.UpdatedOld, ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh, ListSorting.RatingHigh,
ListSorting.RatingLow, ListSorting.RatingLow,
) )
@ -570,7 +585,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
).text ).text
val values = parseJson<MalRoot>(res) val values = parseJson<MalRoot>(res)
val titles = 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) { for (t in titles) {
allTitles[t.id] = t 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 // No time remaining if the show has already ended
try { try {
endDate?.let { 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) { } catch (e: ParseException) {
logError(e) logError(e)
@ -600,7 +617,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH) val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
val currentYear = currentDate.get(Calendar.YEAR) 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") dateFormat.timeZone = TimeZone.getTimeZone("Japan")
val parsedDate = val parsedDate =
dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
@ -644,13 +661,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
id: Int, id: Int,
status: MalStatusType? = null, status: MalStatusType? = null,
score: Int? = null, score: Int? = null,
num_watched_episodes: Int? = null, numWatchedEpisodes: Int? = null,
): Boolean { ): Boolean {
val res = setScoreRequest( val res = setScoreRequest(
id, id,
if (status == null) null else malStatusAsString[maxOf(0, status.value)], if (status == null) null else malStatusAsString[maxOf(0, status.value)],
score, score,
num_watched_episodes numWatchedEpisodes
) )
return if (res.isNullOrBlank()) { return if (res.isNullOrBlank()) {
@ -667,17 +684,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
} }
} }
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest( private suspend fun setScoreRequest(
id: Int, id: Int,
status: String? = null, status: String? = null,
score: Int? = null, score: Int? = null,
num_watched_episodes: Int? = null, numWatchedEpisodes: Int? = null,
): String? { ): String? {
val data = mapOf( val data = mapOf(
"status" to status, "status" to status,
"score" to score?.toString(), "score" to score?.toString(),
"num_watched_episodes" to num_watched_episodes?.toString() "num_watched_episodes" to numWatchedEpisodes?.toString()
).filter { it.value != null } as Map<String, String> ).filterValues { it != null } as Map<String, String>
return app.put( return app.put(
"$apiUrl/v2/anime/$id/my_list_status", "$apiUrl/v2/anime/$id/my_list_status",
@ -690,10 +708,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class ResponseToken( data class ResponseToken(
@JsonProperty("token_type") val token_type: String, @JsonProperty("token_type") val tokenType: String,
@JsonProperty("expires_in") val expires_in: Int, @JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("access_token") val access_token: String, @JsonProperty("access_token") val accessToken: String,
@JsonProperty("refresh_token") val refresh_token: String, @JsonProperty("refresh_token") val refreshToken: String,
) )
data class MalRoot( data class MalRoot(
@ -702,7 +720,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class MalDatum( data class MalDatum(
@JsonProperty("node") val node: MalNode, @JsonProperty("node") val node: MalNode,
@JsonProperty("list_status") val list_status: MalStatus, @JsonProperty("list_status") val listStatus: MalStatus,
) )
data class MalNode( data class MalNode(
@ -719,16 +737,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class MalStatus( data class MalStatus(
@JsonProperty("status") val status: String, @JsonProperty("status") val status: String,
@JsonProperty("score") val score: Int, @JsonProperty("score") val score: Int,
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int, @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
@JsonProperty("is_rewatching") val is_rewatching: Boolean, @JsonProperty("is_rewatching") val isRewatching: Boolean,
@JsonProperty("updated_at") val updated_at: String, @JsonProperty("updated_at") val updatedAt: String,
) )
data class MalUser( data class MalUser(
@JsonProperty("id") val id: Int, @JsonProperty("id") val id: Int,
@JsonProperty("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("location") val location: String, @JsonProperty("location") val location: String,
@JsonProperty("joined_at") val joined_at: String, @JsonProperty("joined_at") val joinedAt: String,
@JsonProperty("picture") val picture: String?, @JsonProperty("picture") val picture: String?,
) )
@ -741,9 +759,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class SmallMalAnime( data class SmallMalAnime(
@JsonProperty("id") val id: Int, @JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String?, @JsonProperty("title") val title: String?,
@JsonProperty("num_episodes") val num_episodes: Int, @JsonProperty("num_episodes") val numEpisodes: Int,
@JsonProperty("my_list_status") val my_list_status: MalStatus?, @JsonProperty("my_list_status") val myListStatus: MalStatus?,
@JsonProperty("main_picture") val main_picture: MalMainPicture?, @JsonProperty("main_picture") val mainPicture: MalMainPicture?,
) )
data class MalSearchNode( data class MalSearchNode(

View file

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

File diff suppressed because it is too large Load diff

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 package com.lagradost.cloudstream3.ui
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.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.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.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope.coroutineContext import kotlinx.coroutines.GlobalScope.coroutineContext
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -42,7 +50,7 @@ class APIRepository(val api: MainAPI) {
private val cache = threadSafeListOf<SavedLoadResponse>() private val cache = threadSafeListOf<SavedLoadResponse>()
private var cacheIndex: Int = 0 private var cacheIndex: Int = 0
const val cacheSize = 20 const val CACHE_SIZE = 20
} }
private fun afterPluginsLoaded(forceReload: Boolean) { private fun afterPluginsLoaded(forceReload: Boolean) {
@ -86,9 +94,9 @@ class APIRepository(val api: MainAPI) {
val add = SavedLoadResponse(unixTime, response, lookingForHash) val add = SavedLoadResponse(unixTime, response, lookingForHash)
synchronized(cache) { synchronized(cache) {
if (cache.size > cacheSize) { if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache cache[cacheIndex] = add // rolling cache
cacheIndex = (cacheIndex + 1) % cacheSize cacheIndex = (cacheIndex + 1) % CACHE_SIZE
} else { } else {
cache.add(add) cache.add(add)
} }
@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) {
data: String, data: String,
isCasting: Boolean, isCasting: Boolean,
subtitleCallback: (SubtitleFile) -> Unit, subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit callback: (ExtractorLink) -> Unit,
): Boolean { ): Boolean {
if (isInvalidData(data)) return false // this makes providers cleaner if (isInvalidData(data)) return false // this makes providers cleaner
return try { 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.view.View.*
import android.widget.* import android.widget.*
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.media3.common.util.UnstableApi
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper 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.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaSeekOptions
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF 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.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.sortSubs
import com.lagradost.cloudstream3.sortUrls 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.RepoLinkGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment 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.AppUtils.toJson
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
@ -97,7 +99,7 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() { UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init { init {
@ -262,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
var isLoadingMore = false var isLoadingMore = false
override fun onMediaStatusUpdated() { override fun onMediaStatusUpdated() {
super.onMediaStatusUpdated() super.onMediaStatusUpdated()
val meta = getCurrentMetaData() val meta = getCurrentMetaData()
@ -294,7 +297,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val generator = RepoLinkGenerator(listOf(epData)) val generator = RepoLinkGenerator(listOf(epData))
val isSuccessful = safeApiCall { val isSuccessful = safeApiCall {
generator.generateLinks(clearCache = false, isCasting = true, generator.generateLinks(
clearCache = false, type = LoadType.Chromecast,
callback = { callback = {
it.first?.let { link -> it.first?.let { link ->
currentLinks.add(link) currentLinks.add(link)

View file

@ -3,12 +3,13 @@ package com.lagradost.cloudstream3.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs import kotlin.math.abs
class GrdLayoutManager(val context: Context, _spanCount: Int) : class GrdLayoutManager(val context: Context, spanCount: Int) :
GridLayoutManager(context, _spanCount) { GridLayoutManager(context, spanCount) {
override fun onFocusSearchFailed( override fun onFocusSearchFailed(
focused: View, focused: View,
focusDirection: Int, focusDirection: Int,
@ -70,8 +71,8 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
val orientation = this.orientation val orientation = this.orientation
// fixes arabic by inverting left and right layout focus // fixes arabic by inverting left and right layout focus
val correctDirection = if(this.isLayoutRTL) { val correctDirection = if (this.isLayoutRTL) {
when(direction) { when (direction) {
View.FOCUS_RIGHT -> View.FOCUS_LEFT View.FOCUS_RIGHT -> View.FOCUS_LEFT
View.FOCUS_LEFT -> View.FOCUS_RIGHT View.FOCUS_LEFT -> View.FOCUS_RIGHT
else -> direction else -> direction
@ -83,12 +84,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
View.FOCUS_DOWN -> { View.FOCUS_DOWN -> {
return spanCount return spanCount
} }
View.FOCUS_UP -> { View.FOCUS_UP -> {
return -spanCount return -spanCount
} }
View.FOCUS_RIGHT -> { View.FOCUS_RIGHT -> {
return 1 return 1
} }
View.FOCUS_LEFT -> { View.FOCUS_LEFT -> {
return -1 return -1
} }
@ -98,12 +102,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
View.FOCUS_DOWN -> { View.FOCUS_DOWN -> {
return 1 return 1
} }
View.FOCUS_UP -> { View.FOCUS_UP -> {
return -1 return -1
} }
View.FOCUS_RIGHT -> { View.FOCUS_RIGHT -> {
return spanCount return spanCount
} }
View.FOCUS_LEFT -> { View.FOCUS_LEFT -> {
return -spanCount return -spanCount
} }
@ -156,3 +163,31 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
layoutManager = manager 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) FrameLayout.LayoutParams.WRAP_CONTENT)
binding.frame.addView(newStar) binding.frame.addView(newStar)
newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX newStar.scaleX += Math.random().toFloat() * 1.5f
newStar.scaleY = newStar.scaleX newStar.scaleY = newStar.scaleX
starW *= newStar.scaleX starW *= newStar.scaleX
starH *= newStar.scaleY 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); NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24);
companion object { 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.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : Fragment() { class WebviewFragment : Fragment() {
@ -29,6 +31,7 @@ class WebviewFragment : Fragment() {
} }
binding?.webView?.webViewClient = object : WebViewClient() { binding?.webView?.webViewClient = object : WebViewClient() {
@OptIn(UnstableApi::class)
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?

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 package com.lagradost.cloudstream3.ui.download
import android.app.Activity
import android.content.DialogInterface import android.content.DialogInterface
import android.widget.Toast import android.net.Uri
import androidx.appcompat.app.AlertDialog 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.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator 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.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus 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.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.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.MainScope
object DownloadButtonSetup { object DownloadButtonSetup {
fun handleDownloadClick(click: DownloadClickEvent) { fun handleDownloadClick(click: DownloadClickEvent) {
val id = click.data.id val id = click.data.id
if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
when (click.action) { when (click.action) {
DOWNLOAD_ACTION_DELETE_FILE -> { DOWNLOAD_ACTION_DELETE_FILE -> {
activity?.let { ctx -> activity?.let { ctx ->
@ -31,9 +33,15 @@ object DownloadButtonSetup {
DialogInterface.OnClickListener { _, which -> DialogInterface.OnClickListener { _, which ->
when (which) { when (which) {
DialogInterface.BUTTON_POSITIVE -> { DialogInterface.BUTTON_POSITIVE -> {
VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id) VideoDownloadManager.deleteFilesAndUpdateSettings(
ctx,
setOf(id),
MainScope()
)
} }
DialogInterface.BUTTON_NEGATIVE -> { DialogInterface.BUTTON_NEGATIVE -> {
// Do nothing on cancel
} }
} }
} }
@ -58,11 +66,13 @@ object DownloadButtonSetup {
} }
} }
} }
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> { DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
VideoDownloadManager.downloadEvent.invoke( VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause) Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
) )
} }
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> { DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
activity?.let { ctx -> activity?.let { ctx ->
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) { if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
@ -81,6 +91,7 @@ object DownloadButtonSetup {
} }
} }
} }
DOWNLOAD_ACTION_LONG_CLICK -> { DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act -> activity?.let { act ->
val length = val length =
@ -90,65 +101,81 @@ object DownloadButtonSetup {
)?.fileLength )?.fileLength
?: 0 ?: 0
if (length > 0) { if (length > 0) {
showToast(R.string.delete, Toast.LENGTH_LONG) showSnackbar(
} else { act,
showToast(R.string.download, Toast.LENGTH_LONG) R.string.offline_file,
Snackbar.LENGTH_LONG
)
} }
} }
} }
DOWNLOAD_ACTION_PLAY_FILE -> { DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act -> 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>( val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString() click.data.parentId.toString()
) ?: return ) ?: return
act.navigate( val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ?.mapNotNull {
DownloadFileGenerator( getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
listOf( }
ExtractorUri( ?.filter { it.parentId == click.data.parentId }
uri = info.path,
id = click.data.id, val currentSeason = click.data.season ?: 0
parentId = click.data.parentId, val currentEpisode = click.data.episode
name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName
season = click.data.season, val items = mutableListOf<ExtractorUri>()
episode = click.data.episode,
// 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, headerName = parent.name,
tvType = parent.type, tvType = parent.type,
basePath = keyInfo.basePath, basePath = keyInfo.basePath,
displayName = keyInfo.displayName, displayName = keyInfo.displayName,
relativePath = keyInfo.relativePath, relativePath = keyInfo.relativePath,
) )
) )
}
act.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(items)
) )
) )
//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
//)
)
} }
} }
} }

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: EasyDownloadButton.IMinimumData)
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,25 +1,33 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.ui.result.setLinearListLayout
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.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadChildFragment : Fragment() { class DownloadChildFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentChildDownloadsBinding? = null
companion object { companion object {
fun newInstance(headerName: String, folder: String): Bundle { fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply { return Bundle().apply {
@ -30,88 +38,170 @@ class DownloadChildFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } detachBackPressedCallback()
binding = null binding = null
super.onDestroyView() super.onDestroyView()
} }
var binding: FragmentChildDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
val folder = arguments?.getString("folder") val folder = arguments?.getString("folder")
val name = arguments?.getString("name") val name = arguments?.getString("name")
if (folder == null) { if (folder == null) {
activity?.onBackPressed() // TODO FIX activity?.onBackPressedDispatcher?.onBackPressed()
return return
} }
fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply { binding?.downloadChildToolbar?.apply {
title = name title = name
if (isLayout(PHONE or EMULATOR)) {
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener { setNavigationOnClickListener {
activity?.onBackPressed() activity?.onBackPressedDispatcher?.onBackPressed()
}
}
setAppBarNoScrollFlagsOnTV()
}
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.childCards) {
if (it.isEmpty()) {
activity?.onBackPressedDispatcher?.onBackPressed()
return@observe
}
(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 {
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> = downloadsViewModel.setIsMultiDeleteState(false)
DownloadChildAdapter(
ArrayList(),
) { click ->
handleDownloadClick(click)
} }
downloadDeleteEventListener = { id: Int -> binding?.btnToggleAll?.setOnClickListener {
val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList val allSelected = downloadsViewModel.isAllSelected()
if (list != null) { val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
if (list.any { it.data.id == id }) { if (allSelected) {
updateList(folder) adapter?.notifySelectionStates()
} downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
} }
} }
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } downloadsViewModel.setIsMultiDeleteState(true)
}
}
binding?.downloadChildList?.adapter = adapter private fun updateDeleteButton(count: Int, selectedBytes: Long) {
binding?.downloadChildList?.layoutManager = GridLayoutManager(context, 1) val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =
updateList(folder) getString(R.string.delete_format).format(count, formattedSize)
} }
} }

View file

@ -1,53 +1,62 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build import android.os.Build
import android.os.Bundle 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast 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.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.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.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick 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.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.player.LinkGenerator
import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.utils.Coroutines.main 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.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.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
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.settings.SettingsFragment.Companion.isTrueTvSettings
import java.net.URI import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage" const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() { class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentDownloadsBinding? = null
private fun View.setLayoutWidth(weight: Long) { private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams( val param = LinearLayout.LayoutParams(
@ -58,173 +67,277 @@ class DownloadFragment : Fragment() {
this.layoutParams = param this.layoutParams = param
} }
private fun setList(list: List<VisualDownloadHeaderCached>) {
main {
(binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list
binding?.downloadList?.adapter?.notifyDataSetChanged()
}
}
override fun onDestroyView() { override fun onDestroyView() {
if (downloadDeleteEventListener != null) { detachBackPressedCallback()
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
downloadDeleteEventListener = null
}
binding = null binding = null
super.onDestroyView() super.onDestroyView()
} }
var binding : FragmentDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
downloadsViewModel = downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
hideKeyboard() 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) { observe(downloadsViewModel.headerCards) {
setList(it) (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding?.downloadLoading?.isVisible = false binding?.downloadLoading?.isVisible = false
binding?.textNoDownloads?.isVisible = it.isEmpty()
} }
observe(downloadsViewModel.availableBytes) { observe(downloadsViewModel.availableBytes) {
binding?.downloadFreeTxt?.text = updateStorageInfo(
getString(R.string.storage_size_format).format( view.context,
getString(R.string.free_storage), it,
formatShortFileSize(view.context, it) R.string.free_storage,
binding?.downloadFreeTxt,
binding?.downloadFree
) )
binding?.downloadFree?.setLayoutWidth(it)
} }
observe(downloadsViewModel.usedBytes) { observe(downloadsViewModel.usedBytes) {
binding?.apply { updateStorageInfo(
downloadUsedTxt.text = view.context,
getString(R.string.storage_size_format).format( it,
getString(R.string.used_storage), R.string.used_storage,
formatShortFileSize(view.context, it) binding?.downloadUsedTxt,
binding?.downloadUsed
) )
downloadUsed.setLayoutWidth(it)
downloadStorageAppbar.isVisible = it > 0 // 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) { observe(downloadsViewModel.downloadBytes) {
binding?.apply { updateStorageInfo(
downloadAppTxt.text = view.context,
getString(R.string.storage_size_format).format( it,
getString(R.string.app_storage), R.string.app_storage,
formatShortFileSize(view.context, it) binding?.downloadAppTxt,
binding?.downloadApp
) )
downloadApp.setLayoutWidth(it) }
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)
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,
nextDown = FOCUS_SELF,
)
}
binding?.apply {
openLocalVideoButton.apply {
isGone = isLayout(TV)
setOnClickListener { openLocalVideo() }
}
downloadStreamButton.apply {
isGone = isLayout(TV)
setOnClickListener { showStreamInputDialog(it.context) }
} }
} }
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
DownloadHeaderAdapter( binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
ArrayList(), handleScroll(scrollY - oldScrollY)
{ click -> }
}
context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot)
}
private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) { when (click.action) {
0 -> { DOWNLOAD_ACTION_GO_TO_CHILD -> {
if (click.data.type.isMovieType()) { if (click.data.type.isEpisodeBased()) {
//wont be called val folder =
} else { getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
val folder = DataStore.getFolderName(
DOWNLOAD_EPISODE_CACHE,
click.data.id.toString()
)
activity?.navigate( activity?.navigate(
R.id.action_navigation_downloads_to_navigation_download_child, R.id.action_navigation_downloads_to_navigation_download_child,
DownloadChildFragment.newInstance(click.data.name, folder) DownloadChildFragment.newInstance(click.data.name, folder)
) )
} }
} }
1 -> {
(activity as AppCompatActivity?)?.loadResult( DOWNLOAD_ACTION_LOAD_RESULT -> {
click.data.url, activity?.loadResult(click.data.url, click.data.apiName)
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) {
{ downloadClickEvent -> val dialog = Dialog(context, R.style.AlertDialogCustom)
if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.updateList(ctx)
}
}
}
)
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)
}
}
}
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
binding?.downloadList?.apply {
this.adapter = adapter
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) val binding = StreamInputBinding.inflate(dialog.layoutInflater)
dialog.setContentView(binding.root) dialog.setContentView(binding.root)
dialog.show() dialog.show()
// If user has clicked the switch do not interfere
var preventAutoSwitching = false var preventAutoSwitching = false
binding.hlsSwitch.setOnClickListener { binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true }
preventAutoSwitching = true
}
fun activateSwitchOnHls(text: String?) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
}
binding.streamReferer.doOnTextChanged { text, _, _, _ -> binding.streamReferer.doOnTextChanged { text, _, _, _ ->
if (!preventAutoSwitching) if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
activateSwitchOnHls(text?.toString())
} }
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
0 0
)?.text?.toString()?.let { copy -> )?.text?.toString()?.let { copy ->
val fixedText = copy.trim() val fixedText = copy.trim()
binding.streamUrl.setText(fixedText) binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText) activateSwitchOnHls(fixedText, binding)
} }
binding.applyBtt.setOnClickListener { binding.applyBtt.setOnClickListener {
@ -233,7 +346,6 @@ class DownloadFragment : Fragment() {
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
} else { } else {
val referer = binding.streamReferer.text?.toString() val referer = binding.streamReferer.text?.toString()
activity?.navigate( activity?.navigate(
R.id.global_to_navigation_player, R.id.global_to_navigation_player,
GeneratorPlayer.newInstance( GeneratorPlayer.newInstance(
@ -245,7 +357,6 @@ class DownloadFragment : Fragment() {
) )
) )
) )
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
} }
} }
@ -254,18 +365,27 @@ class DownloadFragment : Fragment() {
dialog.dismissSafe(activity) 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) 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 package com.lagradost.cloudstream3.ui.download
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.VideoDownloadHelper 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.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() { class DownloadViewModel : ViewModel() {
private val _noDownloadsText = MutableLiveData<String>().apply {
value = ""
}
val noDownloadsText: LiveData<String> = _noDownloadsText
private val _headerCards = private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() } val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
private val _usedBytes = MutableLiveData<Long>() private val _usedBytes = MutableLiveData<Long>()
private val _availableBytes = MutableLiveData<Long>()
private val _downloadBytes = MutableLiveData<Long>()
val usedBytes: LiveData<Long> = _usedBytes val usedBytes: LiveData<Long> = _usedBytes
private val _availableBytes = MutableLiveData<Long>()
val availableBytes: LiveData<Long> = _availableBytes val availableBytes: LiveData<Long> = _availableBytes
private val _downloadBytes = MutableLiveData<Long>()
val downloadBytes: LiveData<Long> = _downloadBytes val downloadBytes: LiveData<Long> = _downloadBytes
fun updateList(context: Context) = viewModelScope.launchSafe { private val _selectedBytes = MutableLiveData<Long>(0)
val children = withContext(Dispatchers.IO) { val selectedBytes: LiveData<Long> = _selectedBytes
val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE)
headers.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) } private val _isMultiDeleteState = MutableLiveData(false)
.distinctBy { it.id } // Remove duplicates val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
private var previousVisual: List<VisualDownloadCached>? = null
fun setIsMultiDeleteState(value: Boolean) {
_isMultiDeleteState.postValue(value)
} }
fun 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)
}
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) {
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 // parentId : bytes
val totalBytesUsedByChild = HashMap<Int, Long>() val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes // parentId : bytes
val currentBytesUsedByChild = HashMap<Int, Long>() val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount // parentId : downloadsCount
val totalDownloads = HashMap<Int, Int>() val totalDownloads = mutableMapOf<Int, Int>()
children.forEach { child ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
if (childFile.fileLength <= 1) return@forEach
// Gets all children downloads
withContext(Dispatchers.IO) {
for (c in children) {
val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue
if (childFile.fileLength <= 1) continue
val len = childFile.totalBytes val len = childFile.totalBytes
val flen = childFile.fileLength val flen = childFile.fileLength
totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len totalBytesUsedByChild.merge(child.parentId, len, Long::plus)
currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1 totalDownloads.merge(child.parentId, 1, Int::plus)
} }
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
} }
val cached = withContext(Dispatchers.IO) { // wont fetch useless keys private fun createVisualDownloadList(
totalDownloads.entries.filter { it.value > 0 }.mapNotNull { context: Context,
context.getKey<VideoDownloadHelper.DownloadHeaderCached>( cached: List<VideoDownloadHelper.DownloadHeaderCached>,
DOWNLOAD_HEADER_CACHE, totalBytesUsedByChild: Map<Int, Long>,
it.key.toString() currentBytesUsedByChild: Map<Int, Long>,
) totalDownloads: Map<Int, Int>
} ): List<VisualDownloadCached.Header> {
} return cached.mapNotNull {
val visual = withContext(Dispatchers.IO) {
cached.mapNotNull { // TODO FIX
val downloads = totalDownloads[it.id] ?: 0 val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null if (bytes <= 0 || downloads <= 0) return@mapNotNull null
val movieEpisode =
if (!it.type.isMovieType()) null val isSelected = selectedItemIds.value?.contains(it.id) ?: false
else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>( val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE, DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString()) getFolderName(it.id.toString(), it.id.toString())
) )
VisualDownloadHeaderCached(
0, VisualDownloadCached.Header(
downloads, currentBytes = currentBytes,
bytes, totalBytes = bytes,
currentBytes, data = it,
it, child = movieEpisode,
movieEpisode currentOngoingDownloads = 0,
totalDownloads = downloads,
isSelected = isSelected,
) )
}.sortedBy { // Prevent order being almost completely random,
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0) // making things difficult to find.
} // episode sorting by episode, lowest to highest }.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 { try {
val stat = StatFs(Environment.getExternalStorageDirectory().path) val stat = StatFs(Environment.getExternalStorageDirectory().path)
val localBytesAvailable = stat.availableBytes
val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
val localDownloadedBytes = visual.sumOf { it.totalBytes } val localDownloadedBytes = visual.sumOf { it.totalBytes }
val localUsedBytes = localTotalBytes - localBytesAvailable
_usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes) _usedBytes.postValue(localUsedBytes)
_availableBytes.postValue(localBytesAvailable) _availableBytes.postValue(localBytesAvailable)
_downloadBytes.postValue(localDownloadedBytes) _downloadBytes.postValue(localDownloadedBytes)
} catch (t : Throwable) { } catch (t: Throwable) {
_downloadBytes.postValue(0) _downloadBytes.postValue(0)
logError(t) 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,264 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.animation.ObjectAnimator
import android.text.format.Formatter.formatShortFileSize
import android.view.View
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines
import com.lagradost.cloudstream3.utils.IDisposable
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.VideoDownloadManager
class EasyDownloadButton : IDisposable {
interface IMinimumData {
val id: Int
}
private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null
private var _imageChangeCallback: ((Pair<Int, String>) -> Unit)? = null
override fun dispose() {
try {
_clickCallback = null
_imageChangeCallback = null
downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it }
downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it }
} catch (e: Exception) {
e.printStackTrace()
}
}
private var downloadProgressEventListener: ((Triple<Int, Long, Long>) -> Unit)? = null
private var downloadStatusEventListener: ((Pair<Int, VideoDownloadManager.DownloadType>) -> Unit)? =
null
fun setUpMaterialButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
downloadButton: MaterialButton,
textView: TextView?,
data: IMinimumData,
clickCallback: (DownloadClickEvent) -> Unit,
) {
setUpDownloadButton(
setupCurrentBytes,
setupTotalBytes,
progressBar,
textView,
data,
downloadButton,
{
downloadButton.setIconResource(it.first)
downloadButton.text = it.second
},
clickCallback
)
}
fun setUpMoreButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
downloadImage: ImageView,
textView: TextView?,
textViewProgress: TextView?,
clickableView: View,
isTextPercentage: Boolean,
data: IMinimumData,
clickCallback: (DownloadClickEvent) -> Unit,
) {
setUpDownloadButton(
setupCurrentBytes,
setupTotalBytes,
progressBar,
textViewProgress,
data,
clickableView,
{ (image, text) ->
downloadImage.isVisible = textViewProgress?.isGone ?: true
downloadImage.setImageResource(image)
textView?.text = text
},
clickCallback, isTextPercentage
)
}
fun setUpButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
downloadImage: ImageView,
textView: TextView?,
data: IMinimumData,
clickCallback: (DownloadClickEvent) -> Unit,
) {
setUpDownloadButton(
setupCurrentBytes,
setupTotalBytes,
progressBar,
textView,
data,
downloadImage,
{
downloadImage.setImageResource(it.first)
},
clickCallback
)
}
private fun setUpDownloadButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
textView: TextView?,
data: IMinimumData,
downloadView: View,
downloadImageChangeCallback: (Pair<Int, String>) -> Unit,
clickCallback: (DownloadClickEvent) -> Unit,
isTextPercentage: Boolean = false
) {
_clickCallback = clickCallback
_imageChangeCallback = downloadImageChangeCallback
var lastState: VideoDownloadManager.DownloadType? = null
var currentBytes = setupCurrentBytes ?: 0
var totalBytes = setupTotalBytes ?: 0
var needImageUpdate = true
fun changeDownloadImage(state: VideoDownloadManager.DownloadType) {
lastState = state
if (currentBytes <= 0) needImageUpdate = true
val img = if (currentBytes > 0) {
when (state) {
VideoDownloadManager.DownloadType.IsPaused -> Pair(
R.drawable.ic_baseline_play_arrow_24,
R.string.download_paused
)
VideoDownloadManager.DownloadType.IsDownloading -> Pair(
R.drawable.netflix_pause,
R.string.downloading
)
else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded)
}
} else {
Pair(R.drawable.netflix_download, R.string.download)
}
_imageChangeCallback?.invoke(
Pair(
img.first,
downloadView.context.getString(img.second)
)
)
}
fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) {
currentBytes = setCurrentBytes
totalBytes = setTotalBytes
if (currentBytes == 0L) {
changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped)
textView?.visibility = View.GONE
progressBar.visibility = View.GONE
} else {
if (lastState == VideoDownloadManager.DownloadType.IsStopped) {
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
}
textView?.visibility = View.VISIBLE
progressBar.visibility = View.VISIBLE
val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes)
val totalMbString = formatShortFileSize(textView?.context, setTotalBytes)
textView?.text =
if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
textView?.context?.getString(R.string.download_size_format)
?.format(currentMbString, totalMbString)
progressBar.let { bar ->
bar.max = (setTotalBytes / 1000).toInt()
if (animate) {
val animation: ObjectAnimator = ObjectAnimator.ofInt(
bar,
"progress",
bar.progress,
(setCurrentBytes / 1000).toInt()
)
animation.duration = 500
animation.setAutoCancel(true)
animation.interpolator = DecelerateInterpolator()
animation.start()
} else {
bar.progress = (setCurrentBytes / 1000).toInt()
}
}
}
}
fixDownloadedBytes(currentBytes, totalBytes, false)
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
downloadProgressEventListener = { downloadData: Triple<Int, Long, Long> ->
if (data.id == downloadData.first) {
if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME
Coroutines.runOnMainThread {
fixDownloadedBytes(downloadData.second, downloadData.third, true)
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
}
}
}
}
downloadStatusEventListener =
{ downloadData: Pair<Int, VideoDownloadManager.DownloadType> ->
if (data.id == downloadData.first) {
if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME
Coroutines.runOnMainThread {
changeDownloadImage(downloadData.second)
}
}
}
}
downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it }
downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it }
downloadView.setOnClickListener {
if (currentBytes <= 0 || totalBytes <= 0) {
_clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
} else {
val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file),
)
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
if ((currentBytes * 100 / totalBytes) < 98) {
list.add(
if (lastState == VideoDownloadManager.DownloadType.IsDownloading)
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
else
Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download)
)
}
it.popupMenuNoIcons(
list
) {
_clickCallback?.invoke(DownloadClickEvent(itemId, data))
}
}
}
downloadView.setOnLongClickListener {
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
return@setOnLongClickListener true
}
}
}

View file

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

View file

@ -13,7 +13,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper
class DownloadButton(context: Context, attributeSet: AttributeSet) : class DownloadButton(context: Context, attributeSet: AttributeSet) :
PieFetchButton(context, attributeSet) { PieFetchButton(context, attributeSet) {
var mainText: TextView? = null private var mainText: TextView? = null
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
progressText = findViewById(R.id.result_movie_download_text_precentage) progressText = findViewById(R.id.result_movie_download_text_precentage)
@ -21,7 +21,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
} }
override fun setStatus(status: DownloadStatusTell?) { override fun setStatus(status: DownloadStatusTell?) {
super.setStatus(status) mainText?.post {
val txt = when (status) { val txt = when (status) {
DownloadStatusTell.IsPaused -> R.string.download_paused DownloadStatusTell.IsPaused -> R.string.download_paused
DownloadStatusTell.IsDownloading -> R.string.downloading DownloadStatusTell.IsDownloading -> R.string.downloading
@ -30,6 +30,9 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
} }
mainText?.setText(txt) mainText?.setText(txt)
} }
super.setStatus(status)
}
override fun setDefaultClickListener( override fun setDefaultClickListener(
card: VideoDownloadHelper.DownloadEpisodeCached, card: VideoDownloadHelper.DownloadEpisodeCached,

View file

@ -1,17 +1,20 @@
package com.lagradost.cloudstream3.ui.download.button package com.lagradost.cloudstream3.ui.download.button
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.MainThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
@ -22,7 +25,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) { BaseFetchButton(context, attributeSet) {
@ -41,6 +44,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
private var iconPaused: Int = 0 private var iconPaused: Int = 0
private var hideWhenIcon: Boolean = true private var hideWhenIcon: Boolean = true
var progressDrawable: Int = 0
var overrideLayout: Int? = null var overrideLayout: Int? = null
companion object { companion object {
@ -53,7 +58,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
} }
private var progressBarBackground: View private var progressBarBackground: View
private var statusView: ImageView var statusView: ImageView
open fun onInflate() {} 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 R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done
) )
iconPaused = getResourceId( 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( 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( iconWaiting = getResourceId(
R.styleable.PieFetchButton_download_icon_waiting, 0 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 fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
val progressDrawable = getResourceId( progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
) )
@ -164,8 +169,9 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
this.setPersistentId(card.id) this.setPersistentId(card.id)
view.setOnClickListener { view.setOnClickListener {
if (isZeroBytes) { if (isZeroBytes) {
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
//callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
} else { } else {
val list = arrayListOf( val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
@ -174,7 +180,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
currentMetaData.apply { currentMetaData.apply {
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
if ((downloadedLength * 100 / totalLength) < 98) { if (progressPercentage < 98) {
list.add( list.add(
if (status == VideoDownloadManager.DownloadType.IsDownloading) if (status == VideoDownloadManager.DownloadType.IsDownloading)
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
@ -192,7 +198,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
list list
) { ) {
callback(DownloadClickEvent(itemId, card)) 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 { view.setOnLongClickListener {
callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card)) 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 return@setOnLongClickListener true
} }
} }
@ -213,7 +219,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
setDefaultClickListener(this, textView, card, callback) setDefaultClickListener(this, textView, card, callback)
} }
/*open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List<UriRequest>) { /* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List<UriRequest>) {
this.setOnClickListener { this.setOnClickListener {
when (this.currentStatus) { when (this.currentStatus) {
null -> { null -> {
@ -239,17 +245,11 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
else -> {} else -> {}
} }
} }
}*/ } */
/** Also sets currentStatus */ @MainThread
override fun setStatus(status: DownloadStatusTell?) { private fun setStatusInternal(status: DownloadStatusTell?) {
currentStatus = status
//progressBar.isVisible =
// status != null && status != DownloadStatusTell.Complete && status != DownloadStatusTell.Error
//progressBarBackground.isVisible = status != null && status != DownloadStatusTell.Complete
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
val animation = AnimationUtils.loadAnimation(context, waitingAnimation) val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
progressBarBackground.startAnimation(animation) progressBarBackground.startAnimation(animation)
@ -263,7 +263,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
progressBarBackground.background = progressBarBackground.background =
ContextCompat.getDrawable(context, progressDrawable) ContextCompat.getDrawable(context, progressDrawable)
val drawable = getDrawableFromStatus(status) val drawable =
getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) }
statusView.setImageDrawable(drawable) statusView.setImageDrawable(drawable)
val isDrawable = drawable != null val isDrawable = drawable != null
@ -277,10 +278,32 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
progressBar.isGone = hide progressBar.isGone = hide
} }
/** Also sets currentStatus */
override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status
// 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)
}
}
} else {
progressBarBackground.post {
setStatusInternal(status)
}
}
}
override fun resetView() { override fun resetView() {
setStatus(null) setStatus(null)
currentMetaData = DownloadMetadata(0, 0, 0, null) currentMetaData = DownloadMetadata(0, 0, 0, null)
isZeroBytes = true isZeroBytes = true
doSetProgress = true
progressBar.progress = 0 progressBar.progress = 0
} }
@ -304,19 +327,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
} }
} }
open fun getDrawableFromStatus(status: DownloadStatusTell?): Drawable? { open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) {
val drawableInt = when (status) {
DownloadStatusTell.IsPaused -> iconPaused DownloadStatusTell.IsPaused -> iconPaused
DownloadStatusTell.IsPending -> iconWaiting DownloadStatusTell.IsPending -> iconWaiting
DownloadStatusTell.IsDownloading -> iconActive DownloadStatusTell.IsDownloading -> iconActive
DownloadStatusTell.IsFailed -> iconError DownloadStatusTell.IsFailed -> iconError
DownloadStatusTell.IsDone -> iconComplete DownloadStatusTell.IsDone -> iconComplete
DownloadStatusTell.IsStopped -> iconRemoved DownloadStatusTell.IsStopped -> iconRemoved
null -> iconInit else -> iconInit
} }.takeIf { it != 0 }
if (drawableInt == 0) {
return null
}
return ContextCompat.getDrawable(this.context, drawableInt)
}
} }

View file

@ -2,32 +2,59 @@ package com.lagradost.cloudstream3.ui.home
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppUtils.isRtl import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
class HomeChildItemAdapter( class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(view) {
val cardList: MutableList<SearchResponse>, /*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 nextFocusUp: Int? = null,
private val nextFocusDown: Int? = null, private val nextFocusDown: Int? = null,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
) : ) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() { BaseAdapter<SearchResponse, Boolean>(fragment, id) {
var isHorizontal: Boolean = false var isHorizontal: Boolean = false
var hasNext: Boolean = false var hasNext: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
val expanded = parent.context.IsBottomLayout() val expanded = parent.context.isBottomLayout()
/* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid /* 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) val root = LayoutInflater.from(parent.context).inflate(layout, parent, false)
@ -39,84 +66,15 @@ class HomeChildItemAdapter(
parent, parent,
false false
) else HomeResultGridBinding.inflate(inflater, parent, false) ) else HomeResultGridBinding.inflate(inflater, parent, false)
return HomeScrollViewHolderState(binding)
return CardViewHolder(
binding,
clickCallback,
itemCount,
nextFocusUp,
nextFocusDown,
isHorizontal,
parent.isRtl()
)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindContent(
when (holder) { holder: ViewHolderState<Boolean>,
is CardViewHolder -> { item: SearchResponse,
holder.itemCount = itemCount // i know ugly af position: Int
holder.bind(cardList[position], position) ) {
} when (val binding = holder.view) {
}
}
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)
)
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 -> { is HomeResultGridBinding -> {
binding.backgroundCard.apply { binding.backgroundCard.apply {
val min = 114.toPx val min = 114.toPx
@ -136,8 +94,6 @@ class HomeChildItemAdapter(
} }
} }
} }
} }
is HomeResultGridExpandedBinding -> { is HomeResultGridExpandedBinding -> {
@ -167,36 +123,20 @@ class HomeChildItemAdapter(
} }
SearchResultBuilder.bind( SearchResultBuilder.bind(
clickCallback, clickCallback = { click ->
card, // 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, position,
itemView, holder.itemView,
nextFocusBehavior,
nextFocusUp, nextFocusUp,
nextFocusDown nextFocusDown
) )
itemView.tag = position
//val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f) holder.itemView.tag = position
//ani.fillAfter = true
//ani.duration = 200
//itemView.startAnimation(ani)
}
} }
} }
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.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -18,23 +17,14 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.*
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis 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.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.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
@ -45,38 +35,31 @@ import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.*
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.AppUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus 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.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.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe 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.getSpanCount
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import java.util.* import java.util.*
const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list"
const val HOME_PREF_HOMEPAGE = "home_pref_homepage"
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
companion object { companion object {
val configEvent = Event<Int>() val configEvent = Event<Int>()
@ -250,7 +233,7 @@ class HomeFragment : Fragment() {
return bottomSheetDialogBuilder return bottomSheetDialogBuilder
} }
fun getPairList( private fun getPairList(
anime: Chip?, anime: Chip?,
cartoons: Chip?, cartoons: Chip?,
tvs: Chip?, tvs: Chip?,
@ -310,6 +293,17 @@ class HomeFragment : Fragment() {
selectedTypes: List<TvType>, selectedTypes: List<TvType>,
validTypes: List<TvType>, validTypes: List<TvType>,
callback: (List<TvType>) -> Unit callback: (List<TvType>) -> Unit
) {
bindChips(header, selectedTypes, validTypes, callback, null, null)
}
fun bindChips(
header: TvtypesChipsBinding?,
selectedTypes: List<TvType>,
validTypes: List<TvType>,
callback: (List<TvType>) -> Unit,
nextFocusDown: Int?,
nextFocusUp: Int?
) { ) {
if (header == null) return if (header == null) return
val pairList = getPairList(header) val pairList = getPairList(header)
@ -317,6 +311,17 @@ class HomeFragment : Fragment() {
val isValid = validTypes.any { types.contains(it) } val isValid = validTypes.any { types.contains(it) }
button?.isVisible = isValid button?.isVisible = isValid
button?.isChecked = isValid && selectedTypes.any { types.contains(it) } button?.isChecked = isValid && selectedTypes.any { types.contains(it) }
button?.isFocusable = true
if (isLayout(TV)) {
button?.isFocusableInTouchMode = true
}
if (nextFocusDown != null)
button?.nextFocusDownId = nextFocusDown
if (nextFocusUp != null)
button?.nextFocusUpId = nextFocusUp
button?.setOnCheckedChangeListener { _, _ -> button?.setOnCheckedChangeListener { _, _ ->
val list = ArrayList<TvType>() val list = ArrayList<TvType>()
for ((sbutton, vvalidTypes) in pairList) { for ((sbutton, vvalidTypes) in pairList) {
@ -356,10 +361,7 @@ class HomeFragment : Fragment() {
var currentApiName = selectedApiName var currentApiName = selectedApiName
var currentValidApis: MutableList<MainAPI> = mutableListOf() var currentValidApis: MutableList<MainAPI> = mutableListOf()
val preSelectedTypes = this.getKey<List<String>>(HOME_PREF_HOMEPAGE) val preSelectedTypes = DataStoreHelper.homePreference.toMutableList()
?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } }
?.toMutableList()
?: mutableListOf(TvType.Movie, TvType.TvSeries)
binding.cancelBtt.setOnClickListener { binding.cancelBtt.setOnClickListener {
dialog.dismissSafe() dialog.dismissSafe()
@ -387,7 +389,7 @@ class HomeFragment : Fragment() {
} }
fun updateList() { fun updateList() {
this.setKey(HOME_PREF_HOMEPAGE, preSelectedTypes) DataStoreHelper.homePreference = preSelectedTypes
arrayAdapter.clear() arrayAdapter.clear()
currentValidApis = validAPIs.filter { api -> currentValidApis = validAPIs.filter { api ->
@ -434,7 +436,7 @@ class HomeFragment : Fragment() {
bottomSheetDialog?.ownShow() bottomSheetDialog?.ownShow()
val layout = 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) val root = inflater.inflate(layout, container, false)
binding = try { binding = try {
FragmentHomeBinding.bind(root) FragmentHomeBinding.bind(root)
@ -448,6 +450,7 @@ class HomeFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
bottomSheetDialog?.ownHide() bottomSheetDialog?.ownHide()
binding = null binding = null
super.onDestroyView() super.onDestroyView()
@ -462,7 +465,7 @@ class HomeFragment : Fragment() {
private val apiChangeClickListener = View.OnClickListener { view -> private val apiChangeClickListener = View.OnClickListener { view ->
view.context.selectHomepage(currentApiName) { api -> view.context.selectHomepage(currentApiName) { api ->
homeViewModel.loadAndCancel(api, forceReload = true,fromUI = true) homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
} }
/*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf()
@ -484,6 +487,10 @@ class HomeFragment : Fragment() {
private var bottomSheetDialog: BottomSheetDialog? = null 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") @SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -491,35 +498,27 @@ class HomeFragment : Fragment() {
fixGrid() fixGrid()
binding?.apply { binding?.apply {
homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
homeApiFab.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener)
homeChangeApi.setOnClickListener(apiChangeClickListener)
homeSwitchAccount.setOnClickListener {
activity?.showAccountSelectLinear()
}
homeRandom.setOnClickListener { homeRandom.setOnClickListener {
if (listHomepageItems.isNotEmpty()) { if (listHomepageItems.isNotEmpty()) {
activity.loadSearchResult(listHomepageItems.random()) activity.loadSearchResult(listHomepageItems.random())
} }
} }
homeMasterAdapter = HomeParentItemAdapterPreview(
homeMasterRecycler.adapter = fragment = this@HomeFragment,
HomeParentItemAdapterPreview( homeViewModel,
mutableListOf(),
homeViewModel
) )
fixPaddingStatusbar(homeLoadingStatusbar) homeMasterRecycler.adapter = homeMasterAdapter
//fixPaddingStatusbar(homeLoadingStatusbar)
if (isTvSettings()) { homeApiFab.isVisible = isLayout(PHONE)
homeApiFab.isVisible = false
if (isTrueTvSettings()) {
homeChangeApiLoading.isVisible = true
homeChangeApiLoading.isFocusable = true
homeChangeApiLoading.isFocusableInTouchMode = true
}
// home_bookmark_select?.isFocusable = true
// home_bookmark_select?.isFocusableInTouchMode = true
} else {
homeApiFab.isVisible = true
homeChangeApiLoading.isVisible = false
}
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@ -527,7 +526,7 @@ class HomeFragment : Fragment() {
homeApiFab.shrink() // hide homeApiFab.shrink() // hide
homeRandom.shrink() homeRandom.shrink()
} else if (dy < -5) { } else if (dy < -5) {
if (!isTvSettings()) { if (isLayout(PHONE)) {
homeApiFab.extend() // show homeApiFab.extend() // show
homeRandom.extend() homeRandom.extend()
} }
@ -535,6 +534,7 @@ class HomeFragment : Fragment() {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
} }
}) })
} }
@ -545,13 +545,14 @@ class HomeFragment : Fragment() {
settingsManager.getBoolean( settingsManager.getBoolean(
getString(R.string.random_button_key), getString(R.string.random_button_key),
false false
) && !isTvSettings() ) && isLayout(PHONE)
binding?.homeRandom?.visibility = View.GONE binding?.homeRandom?.visibility = View.GONE
} }
observe(homeViewModel.apiName) { apiName -> observe(homeViewModel.apiName) { apiName ->
currentApiName = apiName currentApiName = apiName
binding?.homeApiFab?.text = apiName binding?.homeApiFab?.text = apiName
binding?.homeChangeApi?.text = apiName
} }
observe(homeViewModel.page) { data -> observe(homeViewModel.page) { data ->
@ -564,10 +565,11 @@ class HomeFragment : Fragment() {
val mutableListOfResponse = mutableListOf<SearchResponse>() val mutableListOfResponse = mutableListOf<SearchResponse>()
listHomepageItems.clear() listHomepageItems.clear()
(homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList( (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
d.values.toMutableList(), it.copy(
homeMasterRecycler list = it.list.copy(list = it.list.list.toMutableList())
) )
}.toMutableList())
homeLoading.isVisible = false homeLoading.isVisible = false
homeLoadingError.isVisible = false homeLoadingError.isVisible = false
@ -616,7 +618,7 @@ class HomeFragment : Fragment() {
} }
is Resource.Loading -> { is Resource.Loading -> {
(homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(listOf()) (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
homeLoadingShimmer.startShimmer() homeLoadingShimmer.startShimmer()
homeLoading.isVisible = true homeLoading.isVisible = true
homeLoadingError.isVisible = false homeLoadingError.isVisible = false
@ -643,16 +645,18 @@ class HomeFragment : Fragment() {
return@observeNullable return@observeNullable
} }
bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = { val (items, delete) = item
bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = {
homeViewModel.expandAndReturn(it) homeViewModel.expandAndReturn(it)
}, dismissCallback = { }, dismissCallback = {
homeViewModel.popup(null) homeViewModel.popup(null)
bottomSheetDialog = null bottomSheetDialog = null
}) }, deleteCallback = delete)
} }
homeViewModel.reloadStored() homeViewModel.reloadStored()
homeViewModel.loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false) homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false)
//loadHomePage(false) //loadHomePage(false)
// nice profile pic on homepage // nice profile pic on homepage

View file

@ -1,21 +1,30 @@
package com.lagradost.cloudstream3.ui.home 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.databinding.HomepageParentBinding
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.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable 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( class LoadClickCallback(
val action: Int = 0, val action: Int = 0,
@ -25,181 +34,89 @@ class LoadClickCallback(
) )
open class ParentItemAdapter( open class ParentItemAdapter(
private var items: MutableList<HomeViewModel.ExpandableHomepageList>, open val fragment: Fragment,
//private val viewModel: HomeViewModel, id: Int,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null, private val expandCallback: ((String) -> Unit)? = null,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { fragment,
id,
val root = LayoutInflater.from(parent.context).inflate( diffCallback = BaseDiffCallback(
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent, itemSame = { a, b -> a.list.name == b.list.name },
parent, contentSame = { a, b ->
false a.list.list == b.list.list
)
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)
}
}
}) })
) {
//diffResult.dispatchUpdatesTo(this) 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)
} }
class ParentViewHolder override fun restore(state: Bundle) {
constructor( (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
val binding: HomepageParentBinding, state.getSafeParcelable<Parcelable>("value")
// 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
fun update(expand: HomeViewModel.ExpandableHomepageList) { override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
val info = expand.list super.submitList(list?.sortedBy { it.list.list.isEmpty() })
(recyclerView.adapter as? HomeChildItemAdapter?)?.apply { }
updateList(info.list.toMutableList())
hasNext = expand.hasNext override fun onUpdateContent(
} ?: run { holder: ViewHolderState<Bundle>,
recyclerView.adapter = HomeChildItemAdapter( item: HomeViewModel.ExpandableHomepageList,
info.list.toMutableList(), 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, clickCallback = clickCallback,
nextFocusUp = recyclerView.nextFocusUpId, nextFocusUp = homeChildRecyclerview.nextFocusUpId,
nextFocusDown = recyclerView.nextFocusDownId, nextFocusDown = homeChildRecyclerview.nextFocusDownId,
).apply { ).apply {
isHorizontal = info.isHorizontalImages isHorizontal = info.isHorizontalImages
hasNext = item.hasNext
submitList(item.list.list)
} }
recyclerView.setLinearListLayout() homeChildRecyclerview.setLinearListLayout(
} isHorizontal = true,
} nextLeft = startFocus,
nextRight = endFocus,
)
homeChildMoreInfo.text = info.name
fun bind(expand: HomeViewModel.ExpandableHomepageList) { homeChildRecyclerview.addOnScrollListener(object :
val info = expand.list RecyclerView.OnScrollListener() {
recyclerView.adapter = HomeChildItemAdapter(
info.list.toMutableList(),
clickCallback = clickCallback,
nextFocusUp = recyclerView.nextFocusUpId,
nextFocusDown = recyclerView.nextFocusDownId,
).apply {
isHorizontal = info.isHorizontalImages
hasNext = expand.hasNext
}
recyclerView.setLinearListLayout()
title.text = info.name
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
var expandCount = 0 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) super.onScrollStateChanged(recyclerView, newState)
val adapter = recyclerView.adapter val adapter = recyclerView.adapter
@ -223,27 +140,40 @@ open class ParentItemAdapter(
}) })
//(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
if (!isTvSettings()) { if (isLayout(PHONE)) {
title.setOnClickListener { homeChildMoreInfo.setOnClickListener {
moreInfoClickCallback.invoke(expand) 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( @Suppress("DEPRECATION")
private val oldList: List<HomeViewModel.ExpandableHomepageList>, inline fun <reified T> Bundle.getSafeParcelable(key: String): T? =
private val newList: List<HomeViewModel.ExpandableHomepageList> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key)
) : else getParcelable(key, T::class.java)
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]
}

View file

@ -1,24 +1,26 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.APIHolder.getId
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.FragmentHomeHeadBinding import com.lagradost.cloudstream3.databinding.FragmentHomeHeadBinding
@ -26,108 +28,107 @@ import com.lagradost.cloudstream3.databinding.FragmentHomeHeadTvBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.debugException
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage 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.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.getId
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
import com.lagradost.cloudstream3.ui.search.SearchClickCallback 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.DataStoreHelper
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.populateChips
class HomeParentItemAdapterPreview( class HomeParentItemAdapterPreview(
items: MutableList<HomeViewModel.ExpandableHomepageList>, override val fragment: Fragment,
private val viewModel: HomeViewModel, private val viewModel: HomeViewModel,
) : ParentItemAdapter(items, clickCallback = { ) : ParentItemAdapter(fragment, id = "HomeParentItemAdapterPreview".hashCode(),
clickCallback = {
viewModel.click(it) viewModel.click(it)
}, moreInfoClickCallback = { }, moreInfoClickCallback = {
viewModel.popup(it) viewModel.popup(it)
}, expandCallback = { }, expandCallback = {
viewModel.expand(it) viewModel.expand(it)
}) { }) {
val headItems = 1 override val headers = 1
override fun onCreateHeader(parent: ViewGroup): ViewHolderState<Bundle> {
companion object {
private const val VIEW_TYPE_HEADER = 2
private const val VIEW_TYPE_ITEM = 1
}
override fun getItemViewType(position: Int) = when (position) {
0 -> VIEW_TYPE_HEADER
else -> VIEW_TYPE_ITEM
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderViewHolder -> {}
else -> super.onBindViewHolder(holder, position - headItems)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_HEADER -> {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = if (isTvSettings()) FragmentHomeHeadTvBinding.inflate( val binding = if (isLayout(TV or EMULATOR)) FragmentHomeHeadTvBinding.inflate(
inflater, inflater,
parent, parent,
false false
) else FragmentHomeHeadBinding.inflate(inflater, parent, false) ) else FragmentHomeHeadBinding.inflate(inflater, parent, false)
HeaderViewHolder(
binding, if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) {
viewModel, binding.homeBookmarkParentItemMoreInfo.isVisible = true
val marginInDp = 50
val density = binding.horizontalScrollChips.context.resources.displayMetrics.density
val marginInPixels = (marginInDp * density).toInt()
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
) )
} }
VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType) return HeaderViewHolder(binding, viewModel, fragment = fragment)
else -> error("Unhandled viewType=$viewType") }
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)
}
override fun restore(state: Bundle) {
state.getSafeParcelable<Parcelable>("resumeRecyclerView")?.let { recycle ->
resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
}
state.getSafeParcelable<Parcelable>("bookmarkRecyclerView")?.let { recycle ->
bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
} }
} }
override fun getItemCount(): Int { val previewAdapter = HomeScrollAdapter(fragment = fragment)
return super.getItemCount() + headItems private val resumeAdapter = HomeChildItemAdapter(
} fragment,
id = "resumeAdapter".hashCode(),
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()
}
else -> super.onViewDetachedFromWindow(holder)
}
}
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewAttachedToWindow()
}
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(),
nextFocusUp = itemView.nextFocusUpId, nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId nextFocusDown = itemView.nextFocusDownId
) { callback -> ) { callback ->
@ -182,8 +183,9 @@ class HomeParentItemAdapterPreview(
} }
} }
} }
private var bookmarkAdapter: HomeChildItemAdapter = HomeChildItemAdapter( private val bookmarkAdapter = HomeChildItemAdapter(
ArrayList(), fragment,
id = "bookmarkAdapter".hashCode(),
nextFocusUp = itemView.nextFocusUpId, nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId nextFocusDown = itemView.nextFocusDownId
) { callback -> ) { callback ->
@ -191,6 +193,12 @@ class HomeParentItemAdapterPreview(
viewModel.click(callback) viewModel.click(callback)
return@HomeChildItemAdapter return@HomeChildItemAdapter
} }
(callback.view.context?.getActivity() as? MainActivity)?.loadPopup(
callback.card,
load = false
)
/*
callback.view.context?.getActivity()?.showOptionSelectStringRes( callback.view.context?.getActivity()?.showOptionSelectStringRes(
callback.view, callback.view,
callback.card.posterUrl, callback.card.posterUrl,
@ -236,34 +244,33 @@ class HomeParentItemAdapterPreview(
} }
} }
} }
*/
} }
private val previewViewpager: ViewPager2 = private val previewViewpager: ViewPager2 =
itemView.findViewById(R.id.home_preview_viewpager) itemView.findViewById(R.id.home_preview_viewpager)
private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview)
private var resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) private val previewViewpagerText: ViewGroup =
private var resumeRecyclerView: RecyclerView = itemView.findViewById(R.id.home_preview_viewpager_text)
// 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) itemView.findViewById(R.id.home_watch_child_recyclerview)
private var bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder) private val bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder)
private var bookmarkRecyclerView: RecyclerView = private val bookmarkRecyclerView: RecyclerView =
itemView.findViewById(R.id.home_bookmarked_child_recyclerview) itemView.findViewById(R.id.home_bookmarked_child_recyclerview)
private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding) private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account)
private val alternativeHomeAccount: View? =
itemView.findViewById(R.id.alternative_switch_account)
private val previewCallback: ViewPager2.OnPageChangeCallback = private val topPadding: View? = itemView.findViewById(R.id.home_padding)
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { private val alternativeAccountPadding: View? =
previewAdapter.apply { itemView.findViewById(R.id.alternative_account_padding)
if (position >= itemCount - 1 && hasMoreItems) {
hasMoreItems = false // don't make two requests private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding)
viewModel.loadMoreHomeScrollResponses()
}
}
val item = previewAdapter.getItem(position) ?: return
onSelect(item, position)
}
}
fun onSelect(item: LoadResponse, position: Int) { fun onSelect(item: LoadResponse, position: Int) {
(binding as? FragmentHomeHeadTvBinding)?.apply { (binding as? FragmentHomeHeadTvBinding)?.apply {
@ -273,26 +280,12 @@ class HomeParentItemAdapterPreview(
item.plot ?: "" item.plot ?: ""
homePreviewText.text = item.name homePreviewText.text = item.name
homePreviewTags.apply { populateChips(
removeAllViews() homePreviewTags,
item.tags?.forEach { tag -> item.tags?.take(6) ?: emptyList(),
val chip = Chip(context)
val chipDrawable =
ChipDrawable.createFromAttributes(
context,
null,
0,
R.style.ChipFilledSemiTransparent R.style.ChipFilledSemiTransparent
) )
chip.setChipDrawable(chipDrawable)
chip.text = tag
chip.isChecked = false
chip.isCheckable = false
chip.isFocusable = false
chip.isClickable = false
addView(chip)
}
}
homePreviewTags.isGone = homePreviewTags.isGone =
item.tags.isNullOrEmpty() item.tags.isNullOrEmpty()
@ -315,7 +308,7 @@ class HomeParentItemAdapterPreview(
} }
(binding as? FragmentHomeHeadBinding)?.apply { (binding as? FragmentHomeHeadBinding)?.apply {
homePreviewImage.setImage(item.posterUrl, item.posterHeaders) //homePreviewImage.setImage(item.posterUrl, item.posterHeaders)
homePreviewPlay.setOnClickListener { view -> homePreviewPlay.setOnClickListener { view ->
viewModel.click( viewModel.click(
@ -351,14 +344,22 @@ class HomeParentItemAdapterPreview(
homePreviewBookmark.setOnClickListener { fab -> homePreviewBookmark.setOnClickListener { fab ->
fab.context.getActivity()?.showBottomDialog( fab.context.getActivity()?.showBottomDialog(
WatchType.values() WatchType.entries
.map { fab.context.getString(it.stringRes) } .map { fab.context.getString(it.stringRes) }
.toList(), .toList(),
DataStoreHelper.getResultWatchState(id).ordinal, DataStoreHelper.getResultWatchState(id).ordinal,
fab.context.getString(R.string.action_add_to_bookmarks), fab.context.getString(R.string.action_add_to_bookmarks),
showApply = false, showApply = false,
{}) { {}) {
val newValue = WatchType.values()[it] val newValue = WatchType.entries[it]
ResultViewModel2().updateWatchStatus(
newValue,
fab.context,
item
) { statusChanged: Boolean ->
if (!statusChanged) return@updateWatchStatus
homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds(
null, null,
ContextCompat.getDrawable( ContextCompat.getDrawable(
@ -369,50 +370,30 @@ class HomeParentItemAdapterPreview(
null null
) )
homePreviewBookmark.setText(newValue.stringRes) homePreviewBookmark.setText(newValue.stringRes)
}
ResultViewModel2.updateWatchStatus(
item,
newValue
)
} }
} }
} }
} }
fun onViewDetachedFromWindow() { 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.getItemOrNull(position) ?: return
onSelect(item, position)
}
}
override fun onViewDetachedFromWindow() {
previewViewpager.unregisterOnPageChangeCallback(previewCallback) 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
binding.homePreviewChangeApi2.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)
}
}
}
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
}
private val toggleList = listOf<Pair<Chip, WatchType>>( private val toggleList = listOf<Pair<Chip, WatchType>>(
Pair(itemView.findViewById(R.id.home_type_watching_btt), WatchType.WATCHING), Pair(itemView.findViewById(R.id.home_type_watching_btt), WatchType.WATCHING),
Pair(itemView.findViewById(R.id.home_type_completed_btt), WatchType.COMPLETED), Pair(itemView.findViewById(R.id.home_type_completed_btt), WatchType.COMPLETED),
@ -421,6 +402,10 @@ class HomeParentItemAdapterPreview(
Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH), Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH),
) )
private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder)
fun bind() = Unit
init { init {
previewViewpager.setPageTransformer(HomeScrollTransformer()) previewViewpager.setPageTransformer(HomeScrollTransformer())
@ -428,8 +413,16 @@ class HomeParentItemAdapterPreview(
resumeRecyclerView.adapter = resumeAdapter resumeRecyclerView.adapter = resumeAdapter
bookmarkRecyclerView.adapter = bookmarkAdapter bookmarkRecyclerView.adapter = bookmarkAdapter
resumeRecyclerView.setLinearListLayout() resumeRecyclerView.setLinearListLayout(
bookmarkRecyclerView.setLinearListLayout() nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF
)
bookmarkRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF
)
fixPaddingStatusbarMargin(topPadding)
for ((chip, watch) in toggleList) { for ((chip, watch) in toggleList) {
chip.isChecked = false chip.isChecked = false
@ -444,16 +437,24 @@ class HomeParentItemAdapterPreview(
} }
} }
homeAccount?.setOnClickListener {
activity?.showAccountSelectLinear()
}
alternativeHomeAccount?.setOnClickListener {
activity?.showAccountSelectLinear()
}
(binding as? FragmentHomeHeadTvBinding)?.apply { (binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewChangeApi.setOnClickListener { view -> homePreviewChangeApi.setOnClickListener { view ->
view.context.selectHomepage(viewModel.repo?.name) { api -> view.context.selectHomepage(viewModel.repo?.name) { api ->
viewModel.loadAndCancel(api, forceReload = true, fromUI = true) viewModel.loadAndCancel(api, forceReload = true, fromUI = true)
} }
} }
homePreviewChangeApi2.setOnClickListener { view ->
view.context.selectHomepage(viewModel.repo?.name) { api -> homePreviewSearchButton.setOnClickListener { _ ->
viewModel.loadAndCancel(api, forceReload = true, fromUI = true) // Open blank screen.
} viewModel.queryTextSubmit("")
} }
// This makes the hidden next buttons only available when on the info button // This makes the hidden next buttons only available when on the info button
@ -467,31 +468,23 @@ class HomeParentItemAdapterPreview(
} }
homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) { if (!hasFocus) return@setOnFocusChangeListener
previewViewpager.setCurrentItem(previewViewpager.currentItem + 1, true) previewViewpager.setCurrentItem(previewViewpager.currentItem + 1, true)
homePreviewInfoBtt.requestFocus() homePreviewInfoBtt.requestFocus()
} }
}
homePreviewHiddenPrevFocus.setOnFocusChangeListener { _, hasFocus -> homePreviewHiddenPrevFocus.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) { if (!hasFocus) return@setOnFocusChangeListener
previewViewpager.apply { if (previewViewpager.currentItem <= 0) {
if (currentItem <= 0) { (activity as? MainActivity)?.binding?.navRailView?.requestFocus()
findViewById<NavigationRailView?>(R.id.nav_rail_view)?.menu?.getItem(
0
)?.actionView?.requestFocus()
} else { } else {
setCurrentItem(currentItem - 1, true) previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true)
binding.homePreviewPlayBtt.requestFocus() binding.homePreviewPlayBtt.requestFocus()
} }
} }
} }
}
}
(binding as? FragmentHomeHeadBinding)?.apply { (binding as? FragmentHomeHeadBinding)?.apply {
fixPaddingStatusbar(binding.homeSearch)
homeSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { homeSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean { override fun onQueryTextSubmit(query: String): Boolean {
viewModel.queryTextSubmit(query) viewModel.queryTextSubmit(query)
@ -507,10 +500,6 @@ class HomeParentItemAdapterPreview(
} }
private fun updatePreview(preview: Resource<Pair<Boolean, List<LoadResponse>>>) { private fun updatePreview(preview: Resource<Pair<Boolean, List<LoadResponse>>>) {
if (binding is FragmentHomeHeadTvBinding) {
binding.homePreviewChangeApi2.isGone = preview is Resource.Success
}
if (preview is Resource.Success) { if (preview is Resource.Success) {
homeNonePadding.apply { homeNonePadding.apply {
val params = layoutParams val params = layoutParams
@ -523,7 +512,9 @@ class HomeParentItemAdapterPreview(
when (preview) { when (preview) {
is Resource.Success -> { is Resource.Success -> {
if (!previewAdapter.setItems( previewAdapter.submitList(preview.value.second)
previewAdapter.hasMoreItems = preview.value.first
/*if (!.setItems(
preview.value.second, preview.value.second,
preview.value.first preview.value.first
) )
@ -535,32 +526,49 @@ class HomeParentItemAdapterPreview(
previewViewpager.fakeDragBy(1f) previewViewpager.fakeDragBy(1f)
previewViewpager.endFakeDrag() previewViewpager.endFakeDrag()
previewCallback.onPageSelected(0) previewCallback.onPageSelected(0)
previewHeader.isVisible = true //previewHeader.isVisible = true
} }*/
previewViewpager.isVisible = true
previewViewpagerText.isVisible = true
alternativeAccountPadding?.isVisible = false
} }
else -> { else -> {
previewAdapter.setItems(listOf(), false) previewAdapter.submitList(listOf())
previewViewpager.setCurrentItem(0, false) previewViewpager.setCurrentItem(0, false)
previewHeader.isVisible = false previewViewpager.isVisible = false
previewViewpagerText.isVisible = false
alternativeAccountPadding?.isVisible = true
//previewHeader.isVisible = false
} }
} }
} }
private fun updateResume(resumeWatching: List<SearchResponse>) { private fun updateResume(resumeWatching: List<SearchResponse>) {
resumeHolder.isVisible = resumeWatching.isNotEmpty() resumeHolder.isVisible = resumeWatching.isNotEmpty()
resumeAdapter.updateList(resumeWatching) resumeAdapter.submitList(resumeWatching)
if (binding is FragmentHomeHeadBinding) { if (
binding.homeBookmarkParentItemTitle.setOnClickListener { binding is FragmentHomeHeadBinding ||
binding is FragmentHomeHeadTvBinding &&
isLayout(EMULATOR)
) {
val title = (binding as? FragmentHomeHeadBinding)?.homeWatchParentItemTitle
?: (binding as? FragmentHomeHeadTvBinding)?.homeWatchParentItemTitle
title?.setOnClickListener {
viewModel.popup( viewModel.popup(
HomeViewModel.ExpandableHomepageList( HomeViewModel.ExpandableHomepageList(
HomePageList( HomePageList(
binding.homeWatchParentItemTitle.text.toString(), title.text.toString(),
resumeWatching, resumeWatching,
false false
), 1, false ), 1, false
) ),
deleteCallback = {
viewModel.deleteResumeWatching()
}
) )
} }
} }
@ -569,10 +577,17 @@ class HomeParentItemAdapterPreview(
private fun updateBookmarks(data: Pair<Boolean, List<SearchResponse>>) { private fun updateBookmarks(data: Pair<Boolean, List<SearchResponse>>) {
val (visible, list) = data val (visible, list) = data
bookmarkHolder.isVisible = visible bookmarkHolder.isVisible = visible
bookmarkAdapter.updateList(list) bookmarkAdapter.submitList(list)
if (binding is FragmentHomeHeadBinding) { if (
binding.homeBookmarkParentItemTitle.setOnClickListener { 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 } val items = toggleList.map { it.first }.filter { it.isChecked }
if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog
val textSum = items val textSum = items
@ -585,10 +600,42 @@ class HomeParentItemAdapterPreview(
list, list,
false false
), 1, 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,85 +4,56 @@ import android.content.res.Configuration
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.recyclerview.widget.DiffUtil import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
import com.lagradost.cloudstream3.ui.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 import com.lagradost.cloudstream3.utils.UIHelper.setImage
class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { class HomeScrollAdapter(
private var items: MutableList<LoadResponse> = mutableListOf() fragment: Fragment
) : NoStateAdapter<LoadResponse>(fragment) {
var hasMoreItems: Boolean = false var hasMoreItems: Boolean = false
fun getItem(position: Int): LoadResponse? { override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
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 {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = if (isTvSettings()) { val binding = if (isLayout(TV or EMULATOR)) {
HomeScrollViewTvBinding.inflate(inflater, parent, false) HomeScrollViewTvBinding.inflate(inflater, parent, false)
} else { } else {
HomeScrollViewBinding.inflate(inflater, parent, false) HomeScrollViewBinding.inflate(inflater, parent, false)
} }
return CardViewHolder( return ViewHolderState(binding)
binding,
//forceHorizontalPosters
)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindContent(
when (holder) { holder: ViewHolderState<Any>,
is CardViewHolder -> { item: LoadResponse,
holder.bind(items[position]) position: Int,
} ) {
} val binding = holder.view
} val itemView = holder.itemView
class CardViewHolder
constructor(
val binding: ViewBinding,
//private val forceHorizontalPosters: Boolean? = null
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(card: LoadResponse) {
val isHorizontal = val isHorizontal =
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val posterUrl = val posterUrl =
if (isHorizontal) card.backgroundPosterUrl ?: card.posterUrl else card.posterUrl if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl
?: card.backgroundPosterUrl ?: item.backgroundPosterUrl
when (binding) { when (binding) {
is HomeScrollViewBinding -> { is HomeScrollViewBinding -> {
binding.homeScrollPreview.setImage(posterUrl) binding.homeScrollPreview.setImage(posterUrl)
binding.homeScrollPreviewTags.apply { binding.homeScrollPreviewTags.apply {
text = card.tags?.joinToString("") ?: "" text = item.tags?.joinToString("") ?: ""
isGone = card.tags.isNullOrEmpty() isGone = item.tags.isNullOrEmpty()
maxLines = 2
} }
binding.homeScrollPreviewTitle.text = card.name binding.homeScrollPreviewTitle.text = item.name
} }
is HomeScrollViewTvBinding -> { is HomeScrollViewTvBinding -> {
@ -90,25 +61,4 @@ class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
} }
} }
}
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,18 +6,15 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
@ -34,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.SEARCH_ACTION_FOCUSED
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.loadResult 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.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.collections.set import kotlin.collections.set
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
@ -91,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 var repo: APIRepository? = null
private val _apiName = MutableLiveData<String>() private val _apiName = MutableLiveData<String>()
@ -102,7 +114,7 @@ class HomeViewModel : ViewModel() {
private var currentShuffledList: List<SearchResponse> = listOf() private var currentShuffledList: List<SearchResponse> = listOf()
private fun autoloadRepo(): APIRepository { private fun autoloadRepo(): APIRepository {
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage }}) return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
} }
private val _availableWatchStatusTypes = private val _availableWatchStatusTypes =
@ -114,7 +126,7 @@ class HomeViewModel : ViewModel() {
private val _resumeWatching = MutableLiveData<List<SearchResponse>>() private val _resumeWatching = MutableLiveData<List<SearchResponse>>()
private val _preview = MutableLiveData<Resource<Pair<Boolean, List<LoadResponse>>>>() private val _preview = MutableLiveData<Resource<Pair<Boolean, List<LoadResponse>>>>()
private val previewResponses = mutableListOf<LoadResponse>() private val previewResponses = CopyOnWriteArrayList<LoadResponse>()
private val previewResponsesAdded = mutableSetOf<String>() private val previewResponsesAdded = mutableSetOf<String>()
val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching
@ -122,7 +134,7 @@ class HomeViewModel : ViewModel() {
private fun loadResumeWatching() = viewModelScope.launchSafe { private fun loadResumeWatching() = viewModelScope.launchSafe {
val resumeWatchingResult = getResumeWatching() 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 { ioSafe {
// this WILL crash on non tvs, so keep this inside a try catch // this WILL crash on non tvs, so keep this inside a try catch
activity?.addProgramsToContinueWatching(resumeWatchingResult) activity?.addProgramsToContinueWatching(resumeWatchingResult)
@ -140,7 +152,7 @@ class HomeViewModel : ViewModel() {
} }
}?.distinctBy { it.first } ?: return@launchSafe }?.distinctBy { it.first } ?: return@launchSafe
val length = WatchType.values().size val length = WatchType.entries.size
val currentWatchTypes = mutableSetOf<WatchType>() val currentWatchTypes = mutableSetOf<WatchType>()
for (watch in watchStatusIds) { for (watch in watchStatusIds) {
@ -153,10 +165,7 @@ class HomeViewModel : ViewModel() {
currentWatchTypes.remove(WatchType.NONE) currentWatchTypes.remove(WatchType.NONE)
if (currentWatchTypes.size <= 0) { if (currentWatchTypes.size <= 0) {
setKey( DataStoreHelper.homeBookmarkedList = intArrayOf()
HOME_BOOKMARK_VALUE_LIST,
intArrayOf()
)
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf()) _availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
_bookmarks.postValue(Pair(false, ArrayList())) _bookmarks.postValue(Pair(false, ArrayList()))
return@launchSafe return@launchSafe
@ -164,15 +173,13 @@ class HomeViewModel : ViewModel() {
val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first()) val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first())
//if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first()) //if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first())
setKey(
HOME_BOOKMARK_VALUE_LIST, DataStoreHelper.homeBookmarkedList = watchPrefNotNull.map { it.internalId }.toIntArray()
watchPrefNotNull.map { it.internalId }.toIntArray()
)
_availableWatchStatusTypes.postValue( _availableWatchStatusTypes.postValue(
Pair(
watchPrefNotNull, watchPrefNotNull to
currentWatchTypes, currentWatchTypes,
)
) )
val list = withContext(Dispatchers.IO) { val list = withContext(Dispatchers.IO) {
@ -184,8 +191,9 @@ class HomeViewModel : ViewModel() {
} }
private var onGoingLoad: Job? = null private var onGoingLoad: Job? = null
private var isCurrentlyLoadingName : String? = null private var isCurrentlyLoadingName: String? = null
private fun loadAndCancel(api: MainAPI) { private fun loadAndCancel(api: MainAPI) {
//println("loaded ${api.name}")
onGoingLoad?.cancel() onGoingLoad?.cancel()
isCurrentlyLoadingName = api.name isCurrentlyLoadingName = api.name
onGoingLoad = load(api) onGoingLoad = load(api)
@ -289,7 +297,7 @@ class HomeViewModel : ViewModel() {
} }
} }
private fun load(api: MainAPI) : Job = ioSafe { private fun load(api: MainAPI): Job = ioSafe {
repo = //if (api != null) { repo = //if (api != null) {
APIRepository(api) APIRepository(api)
//} else { //} else {
@ -320,7 +328,13 @@ class HomeViewModel : ViewModel() {
val filteredList = val filteredList =
context?.filterHomePageListByFilmQuality(list) ?: list context?.filterHomePageListByFilmQuality(list) ?: list
expandable[list.name] = expandable[list.name] =
ExpandableHomepageList(filteredList, 1, home.hasNext) ExpandableHomepageList(
filteredList.copy(
list = CopyOnWriteArrayList(
filteredList.list
)
), 1, home.hasNext
)
} }
} }
@ -335,8 +349,7 @@ class HomeViewModel : ViewModel() {
val currentList = val currentList =
items.shuffled().filter { it.list.isNotEmpty() } items.shuffled().filter { it.list.isNotEmpty() }
.flatMap { it.list } .flatMap { it.list }
.distinctBy { it.url } .distinctBy { it.url }.toList()
.toList()
if (currentList.isNotEmpty()) { if (currentList.isNotEmpty()) {
val randomItems = val randomItems =
@ -374,7 +387,9 @@ class HomeViewModel : ViewModel() {
} }
is Resource.Failure -> { is Resource.Failure -> {
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
_page.postValue(data!!) _page.postValue(data!!)
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
_preview.postValue(data!!) _preview.postValue(data!!)
} }
@ -384,19 +399,20 @@ class HomeViewModel : ViewModel() {
} }
fun click(callback: SearchClickCallback) { fun click(callback: SearchClickCallback) {
if (callback.action == SEARCH_ACTION_FOCUSED) { if (callback.action != SEARCH_ACTION_FOCUSED) {
//focusCallback(callback.card)
} else {
SearchHelper.handleSearchClickCallback(callback) SearchHelper.handleSearchClickCallback(callback)
} }
} }
private val _popup = MutableLiveData<ExpandableHomepageList?>(null) private val _popup = MutableLiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?>(null)
val popup: LiveData<ExpandableHomepageList?> = _popup val popup: LiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?> = _popup
fun popup(list: ExpandableHomepageList?) { fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) {
_popup.postValue(list) if (list == null)
_popup.postValue(null)
else
_popup.postValue(list to deleteCallback)
} }
private fun bookmarksUpdated(unused: Boolean) { private fun bookmarksUpdated(unused: Boolean) {
@ -404,23 +420,29 @@ class HomeViewModel : ViewModel() {
} }
private fun afterPluginsLoaded(forceReload: Boolean) { private fun afterPluginsLoaded(forceReload: Boolean) {
loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), forceReload) loadAndCancel(DataStoreHelper.currentHomePage, forceReload)
} }
private fun afterMainPluginsLoaded(unused: Boolean = false) { 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 { init {
MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated
MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded
MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded
MainActivity.reloadHomeEvent += ::reloadHome
} }
override fun onCleared() { override fun onCleared() {
MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated
MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded
MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
MainActivity.reloadHomeEvent -= ::reloadHome
super.onCleared() super.onCleared()
} }
@ -434,15 +456,19 @@ class HomeViewModel : ViewModel() {
// do nothing // do nothing
} }
fun reloadStored() { fun loadStoredData() {
loadResumeWatching()
val list = EnumSet.noneOf(WatchType::class.java) 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) list.addAll(it)
} }
loadStoredData(list) loadStoredData(list)
} }
fun reloadStored() {
loadResumeWatching()
loadStoredData()
}
fun click(load: LoadClickCallback) { fun click(load: LoadClickCallback) {
loadResult(load.response.url, load.response.apiName, load.action) loadResult(load.response.url, load.response.apiName, load.action)
} }
@ -454,9 +480,9 @@ class HomeViewModel : ViewModel() {
fromUI: Boolean = false fromUI: Boolean = false
) = ) =
ioSafe { ioSafe {
//println("trying to load $preferredApiName")
// Since plugins are loaded in stages this function can get called multiple times. // Since plugins are loaded in stages this function can get called multiple times.
// The issue with this is that the homepage may be fetched multiple times while the first request is loading // The issue with this is that the homepage may be fetched multiple times while the first request is loading
val api = getApiFromNameNull(preferredApiName)
// api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true // api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true
val currentPage = page.value val currentPage = page.value
@ -466,9 +492,10 @@ class HomeViewModel : ViewModel() {
return@ioSafe return@ioSafe
} }
val api = getApiFromNameNull(preferredApiName)
if (preferredApiName == noneApi.name) { if (preferredApiName == noneApi.name) {
// just set to random // just set to random
if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name) if (fromUI) DataStoreHelper.currentHomePage = noneApi.name
loadAndCancel(noneApi) loadAndCancel(noneApi)
} else if (preferredApiName == randomApi.name) { } else if (preferredApiName == randomApi.name) {
// randomize the api, if none exist like if not loaded or not installed // randomize the api, if none exist like if not loaded or not installed
@ -479,19 +506,21 @@ class HomeViewModel : ViewModel() {
} else { } else {
val apiRandom = validAPIs.random() val apiRandom = validAPIs.random()
loadAndCancel(apiRandom) loadAndCancel(apiRandom)
if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name
} }
} else if (api == null) { } else if (api == null) {
// API is not found aka not loaded or removed, post the loading // API is not found aka not loaded or removed, post the loading
// progress if waiting for plugins, otherwise nothing // progress if waiting for plugins, otherwise nothing
if(PluginManager.loadedLocalPlugins) { if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
loadAndCancel(noneApi) loadAndCancel(noneApi)
} else { } else {
_page.postValue(Resource.Loading()) _page.postValue(Resource.Loading())
if (preferredApiName != null)
_apiName.postValue(preferredApiName!!)
} }
} else { } else {
// if the api is found, then set it to it and save key // if the api is found, then set it to it and save key
if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, api.name) if (fromUI) DataStoreHelper.currentHomePage = api.name
loadAndCancel(api) loadAndCancel(api)
} }
} }

View file

@ -1,50 +1,73 @@
package com.lagradost.cloudstream3.ui.library package com.lagradost.cloudstream3.ui.library
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.view.animation.AlphaAnimation
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.allViews
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels 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.google.android.material.tabs.TabLayoutMediator
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity 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.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs import kotlin.math.abs
const val LIBRARY_FOLDER = "library_folder" const val LIBRARY_FOLDER = "library_folder"
enum class LibraryOpenerType(@StringRes val stringRes: Int) { 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), Provider(R.string.none),
Browser(R.string.browser), Browser(R.string.browser),
Search(R.string.search), Search(R.string.search),
@ -63,6 +86,8 @@ data class ProviderLibraryData(
class LibraryFragment : Fragment() { class LibraryFragment : Fragment() {
companion object { companion object {
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
fun newInstance() = LibraryFragment() fun newInstance() = LibraryFragment()
/** /**
@ -74,13 +99,26 @@ class LibraryFragment : Fragment() {
private val libraryViewModel: LibraryViewModel by activityViewModels() private val libraryViewModel: LibraryViewModel by activityViewModels()
var binding: FragmentLibraryBinding? = null var binding: FragmentLibraryBinding? = null
private var toggleRandomButton = false
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View { ): View {
val localBinding = FragmentLibraryBinding.inflate(inflater, container, false) val layout =
binding = localBinding if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library
return localBinding.root 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) //return inflater.inflate(R.layout.fragment_library, container, false)
} }
@ -97,24 +135,45 @@ class LibraryFragment : Fragment() {
super.onSaveInstanceState(outState) 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
fixPaddingStatusbar(binding?.searchStatusBarPadding) fixPaddingStatusbar(binding?.searchStatusBarPadding)
binding?.sortFab?.setOnClickListener { binding?.sortFab?.setOnClickListener(sortChangeClickListener)
val methods = libraryViewModel.sortingMethods.map { binding?.librarySort?.setOnClickListener(sortChangeClickListener)
txt(it.stringRes).asString(view.context)
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)
}
} }
activity?.showBottomDialog(methods, // Set the color for the search exit icon to the correct theme text color
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), val searchExitIcon =
txt(R.string.sort_by).asString(view.context), binding?.mainSearch?.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
false, val searchExitIconColor = TypedValue()
{},
{ activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true)
val method = libraryViewModel.sortingMethods[it] searchExitIcon?.setColorFilter(searchExitIconColor.data)
libraryViewModel.sort(method)
}) val searchCallback = Runnable {
val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable
libraryViewModel.sort(ListSorting.Query, newText)
} }
binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
@ -133,7 +192,12 @@ class LibraryFragment : Fragment() {
return true 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 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 * 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 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 = val selectedIndex =
when { when {
savedSelection == null -> 0 savedSelection == null -> 0
@ -215,7 +298,7 @@ class LibraryFragment : Fragment() {
} }
setKey( setKey(
LIBRARY_FOLDER, "$currentAccount/$LIBRARY_FOLDER",
key, key,
savedData, savedData,
) )
@ -228,14 +311,16 @@ class LibraryFragment : Fragment() {
} }
binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
binding?.viewpager?.adapter =
binding?.viewpager?.adapter ?: ViewpagerAdapter( binding?.viewpager?.adapter = ViewpagerAdapter(
mutableListOf(), fragment = this,
{ isScrollingDown: Boolean -> { isScrollingDown: Boolean ->
if (isScrollingDown) { if (isScrollingDown) {
binding?.sortFab?.shrink() binding?.sortFab?.shrink()
binding?.libraryRandom?.shrink()
} else { } else {
binding?.sortFab?.extend() binding?.sortFab?.extend()
binding?.libraryRandom?.extend()
} }
}) callback@{ searchClickCallback -> }) callback@{ searchClickCallback ->
// To prevent future accidents // To prevent future accidents
@ -251,57 +336,19 @@ class LibraryFragment : Fragment() {
when (searchClickCallback.action) { when (searchClickCallback.action) {
SEARCH_ACTION_SHOW_METADATA -> { SEARCH_ACTION_SHOW_METADATA -> {
activity?.showPluginSelectionDialog( (activity as? MainActivity)?.loadPopup(
searchClickCallback.card,
load = false
)
/*activity?.showPluginSelectionDialog(
syncId, syncId,
syncName, syncName,
searchClickCallback.card.apiName searchClickCallback.card.apiName
) )*/
} }
SEARCH_ACTION_LOAD -> { SEARCH_ACTION_LOAD -> {
// This basically first selects the individual opener and if that is default then loadLibraryItem(syncName, syncId, searchClickCallback.card)
// 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
)
}
}
} }
} }
} }
@ -309,6 +356,7 @@ class LibraryFragment : Fragment() {
binding?.apply { binding?.apply {
viewpager.offscreenPageLimit = 2 viewpager.offscreenPageLimit = 2
viewpager.reduceDragSensitivity() viewpager.reduceDragSensitivity()
searchBar.setExpanded(true)
} }
val startLoading = Runnable { val startLoading = Runnable {
@ -339,7 +387,6 @@ class LibraryFragment : Fragment() {
val pages = resource.value val pages = resource.value
val showNotice = pages.all { it.items.isEmpty() } val showNotice = pages.all { it.items.isEmpty() }
binding?.apply { binding?.apply {
emptyListTextview.isVisible = showNotice emptyListTextview.isVisible = showNotice
if (showNotice) { if (showNotice) {
@ -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 // Using notifyItemRangeChanged keeps the animations when sorting
viewpager.adapter?.notifyItemRangeChanged( /*viewpager.adapter?.notifyItemRangeChanged(
0, 0,
viewpager.adapter?.itemCount ?: 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 // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
// Without this there would be a flashing effect: // Without this there would be a flashing effect:
@ -392,13 +455,32 @@ class LibraryFragment : Fragment() {
viewpager, viewpager,
) { tab, position -> ) { tab, position ->
tab.text = pages.getOrNull(position)?.title?.asStringNull(context) 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 { tab.view.setOnClickListener {
val currentItem = val currentItem =
binding?.viewpager?.currentItem ?: return@setOnClickListener binding?.viewpager?.currentItem ?: return@setOnClickListener
val distance = abs(position - currentItem) val distance = abs(position - currentItem)
hideViewpager(distance) hideViewpager(distance)
} }
//Expand the appBar on tab focus
tab.view.setOnFocusChangeListener { _, _ ->
binding?.searchBar?.setExpanded(true)
}
}.attach() }.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) { override fun onConfigurationChanged(newConfig: Configuration) {
(binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind() binding?.viewpager?.adapter?.notifyDataSetChanged()
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
} }
}
class MenuSearchView(context: Context) : SearchView(context) { private val sortChangeClickListener = View.OnClickListener { view ->
override fun onActionViewCollapsed() { val methods = libraryViewModel.sortingMethods.map {
super.onActionViewCollapsed() 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)

View file

@ -6,11 +6,14 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
enum class ListSorting(@StringRes val stringRes: Int) { enum class ListSorting(@StringRes val stringRes: Int) {
Query(R.string.none), Query(R.string.none),
@ -20,11 +23,20 @@ enum class ListSorting(@StringRes val stringRes: Int) {
UpdatedOld(R.string.sort_updated_old), UpdatedOld(R.string.sort_updated_old),
AlphabeticalA(R.string.sort_alphabetical_a), AlphabeticalA(R.string.sort_alphabetical_a),
AlphabeticalZ(R.string.sort_alphabetical_z), 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" const val LAST_SYNC_API_KEY = "last_sync_api"
class LibraryViewModel : ViewModel() { 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) private val _pages: MutableLiveData<Resource<List<SyncAPI.Page>>> = MutableLiveData(null)
val pages: LiveData<Resource<List<SyncAPI.Page>>> = _pages val pages: LiveData<Resource<List<SyncAPI.Page>>> = _pages
@ -35,12 +47,12 @@ class LibraryViewModel : ViewModel() {
get() = SyncApis.filter { it.hasAccount() } get() = SyncApis.filter { it.hasAccount() }
var currentSyncApi = availableSyncApis.let { allApis -> 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() availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull()
} }
private set(value) { private set(value) {
field = value field = value
setKey(LAST_SYNC_API_KEY, field?.name) setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name)
} }
val availableApiNames: List<String> val availableApiNames: List<String>
@ -58,13 +70,21 @@ class LibraryViewModel : ViewModel() {
reloadPages(true) reloadPages(true)
} }
fun sort(method: ListSorting, query: String? = null) { fun sort(method: ListSorting, query: String? = null) = ioSafe {
val currentList = pages.value ?: return 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 currentSortingMethod = method
(currentList as? Resource.Success)?.value?.forEachIndexed { _, page -> DataStoreHelper.librarySortingMode = method.ordinal
items.forEach { page ->
page.sort(method, query) page.sort(method, query)
} }
_pages.postValue(currentList) _pages.postValue(Resource.Success(items))
} }
fun reloadPages(forceReload: Boolean) { fun reloadPages(forceReload: Boolean) {
@ -85,8 +105,6 @@ class LibraryViewModel : ViewModel() {
val library = (libraryResource as? Resource.Success)?.value ?: return@let val library = (libraryResource as? Resource.Success)?.value ?: return@let
sortingMethods = library.supportedListSorting.toList() sortingMethods = library.supportedListSorting.toList()
currentSortingMethod = null
repo.requireLibraryRefresh = false repo.requireLibraryRefresh = false
val pages = library.allLibraryLists.map { 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.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -26,7 +26,7 @@ class PageAdapter(
private val resView: AutofitRecyclerView, private val resView: AutofitRecyclerView,
val clickCallback: (SearchClickCallback) -> Unit val clickCallback: (SearchClickCallback) -> Unit
) : ) :
AppUtils.DiffAdapter<SyncAPI.LibraryItem>(items) { AppContextUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return LibraryItemViewHolder( return LibraryItemViewHolder(

View file

@ -1,71 +1,112 @@
package com.lagradost.cloudstream3.ui.library package com.lagradost.cloudstream3.ui.library
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.doOnAttach import androidx.core.view.doOnAttach
import androidx.recyclerview.widget.RecyclerView import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView.OnFlingListener 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.databinding.LibraryViewpagerPageBinding
import com.lagradost.cloudstream3.syncproviders.SyncAPI 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.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 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( class ViewpagerAdapter(
var pages: List<SyncAPI.Page>, fragment: Fragment,
val scrollCallback: (isScrollingDown: Boolean) -> Unit, val scrollCallback: (isScrollingDown: Boolean) -> Unit,
val clickCallback: (SearchClickCallback) -> Unit val clickCallback: (SearchClickCallback) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : BaseAdapter<SyncAPI.Page, Bundle>(fragment,
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { id = "ViewpagerAdapter".hashCode(),
return PageViewHolder( 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) LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) )
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onUpdateContent(
when (holder) { holder: ViewHolderState<Bundle>,
is PageViewHolder -> { item: SyncAPI.Page,
holder.bind(pages[position], unbound.remove(position)) 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
/** binding.pageRecyclerview.tag = position
* 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 { binding.pageRecyclerview.apply {
spanCount = spanCount =
this@PageViewHolder.itemView.context.getSpanCount() ?: 3 binding.root.context.getSpanCount() ?: 3
if (adapter == null || rebind) { if (adapter == null) { // || rebind
// Only add the items after it has been attached since the items rely on ItemWidth // Only add the items after it has been attached since the items rely on ItemWidth
// Which is only determined after the recyclerview is attached. // Which is only determined after the recyclerview is attached.
// If this fails then item height becomes 0 when there is only one item // If this fails then item height becomes 0 when there is only one item
doOnAttach { doOnAttach {
adapter = PageAdapter( adapter = PageAdapter(
page.items.toMutableList(), item.items.toMutableList(),
this, this,
clickCallback clickCallback
) )
} }
} else { } else {
(adapter as? PageAdapter)?.updateList(page.items) (adapter as? PageAdapter)?.updateList(item.items)
scrollToPosition(0) // scrollToPosition(0)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val diff = scrollY - oldScrollY val diff = scrollY - oldScrollY
//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)
}
}
if (diff == 0) return@setOnScrollChangeListener if (diff == 0) return@setOnScrollChangeListener
scrollCallback.invoke(diff > 0) scrollCallback.invoke(diff > 0)
@ -79,12 +120,5 @@ class ViewpagerAdapter(
} }
} }
} }
}
}
override fun getItemCount(): Int {
return pages.size
} }
} }

View file

@ -1,12 +1,16 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint 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.AnimatedImageDrawable
import android.graphics.drawable.AnimatedVectorDrawable import android.graphics.drawable.AnimatedVectorDrawable
import android.media.metrics.PlaybackErrorEvent import android.media.metrics.PlaybackErrorEvent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -17,30 +21,36 @@ import android.widget.ProgressBar
import android.widget.Toast import android.widget.Toast
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import androidx.media3.ui.TimeBar
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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.canEnterPipMode
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.keyEventListener
import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall 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.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
@ -73,12 +83,12 @@ abstract class AbstractPlayerFragment(
var isBuffering = true var isBuffering = true
protected open var hasPipModeSupport = true protected open var hasPipModeSupport = true
var playerPausePlayHolderHolder : FrameLayout? = null var playerPausePlayHolderHolder: FrameLayout? = null
var playerPausePlay : ImageView? = null var playerPausePlay: ImageView? = null
var playerBuffering : ProgressBar? = null var playerBuffering: ProgressBar? = null
var playerView : PlayerView? = null var playerView: PlayerView? = null
var piphide : FrameLayout? = null var piphide: FrameLayout? = null
var subtitleHolder : FrameLayout? = null var subtitleHolder: FrameLayout? = null
@LayoutRes @LayoutRes
protected open var layout: Int = R.layout.fragment_player protected open var layout: Int = R.layout.fragment_player
@ -91,11 +101,13 @@ abstract class AbstractPlayerFragment(
throw NotImplementedError() throw NotImplementedError()
} }
open fun playerPositionChanged(posDur: Pair<Long, Long>) { open fun playerPositionChanged(position: Long, duration: Long) {
throw NotImplementedError() throw NotImplementedError()
} }
open fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) { open fun playerStatusChanged(){}
open fun playerDimensionsLoaded(width: Int, height: Int) {
throw NotImplementedError() throw NotImplementedError()
} }
@ -131,8 +143,10 @@ abstract class AbstractPlayerFragment(
} }
} }
private fun updateIsPlaying(playing: Pair<CSPlayerLoading, CSPlayerLoading>) { private fun updateIsPlaying(
val (wasPlaying, isPlaying) = playing wasPlaying: CSPlayerLoading,
isPlaying: CSPlayerLoading
) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
@ -180,7 +194,11 @@ abstract class AbstractPlayerFragment(
canEnterPipMode = isPlayingRightNow && hasPipModeSupport canEnterPipMode = isPlayingRightNow && hasPipModeSupport
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.let { act -> activity?.let { act ->
PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow, player.getAspectRatio()) PlayerPipHelper.updatePIPModeActions(
act,
isPlayingRightNow,
player.getAspectRatio()
)
} }
} }
} }
@ -202,10 +220,10 @@ abstract class AbstractPlayerFragment(
return return
} }
player.handleEvent( player.handleEvent(
CSPlayerEvent.values()[intent.getIntExtra( CSPlayerEvent.entries[intent.getIntExtra(
EXTRA_CONTROL_TYPE, EXTRA_CONTROL_TYPE,
0 0
)] )], source = PlayerEventSource.UI
) )
} }
} }
@ -215,7 +233,7 @@ abstract class AbstractPlayerFragment(
val isPlaying = player.getIsPlaying() val isPlaying = player.getIsPlaying()
val isPlayingValue = val isPlayingValue =
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
updateIsPlaying(Pair(isPlayingValue, isPlayingValue)) updateIsPlaying(isPlayingValue, isPlayingValue)
} else { } else {
// Restore the full-screen UI. // Restore the full-screen UI.
piphide?.isVisible = true piphide?.isVisible = true
@ -244,11 +262,11 @@ abstract class AbstractPlayerFragment(
private fun requestAudioFocus() { private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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) { fun showToast(message: String, gotoNext: Boolean = false) {
if (gotoNext && hasNextMirror()) { if (gotoNext && hasNextMirror()) {
showToast( showToast(
@ -325,11 +343,15 @@ abstract class AbstractPlayerFragment(
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun playerUpdated(player: Any?) { private fun playerUpdated(player: Any?) {
if (player is ExoPlayer) { if (player is ExoPlayer) {
context?.let { ctx -> context?.let { ctx ->
mMediaSession?.release() mMediaSession?.release()
mMediaSession = MediaSession.Builder(ctx, player).build() mMediaSession = MediaSession.Builder(ctx, player)
// Ensure unique ID for concurrent players
.setId(unixTimeMs.toString())
.build()
} }
// Necessary for multiple combined videos // Necessary for multiple combined videos
@ -362,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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
resizeMode = getKey(RESIZE_MODE_KEY) ?: 0 resizeMode = DataStoreHelper.resizeMode
resize(resizeMode, false) resize(resizeMode, false)
player.releaseCallbacks() player.releaseCallbacks()
player.initCallbacks( player.initCallbacks(
playerUpdated = ::playerUpdated, eventHandler = ::mainCallback,
updateIsPlaying = ::updateIsPlaying,
playerError = ::playerError,
requestAutoFocus = ::requestAudioFocus,
nextEpisode = ::nextEpisode,
prevEpisode = ::prevEpisode,
playerPositionChanged = ::playerPositionChanged,
playerDimensionsLoaded = ::playerDimensionsLoaded,
requestedListeningPercentages = listOf( requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE, SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE, PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE, NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE, UPDATE_SYNC_PROGRESS_PERCENTAGE,
), ),
subtitlesUpdates = ::subtitlesChanged,
embeddedSubtitlesFetched = ::embeddedSubtitlesFetched,
onTracksInfoChanged = ::onTracksInfoChanged,
onTimestampInvoked = ::onTimestamp,
onTimestampSkipped = ::onTimestampSkipped
) )
if (player is CS3IPlayer) { 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) subView = playerView?.findViewById(R.id.exo_subtitles)
subStyle = SubtitlesFragment.getCurrentSavedStyle() subStyle = SubtitlesFragment.getCurrentSavedStyle()
player.initSubtitles(subView, subtitleHolder, subStyle) 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 SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged
@ -441,6 +598,8 @@ abstract class AbstractPlayerFragment(
keyEventListener = null keyEventListener = null
canEnterPipMode = false canEnterPipMode = false
mMediaSession?.release() mMediaSession?.release()
mMediaSession = null
playerView?.player = null
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
keepScreenOn(false) keepScreenOn(false)
@ -448,16 +607,17 @@ abstract class AbstractPlayerFragment(
} }
fun nextResize() { fun nextResize() {
resizeMode = (resizeMode + 1) % PlayerResize.values().size resizeMode = (resizeMode + 1) % PlayerResize.entries.size
resize(resizeMode, true) resize(resizeMode, true)
} }
fun resize(resize: Int, showToast: Boolean) { fun resize(resize: Int, showToast: Boolean) {
resize(PlayerResize.values()[resize], showToast) resize(PlayerResize.entries[resize], showToast)
} }
@SuppressLint("UnsafeOptInUsageError")
fun resize(resize: PlayerResize, showToast: Boolean) { fun resize(resize: PlayerResize, showToast: Boolean) {
setKey(RESIZE_MODE_KEY, resize.ordinal) DataStoreHelper.resizeMode = resize.ordinal
val type = when (resize) { val type = when (resize) {
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT

View file

@ -1,27 +1,32 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.media3.common.C import androidx.annotation.OptIn
import androidx.preference.PreferenceManager import androidx.media3.common.C.TIME_UNSET
import androidx.media3.common.C.* 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.Format
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackGroup import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSourceFactory import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.CacheDataSource
@ -30,8 +35,13 @@ import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters 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.ClippingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
@ -41,21 +51,27 @@ import androidx.media3.exoplayer.text.TextRenderer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
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.EpisodeSkip
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
import java.io.File import java.io.File
import java.util.UUID
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSession import javax.net.ssl.SSLSession
@ -72,14 +88,24 @@ const val toleranceBeforeUs = 300_000L
* seek position, in microseconds. Must be non-negative. * seek position, in microseconds. Must be non-negative.
*/ */
const val toleranceAfterUs = 300_000L const val toleranceAfterUs = 300_000L
@OptIn(UnstableApi::class)
class CS3IPlayer : IPlayer { class CS3IPlayer : IPlayer {
private var isPlaying = false private var isPlaying = false
private var exoPlayer: ExoPlayer? = null 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 cacheSize = 0L
var simpleCacheSize = 0L var simpleCacheSize = 0L
var videoBufferMs = 0L var videoBufferMs = 0L
val imageGenerator = IPreviewGenerator.new()
private val seekActionTime = 30000L private val seekActionTime = 30000L
private var ignoreSSL: Boolean = true private var ignoreSSL: Boolean = true
@ -102,7 +128,16 @@ class CS3IPlayer : IPlayer {
* */ * */
data class MediaItemSlice( data class MediaItemSlice(
val mediaItem: MediaItem, 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 override fun getDuration(): Long? = exoPlayer?.duration
@ -116,80 +151,24 @@ class CS3IPlayer : IPlayer {
* Boolean = if it's active * Boolean = if it's active
* */ * */
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>() 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 private var requestedListeningPercentages: List<Int>? = null
/** Fired when seeking the player or on requestedListeningPercentages, private var eventHandler: ((PlayerEvent) -> Unit)? = null
* used to make things appear on que
* position, duration */
private var playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null
private var nextEpisode: (() -> Unit)? = null fun event(event: PlayerEvent) {
private var prevEpisode: (() -> Unit)? = null eventHandler?.invoke(event)
}
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
override fun releaseCallbacks() { override fun releaseCallbacks() {
playerUpdated = null eventHandler = 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
} }
override fun initCallbacks( override fun initCallbacks(
playerUpdated: (Any?) -> Unit, eventHandler: ((PlayerEvent) -> Unit),
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)?,
requestAutoFocus: (() -> Unit)?,
playerError: ((Exception) -> Unit)?,
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)?,
requestedListeningPercentages: List<Int>?, 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.requestedListeningPercentages = requestedListeningPercentages
this.playerPositionChanged = playerPositionChanged this.eventHandler = eventHandler
this.nextEpisode = nextEpisode
this.prevEpisode = prevEpisode
this.subtitlesUpdates = subtitlesUpdates
this.embeddedSubtitlesFetched = embeddedSubtitlesFetched
this.onTracksInfoChanged = onTracksInfoChanged
this.onTimestampInvoked = onTimestampInvoked
this.onTimestampSkipped = onTimestampSkipped
} }
// I know, this is not a perfect solution, however it works for fixing subs // I know, this is not a perfect solution, however it works for fixing subs
@ -198,7 +177,7 @@ class CS3IPlayer : IPlayer {
try { try {
Handler(it).post { Handler(it).post {
try { try {
seekTime(1L) seekTime(1L, source = PlayerEventSource.Player)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
@ -213,6 +192,14 @@ class CS3IPlayer : IPlayer {
subtitleHelper.initSubtitles(subView, subHolder, style) 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( override fun loadPlayer(
context: Context, context: Context,
sameEpisode: Boolean, sameEpisode: Boolean,
@ -221,7 +208,8 @@ class CS3IPlayer : IPlayer {
startPosition: Long?, startPosition: Long?,
subtitles: Set<SubtitleData>, subtitles: Set<SubtitleData>,
subtitle: SubtitleData?, subtitle: SubtitleData?,
autoPlay: Boolean? autoPlay: Boolean?,
preview: Boolean,
) { ) {
Log.i(TAG, "loadPlayer") Log.i(TAG, "loadPlayer")
if (sameEpisode) { if (sameEpisode) {
@ -240,19 +228,38 @@ class CS3IPlayer : IPlayer {
// release the current exoplayer and cache // release the current exoplayer and cache
releasePlayer() releasePlayer()
if (link != null) { 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) loadOnlinePlayer(context, link)
} else if (data != null) { } else if (data != null) {
loadOfflinePlayer(context, data) (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>) { override fun setActiveSubtitles(subtitles: Set<SubtitleData>) {
Log.i(TAG, "setActiveSubtitles ${subtitles.size}") Log.i(TAG, "setActiveSubtitles ${subtitles.size}")
subtitleHelper.setAllSubtitles(subtitles) subtitleHelper.setAllSubtitles(subtitles)
} }
var currentSubtitles: SubtitleData? = null private var currentSubtitles: SubtitleData? = null
private fun List<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? { private fun List<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? {
if (id == null) return null if (id == null) return null
@ -397,7 +404,7 @@ class CS3IPlayer : IPlayer {
if (subtitle == null) { if (subtitle == null) {
trackSelector.setParameters( trackSelector.setParameters(
trackSelector.buildUponParameters() trackSelector.buildUponParameters()
.setPreferredTextLanguage(null) .setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
.clearOverridesOfType(TRACK_TYPE_TEXT) .clearOverridesOfType(TRACK_TYPE_TEXT)
) )
} else { } else {
@ -415,6 +422,7 @@ class CS3IPlayer : IPlayer {
.apply { .apply {
val track = getTextTrack(subtitle.getId()) val track = getTextTrack(subtitle.getId())
if (track != null) { if (track != null) {
setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
setOverrideForType( setOverrideForType(
TrackSelectionOverride( TrackSelectionOverride(
track.first, track.first,
@ -443,7 +451,7 @@ class CS3IPlayer : IPlayer {
} ?: false } ?: false
} }
var currentSubtitleOffset: Long = 0 private var currentSubtitleOffset: Long = 0
override fun setSubtitleOffset(offset: Long) { override fun setSubtitleOffset(offset: Long) {
currentSubtitleOffset = offset currentSubtitleOffset = offset
@ -451,7 +459,7 @@ class CS3IPlayer : IPlayer {
} }
override fun getSubtitleOffset(): Long { override fun getSubtitleOffset(): Long {
return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset return currentSubtitleOffset
} }
override fun getCurrentPreferredSubtitle(): SubtitleData? { override fun getCurrentPreferredSubtitle(): SubtitleData? {
@ -478,7 +486,7 @@ class CS3IPlayer : IPlayer {
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
playbackPosition = exo.currentPosition playbackPosition = exo.currentPosition
currentWindow = exo.currentWindowIndex currentWindow = exo.currentMediaItemIndex
isPlaying = exo.isPlaying isPlaying = exo.isPlaying
} }
} }
@ -489,7 +497,11 @@ class CS3IPlayer : IPlayer {
if (saveTime) if (saveTime)
updatedTime() updatedTime()
exoPlayer?.release() exoPlayer?.apply {
playWhenReady = false
stop()
release()
}
//simpleCache?.release() //simpleCache?.release()
currentTextRenderer = null currentTextRenderer = null
@ -501,14 +513,14 @@ class CS3IPlayer : IPlayer {
Log.i(TAG, "onStop") Log.i(TAG, "onStop")
saveData() saveData()
exoPlayer?.pause() handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
//releasePlayer() //releasePlayer()
} }
override fun onPause() { override fun onPause() {
Log.i(TAG, "onPause") Log.i(TAG, "onPause")
saveData() saveData()
exoPlayer?.pause() handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
//releasePlayer() //releasePlayer()
} }
@ -518,6 +530,7 @@ class CS3IPlayer : IPlayer {
} }
override fun release() { override fun release() {
imageGenerator.release()
releasePlayer() releasePlayer()
} }
@ -532,12 +545,15 @@ class CS3IPlayer : IPlayer {
**/ **/
var preferredAudioTrackLanguage: String? = null var preferredAudioTrackLanguage: String? = null
get() { get() {
return field ?: getKey(PREFERRED_AUDIO_LANGUAGE_KEY, field)?.also { return field ?: getKey(
"$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY",
field
)?.also {
field = it field = it
} }
} }
set(value) { set(value) {
setKey(PREFERRED_AUDIO_LANGUAGE_KEY, value) setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value)
field = value field = value
} }
@ -585,57 +601,16 @@ class CS3IPlayer : IPlayer {
} }
private fun Context.createOfflineSource(): DataSource.Factory { 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? { private fun getCache(context: Context, cacheSize: Long): SimpleCache? {
return try { return try {
val databaseProvider = StandaloneDatabaseProvider(context) val databaseProvider = StandaloneDatabaseProvider(context)
SimpleCache( SimpleCache(
File( File(
context.cacheDir, "exoplayer" context.cacheDir, "exoplayer"
).also { it.deleteOnExit() }, // Ensures always fresh file ).also { deleteFileOnExit(it) }, // Ensures always fresh file
LeastRecentlyUsedCacheEvictor(cacheSize), LeastRecentlyUsedCacheEvictor(cacheSize),
databaseProvider databaseProvider
) )
@ -662,12 +637,7 @@ class CS3IPlayer : IPlayer {
private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector {
val trackSelector = DefaultTrackSelector(context) val trackSelector = DefaultTrackSelector(context)
trackSelector.parameters = DefaultTrackSelector.ParametersBuilder(context) trackSelector.parameters = trackSelector.buildUponParameters()
// .setRendererDisabled(C.TRACK_TYPE_VIDEO, true)
.setRendererDisabled(C.TRACK_TYPE_TEXT, true)
// Experimental, I think this causes issues with audio track init 5001
// .setTunnelingEnabled(true)
.setDisabledTextTrackSelectionFlags(C.TRACK_TYPE_TEXT)
// This will not force higher quality videos to fail // This will not force higher quality videos to fail
// but will make the m3u8 pick the correct preferred // but will make the m3u8 pick the correct preferred
.setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE) .setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE)
@ -701,9 +671,9 @@ class CS3IPlayer : IPlayer {
ExoPlayer.Builder(context) ExoPlayer.Builder(context)
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
DefaultRenderersFactory(context).apply { DefaultRenderersFactory(context).apply {
// setEnableDecoderFallback(true) setEnableDecoderFallback(true)
// Enable Ffmpeg extension // Enable Ffmpeg extension
// setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
}.createRenderers( }.createRenderers(
eventHandler, eventHandler,
videoRendererEventListener, videoRendererEventListener,
@ -712,13 +682,13 @@ class CS3IPlayer : IPlayer {
metadataRendererOutput metadataRendererOutput
).map { ).map {
if (it is TextRenderer) { if (it is TextRenderer) {
currentTextRenderer = CustomTextRenderer( val currentTextRenderer = CustomTextRenderer(
subtitleOffset, subtitleOffset,
textRendererOutput, textRendererOutput,
eventHandler.looper, eventHandler.looper,
CustomSubtitleDecoderFactory() CustomSubtitleDecoderFactory()
) ).also { renderer -> this.currentTextRenderer = renderer }
currentTextRenderer!! currentTextRenderer
} else it } else it
}.toTypedArray() }.toTypedArray()
} }
@ -762,15 +732,33 @@ class CS3IPlayer : IPlayer {
// If there is only one item then treat it as normal, if multiple: concatenate the items. // If there is only one item then treat it as normal, if multiple: concatenate the items.
val videoMediaSource = if (mediaItemSlices.size == 1) { 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 { } else {
val source = ConcatenatingMediaSource() val source = ConcatenatingMediaSource()
mediaItemSlices.map { mediaItemSlices.map { item ->
source.addMediaSource( source.addMediaSource(
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
ClippingMediaSource( ClippingMediaSource(
factory.createMediaSource(it.mediaItem), factory.createMediaSource(item.mediaItem),
it.durationUs item.durationUs
) )
) )
} }
@ -803,43 +791,55 @@ class CS3IPlayer : IPlayer {
return null return null
} }
fun updatedTime(writePosition: Long? = null) { fun updatedTime(
writePosition: Long? = null,
source: PlayerEventSource = PlayerEventSource.Player
) {
val position = writePosition ?: exoPlayer?.currentPosition val position = writePosition ?: exoPlayer?.currentPosition
getCurrentTimestamp(position)?.let { timestamp -> getCurrentTimestamp(position)?.let { timestamp ->
onTimestampInvoked?.invoke(timestamp) event(TimestampInvokedEvent(timestamp, source))
} }
val duration = exoPlayer?.contentDuration val duration = exoPlayer?.contentDuration
if (duration != null && position != null) { if (duration != null && position != null) {
playerPositionChanged?.invoke(Pair(position, duration)) event(
PositionEvent(
source,
fromMs = exoPlayer?.currentPosition ?: 0,
position,
duration
)
)
} }
} }
override fun seekTime(time: Long) { override fun seekTime(time: Long, source: PlayerEventSource) {
exoPlayer?.seekTime(time) exoPlayer?.seekTime(time, source)
} }
override fun seekTo(time: Long) { override fun seekTo(time: Long, source: PlayerEventSource) {
updatedTime(time) updatedTime(time, source)
exoPlayer?.seekTo(time) exoPlayer?.seekTo(time)
} }
private fun ExoPlayer.seekTime(time: Long) { private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) {
updatedTime(currentPosition + time) updatedTime(currentPosition + time, source)
seekTo(currentPosition + time) seekTo(currentPosition + time)
} }
override fun handleEvent(event: CSPlayerEvent) { override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) {
Log.i(TAG, "handleEvent ${event.name}") Log.i(TAG, "handleEvent ${event.name}")
try { try {
exoPlayer?.apply { exoPlayer?.apply {
when (event) { when (event) {
CSPlayerEvent.Play -> { CSPlayerEvent.Play -> {
event(PlayEvent(source))
play() play()
} }
CSPlayerEvent.Pause -> { CSPlayerEvent.Pause -> {
event(PauseEvent(source))
pause() pause()
} }
@ -856,32 +856,48 @@ class CS3IPlayer : IPlayer {
CSPlayerEvent.PlayPauseToggle -> { CSPlayerEvent.PlayPauseToggle -> {
if (isPlaying) { if (isPlaying) {
pause() handleEvent(CSPlayerEvent.Pause, source)
} else { } else {
play() handleEvent(CSPlayerEvent.Play, source)
} }
} }
CSPlayerEvent.SeekForward -> seekTime(seekActionTime) CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source)
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime)
CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source)
CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke()
CSPlayerEvent.Restart -> seekTo(0, source)
CSPlayerEvent.NextEpisode -> event(
EpisodeSeekEvent(
offset = 1,
source = source
)
)
CSPlayerEvent.PrevEpisode -> event(
EpisodeSeekEvent(
offset = -1,
source = source
)
)
CSPlayerEvent.SkipCurrentChapter -> { CSPlayerEvent.SkipCurrentChapter -> {
//val dur = this@CS3IPlayer.getDuration() ?: return@apply //val dur = this@CS3IPlayer.getDuration() ?: return@apply
getCurrentTimestamp()?.let { lastTimeStamp -> getCurrentTimestamp()?.let { lastTimeStamp ->
if (lastTimeStamp.skipToNextEpisode) { if (lastTimeStamp.skipToNextEpisode) {
handleEvent(CSPlayerEvent.NextEpisode) handleEvent(CSPlayerEvent.NextEpisode, source)
} else { } else {
seekTo(lastTimeStamp.endMs + 1L) seekTo(lastTimeStamp.endMs + 1L)
} }
onTimestampSkipped?.invoke(lastTimeStamp) event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
} }
} }
} }
} }
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "handleEvent error", e) Log.e(TAG, "handleEvent error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
@ -920,18 +936,14 @@ class CS3IPlayer : IPlayer {
requestSubtitleUpdate = ::reloadSubs requestSubtitleUpdate = ::reloadSubs
playerUpdated?.invoke(exoPlayer) event(PlayerAttachedEvent(exoPlayer))
exoPlayer?.prepare() exoPlayer?.prepare()
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
updateIsPlaying?.invoke( event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
Pair(
CSPlayerLoading.IsBuffering,
CSPlayerLoading.IsBuffering
)
)
isPlaying = exo.isPlaying isPlaying = exo.isPlaying
} }
exoPlayer?.addListener(object : Player.Listener { exoPlayer?.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
normalSafeApiCall { normalSafeApiCall {
@ -961,22 +973,24 @@ class CS3IPlayer : IPlayer {
format.id!!, format.id!!,
SubtitleOrigin.EMBEDDED_IN_VIDEO, SubtitleOrigin.EMBEDDED_IN_VIDEO,
format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
emptyMap() emptyMap(),
format.language
) )
} }
embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
onTracksInfoChanged?.invoke() event(TracksChangedEvent())
subtitlesUpdates?.invoke() event(SubtitlesUpdatedEvent())
} }
} }
//fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead.
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
updateIsPlaying?.invoke( event(
Pair( StatusEvent(
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused,
if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.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 isPlaying = exo.isPlaying
@ -998,23 +1012,15 @@ class CS3IPlayer : IPlayer {
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
// Only play next episode if autoplay is on (default) event(VideoEndedEvent())
if (PreferenceManager.getDefaultSharedPreferences(context)
?.getBoolean(
context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key),
true
) == true
) {
handleEvent(CSPlayerEvent.NextEpisode)
}
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
Player.STATE_IDLE -> { Player.STATE_IDLE -> {
// IDLE
} }
else -> Unit else -> Unit
@ -1039,7 +1045,7 @@ class CS3IPlayer : IPlayer {
} }
else -> { else -> {
playerError?.invoke(error) event(ErrorEvent(error))
} }
} }
@ -1053,7 +1059,7 @@ class CS3IPlayer : IPlayer {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
if (isPlaying) { if (isPlaying) {
requestAutoFocus?.invoke() event(RequestAudioFocusEvent())
onRenderFirst() onRenderFirst()
} }
} }
@ -1066,6 +1072,9 @@ class CS3IPlayer : IPlayer {
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
// Resets subtitle delay on ended video
setSubtitleOffset(0)
// Only play next episode if autoplay is on (default) // Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(context) if (PreferenceManager.getDefaultSharedPreferences(context)
?.getBoolean( ?.getBoolean(
@ -1073,12 +1082,15 @@ class CS3IPlayer : IPlayer {
true true
) == true ) == true
) { ) {
handleEvent(CSPlayerEvent.NextEpisode) handleEvent(
CSPlayerEvent.NextEpisode,
source = PlayerEventSource.Player
)
} }
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
Player.STATE_IDLE -> { Player.STATE_IDLE -> {
@ -1091,27 +1103,28 @@ class CS3IPlayer : IPlayer {
override fun onVideoSizeChanged(videoSize: VideoSize) { override fun onVideoSizeChanged(videoSize: VideoSize) {
super.onVideoSizeChanged(videoSize) super.onVideoSizeChanged(videoSize)
playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height)) event(ResizedEvent(height = videoSize.height, width = videoSize.width))
} }
override fun onRenderedFirstFrame() { override fun onRenderedFirstFrame() {
super.onRenderedFirstFrame() super.onRenderedFirstFrame()
onRenderFirst() onRenderFirst()
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
}) })
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "loadExo error", e) Log.e(TAG, "loadExo error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList() private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) { override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
lastTimeStamps = timeStamps lastTimeStamps = timeStamps
timeStamps.forEach { timestamp -> timeStamps.forEach { timestamp ->
exoPlayer?.createMessage { _, _ -> exoPlayer?.createMessage { _, _ ->
updatedTime() updatedTime(source = PlayerEventSource.Player)
//if (payload is EpisodeSkip.SkipStamp) // this should always be true //if (payload is EpisodeSkip.SkipStamp) // this should always be true
// onTimestampInvoked?.invoke(payload) // onTimestampInvoked?.invoke(payload)
} }
@ -1121,7 +1134,7 @@ class CS3IPlayer : IPlayer {
?.setDeleteAfterDelivery(false) ?.setDeleteAfterDelivery(false)
?.send() ?.send()
} }
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
fun onRenderFirst() { fun onRenderFirst() {
@ -1141,7 +1154,7 @@ class CS3IPlayer : IPlayer {
if (invalid) { if (invalid) {
releasePlayer(saveTime = false) releasePlayer(saveTime = false)
playerError?.invoke(InvalidFileException("Too short playback")) event(ErrorEvent(InvalidFileException("Too short playback")))
return return
} }
@ -1150,7 +1163,7 @@ class CS3IPlayer : IPlayer {
val width = format?.width val width = format?.width
val height = format?.height val height = format?.height
if (height != null && width != null) { if (height != null && width != null) {
playerDimensionsLoaded?.invoke(Pair(width, height)) event(ResizedEvent(width = width, height = height))
updatedTime() updatedTime()
exoPlayer?.apply { exoPlayer?.apply {
requestedListeningPercentages?.forEach { percentage -> requestedListeningPercentages?.forEach { percentage ->
@ -1184,9 +1197,9 @@ class CS3IPlayer : IPlayer {
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources)
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "loadOfflinePlayer error", e) Log.e(TAG, "loadOfflinePlayer error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
@ -1201,7 +1214,7 @@ class CS3IPlayer : IPlayer {
.setMimeType(sub.mimeType) .setMimeType(sub.mimeType)
.setLanguage("_${sub.name}") .setLanguage("_${sub.name}")
.setId(sub.getId()) .setId(sub.getId())
.setSelectionFlags(SELECTION_FLAG_DEFAULT) .setSelectionFlags(0)
.build() .build()
when (sub.origin) { when (sub.origin) {
SubtitleOrigin.DOWNLOADED_FILE -> { SubtitleOrigin.DOWNLOADED_FILE -> {
@ -1245,6 +1258,7 @@ class CS3IPlayer : IPlayer {
return exoPlayer != null return exoPlayer != null
} }
@SuppressLint("UnsafeOptInUsageError")
private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { private fun loadOnlinePlayer(context: Context, link: ExtractorLink) {
Log.i(TAG, "loadOnlinePlayer $link") Log.i(TAG, "loadOnlinePlayer $link")
try { try {
@ -1261,18 +1275,37 @@ class CS3IPlayer : IPlayer {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
} }
val mime = when { val mime = when (link.type) {
link.isM3u8 -> MimeTypes.APPLICATION_M3U8 ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8
link.isDash -> MimeTypes.APPLICATION_MPD ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD
else -> MimeTypes.VIDEO_MP4 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) MediaItemSlice(getMediaItem(mime, it.url), it.durationUs)
} }
} else {
is DrmExtractorLink -> {
listOf( 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 // Single sliced list with unset length
MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE)
) )
@ -1298,16 +1331,16 @@ class CS3IPlayer : IPlayer {
} }
loadExo(context, mediaItems, subSources, cacheFactory) loadExo(context, mediaItems, subSources, cacheFactory)
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "loadOnlinePlayer error", e) Log.e(TAG, "loadOnlinePlayer error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
override fun reloadPlayer(context: Context) { override fun reloadPlayer(context: Context) {
Log.i(TAG, "reloadPlayer") Log.i(TAG, "reloadPlayer")
exoPlayer?.release() releasePlayer(false)
currentLink?.let { currentLink?.let {
loadOnlinePlayer(context, it) loadOnlinePlayer(context, it)
} ?: currentDownloadedFile?.let { } ?: currentDownloadedFile?.let {

View file

@ -2,9 +2,11 @@ package com.lagradost.cloudstream3.ui.player
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.OptIn
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.text.ExoplayerCuesDecoder import androidx.media3.exoplayer.text.ExoplayerCuesDecoder
import androidx.media3.exoplayer.text.SubtitleDecoderFactory import androidx.media3.exoplayer.text.SubtitleDecoderFactory
import androidx.media3.extractor.text.SubtitleDecoder 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 * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
* enough to identify the subtitle format. * enough to identify the subtitle format.
**/ **/
@OptIn(UnstableApi::class)
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
companion object { companion object {
fun updateForcedEncoding(context: Context) { fun updateForcedEncoding(context: Context) {
@ -70,7 +73,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
RegexOption.IGNORE_CASE RegexOption.IGNORE_CASE
), ),
) )
val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\d\s]*?[])}]\s*""")) val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\s]*?[])}]\s*"""))
//https://emptycharacter.com/ //https://emptycharacter.com/
//https://www.fileformat.info/info/unicode/char/200b/index.htm //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 */ /** 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 { class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
override fun supportsFormat(format: Format): Boolean { override fun supportsFormat(format: Format): Boolean {
// return SubtitleDecoderFactory.DEFAULT.supportsFormat(format) // return SubtitleDecoderFactory.DEFAULT.supportsFormat(format)

View file

@ -1,9 +1,12 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.os.Looper 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.SubtitleDecoderFactory
import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextOutput
@OptIn(UnstableApi::class)
class CustomTextRenderer( class CustomTextRenderer(
offset: Long, offset: Long,
output: TextOutput?, output: TextOutput?,

View file

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

View file

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

View file

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

View file

@ -2,20 +2,22 @@ package com.lagradost.cloudstream3.ui.player
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Resources import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.text.Editable import android.text.Editable
import android.util.DisplayMetrics import android.text.format.DateUtils
import android.view.KeyEvent import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
@ -23,18 +25,25 @@ import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHO
import android.view.animation.AlphaAnimation import android.view.animation.AlphaAnimation
import android.view.animation.Animation import android.view.animation.Animation
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import androidx.annotation.OptIn
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.blue import androidx.core.graphics.blue
import androidx.core.graphics.green import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import androidx.core.view.children
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.media3.common.util.UnstableApi
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.keyEventListener
import com.lagradost.cloudstream3.CommonActivity.playerEventListener 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.R
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding
@ -43,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.player.source_priority.QualityDataHelper
import com.lagradost.cloudstream3.ui.result.setText import com.lagradost.cloudstream3.ui.result.setText
import com.lagradost.cloudstream3.ui.result.txt 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.SingleSelectionHelper.showDialog
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
@ -53,8 +66,13 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UserPreferenceDelegate
import com.lagradost.cloudstream3.utils.Vector2 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_SEEK_TIME = 7000L // when swipe seeking
const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage
@ -68,12 +86,12 @@ private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay"
// All the UI Logic for the player // All the UI Logic for the player
open class FullScreenPlayer : AbstractPlayerFragment() { open class FullScreenPlayer : AbstractPlayerFragment() {
private var isVerticalOrientation: Boolean = false
protected open var lockRotation = true protected open var lockRotation = true
protected open var isFullScreenPlayer = true protected open var isFullScreenPlayer = true
protected open var isTv = false
protected var playerBinding: PlayerCustomLayoutBinding? = null protected var playerBinding: PlayerCustomLayoutBinding? = null
private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false)
// state of player UI // state of player UI
protected var isShowing = false protected var isShowing = false
@ -96,14 +114,17 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
// protected var currentPrefQuality = // protected var currentPrefQuality =
// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell // Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell
protected var fastForwardTime = 10000L protected var fastForwardTime = 10000L
protected var androidTVInterfaceOffSeekTime = 10000L; protected var androidTVInterfaceOffSeekTime = 10000L
protected var androidTVInterfaceOnSeekTime = 30000L; protected var androidTVInterfaceOnSeekTime = 30000L
protected var swipeHorizontalEnabled = false protected var swipeHorizontalEnabled = false
protected var swipeVerticalEnabled = false protected var swipeVerticalEnabled = false
protected var playBackSpeedEnabled = false protected var playBackSpeedEnabled = false
protected var playerResizeEnabled = false protected var playerResizeEnabled = false
protected var doubleTapEnabled = false protected var doubleTapEnabled = false
protected var doubleTapPauseEnabled = true protected var doubleTapPauseEnabled = true
protected var playerRotateEnabled = false
protected var autoPlayerRotateEnabled = false
private var hideControlsNames = false
protected var subtitleDelay protected var subtitleDelay
set(value) = try { set(value) = try {
@ -122,19 +143,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var useTrueSystemBrightness = true protected var useTrueSystemBrightness = true
private val fullscreenNotch = true //TODO SETTING 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 statusBarHeight: Int? = null
private var navigationBarHeight: Int? = null private var navigationBarHeight: Int? = null
@ -186,7 +194,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
open fun openOnlineSubPicker( open fun openOnlineSubPicker(
context: Context, context: Context,
imdbId: Long?, loadResponse: LoadResponse?,
dismissCallback: (() -> Unit) dismissCallback: (() -> Unit)
) { ) {
throw NotImplementedError() throw NotImplementedError()
@ -238,6 +246,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
fadeAnimation.duration = 100 fadeAnimation.duration = 100
fadeAnimation.fillAfter = true fadeAnimation.fillAfter = true
@OptIn(UnstableApi::class)
val sView = subView val sView = subView
val sStyle = subStyle val sStyle = subStyle
if (sView != null && sStyle != null) { if (sView != null && sStyle != null) {
@ -251,7 +260,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat()
playerBinding?.apply { playerBinding?.apply {
playerOpenSource.let { playerOpenSource.let {
ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply {
@ -292,6 +300,79 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.getCurrentPreferredSubtitle() == null player.getCurrentPreferredSubtitle() == null
} }
private fun restoreOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation
val orientation = when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
Configuration.ORIENTATION_PORTRAIT ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> dynamicOrientation()
}
activity.requestedOrientation = orientation
}
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) {
lockOrientation(this)
} else {
if (ignoreDynamicOrientation) {
// restore when lock is disabled
restoreOrientationWithSensor(this)
} else {
this.requestedOrientation = dynamicOrientation()
}
}
}
}
}
protected fun enterFullscreen() { protected fun enterFullscreen() {
if (isFullScreenPlayer) { if (isFullScreenPlayer) {
activity?.hideSystemUI() activity?.hideSystemUI()
@ -301,12 +382,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
activity?.window?.attributes = params activity?.window?.attributes = params
} }
} }
if (lockRotation) updateOrientation()
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} }
protected fun exitFullscreen() { protected fun exitFullscreen() {
activity?.showSystemUI()
//if (lockRotation) //if (lockRotation)
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
@ -318,6 +397,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
} }
activity?.window?.attributes = lp activity?.window?.attributes = lp
activity?.showSystemUI()
} }
override fun onResume() { override fun onResume() {
@ -334,7 +414,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private fun setPlayBackSpeed(speed: Float) { private fun setPlayBackSpeed(speed: Float) {
try { try {
setKey(PLAYBACK_SPEED_KEY, speed) DataStoreHelper.playBackSpeed = speed
playerBinding?.playerSpeedBtt?.text = playerBinding?.playerSpeedBtt?.text =
getString(R.string.player_speed_text_format).format(speed) getString(R.string.player_speed_text_format).format(speed)
.replace(".0x", "x") .replace(".0x", "x")
@ -424,6 +504,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
player.seekTime(1L) player.seekTime(1L)
} }
resetBtt.setOnClickListener {
subtitleDelay = 0
dialog.dismissSafe(activity)
player.seekTime(1L)
}
cancelBtt.setOnClickListener { cancelBtt.setOnClickListener {
subtitleDelay = beforeOffset subtitleDelay = beforeOffset
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
@ -561,6 +646,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} }
isLocked = !isLocked isLocked = !isLocked
updateOrientation(true) // set true to ignore auto rotate to stay in current orientation
if (isLocked && isShowing) { if (isLocked && isShowing) {
playerBinding?.playerHolder?.postDelayed({ playerBinding?.playerHolder?.postDelayed({
if (isLocked && isShowing) { if (isLocked && isShowing) {
@ -653,6 +740,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private var currentTapIndex = 0 private var currentTapIndex = 0
protected fun autoHide() { protected fun autoHide() {
currentTapIndex++ currentTapIndex++
delayHide()
}
override fun playerStatusChanged() {
super.playerStatusChanged()
delayHide()
}
private fun delayHide() {
val index = currentTapIndex val index = currentTapIndex
playerBinding?.playerHolder?.postDelayed({ playerBinding?.playerHolder?.postDelayed({
if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) {
@ -839,7 +935,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
currentTouch currentTouch
)?.let { seekTo -> )?.let { seekTo ->
if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) {
player.seekTo(seekTo) player.seekTo(seekTo, PlayerEventSource.UI)
} }
} }
} }
@ -874,7 +970,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} }
else -> { else -> {
player.handleEvent(CSPlayerEvent.PlayPauseToggle) player.handleEvent(
CSPlayerEvent.PlayPauseToggle,
PlayerEventSource.UI
)
} }
} }
} else if (doubleTapEnabled && isFullScreenPlayer) { } else if (doubleTapEnabled && isFullScreenPlayer) {
@ -1067,6 +1166,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
return true return true
} }
@SuppressLint("GestureBackNavigation")
private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean {
if (hasNavigated) { if (hasNavigated) {
autoHide() autoHide()
@ -1083,6 +1183,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} }
} }
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_UP -> { KeyEvent.KEYCODE_DPAD_UP -> {
if (!isShowing) { if (!isShowing) {
onClickChange() onClickChange()
@ -1131,7 +1232,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
// netflix capture back and hide ~monke // netflix capture back and hide ~monke
KeyEvent.KEYCODE_BACK -> { KeyEvent.KEYCODE_BACK -> {
if (isShowing && isTv) { if (isShowing && isLayout(TV or EMULATOR)) {
onClickChange() onClickChange()
return true return true
} }
@ -1149,6 +1250,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
// if nothing has loaded these buttons should not be visible // if nothing has loaded these buttons should not be visible
playerBinding?.apply { playerBinding?.apply {
playerSkipEpisode.isVisible = false playerSkipEpisode.isVisible = false
playerGoForward.isVisible = false
playerTracksBtt.isVisible = false playerTracksBtt.isVisible = false
playerSkipOp.isVisible = false playerSkipOp.isVisible = false
shadowOverlay.isVisible = false shadowOverlay.isVisible = false
@ -1170,7 +1272,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// init variables // init variables
setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f) setPlayBackSpeed(DataStoreHelper.playBackSpeed)
savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let {
subtitleDelay = it subtitleDelay = it
} }
@ -1222,6 +1324,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.handleEvent(CSPlayerEvent.SeekBack) player.handleEvent(CSPlayerEvent.SeekBack)
} }
PlayerEventType.Restart -> {
player.handleEvent(CSPlayerEvent.Restart)
}
PlayerEventType.ToggleMute -> { PlayerEventType.ToggleMute -> {
player.handleEvent(CSPlayerEvent.ToggleMute) player.handleEvent(CSPlayerEvent.ToggleMute)
} }
@ -1257,15 +1363,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} else false } else false
} }
//player_episodes_button?.setOnClickListener {
// player_episodes_button?.isGone = true
// player_episode_list?.isVisible = true
//}
//
//player_episode_list?.adapter = PlayerEpisodeAdapter { click ->
//
//}
try { try {
context?.let { ctx -> context?.let { ctx ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
@ -1301,6 +1398,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
ctx.getString(R.string.playback_speed_enabled_key), ctx.getString(R.string.playback_speed_enabled_key),
false 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 = playerResizeEnabled =
settingsManager.getBoolean( settingsManager.getBoolean(
ctx.getString(R.string.player_resize_enabled_key), ctx.getString(R.string.player_resize_enabled_key),
@ -1318,6 +1423,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
false false
) )
hideControlsNames = settingsManager.getBoolean(ctx.getString(R.string.hide_player_control_names_key), false)
val profiles = QualityDataHelper.getProfiles() val profiles = QualityDataHelper.getProfiles()
val type = if (ctx.isUsingMobileData()) val type = if (ctx.isUsingMobileData())
QualityDataHelper.QualityProfileType.Data QualityDataHelper.QualityProfileType.Data
@ -1337,20 +1444,57 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
playerBinding?.apply { playerBinding?.apply {
playerSpeedBtt.isVisible = playBackSpeedEnabled playerSpeedBtt.isVisible = playBackSpeedEnabled
playerResizeBtt.isVisible = playerResizeEnabled playerResizeBtt.isVisible = playerResizeEnabled
playerRotateBtt.isVisible = playerRotateEnabled
if (hideControlsNames) {
hideControlsNames()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
playerBinding?.apply { 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 { playerPausePlay.setOnClickListener {
autoHide() autoHide()
player.handleEvent(CSPlayerEvent.PlayPauseToggle) player.handleEvent(CSPlayerEvent.PlayPauseToggle)
} }
exoDuration.setOnClickListener {
setRemainingTimeCounter(true)
}
timeLeft.setOnClickListener {
setRemainingTimeCounter(false)
}
skipChapterButton.setOnClickListener { skipChapterButton.setOnClickListener {
player.handleEvent(CSPlayerEvent.SkipCurrentChapter) player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
} }
playerRotateBtt.setOnClickListener {
autoHide()
toggleRotate()
}
// init clicks // init clicks
playerResizeBtt.setOnClickListener { playerResizeBtt.setOnClickListener {
autoHide() autoHide()
@ -1372,6 +1516,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.handleEvent(CSPlayerEvent.NextEpisode) player.handleEvent(CSPlayerEvent.NextEpisode)
} }
playerGoForward.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.NextEpisode)
}
playerRestart.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.Restart)
}
playerLock.setOnClickListener { playerLock.setOnClickListener {
autoHide() autoHide()
toggleLock() toggleLock()
@ -1426,34 +1580,74 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
return@setOnTouchListener false return@setOnTouchListener false
} }
} }
// cs3 is peak media center
setRemainingTimeCounter(durationMode || isLayout(TV))
playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ ->
updateRemainingTime()
}
// init UI // init UI
try { try {
uiReset() 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) { } catch (e: Exception) {
logError(e) 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.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
@ -13,6 +14,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.core.animation.addListener import androidx.core.animation.addListener
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone import androidx.core.view.isGone
@ -21,16 +23,21 @@ import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.Format.NO_VALUE
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import com.hippo.unifile.UniFile import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast 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.DialogOnlineSubtitlesBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage 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.QualityDataHelper
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
import com.lagradost.cloudstream3.ui.result.* 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.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1
import com.lagradost.cloudstream3.utils.* 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.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
@ -52,7 +64,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.io.Serializable
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
@ -68,7 +82,10 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
val subsProviders 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 val subsProvidersIsActive
get() = subsProviders.isNotEmpty() get() = subsProviders.isNotEmpty()
} }
@ -101,10 +118,33 @@ class GeneratorPlayer : FullScreenPlayer() {
binding?.playerLoadingOverlay?.isVisible = true binding?.playerLoadingOverlay?.isVisible = true
} }
private fun setSubtitles(sub: SubtitleData?): Boolean { private fun setSubtitles(subtitle: SubtitleData?): Boolean {
currentSelectedSubtitles = sub // If subtitle is changed -> Save the language
//Log.i(TAG, "setSubtitles = $sub") if (subtitle != currentSelectedSubtitles) {
return player.setPreferredSubtitles(sub) 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>) { override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
@ -122,6 +162,13 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
} }
override fun playerStatusChanged() {
super.playerStatusChanged()
if (player.getIsPlaying()) {
viewModel.forceClearCache = false
}
}
private fun noSubtitles(): Boolean { private fun noSubtitles(): Boolean {
return setSubtitles(null) return setSubtitles(null)
} }
@ -135,7 +182,7 @@ class GeneratorPlayer : FullScreenPlayer() {
return durPos.position return durPos.position
} }
var currentVerifyLink: Job? = null private var currentVerifyLink: Job? = null
private fun loadExtractorJob(extractorLink: ExtractorLink?) { private fun loadExtractorJob(extractorLink: ExtractorLink?) {
currentVerifyLink?.cancel() currentVerifyLink?.cancel()
@ -181,6 +228,7 @@ class GeneratorPlayer : FullScreenPlayer() {
(if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle(
currentSubs, settings = true, downloads = true currentSubs, settings = true, downloads = true
), ),
preview = isFullScreenPlayer
) )
} }
@ -190,7 +238,7 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun closestQuality(target: Int?): Qualities { private fun closestQuality(target: Int?): Qualities {
if (target == null) return Qualities.Unknown 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( private fun getLinkPriority(
@ -220,6 +268,7 @@ class GeneratorPlayer : FullScreenPlayer() {
var episode: Int? = null, var episode: Int? = null,
var season: Int? = null, var season: Int? = null,
var name: String? = null, var name: String? = null,
var imdbId: String? = null,
) )
private fun getMetaData(): TempMetaData { private fun getMetaData(): TempMetaData {
@ -246,7 +295,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
override fun openOnlineSubPicker( override fun openOnlineSubPicker(
context: Context, imdbId: Long?, dismissCallback: (() -> Unit) context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit)
) { ) {
val providers = subsProviders val providers = subsProviders
val isSingleProvider = subsProviders.size == 1 val isSingleProvider = subsProviders.size == 1
@ -322,8 +371,6 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE
binding.subtitleAdapter.adapter = arrayAdapter binding.subtitleAdapter.adapter = arrayAdapter
val adapter =
binding.subtitleAdapter.adapter as? ArrayAdapter<AbstractSubtitleEntities.SubtitleEntity>
binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ -> binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ ->
currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener
@ -334,11 +381,12 @@ class GeneratorPlayer : FullScreenPlayer() {
fun setSubtitlesList(list: List<AbstractSubtitleEntities.SubtitleEntity>) { fun setSubtitlesList(list: List<AbstractSubtitleEntities.SubtitleEntity>) {
currentSubtitles = list currentSubtitles = list
adapter?.clear() arrayAdapter.clear()
adapter?.addAll(currentSubtitles) arrayAdapter.addAll(currentSubtitles)
} }
val currentTempMeta = getMetaData() val currentTempMeta = getMetaData()
// bruh idk why it is not correct // bruh idk why it is not correct
val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent)) val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent))
binding.searchLoadingBar.progressTintList = color binding.searchLoadingBar.progressTintList = color
@ -386,7 +434,10 @@ class GeneratorPlayer : FullScreenPlayer() {
val search = val search =
AbstractSubtitleEntities.SubtitleSearch( AbstractSubtitleEntities.SubtitleSearch(
query = query ?: return@ioSafe, query = query ?: return@ioSafe,
imdb = imdbId, imdbId = loadResponse?.getImdbId(),
tmdbId = loadResponse?.getTMDbId()?.toInt(),
malId = loadResponse?.getMalId()?.toInt(),
aniListId = loadResponse?.getAniListId()?.toInt(),
epNumber = currentTempMeta.episode, epNumber = currentTempMeta.episode,
seasonNumber = currentTempMeta.season, seasonNumber = currentTempMeta.season,
lang = currentLanguageTwoLetters.ifBlank { null }, lang = currentLanguageTwoLetters.ifBlank { null },
@ -442,16 +493,21 @@ class GeneratorPlayer : FullScreenPlayer() {
currentSubtitle?.let { currentSubtitle -> currentSubtitle?.let { currentSubtitle ->
providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api ->
ioSafe { ioSafe {
val url = api.load(currentSubtitle) ?: return@ioSafe val subtitles =
val subtitle = SubtitleData( api.getResource(currentSubtitle).getSubtitles().map { resource ->
name = getName(currentSubtitle, true), SubtitleData(
url = url, name = resource.name ?: getName(currentSubtitle, true),
origin = SubtitleOrigin.URL, url = resource.url,
mimeType = url.toSubtitleMimeType(), origin = resource.origin,
headers = currentSubtitle.headers mimeType = resource.url.toSubtitleMimeType(),
headers = currentSubtitle.headers,
currentSubtitle.lang
) )
}
if (subtitles.isNotEmpty()) {
runOnMainThread { runOnMainThread {
addAndSelectSubtitles(subtitle) addAndSelectSubtitles(*subtitles.toTypedArray())
}
} }
} }
} }
@ -468,7 +524,7 @@ class GeneratorPlayer : FullScreenPlayer() {
//TODO: Set year text from currently loaded movie on Player //TODO: Set year text from currently loaded movie on Player
//dialog.subtitles_search_year?.setText(currentTempMeta.year) //dialog.subtitles_search_year?.setText(currentTempMeta.year)
} }
@OptIn(UnstableApi::class)
private fun openSubPicker() { private fun openSubPicker() {
try { try {
subsPathPicker.launch( subsPathPicker.launch(
@ -489,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 ctx = context ?: return
val subs = currentSubs + subtitleData val subs = currentSubs + subtitleData
@ -501,13 +561,13 @@ class GeneratorPlayer : FullScreenPlayer() {
player.saveData() player.saveData()
player.reloadPlayer(ctx) player.reloadPlayer(ctx)
setSubtitles(subtitleData) setSubtitles(selectedSubtitle)
viewModel.addSubtitles(setOf(subtitleData)) viewModel.addSubtitles(subtitleData.toSet())
selectSourceDialog?.dismissSafe() selectSourceDialog?.dismissSafe()
showToast( 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 Toast.LENGTH_LONG
) )
} }
@ -520,22 +580,24 @@ class GeneratorPlayer : FullScreenPlayer() {
if (uri == null) return@normalSafeApiCall if (uri == null) return@normalSafeApiCall
val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall
// RW perms for the path // RW perms for the path
val flags = ctx.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
ctx.contentResolver.takePersistableUriPermission(uri, flags) val file = SafeFile.fromUri(ctx, uri)
val fileName = file?.name()
val file = UniFile.fromUri(ctx, uri) println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName")
println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}")
// DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES
val name = file.name ?: uri.toString() val name = fileName ?: uri.toString()
val subtitleData = SubtitleData( val subtitleData = SubtitleData(
name, name,
uri.toString(), uri.toString(),
SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(), name.toSubtitleMimeType(),
emptyMap() emptyMap(),
null
) )
addAndSelectSubtitles(subtitleData) addAndSelectSubtitles(subtitleData)
@ -551,7 +613,7 @@ class GeneratorPlayer : FullScreenPlayer() {
//println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs")
context?.let { ctx -> context?.let { ctx ->
val isPlaying = player.getIsPlaying() val isPlaying = player.getIsPlaying()
player.handleEvent(CSPlayerEvent.Pause) player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
val currentSubtitles = sortSubs(currentSubs) val currentSubtitles = sortSubs(currentSubs)
val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
@ -584,6 +646,8 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
if (subsProvidersIsActive) { if (subsProvidersIsActive) {
val currentLoadResponse = viewModel.getLoadResponse()
val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( val loadFromOpenSubsFooter: TextView = layoutInflater.inflate(
R.layout.sort_bottom_footer_add_choice, null R.layout.sort_bottom_footer_add_choice, null
) as TextView ) as TextView
@ -594,7 +658,7 @@ class GeneratorPlayer : FullScreenPlayer() {
loadFromOpenSubsFooter.setOnClickListener { loadFromOpenSubsFooter.setOnClickListener {
shouldDismiss = false shouldDismiss = false
sourceDialog.dismissSafe(activity) sourceDialog.dismissSafe(activity)
openOnlineSubPicker(it.context, null) { openOnlineSubPicker(it.context, currentLoadResponse) {
dismiss() dismiss()
} }
} }
@ -733,7 +797,6 @@ class GeneratorPlayer : FullScreenPlayer() {
settingsManager.edit().putString( settingsManager.edit().putString(
ctx.getString(R.string.subtitles_encoding_key), prefValues[it] ctx.getString(R.string.subtitles_encoding_key), prefValues[it]
).apply() ).apply()
updateForcedEncoding(ctx) updateForcedEncoding(ctx)
dismiss() dismiss()
player.seekTime(-1) // to update subtitles, a dirty trick player.seekTime(-1) // to update subtitles, a dirty trick
@ -883,12 +946,17 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
override fun playerError(exception: Exception) { override fun playerError(exception: Throwable) {
Log.i(TAG, "playerError = $currentSelectedLink") Log.i(TAG, "playerError = $currentSelectedLink")
if (!hasNextMirror()) {
viewModel.forceClearCache = true
}
super.playerError(exception) super.playerError(exception)
} }
private fun noLinksFound() { private fun noLinksFound() {
viewModel.forceClearCache = true
showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT)
activity?.popCurrentPage() activity?.popCurrentPage()
} }
@ -945,14 +1013,13 @@ class GeneratorPlayer : FullScreenPlayer() {
var maxEpisodeSet: Int? = null var maxEpisodeSet: Int? = null
var hasRequestedStamps: Boolean = false var hasRequestedStamps: Boolean = false
override fun playerPositionChanged(posDur: Pair<Long, Long>) { override fun playerPositionChanged(position: Long, duration: Long) {
// Don't save livestream data // Don't save livestream data
if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return
// Don't save NSFW data // Don't save NSFW data
if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return 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 (duration <= 0L) return // idk how you achieved this, but div by zero crash
if (!hasRequestedStamps) { if (!hasRequestedStamps) {
hasRequestedStamps = true hasRequestedStamps = true
@ -1023,7 +1090,7 @@ class GeneratorPlayer : FullScreenPlayer() {
ctx.getString(R.string.episode_sync_enabled_key), true ctx.getString(R.string.episode_sync_enabled_key), true
) )
) maxEpisodeSet = meta.episode ) maxEpisodeSet = meta.episode
sync.modifyMaxEpisode(meta.episode) sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode)
} }
} }
@ -1032,9 +1099,16 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
playerBinding?.playerSkipOp?.isVisible = isOpVisible playerBinding?.playerSkipOp?.isVisible = isOpVisible
when {
isLayout(PHONE) ->
playerBinding?.playerSkipEpisode?.isVisible = playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true !isOpVisible && viewModel.hasNextEpisode() == true
else ->
playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true
}
if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) {
viewModel.preLoadNextLinks() viewModel.preLoadNextLinks()
} }
@ -1189,7 +1263,7 @@ class GeneratorPlayer : FullScreenPlayer() {
fun setPlayerDimen(widthHeight: Pair<Int, Int>?) { fun setPlayerDimen(widthHeight: Pair<Int, Int>?) {
val extra = if (widthHeight != null) { val extra = if (widthHeight != null) {
val (width, height) = widthHeight val (width, height) = widthHeight
"${width}x${height}" "- ${width}x${height}"
} else { } else {
"" ""
} }
@ -1200,7 +1274,7 @@ class GeneratorPlayer : FullScreenPlayer() {
0 -> "" 0 -> ""
1 -> extra 1 -> extra
2 -> source 2 -> source
3 -> "$source - $extra" 3 -> "$source $extra"
else -> "" else -> ""
} }
playerBinding?.playerVideoTitleRez?.apply { playerBinding?.playerVideoTitleRez?.apply {
@ -1209,14 +1283,15 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
} }
override fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) { override fun playerDimensionsLoaded(width: Int, height: Int) {
setPlayerDimen(widthHeight) super.playerDimensionsLoaded(width, height)
setPlayerDimen(width to height)
} }
private fun unwrapBundle(savedInstanceState: Bundle?) { private fun unwrapBundle(savedInstanceState: Bundle?) {
Log.i(TAG, "unwrapBundle = $savedInstanceState") Log.i(TAG, "unwrapBundle = $savedInstanceState")
savedInstanceState?.let { bundle -> savedInstanceState?.let { bundle ->
sync.addSyncs(bundle.getSerializable("syncData") as? HashMap<String, String>?) sync.addSyncs(bundle.getSafeSerializable<HashMap<String, String>>("syncData"))
} }
} }
@ -1224,8 +1299,8 @@ class GeneratorPlayer : FullScreenPlayer() {
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? { ): View? {
// this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason // 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 =
layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player
viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java]
sync = ViewModelProvider(this)[SyncViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java]
@ -1252,7 +1327,6 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun displayTimeStamp(show: Boolean) { private fun displayTimeStamp(show: Boolean) {
if (timestampShowState == show) return if (timestampShowState == show) return
skipIndex++ skipIndex++
println("displayTimeStamp = $show")
timestampShowState = show timestampShowState = show
playerBinding?.skipChapterButton?.apply { playerBinding?.skipChapterButton?.apply {
val showWidth = 170.toPx val showWidth = 170.toPx
@ -1274,7 +1348,18 @@ class GeneratorPlayer : FullScreenPlayer() {
from, to from, to
).apply { ).apply {
addListener(onEnd = { addListener(onEnd = {
if (!show) playerBinding?.skipChapterButton?.isVisible = false if (show) {
if (!isShowing) {
// Automatically request focus if the menu is not opened
playerBinding?.skipChapterButton?.requestFocus()
}
} else {
playerBinding?.skipChapterButton?.isVisible = false
if (!isShowing) {
// Automatically return focus to play pause
playerBinding?.playerPausePlay?.requestFocus()
}
}
}) })
addUpdateListener { valueAnimator -> addUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as Int val value = valueAnimator.animatedValue as Int
@ -1294,7 +1379,6 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
if (timestamp != null) { if (timestamp != null) {
println("timestamp: $timestamp")
playerBinding?.skipChapterButton?.setText(timestamp.uiText) playerBinding?.skipChapterButton?.setText(timestamp.uiText)
displayTimeStamp(true) displayTimeStamp(true)
val currentIndex = skipIndex val currentIndex = skipIndex
@ -1346,6 +1430,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
binding?.playerLoadingGoBack?.setOnClickListener { binding?.playerLoadingGoBack?.setOnClickListener {
exitFullscreen()
player.release() player.release()
activity?.popCurrentPage() activity?.popCurrentPage()
} }
@ -1377,7 +1462,7 @@ class GeneratorPlayer : FullScreenPlayer() {
observe(viewModel.currentLinks) { observe(viewModel.currentLinks) {
currentLinks = it currentLinks = it
val turnVisible = it.isNotEmpty() val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true
val wasGone = binding?.overlayLoadingSkipButton?.isGone == true val wasGone = binding?.overlayLoadingSkipButton?.isGone == true
binding?.overlayLoadingSkipButton?.isVisible = turnVisible binding?.overlayLoadingSkipButton?.isVisible = turnVisible
@ -1423,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)

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