Compare commits

...

148 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
KingLucius
a2e63174be Set play button to first unwatched episode on TV 2024-03-19 17:47:36 +02:00
457 changed files with 13805 additions and 54243 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}")

7
.idea/gradle.xml generated
View file

@ -4,17 +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="jbr-17" /> <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

@ -1,5 +1,6 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties 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 org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.URL import java.net.URL
@ -9,7 +10,6 @@ plugins {
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("kotlin-android") id("kotlin-android")
id("org.jetbrains.dokka") id("org.jetbrains.dokka")
id("androidx.baselineprofile")
} }
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
@ -42,8 +42,8 @@ android {
}*/ }*/
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")
@ -60,8 +60,8 @@ android {
minSdk = 21 minSdk = 21
targetSdk = 33 /* Android 14 is Fu*ked targetSdk = 33 /* Android 14 is Fu*ked
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/ ^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
versionCode = 63 versionCode = 64
versionName = "4.3.2" 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() ?: "")
@ -71,9 +71,9 @@ android {
val localProperties = gradleLocalProperties(rootDir) val localProperties = gradleLocalProperties(rootDir)
buildConfigField( buildConfigField(
"String", "long",
"BUILDDATE", "BUILD_DATE",
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" "${System.currentTimeMillis()}"
) )
buildConfigField( buildConfigField(
"String", "String",
@ -124,7 +124,11 @@ android {
resValue("bool", "is_prerelease", "true") resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true") buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease" applicationIdSuffix = ".prerelease"
signingConfig = signingConfigs.getByName("prerelease") if (signingConfigs.names.contains("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()
} }
@ -155,24 +159,24 @@ repositories {
dependencies { dependencies {
// Testing // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20231013") testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test:core") androidTestImplementation("androidx.test:core")
implementation("androidx.test.ext:junit-ktx:1.1.5") implementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// Android Core & Lifecycle // Android Core & Lifecycle
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7") implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
// Design & UI // 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("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
@ -182,9 +186,9 @@ dependencies {
implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0") implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
// For KSP -> Official Annotation Processors are Not Yet Supported for KSP // For KSP -> Official Annotation Processors are Not Yet Supported for KSP
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.1.0") ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("com.google.guava:guava:32.1.3-android") implementation("com.google.guava:guava:33.2.1-android")
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.1.0") implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
// Media 3 (ExoPlayer) // Media 3 (ExoPlayer)
implementation("androidx.media3:media3-ui:1.1.1") implementation("androidx.media3:media3-ui:1.1.1")
@ -200,9 +204,9 @@ dependencies {
// PlayBack // PlayBack
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker 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.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
implementation("com.github.teamnewpipe:NewPipeExtractor:6dc25f7") /* For Trailers implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers
^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */ ^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
implementation("com.github.albfernandez:juniversalchardet:2.4.0") // Subtitle Decoding implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
// Crash Reports (AcraApplication.kt) // Crash Reports (AcraApplication.kt)
implementation("ch.acra:acra-core:5.11.3") implementation("ch.acra:acra-core:5.11.3")
@ -213,18 +217,17 @@ dependencies {
implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors 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("com.github.discord:OverlappingPanels:0.1.5") // Gestures
implementation ("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview 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
// Extensions & Other Libs // Extensions & Other Libs
implementation("org.mozilla:rhino:1.7.13") /* run JavaScript implementation("org.mozilla:rhino:1.7.15") // run JavaScript
^ Don't Bump RhinoJS to 1.7.14,`NoClassDefFoundError` Occurs and Trailers won't play (even with Desugaring)
NewPipeExtractor Issue */
implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance 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("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("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.10.0") // TMDB API v3 Wrapper Made with RetroFit implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") 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 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 ^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
Level 25 or Less. */ Level 25 or Less. */
@ -234,22 +237,45 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
// Baseline Profile and Automation implementation(project(":library") {
implementation("androidx.profileinstaller:profileinstaller:1.3.1") // There does not seem to be a good way of getting the android flavor.
implementation("androidx.test.uiautomator:uiautomator:2.3.0") val isDebug = gradle.startParameter.taskRequests.any { task ->
"baselineProfile"(project(":baselineprofile")) task.args.any { arg ->
arg.contains("debug", true)
}
}
this.extra.set("isDebug", isDebug)
})
} }
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
} }
// For 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") )
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> { tasks.withType<KotlinCompile> {

View file

@ -154,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")
@ -166,7 +166,6 @@ class ExampleInstrumentedTest {
TestingUtils.getDeferredProviderTests( TestingUtils.getDeferredProviderTests(
this, this,
getAllProviders(), getAllProviders(),
::println
) { _, _ -> } ) { _, _ -> }
} }
} }

View file

@ -97,7 +97,7 @@
--> -->
<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"

View file

@ -8,13 +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.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.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser 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
@ -34,6 +35,7 @@ import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.PrintStream import java.io.PrintStream
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
@ -80,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) {
@ -105,7 +101,6 @@ class AcraApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
//NativeCrashHandler.initCrashHandler()
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))
@ -151,6 +146,7 @@ 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? { fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {

View file

@ -5,6 +5,7 @@ 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.DisplayMetrics
@ -29,13 +30,14 @@ import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.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.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.Globals.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
@ -163,7 +165,7 @@ object CommonActivity {
val toast = Toast(act) val toast = Toast(act)
toast.duration = duration ?: Toast.LENGTH_SHORT toast.duration = duration ?: Toast.LENGTH_SHORT
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.view = binding.root toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
currentToast = toast currentToast = toast
toast.show() toast.show()
@ -275,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
@ -351,8 +376,8 @@ object CommonActivity {
currentLook = currentLook.parent as? View ?: break currentLook = currentLook.parent as? View ?: break
}*/ }*/
private fun View.hasContent() : Boolean { private fun View.hasContent(): Boolean {
return isShown && when(this) { return isShown && when (this) {
//is RecyclerView -> this.childCount > 0 //is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0 is ViewGroup -> this.childCount > 0
else -> true else -> true
@ -463,20 +488,6 @@ object CommonActivity {
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) {

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)
@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance return instance
} }
} }
init {
client = builder.readTimeout(30, TimeUnit.SECONDS).build()
}
} }

View file

@ -44,9 +44,6 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.Session import com.google.android.gms.cast.framework.Session
import com.google.android.gms.cast.framework.SessionManager import com.google.android.gms.cast.framework.SessionManager
@ -59,9 +56,7 @@ import com.google.common.collect.Comparators.min
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.initAll import com.lagradost.cloudstream3.APIHolder.initAll
import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
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
@ -73,6 +68,7 @@ import com.lagradost.cloudstream3.CommonActivity.screenHeight
import com.lagradost.cloudstream3.CommonActivity.setActivityInstance import com.lagradost.cloudstream3.CommonActivity.setActivityInstance
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale import com.lagradost.cloudstream3.CommonActivity.updateLocale
import com.lagradost.cloudstream3.CommonActivity.updateTheme
import com.lagradost.cloudstream3.databinding.ActivityMainBinding import com.lagradost.cloudstream3.databinding.ActivityMainBinding
import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding
import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
@ -87,20 +83,22 @@ import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.services.SubscriptionWorkManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringPlayer
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.HomeViewModel
import com.lagradost.cloudstream3.ui.library.LibraryViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.player.LinkGenerator
@ -110,6 +108,7 @@ import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.SyncViewModel
import com.lagradost.cloudstream3.ui.result.setImage import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.ui.result.setText import com.lagradost.cloudstream3.ui.result.setText
import com.lagradost.cloudstream3.ui.result.setTextHtml
import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
@ -122,20 +121,27 @@ import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
import com.lagradost.cloudstream3.utils.ApkInstaller import com.lagradost.cloudstream3.utils.ApkInstaller
import com.lagradost.cloudstream3.utils.AppUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.AppUtils.isLtr import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppUtils.isNetworkAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr
import com.lagradost.cloudstream3.utils.AppUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable
import com.lagradost.cloudstream3.utils.AppUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
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.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.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
@ -147,6 +153,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -158,8 +165,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import com.lagradost.nicehttp.Requests import com.lagradost.cloudstream3.utils.fcast.FcastManager
import com.lagradost.nicehttp.ResponseParser
import com.lagradost.safefile.SafeFile import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -170,7 +176,6 @@ import java.net.URLDecoder
import java.nio.charset.Charset import java.nio.charset.Charset
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.reflect.KClass
import kotlin.system.exitProcess import kotlin.system.exitProcess
//https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 //https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
@ -183,117 +188,92 @@ import kotlin.system.exitProcess
//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225 //https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225
const val VLC_PACKAGE = "org.videolan.vlc" class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
const val MPV_PACKAGE = "is.xyz.mpv"
const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
//TODO REFACTOR AF
open class ResultResume(
val packageString: String,
val action: String = Intent.ACTION_VIEW,
val position: String? = null,
val duration: String? = null,
var launcher: ActivityResultLauncher<Intent>? = null,
) {
val defaultTime = -1L
val lastId get() = "${packageString}_last_open_id"
suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
val intent = Intent(action)
if (id != null)
setKey(lastId, id)
else
removeKey(lastId)
intent.setPackage(packageString)
callback.invoke(intent)
launcher?.launch(intent)
}
open fun getPosition(intent: Intent?): Long {
return defaultTime
}
open fun getDuration(intent: Intent?): Long {
return defaultTime
}
}
val VLC = object : ResultResume(
VLC_PACKAGE,
// Android 13 intent restrictions fucks up specifically launching the VLC player
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
"org.videolan.vlc.player.result"
} else {
Intent.ACTION_VIEW
},
"extra_position",
"extra_duration",
) {
override fun getPosition(intent: Intent?): Long {
return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
}
override fun getDuration(intent: Intent?): Long {
return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
}
}
val MPV = object : ResultResume(
MPV_PACKAGE,
//"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
position = "position",
duration = "duration",
) {
override fun getPosition(intent: Intent?): Long {
return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime
}
override fun getDuration(intent: Intent?): Long {
return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime
}
}
val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
val resumeApps = arrayOf(
VLC, MPV, WEB_VIDEO
)
// Short name for requests client to make it nicer to use
var app = Requests(responseParser = object : ResponseParser {
val mapper: ObjectMapper = jacksonObjectMapper().configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
)
override fun <T : Any> parse(text: String, kClass: KClass<T>): T {
return mapper.readValue(text, kClass.java)
}
override fun <T : Any> parseSafe(text: String, kClass: KClass<T>): T? {
return try {
mapper.readValue(text, kClass.java)
} catch (e: Exception) {
null
}
}
override fun writeValueAsString(obj: Any): String {
return mapper.writeValueAsString(obj)
}
}).apply {
defaultHeaders = mapOf("user-agent" to USER_AGENT)
}
class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
BiometricAuthenticator.BiometricAuthCallback {
companion object { companion object {
const val VLC_PACKAGE = "org.videolan.vlc"
const val MPV_PACKAGE = "is.xyz.mpv"
const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
//TODO REFACTOR AF
open class ResultResume(
val packageString: String,
val action: String = Intent.ACTION_VIEW,
val position: String? = null,
val duration: String? = null,
var launcher: ActivityResultLauncher<Intent>? = null,
) {
val defaultTime = -1L
val lastId get() = "${packageString}_last_open_id"
suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
val intent = Intent(action)
if (id != null)
setKey(lastId, id)
else
removeKey(lastId)
intent.setPackage(packageString)
callback.invoke(intent)
launcher?.launch(intent)
}
open fun getPosition(intent: Intent?): Long {
return defaultTime
}
open fun getDuration(intent: Intent?): Long {
return defaultTime
}
}
val VLC = object : ResultResume(
VLC_PACKAGE,
// Android 13 intent restrictions fucks up specifically launching the VLC player
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
"org.videolan.vlc.player.result"
} else {
Intent.ACTION_VIEW
},
"extra_position",
"extra_duration",
) {
override fun getPosition(intent: Intent?): Long {
return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
}
override fun getDuration(intent: Intent?): Long {
return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
}
}
val MPV = object : ResultResume(
MPV_PACKAGE,
//"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
position = "position",
duration = "duration",
) {
override fun getPosition(intent: Intent?): Long {
return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong()
?: defaultTime
}
override fun getDuration(intent: Intent?): Long {
return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong()
?: defaultTime
}
}
val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
val resumeApps = arrayOf(
VLC, MPV, WEB_VIDEO
)
const val TAG = "MAINACT" const val TAG = "MAINACT"
const val ANIMATED_OUTLINE: Boolean = false const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null var lastError: String? = null
@ -371,7 +351,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
println("Repository url: $realUrl") println("Repository url: $realUrl")
loadRepository(realUrl) loadRepository(realUrl)
return true return true
} else if (str.contains(appString)) { } else if (str.contains(APP_STRING)) {
for (api in OAuth2Apis) { for (api in OAuth2Apis) {
if (str.contains("/${api.redirectUrl}")) { if (str.contains("/${api.redirectUrl}")) {
ioSafe { ioSafe {
@ -401,15 +381,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
} }
// This specific intent is used for the gradle deployWithAdb // This specific intent is used for the gradle deployWithAdb
// https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46 // https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46
if (str == "$appString:") { if (str == "$APP_STRING:") {
PluginManager.hotReloadAllLocalPlugins(activity) PluginManager.hotReloadAllLocalPlugins(activity)
} }
} else if (safeURI(str)?.scheme == appStringRepo) { } else if (safeURI(str)?.scheme == APP_STRING_REPO) {
val url = str.replaceFirst(appStringRepo, "https") val url = str.replaceFirst(APP_STRING_REPO, "https")
loadRepository(url) loadRepository(url)
return true return true
} else if (safeURI(str)?.scheme == appStringSearch) { } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) {
val query = str.substringAfter("$appStringSearch://") val query = str.substringAfter("$APP_STRING_SEARCH://")
nextSearchQuery = nextSearchQuery =
try { try {
URLDecoder.decode(query, "UTF-8") URLDecoder.decode(query, "UTF-8")
@ -423,7 +403,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
R.id.navigation_search R.id.navigation_search
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId = activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search R.id.navigation_search
} else if (safeURI(str)?.scheme == appStringPlayer) { } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
val uri = Uri.parse(str) val uri = Uri.parse(str)
val name = uri.getQueryParameter("name") val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8") val url = URLDecoder.decode(uri.authority, "UTF-8")
@ -437,9 +417,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
) )
) )
) )
} else if (safeURI(str)?.scheme == appStringResumeWatching) { } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
val id = val id =
str.substringAfter("$appStringResumeWatching://").toIntOrNull() str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull()
?: return false ?: return false
ioSafe { ioSafe {
val resumeWatchingCard = val resumeWatchingCard =
@ -493,7 +473,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
) DubStatus.Dubbed else DubStatus.Subbed, null ) DubStatus.Dubbed else DubStatus.Subbed, null
) )
} else { } else {
viewModel.loadSmall(this, result) viewModel.loadSmall(result)
} }
} }
@ -508,6 +488,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
updateLocale() // android fucks me by chaining lang when rotating the phone updateLocale() // android fucks me by chaining lang when rotating the phone
updateTheme(this) // Update if system theme
val navHostFragment = val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
@ -592,23 +573,44 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
false false
} }
} }
binding?.apply { binding?.apply {
navView.isVisible = isNavVisible && !landscape
navRailView.isVisible = isNavVisible && landscape navRailView.isVisible = isNavVisible && landscape
navView.isVisible = isNavVisible && !landscape
// Hide library on TV since it is not supported yet :( /**
//val isTrueTv = isTrueTvSettings() * We need to make sure if we return to a sub-fragment,
//navView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv * the correct navigation item is selected so that it does not
//navRailView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv * highlight the wrong one in UI.
*/
// Hide downloads on TV when (destination.id) {
//navView.menu.findItem(R.id.navigation_downloads)?.isVisible = !isTrueTv in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
//navRailView.menu.findItem(R.id.navigation_downloads)?.isVisible = !isTrueTv navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
}
in listOf(
R.id.navigation_settings,
R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player,
R.id.navigation_settings_updates,
R.id.navigation_settings_ui,
R.id.navigation_settings_account,
R.id.navigation_settings_providers,
R.id.navigation_settings_general,
R.id.navigation_settings_extensions,
R.id.navigation_settings_plugins,
R.id.navigation_test_providers
) -> {
navRailView.menu.findItem(R.id.navigation_settings).isChecked = true
navView.menu.findItem(R.id.navigation_settings).isChecked = true
}
}
} }
} }
//private var mCastSession: CastSession? = null //private var mCastSession: CastSession? = null
lateinit var mSessionManager: SessionManager var mSessionManager: SessionManager? = null
private val mSessionManagerListener: SessionManagerListener<Session> by lazy { SessionManagerListenerImpl() } private val mSessionManagerListener: SessionManagerListener<Session> by lazy { SessionManagerListenerImpl() }
private inner class SessionManagerListenerImpl : SessionManagerListener<Session> { private inner class SessionManagerListenerImpl : SessionManagerListener<Session> {
@ -648,8 +650,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
setActivityInstance(this) setActivityInstance(this)
try { try {
if (isCastApiAvailable()) { if (isCastApiAvailable()) {
//mCastSession = mSessionManager.currentCastSession mSessionManager?.addSessionManagerListener(mSessionManagerListener)
mSessionManager.addSessionManagerListener(mSessionManagerListener)
} }
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -665,7 +666,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
} }
try { try {
if (isCastApiAvailable()) { if (isCastApiAvailable()) {
mSessionManager.removeSessionManagerListener(mSessionManagerListener) mSessionManager?.removeSessionManagerListener(mSessionManagerListener)
//mCastSession = null //mCastSession = null
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -673,7 +674,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
} }
} }
override fun dispatchKeyEvent(event: KeyEvent?): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val response = CommonActivity.dispatchKeyEvent(this, event) val response = CommonActivity.dispatchKeyEvent(this, event)
if (response != null) if (response != null)
return response return response
@ -769,7 +770,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
list.forEach { custom -> list.forEach { custom ->
allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
?.let { ?.let {
allProviders.add(it.javaClass.newInstance().apply { allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply {
name = custom.name name = custom.name
lang = custom.lang lang = custom.lang
mainUrl = custom.url.trimEnd('/') mainUrl = custom.url.trimEnd('/')
@ -792,14 +793,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
lateinit var viewModel: ResultViewModel2 lateinit var viewModel: ResultViewModel2
lateinit var syncViewModel: SyncViewModel lateinit var syncViewModel: SyncViewModel
private var libraryViewModel: LibraryViewModel? = null
/** kinda dirty, however it signals that we should use the watch status as sync or not*/ /** kinda dirty, however it signals that we should use the watch status as sync or not*/
var isLocalList: Boolean = false var isLocalList: Boolean = false
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
viewModel =
ViewModelProvider(this)[ResultViewModel2::class.java] viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
syncViewModel = syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java]
ViewModelProvider(this)[SyncViewModel::class.java]
return super.onCreateView(name, context, attrs) return super.onCreateView(name, context, attrs)
} }
@ -1150,7 +1151,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
try { try {
if (isCastApiAvailable()) { if (isCastApiAvailable()) {
mSessionManager = CastContext.getSharedInstance(this).sessionManager CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager }
} }
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
@ -1231,18 +1232,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
changeStatusBarState(isLayout(EMULATOR)) changeStatusBarState(isLayout(EMULATOR))
/** Biometric stuff for users without accounts **/ /** Biometric stuff for users without accounts **/
val authEnabled = settingsManager.getBoolean(getString(R.string.biometric_key), false)
val noAccounts = settingsManager.getBoolean( val noAccounts = settingsManager.getBoolean(
getString(R.string.skip_startup_account_select_key), getString(R.string.skip_startup_account_select_key),
false false
) || accounts.count() <= 1 ) || accounts.count() <= 1
if (isLayout(PHONE) && authEnabled && noAccounts) { if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) {
if (deviceHasPasswordPinLock(this)) { if (deviceHasPasswordPinLock(this)) {
startBiometricAuthentication(this, R.string.biometric_authentication_title, false) startBiometricAuthentication(this, R.string.biometric_authentication_title, false)
BiometricAuthenticator.promptInfo?.let { promt -> promptInfo?.let { prompt ->
BiometricAuthenticator.biometricPrompt?.authenticate(promt) biometricPrompt?.authenticate(prompt)
} }
// hide background while authenticating, Sorry moms & dads 🙏 // hide background while authenticating, Sorry moms & dads 🙏
@ -1257,17 +1257,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
this.setKey(getString(R.string.jsdelivr_proxy_key), false) this.setKey(getString(R.string.jsdelivr_proxy_key), false)
} else { } else {
this.setKey(getString(R.string.jsdelivr_proxy_key), true) this.setKey(getString(R.string.jsdelivr_proxy_key), true)
val parentView: View = findViewById(android.R.id.content) showSnackbar(
Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG) this@MainActivity,
.let { snackbar -> R.string.jsdelivr_enabled,
snackbar.setAction(R.string.revert) { Snackbar.LENGTH_LONG,
setKey(getString(R.string.jsdelivr_proxy_key), false) R.string.revert
} ) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground))
snackbar.setTextColor(colorFromAttribute(R.attr.textColor))
snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary))
snackbar.show()
}
} }
} }
} }
@ -1400,7 +1395,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
} }
} }
observe(viewModel.watchStatus,::setWatchStatus) observe(viewModel.watchStatus, ::setWatchStatus)
observe(syncViewModel.userData, ::setUserData) observe(syncViewModel.userData, ::setUserData)
observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus) observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus)
@ -1438,7 +1433,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
resultviewPreviewMetaDuration.setText(d.durationText) resultviewPreviewMetaDuration.setText(d.durationText)
resultviewPreviewMetaRating.setText(d.ratingText) resultviewPreviewMetaRating.setText(d.ratingText)
resultviewPreviewDescription.setText(d.plotText) resultviewPreviewDescription.setTextHtml(d.plotText)
resultviewPreviewPoster.setImage( resultviewPreviewPoster.setImage(
d.posterImage ?: d.posterBackgroundImage d.posterImage ?: d.posterBackgroundImage
) )
@ -1453,13 +1448,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
val value = viewModel.watchStatus.value ?: WatchType.NONE val value = viewModel.watchStatus.value ?: WatchType.NONE
this@MainActivity.showBottomDialog( this@MainActivity.showBottomDialog(
WatchType.values().map { getString(it.stringRes) }.toList(), WatchType.entries.map { getString(it.stringRes) }.toList(),
value.ordinal, value.ordinal,
this@MainActivity.getString(R.string.action_add_to_bookmarks), this@MainActivity.getString(R.string.action_add_to_bookmarks),
showApply = false, showApply = false,
{}) { {}) {
viewModel.updateWatchStatus( viewModel.updateWatchStatus(
WatchType.values()[it], WatchType.entries[it],
this@MainActivity this@MainActivity
) )
} }
@ -1469,12 +1464,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
?: SyncWatchType.NONE ?: SyncWatchType.NONE
this@MainActivity.showBottomDialog( this@MainActivity.showBottomDialog(
SyncWatchType.values().map { getString(it.stringRes) }.toList(), SyncWatchType.entries.map { getString(it.stringRes) }.toList(),
value.ordinal, value.ordinal,
this@MainActivity.getString(R.string.action_add_to_bookmarks), this@MainActivity.getString(R.string.action_add_to_bookmarks),
showApply = false, showApply = false,
{}) { {}) {
syncViewModel.setStatus(SyncWatchType.values()[it].internalId) syncViewModel.setStatus(SyncWatchType.entries[it].internalId)
syncViewModel.publishUserData() syncViewModel.publishUserData()
} }
} }
@ -1555,6 +1550,26 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
logError(e) logError(e)
} }
} }
// we need to run this after we init all apis, otherwise currentSyncApi will fuck itself
this@MainActivity.runOnUiThread {
// Change library icon with logo of current api in sync
libraryViewModel = ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java]
libraryViewModel?.currentApiName?.observe(this@MainActivity) {
val syncAPI = libraryViewModel?.currentSyncApi
Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}")
val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) {
R.drawable.library_icon
} else {
syncAPI?.icon ?: R.drawable.library_icon
}
binding?.apply {
navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
navView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
}
}
}
} }
SearchResultBuilder.updateCache(this) SearchResultBuilder.updateCache(this)
@ -1586,7 +1601,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
if (isLayout(TV or EMULATOR)) { if (isLayout(TV or EMULATOR)) {
if (navDestination.matchDestination(R.id.navigation_home)) { if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback() attachBackPressedCallback {
showConfirmExitDialog()
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
} else detachBackPressedCallback() } else detachBackPressedCallback()
} }
} }
@ -1754,6 +1774,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
runAutoUpdate() runAutoUpdate()
} }
FcastManager().init(this, false)
APIRepository.dubStatusActive = getApiDubstatusSettings() APIRepository.dubStatusActive = getApiDubstatusSettings()
try { try {
@ -1825,26 +1847,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
binding?.navHostFragment?.isInvisible = false binding?.navHostFragment?.isInvisible = false
} }
private var backPressedCallback: OnBackPressedCallback? = null override fun onAuthenticationError() {
finish()
private fun attachBackPressedCallback() {
if (backPressedCallback == null) {
backPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
showConfirmExitDialog()
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
}
}
backPressedCallback?.isEnabled = true
onBackPressedDispatcher.addCallback(this, backPressedCallback ?: return)
}
private fun detachBackPressedCallback() {
backPressedCallback?.isEnabled = false
} }
suspend fun checkGithubConnectivity(): Boolean { suspend fun checkGithubConnectivity(): Boolean {

View file

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

View file

@ -1,69 +0,0 @@
// ! Bu araç @keyiflerolsun tarafından | @KekikAkademi için yazılmıştır.
package com.lagradost.cloudstream3.extractors
import android.util.Log
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.utils.*
open class ContentX : ExtractorApi() {
override val name = "ContentX"
override val mainUrl = "https://contentx.me"
override val requiresReferer = true
override suspend fun getUrl(url: String, referer: String?, subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit) {
val ext_ref = referer ?: ""
Log.d("Kekik_${this.name}", "url » ${url}")
val i_source = app.get(url, referer=ext_ref).text
val i_extract = Regex("""window\.openPlayer\('([^']+)'""").find(i_source)!!.groups[1]?.value ?: throw ErrorLoadingException("i_extract is null")
val sub_urls = mutableSetOf<String>()
Regex("""\"file\":\"([^\"]+)\",\"label\":\"([^\"]+)\"""").findAll(i_source).forEach {
val (sub_url, sub_lang) = it.destructured
if (sub_url in sub_urls) { return@forEach }
sub_urls.add(sub_url)
subtitleCallback.invoke(
SubtitleFile(
lang = sub_lang.replace("\\u0131", "ı").replace("\\u0130", "İ").replace("\\u00fc", "ü").replace("\\u00e7", "ç"),
url = fixUrl(sub_url.replace("\\", ""))
)
)
}
val vid_source = app.get("${mainUrl}/source2.php?v=${i_extract}", referer=ext_ref).text
val vid_extract = Regex("""file\":\"([^\"]+)""").find(vid_source)!!.groups[1]?.value ?: throw ErrorLoadingException("vid_extract is null")
val m3u_link = vid_extract.replace("\\", "")
callback.invoke(
ExtractorLink(
source = this.name,
name = this.name,
url = m3u_link,
referer = url,
quality = Qualities.Unknown.value,
isM3u8 = true
)
)
val i_dublaj = Regex(""",\"([^']+)\",\"Türkçe""").find(i_source)!!.groups[1]?.value
if (i_dublaj != null) {
val dublaj_source = app.get("${mainUrl}/source2.php?v=${i_dublaj}", referer=ext_ref).text
val dublaj_extract = Regex("""file\":\"([^\"]+)""").find(dublaj_source)!!.groups[1]?.value ?: throw ErrorLoadingException("dublaj_extract is null")
val dublaj_link = dublaj_extract.replace("\\", "")
callback.invoke(
ExtractorLink(
source = "${this.name} Türkçe Dublaj",
name = "${this.name} Türkçe Dublaj",
url = dublaj_link,
referer = url,
quality = Qualities.Unknown.value,
isM3u8 = true
)
)
}
}
}

View file

@ -1,50 +0,0 @@
// ! Bu araç @keyiflerolsun tarafından | @KekikAkademi için yazılmıştır.
package com.lagradost.cloudstream3.extractors
import android.util.Log
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.utils.*
open class RapidVid : ExtractorApi() {
override val name = "RapidVid"
override val mainUrl = "https://rapidvid.net"
override val requiresReferer = true
override suspend fun getUrl(url: String, referer: String?, subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit) {
val ext_ref = referer ?: ""
val video_req = app.get(url, referer=ext_ref).text
val sub_urls = mutableSetOf<String>()
Regex("""captions\",\"file\":\"([^\"]+)\",\"label\":\"([^\"]+)\"""").findAll(video_req).forEach {
val (sub_url, sub_lang) = it.destructured
if (sub_url in sub_urls) { return@forEach }
sub_urls.add(sub_url)
subtitleCallback.invoke(
SubtitleFile(
lang = sub_lang.replace("\\u0131", "ı").replace("\\u0130", "İ").replace("\\u00fc", "ü").replace("\\u00e7", "ç"),
url = fixUrl(sub_url.replace("\\", ""))
)
)
}
val extracted_value = Regex("""file": "(.*)",""").find(video_req)?.groupValues?.get(1) ?: throw ErrorLoadingException("File not found")
val bytes = extracted_value.split("\\x").filter { it.isNotEmpty() }.map { it.toInt(16).toByte() }.toByteArray()
val decoded = String(bytes, Charsets.UTF_8)
Log.d("Kekik_${this.name}", "decoded » ${decoded}")
callback.invoke(
ExtractorLink(
source = this.name,
name = this.name,
url = decoded,
referer = ext_ref,
quality = Qualities.Unknown.value,
isM3u8 = true
)
)
}
}

View file

@ -1,34 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
open class StreamWishExtractor : ExtractorApi() {
override var name = "StreamWish"
override var mainUrl = "https://streamwish.to"
override val requiresReferer = false
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
val response = app.get(
url, referer = referer ?: "$mainUrl/", interceptor = WebViewResolver(
Regex("""master\.m3u8""")
)
)
val sources = mutableListOf<ExtractorLink>()
if (response.url.contains("m3u8"))
sources.add(
ExtractorLink(
source = name,
name = name,
url = response.url,
referer = referer ?: "$mainUrl/",
quality = Qualities.Unknown.value,
isM3u8 = true
)
)
return sources
}
}

View file

@ -1,50 +0,0 @@
// ! Bu araç @keyiflerolsun tarafından | @KekikAkademi için yazılmıştır.
package com.lagradost.cloudstream3.extractors
import android.util.Log
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.utils.*
open class VidMoxy : ExtractorApi() {
override val name = "VidMoxy"
override val mainUrl = "https://vidmoxy.com"
override val requiresReferer = true
override suspend fun getUrl(url: String, referer: String?, subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit) {
val ext_ref = referer ?: ""
val video_req = app.get(url, referer=ext_ref).text
val sub_urls = mutableSetOf<String>()
Regex("""captions\",\"file\":\"([^\"]+)\",\"label\":\"([^\"]+)\"""").findAll(video_req).forEach {
val (sub_url, sub_lang) = it.destructured
if (sub_url in sub_urls) { return@forEach }
sub_urls.add(sub_url)
subtitleCallback.invoke(
SubtitleFile(
lang = sub_lang.replace("\\u0131", "ı").replace("\\u0130", "İ").replace("\\u00fc", "ü").replace("\\u00e7", "ç"),
url = fixUrl(sub_url.replace("\\", ""))
)
)
}
val extracted_value = Regex("""file": "(.*)",""").find(video_req)?.groupValues?.get(1) ?: throw ErrorLoadingException("File not found")
val bytes = extracted_value.split("\\x").filter { it.isNotEmpty() }.map { it.toInt(16).toByte() }.toByteArray()
val decoded = String(bytes, Charsets.UTF_8)
Log.d("Kekik_${this.name}", "decoded » ${decoded}")
callback.invoke(
ExtractorLink(
source = this.name,
name = this.name,
url = decoded,
referer = ext_ref,
quality = Qualities.Unknown.value,
isM3u8 = true
)
)
}
}

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,116 +0,0 @@
package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.base64Encode
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
// Code found in https://github.com/KillerDogeEmpire/vidplay-keys
// special credits to @KillerDogeEmpire for providing key
class MyCloud : Vidplay() {
override val name = "MyCloud"
override val mainUrl = "https://mcloud.bz"
}
class VidplayOnline : Vidplay() {
override val mainUrl = "https://vidplay.online"
}
open class Vidplay : ExtractorApi() {
override val name = "Vidplay"
override val mainUrl = "https://vidplay.site"
override val requiresReferer = true
open val key =
"https://raw.githubusercontent.com/KillerDogeEmpire/vidplay-keys/keys/keys.json"
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val id = url.substringBefore("?").substringAfterLast("/")
val encodeId = encodeId(id, getKeys())
val mediaUrl = callFutoken(encodeId, url)
val res = app.get(
"$mediaUrl", headers = mapOf(
"Accept" to "application/json, text/javascript, */*; q=0.01",
"X-Requested-With" to "XMLHttpRequest",
), referer = url
).parsedSafe<Response>()?.result
res?.sources?.map {
M3u8Helper.generateM3u8(
this.name,
it.file ?: return@map,
"$mainUrl/"
).forEach(callback)
}
res?.tracks?.filter { it.kind == "captions" }?.map {
subtitleCallback.invoke(
SubtitleFile(it.label ?: return@map, it.file ?: return@map)
)
}
}
private suspend fun getKeys(): List<String> {
return app.get(key).parsed()
}
private suspend fun callFutoken(id: String, url: String): String? {
val script = app.get("$mainUrl/futoken").text
val k = "k='(\\S+)'".toRegex().find(script)?.groupValues?.get(1) ?: return null
val a = mutableListOf(k)
for (i in id.indices) {
a.add((k[i % k.length].code + id[i].code).toString())
}
return "$mainUrl/mediainfo/${a.joinToString(",")}?${url.substringAfter("?")}"
}
private fun encodeId(id: String, keyList: List<String>): String {
val cipher1 = Cipher.getInstance("RC4")
val cipher2 = Cipher.getInstance("RC4")
cipher1.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(keyList[0].toByteArray(), "RC4"),
cipher1.parameters
)
cipher2.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(keyList[1].toByteArray(), "RC4"),
cipher2.parameters
)
var input = id.toByteArray()
input = cipher1.doFinal(input)
input = cipher2.doFinal(input)
return base64Encode(input).replace("/", "_")
}
data class Tracks(
@JsonProperty("file") val file: String? = null,
@JsonProperty("label") val label: String? = null,
@JsonProperty("kind") val kind: String? = null,
)
data class Sources(
@JsonProperty("file") val file: String? = null,
)
data class Result(
@JsonProperty("sources") val sources: ArrayList<Sources>? = arrayListOf(),
@JsonProperty("tracks") val tracks: ArrayList<Tracks>? = arrayListOf(),
)
data class Response(
@JsonProperty("result") val result: Result? = null,
)
}

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

@ -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
) )

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

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
@ -164,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> =
@ -340,7 +342,7 @@ object PluginManager {
//Omit non-NSFW if mode is set to NSFW only //Omit non-NSFW if mode is set to NSFW only
if (mode == AutoDownloadMode.NsfwOnly) { if (mode == AutoDownloadMode.NsfwOnly) {
if (tvtypes.contains(TvType.NSFW.name) == false) { if (!tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null return@mapNotNull null
} }
} }
@ -429,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()
@ -506,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))
@ -519,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,
@ -568,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 }
@ -722,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 if (ActivityCompat.checkSelfPermission(
notify((System.currentTimeMillis() / 1000).toInt(), notification) 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

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

View file

@ -10,7 +10,7 @@ import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit

View file

@ -10,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.logError 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

View file

@ -59,7 +59,7 @@ class SubtitleResource {
return file return file
} }
fun unzip(file: File): List<Pair<String, File>> { private fun unzip(file: File): List<Pair<String, File>> {
val entries = mutableListOf<Pair<String, File>>() val entries = mutableListOf<Pair<String, File>>()
ZipInputStream(file.inputStream()).use { zipInputStream -> ZipInputStream(file.inputStream()).use { zipInputStream ->

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,20 +3,26 @@ 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.syncproviders.providers.SubScene 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 simklApi = SimklApi(0)
val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
val subScene = SubScene() 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
@ -27,7 +33,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// 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, simklApi //nginxApi malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
) )
// used for active syncing // used for active syncing
@ -37,32 +43,35 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
) )
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,
subScene 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

@ -2,19 +2,10 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
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.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,
Simkl,
LocalList,
}
interface SyncAPI : OAuth2API { interface SyncAPI : OAuth2API {
/** /**
@ -134,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
} }
} }
@ -168,9 +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 plot : String? = null,
val rating: Int? = null, val rating: Int? = null,
val tags: List<String>? = null, val tags: List<String>? = null
) : SearchResponse ) : SearchResponse
} }

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

@ -16,15 +16,16 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.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"
@ -32,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
@ -62,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"]!!
@ -86,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,
@ -100,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(),
@ -300,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))
} }
@ -495,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,
@ -535,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(
@ -630,8 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
?: this.media.coverImage.medium, ?: this.media.coverImage.medium,
null, null,
null, null,
this.media.seasonYear.toYear(),
null, null,
plot = this.media.description plot = this.media.description,
) )
} }
} }
@ -646,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>? {
@ -658,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)
} }
@ -677,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>()
} }
@ -688,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,
) )
@ -763,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(
@ -786,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) {
@ -835,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,
@ -857,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)
@ -877,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(
@ -1049,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(
@ -1089,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(
@ -1129,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(
@ -1162,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"
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 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 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

@ -21,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
@ -118,8 +119,11 @@ class LocalList : 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,
) )
) )
} }

View file

@ -19,14 +19,19 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.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
@ -40,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"
@ -50,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,
@ -83,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
) )
} }
} }
@ -177,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
} }
@ -189,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
) )
} }
@ -243,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 = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)) , status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
isFavorite = null, isFavorite = null,
watchedEpisodes = data?.num_episodes_watched, watchedEpisodes = data?.numEpisodesWatched,
) )
} }
@ -290,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) {
@ -301,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"]!!
@ -350,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) {
@ -394,56 +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, 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}
) )
} }
} }
@ -469,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>? {
@ -490,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>()
} }
@ -508,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,
) )
@ -572,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
} }
@ -581,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)
@ -602,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
@ -646,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()) {
@ -669,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",
@ -692,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(
@ -704,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(
@ -721,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?,
) )
@ -743,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

@ -29,10 +29,10 @@ 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
} }
@ -48,7 +48,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
chain.request().newBuilder() chain.request().newBuilder()
.removeHeader("user-agent") .removeHeader("user-agent")
.addHeader("user-agent", userAgent) .addHeader("user-agent", userAgent)
.addHeader("Api-Key", apiKey) .addHeader("Api-Key", API_KEY)
.build() .build()
) )
} }
@ -65,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")
} }
@ -114,7 +114,7 @@ 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(
"Content-Type" to "application/json", "Content-Type" to "application/json",
), ),
@ -133,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
}) })
) )
@ -185,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
@ -196,8 +196,8 @@ 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(
@ -232,7 +232,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
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() ?: ""
@ -265,11 +265,11 @@ 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("Content-Type", "application/json"), Pair("Content-Type", "application/json"),
Pair("Accept", "*/*") Pair("Accept", "*/*")
@ -298,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(
@ -323,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(

View file

@ -12,7 +12,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
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.BuildConfig import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mapper
@ -22,13 +24,14 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
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.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.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import java.math.BigInteger import java.math.BigInteger
@ -36,6 +39,7 @@ import java.security.SecureRandom
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant import java.time.Instant
import java.util.Date import java.util.Date
import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
@ -45,6 +49,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "Simkl" override var name = "Simkl"
override val key = "simkl-key" override val key = "simkl-key"
override val redirectUrl = "simkl" override val redirectUrl = "simkl"
override val supportDeviceAuth = true
override val idPrefix = "simkl" override val idPrefix = "simkl"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override var mainUrl = "https://api.simkl.com" override var mainUrl = "https://api.simkl.com"
@ -141,8 +146,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
} }
companion object { companion object {
private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID
private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET
private var lastLoginState = "" private var lastLoginState = ""
const val SIMKL_TOKEN_KEY: String = "simkl_token" const val SIMKL_TOKEN_KEY: String = "simkl_token"
@ -151,10 +156,10 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time"
/** 2014-09-01T09:10:11Z -> 1409562611 */ /** 2014-09-01T09:10:11Z -> 1409562611 */
private const val simklDateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"
fun getUnixTime(string: String?): Long? { fun getUnixTime(string: String?): Long? {
return try { return try {
SimpleDateFormat(simklDateFormat).apply { SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply {
this.timeZone = TimeZone.getTimeZone("UTC") this.timeZone = TimeZone.getTimeZone("UTC")
}.parse( }.parse(
string ?: return null string ?: return null
@ -168,7 +173,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
/** 1409562611 -> 2014-09-01T09:10:11Z */ /** 1409562611 -> 2014-09-01T09:10:11Z */
fun getDateTime(unixTime: Long?): String? { fun getDateTime(unixTime: Long?): String? {
return try { return try {
SimpleDateFormat(simklDateFormat).apply { SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply {
this.timeZone = TimeZone.getTimeZone("UTC") this.timeZone = TimeZone.getTimeZone("UTC")
}.format( }.format(
Date.from( Date.from(
@ -182,32 +187,6 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
} }
} }
/**
* Set of sync services simkl is compatible with.
* Add more as required: https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id
*/
enum class SyncServices(val originalName: String) {
Simkl("simkl"),
Imdb("imdb"),
Tmdb("tmdb"),
AniList("anilist"),
Mal("mal"),
}
/**
* The ID string is a way to keep a collection of services in one single ID using a map
* This adds a database service (like imdb) to the string and returns the new string.
*/
fun addIdToString(idString: String?, database: SyncServices, id: String?): String? {
if (id == null) return idString
return (readIdFromString(idString) + mapOf(database to id)).toJson()
}
/** Read the id string to get all other ids */
fun readIdFromString(idString: String?): Map<SyncServices, String> {
return tryParseJson(idString) ?: return emptyMap()
}
fun getPosterUrl(poster: String): String { fun getPosterUrl(poster: String): String {
return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp" return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp"
} }
@ -231,7 +210,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
companion object { companion object {
fun fromString(string: String): SimklListStatusType? { fun fromString(string: String): SimklListStatusType? {
return SimklListStatusType.values().firstOrNull { return SimklListStatusType.entries.firstOrNull {
it.originalName == string it.originalName == string
} }
} }
@ -242,17 +221,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
data class TokenRequest( data class TokenRequest(
@JsonProperty("code") val code: String, @JsonProperty("code") val code: String,
@JsonProperty("client_id") val client_id: String = clientId, @JsonProperty("client_id") val clientId: String = CLIENT_ID,
@JsonProperty("client_secret") val client_secret: String = clientSecret, @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET,
@JsonProperty("redirect_uri") val redirect_uri: String = "$appString://simkl", @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl",
@JsonProperty("grant_type") val grant_type: String = "authorization_code" @JsonProperty("grant_type") val grantType: String = "authorization_code"
) )
data class TokenResponse( data class TokenResponse(
/** No expiration date */ /** No expiration date */
val access_token: String, @JsonProperty("access_token") val accessToken: String,
val token_type: String, @JsonProperty("token_type") val tokenType: String,
val scope: String @JsonProperty("scope") val scope: String
) )
// ------------------- // -------------------
@ -267,17 +246,32 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
) )
} }
data class PinAuthResponse(
@JsonProperty("result") val result: String,
@JsonProperty("device_code") val deviceCode: String,
@JsonProperty("user_code") val userCode: String,
@JsonProperty("verification_url") val verificationUrl: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("interval") val interval: Int,
)
data class PinExchangeResponse(
@JsonProperty("result") val result: String,
@JsonProperty("message") val message: String? = null,
@JsonProperty("access_token") val accessToken: String? = null,
)
// ------------------- // -------------------
data class ActivitiesResponse( data class ActivitiesResponse(
val all: String?, @JsonProperty("all") val all: String?,
val tv_shows: UpdatedAt, @JsonProperty("tv_shows") val tvShows: UpdatedAt,
val anime: UpdatedAt, @JsonProperty("anime") val anime: UpdatedAt,
val movies: UpdatedAt, @JsonProperty("movies") val movies: UpdatedAt,
) { ) {
data class UpdatedAt( data class UpdatedAt(
val all: String?, @JsonProperty("all") val all: String?,
val removed_from_list: String?, @JsonProperty("removed_from_list") val removedFromList: String?,
val rated_at: String?, @JsonProperty("rated_at") val ratedAt: String?,
) )
} }
@ -316,7 +310,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("title") val title: String?, @JsonProperty("title") val title: String?,
@JsonProperty("year") val year: Int?, @JsonProperty("year") val year: Int?,
@JsonProperty("ids") val ids: Ids?, @JsonProperty("ids") val ids: Ids?,
@JsonProperty("total_episodes") val total_episodes: Int? = null, @JsonProperty("total_episodes") val totalEpisodes: Int? = null,
@JsonProperty("status") val status: String? = null, @JsonProperty("status") val status: String? = null,
@JsonProperty("poster") val poster: String? = null, @JsonProperty("poster") val poster: String? = null,
@JsonProperty("type") val type: String? = null, @JsonProperty("type") val type: String? = null,
@ -344,13 +338,13 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("anilist") val anilist: String? = null, @JsonProperty("anilist") val anilist: String? = null,
) { ) {
companion object { companion object {
fun fromMap(map: Map<SyncServices, String>): Ids { fun fromMap(map: Map<SimklSyncServices, String>): Ids {
return Ids( return Ids(
simkl = map[SyncServices.Simkl]?.toIntOrNull(), simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(),
imdb = map[SyncServices.Imdb], imdb = map[SimklSyncServices.Imdb],
tmdb = map[SyncServices.Tmdb], tmdb = map[SimklSyncServices.Tmdb],
mal = map[SyncServices.Mal], mal = map[SimklSyncServices.Mal],
anilist = map[SyncServices.AniList] anilist = map[SimklSyncServices.AniList]
) )
} }
} }
@ -548,7 +542,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
} }
debugPrint { "Requesting episodes from $url" } debugPrint { "Requesting episodes from $url" }
return app.get(url, params = mapOf("client_id" to clientId)) return app.get(url, params = mapOf("client_id" to CLIENT_ID))
.parsedSafe<Array<EpisodeMetadata>>()?.also { .parsedSafe<Array<EpisodeMetadata>>()?.also {
val cacheTime = val cacheTime =
if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value
@ -566,7 +560,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("seasons") seasons: List<Season>? = null, @JsonProperty("seasons") seasons: List<Season>? = null,
@JsonProperty("episodes") episodes: List<Season.Episode>? = null, @JsonProperty("episodes") episodes: List<Season.Episode>? = null,
@JsonProperty("rating") val rating: Int? = null, @JsonProperty("rating") val rating: Int? = null,
@JsonProperty("rated_at") val rated_at: String? = null, @JsonProperty("rated_at") val ratedAt: String? = null,
) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes)
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -575,7 +569,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("year") year: Int?, @JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?, @JsonProperty("ids") ids: Ids?,
@JsonProperty("rating") val rating: Int, @JsonProperty("rating") val rating: Int,
@JsonProperty("rated_at") val rated_at: String? = getDateTime(unixTime) @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime)
) : MediaObject(title, year, ids) ) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -584,7 +578,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("year") year: Int?, @JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?, @JsonProperty("ids") ids: Ids?,
@JsonProperty("to") val to: String, @JsonProperty("to") val to: String,
@JsonProperty("watched_at") val watched_at: String? = getDateTime(unixTime) @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime)
) : MediaObject(title, year, ids) ) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -639,24 +633,24 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
} }
interface Metadata { interface Metadata {
val last_watched_at: String? val lastWatchedAt: String?
val status: String? val status: String?
val user_rating: Int? val userRating: Int?
val last_watched: String? val lastWatched: String?
val watched_episodes_count: Int? val watchedEpisodesCount: Int?
val total_episodes_count: Int? val totalEpisodesCount: Int?
fun getIds(): ShowMetadata.Show.Ids fun getIds(): ShowMetadata.Show.Ids
fun toLibraryItem(): SyncAPI.LibraryItem fun toLibraryItem(): SyncAPI.LibraryItem
} }
data class MovieMetadata( data class MovieMetadata(
override val last_watched_at: String?, @JsonProperty("last_watched_at") override val lastWatchedAt: String?,
override val status: String, @JsonProperty("status") override val status: String,
override val user_rating: Int?, @JsonProperty("user_rating") override val userRating: Int?,
override val last_watched: String?, @JsonProperty("last_watched") override val lastWatched: String?,
override val watched_episodes_count: Int?, @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?,
override val total_episodes_count: Int?, @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?,
val movie: ShowMetadata.Show val movie: ShowMetadata.Show
) : Metadata { ) : Metadata {
override fun getIds(): ShowMetadata.Show.Ids { override fun getIds(): ShowMetadata.Show.Ids {
@ -668,27 +662,28 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
this.movie.title, this.movie.title,
"https://simkl.com/tv/${movie.ids.simkl}", "https://simkl.com/tv/${movie.ids.simkl}",
movie.ids.simkl.toString(), movie.ids.simkl.toString(),
this.watched_episodes_count, this.watchedEpisodesCount,
this.total_episodes_count, this.totalEpisodesCount,
this.user_rating?.times(10), this.userRating?.times(10),
getUnixTime(last_watched_at) ?: 0, getUnixTime(lastWatchedAt) ?: 0,
"Simkl", "Simkl",
TvType.Movie, TvType.Movie,
this.movie.poster?.let { getPosterUrl(it) }, this.movie.poster?.let { getPosterUrl(it) },
null, null,
null, null,
movie.ids.simkl, this.movie.year?.toYear(),
movie.ids.simkl
) )
} }
} }
data class ShowMetadata( data class ShowMetadata(
@JsonProperty("last_watched_at") override val last_watched_at: String?, @JsonProperty("last_watched_at") override val lastWatchedAt: String?,
@JsonProperty("status") override val status: String, @JsonProperty("status") override val status: String,
@JsonProperty("user_rating") override val user_rating: Int?, @JsonProperty("user_rating") override val userRating: Int?,
@JsonProperty("last_watched") override val last_watched: String?, @JsonProperty("last_watched") override val lastWatched: String?,
@JsonProperty("watched_episodes_count") override val watched_episodes_count: Int?, @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?,
@JsonProperty("total_episodes_count") override val total_episodes_count: Int?, @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?,
@JsonProperty("show") val show: Show @JsonProperty("show") val show: Show
) : Metadata { ) : Metadata {
override fun getIds(): Show.Ids { override fun getIds(): Show.Ids {
@ -700,15 +695,16 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
this.show.title, this.show.title,
"https://simkl.com/tv/${show.ids.simkl}", "https://simkl.com/tv/${show.ids.simkl}",
show.ids.simkl.toString(), show.ids.simkl.toString(),
this.watched_episodes_count, this.watchedEpisodesCount,
this.total_episodes_count, this.totalEpisodesCount,
this.user_rating?.times(10), this.userRating?.times(10),
getUnixTime(last_watched_at) ?: 0, getUnixTime(lastWatchedAt) ?: 0,
"Simkl", "Simkl",
TvType.Anime, TvType.Anime,
this.show.poster?.let { getPosterUrl(it) }, this.show.poster?.let { getPosterUrl(it) },
null, null,
null, null,
this.show.year?.toYear(),
show.ids.simkl show.ids.simkl
) )
} }
@ -732,13 +728,13 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("anilist") val anilist: String?, @JsonProperty("anilist") val anilist: String?,
@JsonProperty("traktslug") val traktslug: String? @JsonProperty("traktslug") val traktslug: String?
) { ) {
fun matchesId(database: SyncServices, id: String): Boolean { fun matchesId(database: SimklSyncServices, id: String): Boolean {
return when (database) { return when (database) {
SyncServices.Simkl -> this.simkl == id.toIntOrNull() SimklSyncServices.Simkl -> this.simkl == id.toIntOrNull()
SyncServices.AniList -> this.anilist == id SimklSyncServices.AniList -> this.anilist == id
SyncServices.Mal -> this.mal == id SimklSyncServices.Mal -> this.mal == id
SyncServices.Tmdb -> this.tmdb == id SimklSyncServices.Tmdb -> this.tmdb == id
SyncServices.Imdb -> this.imdb == id SimklSyncServices.Imdb -> this.imdb == id
} }
} }
} }
@ -757,7 +753,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
chain.request() chain.request()
.newBuilder() .newBuilder()
.addHeader("Authorization", "Bearer $token") .addHeader("Authorization", "Bearer $token")
.addHeader("simkl-api-key", clientId) .addHeader("simkl-api-key", CLIENT_ID)
.build() .build()
) )
} }
@ -818,7 +814,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
val episodeConstructor = SimklEpisodeConstructor( val episodeConstructor = SimklEpisodeConstructor(
searchResult.ids?.simkl, searchResult.ids?.simkl,
searchResult.type, searchResult.type,
searchResult.total_episodes, searchResult.totalEpisodes,
searchResult.hasEnded() searchResult.hasEnded()
) )
@ -840,12 +836,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
) )
} }
?: return null, ?: return null,
score = foundItem.user_rating, score = foundItem.userRating,
watchedEpisodes = foundItem.watched_episodes_count, watchedEpisodes = foundItem.watchedEpisodesCount,
maxEpisodes = searchResult.total_episodes, maxEpisodes = searchResult.totalEpisodes,
episodeConstructor = episodeConstructor, episodeConstructor = episodeConstructor,
oldEpisodes = foundItem.watched_episodes_count ?: 0, oldEpisodes = foundItem.watchedEpisodesCount ?: 0,
oldScore = foundItem.user_rating, oldScore = foundItem.userRating,
oldStatus = foundItem.status oldStatus = foundItem.status
) )
} else { } else {
@ -853,7 +849,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
score = 0, score = 0,
watchedEpisodes = 0, watchedEpisodes = 0,
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.total_episodes, maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
episodeConstructor = episodeConstructor, episodeConstructor = episodeConstructor,
oldEpisodes = 0, oldEpisodes = 0,
oldStatus = null, oldStatus = null,
@ -899,12 +895,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
/** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */
suspend fun searchByIds(serviceMap: Map<SyncServices, String>): Array<MediaObject>? { private suspend fun searchByIds(serviceMap: Map<SimklSyncServices, String>): Array<MediaObject>? {
if (serviceMap.isEmpty()) return emptyArray() if (serviceMap.isEmpty()) return emptyArray()
return app.get( return app.get(
"$mainUrl/search/id", "$mainUrl/search/id",
params = mapOf("client_id" to clientId) + serviceMap.map { (service, id) -> params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) ->
service.originalName to id service.originalName to id
} }
).parsedSafe() ).parsedSafe()
@ -912,14 +908,14 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
return app.get( return app.get(
"$mainUrl/search/", params = mapOf("client_id" to clientId, "q" to name) "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() } ).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
} }
override fun authenticate(activity: FragmentActivity?) { override fun authenticate(activity: FragmentActivity?) {
lastLoginState = BigInteger(130, SecureRandom()).toString(32) lastLoginState = BigInteger(130, SecureRandom()).toString(32)
val url = val url =
"https://simkl.com/oauth/authorize?response_type=code&client_id=$clientId&redirect_uri=$appString://${redirectUrl}&state=$lastLoginState" "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState"
openBrowser(url, activity) openBrowser(url, activity)
} }
@ -969,15 +965,15 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
val activities = getActivities() val activities = getActivities()
val lastCacheUpdate = getKey<Long>(accountId, SIMKL_CACHED_LIST_TIME) val lastCacheUpdate = getKey<Long>(accountId, SIMKL_CACHED_LIST_TIME)
val lastRemoval = listOf( val lastRemoval = listOf(
activities?.tv_shows?.removed_from_list, activities?.tvShows?.removedFromList,
activities?.anime?.removed_from_list, activities?.anime?.removedFromList,
activities?.movies?.removed_from_list activities?.movies?.removedFromList
).maxOf { ).maxOf {
getUnixTime(it) ?: -1 getUnixTime(it) ?: -1
} }
val lastRealUpdate = val lastRealUpdate =
listOf( listOf(
activities?.tv_shows?.all, activities?.tvShows?.all,
activities?.anime?.all, activities?.anime?.all,
activities?.movies?.all, activities?.movies?.all,
).maxOf { ).maxOf {
@ -1034,6 +1030,8 @@ class SimklApi(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,
) )
@ -1045,6 +1043,44 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
} }
override suspend fun getDevicePin(): OAuth2API.PinAuthData? {
val pinAuthResp = app.get(
"$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}"
).parsedSafe<PinAuthResponse>() ?: return null
return OAuth2API.PinAuthData(
deviceCode = pinAuthResp.deviceCode,
userCode = pinAuthResp.userCode,
verificationUrl = pinAuthResp.verificationUrl,
expiresIn = pinAuthResp.expiresIn,
interval = pinAuthResp.interval
)
}
override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean {
val pinAuthResp = app.get(
"$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID"
).parsedSafe<PinExchangeResponse>() ?: return false
if (pinAuthResp.accessToken != null) {
switchToNewAccount()
setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken)
val user = getUser()
if (user == null) {
removeKey(accountId, SIMKL_TOKEN_KEY)
switchToOldAccount()
return false
}
setKey(accountId, SIMKL_USER_KEY, user)
registerAccount()
requireLibraryRefresh = true
return true
}
return false
}
override suspend fun handleRedirect(url: String): Boolean { override suspend fun handleRedirect(url: String): Boolean {
val uri = url.toUri() val uri = url.toUri()
val state = uri.getQueryParameter("state") val state = uri.getQueryParameter("state")
@ -1058,7 +1094,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
).parsedSafe<TokenResponse>() ?: return false ).parsedSafe<TokenResponse>() ?: return false
switchToNewAccount() switchToNewAccount()
setKey(accountId, SIMKL_TOKEN_KEY, token.access_token) setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken)
val user = getUser() val user = getUser()
if (user == null) { if (user == null) {

View file

@ -1,118 +0,0 @@
package com.lagradost.cloudstream3.syncproviders.providers
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.providers.IndexSubtitleApi.Companion.getOrdinal
import com.lagradost.cloudstream3.utils.SubtitleHelper
class SubScene : AbstractSubProvider {
val mainUrl = "https://subscene.com"
val name = "Subscene"
override val idPrefix = "subscene"
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
val seasonName =
query.seasonNumber?.let { number ->
// Need to translate "7" to "Seventh Season"
getOrdinal(number)?.let { words -> " - $words Season" }
} ?: ""
val fullQuery = query.query + seasonName
val doc = app.post(
"$mainUrl/subtitles/searchbytitle",
data = mapOf("query" to fullQuery, "l" to "")
).document
return doc.select("div.title a").map { element ->
val href = "$mainUrl${element.attr("href")}"
val title = element.text()
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = idPrefix,
name = title,
source = name,
data = href,
lang = query.lang ?: "en",
epNumber = query.epNumber
)
}.distinctBy { it.data }
}
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
val resultDoc = app.get(data.data).document
val queryLanguage = SubtitleHelper.fromTwoLettersToLanguage(data.lang) ?: "English"
val results = resultDoc.select("table tbody tr").mapNotNull { element ->
val anchor = element.select("a")
val href = anchor.attr("href") ?: return@mapNotNull null
val fixedHref = "$mainUrl${href}"
val spans = anchor.select("span")
val language = spans.firstOrNull()?.text()
val title = spans.getOrNull(1)?.text()
val isPositive = anchor.select("span.positive-icon").isNotEmpty()
TableElement(title, language, fixedHref, isPositive)
}.sortedBy {
it.getScore(queryLanguage, data.epNumber)
}
debugPrint { "$name found subtitles: ${results.takeLast(3)}" }
// Last = highest score
val selectedResult = results.lastOrNull() ?: return
val subtitleDocument = app.get(selectedResult.href).document
val subtitleDownloadUrl =
"$mainUrl${subtitleDocument.select("div.download a").attr("href")}"
this.addZipUrl(subtitleDownloadUrl) { name, _ ->
name
}
}
/**
* Class to manage the various different subtitle results and rank them.
*/
data class TableElement(
val title: String?,
val language: String?,
val href: String,
val isPositive: Boolean
) {
private fun matchesLanguage(other: String): Boolean {
return language != null && (language.contains(other, ignoreCase = true) ||
other.contains(language, ignoreCase = true))
}
/**
* Scores in this order:
* Preferred Language > Episode number > Positive rating > English Language
*/
fun getScore(queryLanguage: String, episodeNum: Int?): Int {
var score = 0
if (this.matchesLanguage(queryLanguage)) {
score += 8
}
// Matches Episode 7 using "E07" with any number of leading zeroes
if (episodeNum != null && title != null && title.contains(
Regex(
"""E0*${episodeNum}""",
RegexOption.IGNORE_CASE
)
)
) {
score += 4
}
if (isPositive) {
score += 2
}
if (this.matchesLanguage("English")) {
score += 1
}
return score
}
}
}

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

@ -50,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) {
@ -94,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)
} }

View file

@ -85,7 +85,7 @@ abstract class BaseAdapter<
AsyncDifferConfig.Builder(diffCallback).build() AsyncDifferConfig.Builder(diffCallback).build()
) )
fun submitList(list: List<T>?) { open fun submitList(list: List<T>?) {
// deep copy at least the top list, because otherwise adapter can go crazy // deep copy at least the top list, because otherwise adapter can go crazy
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
} }
@ -112,6 +112,7 @@ abstract class BaseAdapter<
holder.onViewDetachedFromWindow() holder.onViewDetachedFromWindow()
} }
@Suppress("UNCHECKED_CAST")
fun save(recyclerView: RecyclerView) { fun save(recyclerView: RecyclerView) {
for (child in recyclerView.children) { for (child in recyclerView.children) {
val holder = val holder =
@ -124,6 +125,7 @@ abstract class BaseAdapter<
stateViewModel.layoutManagerStates[id]?.clear() stateViewModel.layoutManagerStates[id]?.clear()
} }
@Suppress("UNCHECKED_CAST")
private fun getState(holder: ViewHolderState<S>): S? = private fun getState(holder: ViewHolderState<S>): S? =
stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S

View file

@ -6,6 +6,7 @@ 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
@ -23,13 +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.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
@ -263,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()

View file

@ -8,8 +8,8 @@ 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,

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

@ -15,7 +15,7 @@ open class NonFinalAdapterListUpdateCallback
/** /**
* Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter. * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter.
* *
* @param adapter The Adapter to send updates to. * @param mAdapter The Adapter to send updates to.
*/(private var mAdapter: RecyclerView.Adapter<*>) : */(private var mAdapter: RecyclerView.Adapter<*>) :
ListUpdateCallback { ListUpdateCallback {

View file

@ -13,7 +13,7 @@ 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
} }
} }
@ -36,6 +36,6 @@ enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @Dr
REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_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
} }
} }

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

@ -27,7 +27,7 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.result.setImage import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe

View file

@ -23,14 +23,18 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.BiometricAuthenticator 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.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.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
class AccountSelectActivity : AppCompatActivity(), BiometricAuthenticator.BiometricAuthCallback { class AccountSelectActivity : AppCompatActivity(), BiometricCallback {
lateinit var viewModel: AccountViewModel lateinit var viewModel: AccountViewModel
@ -48,7 +52,6 @@ class AccountSelectActivity : AppCompatActivity(), BiometricAuthenticator.Biomet
) )
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val authEnabled = settingsManager.getBoolean(getString(R.string.biometric_key), false)
val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
) || accounts.count() <= 1 ) || accounts.count() <= 1
@ -56,7 +59,7 @@ class AccountSelectActivity : AppCompatActivity(), BiometricAuthenticator.Biomet
fun askBiometricAuth() { fun askBiometricAuth() {
if (isLayout(PHONE) && authEnabled) { if (isLayout(PHONE) && isAuthEnabled(this)) {
if (deviceHasPasswordPinLock(this)) { if (deviceHasPasswordPinLock(this)) {
startBiometricAuthentication( startBiometricAuthentication(
this, this,
@ -64,8 +67,8 @@ class AccountSelectActivity : AppCompatActivity(), BiometricAuthenticator.Biomet
false false
) )
BiometricAuthenticator.promptInfo?.let { promt -> promptInfo?.let { prompt ->
BiometricAuthenticator.biometricPrompt?.authenticate(promt) biometricPrompt?.authenticate(prompt)
} }
} }
} }
@ -189,4 +192,8 @@ class AccountSelectActivity : AppCompatActivity(), BiometricAuthenticator.Biomet
override fun onAuthenticationSuccess() { override fun onAuthenticationSuccess() {
Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity") Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity")
} }
override fun onAuthenticationError() {
finish()
}
} }

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,64 +101,80 @@ 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,
episode = click.data.episode,
headerName = parent.name,
tvType = parent.type,
basePath = keyInfo.basePath, val items = mutableListOf<ExtractorUri>()
displayName = keyInfo.displayName,
relativePath = keyInfo.relativePath, // Make sure we only get this episode and episodes after it,
) // and that we can go to the next season if we need to.
) val allRelevantEpisodes = episodes
?.sortedWith(
compareByDescending<VideoDownloadHelper.DownloadEpisodeCached> { it.id == click.data.id }
.thenBy { it.season ?: 0 }
.thenBy { it.episode }
)
?.filter {
if (it.season == null) return@filter true
val isCurrentOrLaterInSeason = it.season == currentSeason && (it.episode >= currentEpisode || it.id == click.data.id)
val isInFutureSeasons = it.season > currentSeason
isCurrentOrLaterInSeason || isInFutureSeasons
}
allRelevantEpisodes?.forEach {
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString()
) ?: return@forEach
items.add(
ExtractorUri(
// We just use a temporary placeholder for the URI,
// it will be updated in generateLinks().
// We just do this for performance since getting
// all paths at once can be quite expensive.
uri = Uri.EMPTY,
id = it.id,
parentId = it.parentId,
name = act.getString(R.string.downloaded_file),
season = it.season,
episode = it.episode,
headerName = parent.name,
tvType = parent.type,
basePath = keyInfo.basePath,
displayName = keyInfo.displayName,
relativePath = keyInfo.relativePath,
) )
) )
//R.id.global_to_navigation_player, PlayerFragment.newInstance( }
// UriData(
// info.path.toString(), act.navigate(
// keyInfo.basePath, R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
// keyInfo.relativePath, DownloadFileGenerator(items)
// 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: VideoDownloadHelper.DownloadEpisodeCached)
class DownloadChildAdapter(
var cardList: List<VisualDownloadChildCached>,
private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadChildViewHolder(
DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false),
clickCallback
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DownloadChildViewHolder -> {
holder.bind(cardList[position])
}
}
}
override fun getItemCount(): Int {
return cardList.size
}
class DownloadChildViewHolder
constructor(
val binding: DownloadChildEpisodeBinding,
private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
/*private val title: TextView = itemView.download_child_episode_text
private val extraInfo: TextView = itemView.download_child_episode_text_extra
private val holder: CardView = itemView.download_child_episode_holder
private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
private val downloadImage: ImageView = itemView.download_child_episode_download*/
fun bind(card: VisualDownloadChildCached) {
val d = card.data
val posDur = getViewPos(d.id)
binding.downloadChildEpisodeProgress.apply {
if (posDur != null) {
val visualPos = posDur.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback)
binding.downloadChildEpisodeText.apply {
text = context.getNameFull(d.name, d.episode, d.season)
isSelected = true // is needed for text repeating
}
binding.downloadChildEpisodeHolder.setOnClickListener {
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
}
}
}
}

View file

@ -1,26 +1,33 @@
package com.lagradost.cloudstream3.ui.download 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.RecyclerView import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.utils.DataStore.getKeys 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 {
@ -31,92 +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?.onBackPressedDispatcher?.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?.onBackPressedDispatcher?.onBackPressed() // TODO FIX activity?.onBackPressedDispatcher?.onBackPressed()
return return
} }
fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply { binding?.downloadChildToolbar?.apply {
title = name title = name
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) if (isLayout(PHONE or EMULATOR)) {
setNavigationOnClickListener { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
activity?.onBackPressedDispatcher?.onBackPressed() setNavigationOnClickListener {
} activity?.onBackPressedDispatcher?.onBackPressed()
}
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
DownloadChildAdapter(
ArrayList(),
) { click ->
handleDownloadClick(click)
}
downloadDeleteEventListener = { id: Int ->
val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList
if (list != null) {
if (list.any { it.data.id == id }) {
updateList(folder)
} }
} }
setAppBarNoScrollFlagsOnTV()
} }
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
binding?.downloadChildList?.adapter = adapter observe(downloadsViewModel.childCards) {
binding?.downloadChildList?.setLinearListLayout( if (it.isEmpty()) {
isHorizontal = false, activity?.onBackPressedDispatcher?.onBackPressed()
nextDown = FOCUS_SELF, return@observe
nextRight = FOCUS_SELF }
)//layoutManager = GridLayoutManager(context, 1)
updateList(folder) (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
}
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
detachBackPressedCallback()
downloadsViewModel.clearSelectedItems()
binding?.downloadChildToolbar?.isVisible = true
}
}
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
{},
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
}
)
binding?.downloadChildList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF,
)
}
context?.let { downloadsViewModel.updateChildList(it, folder) }
fixPaddingStatusbar(binding?.downloadChildRoot)
}
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadChildToolbar?.isVisible = false
activity?.attachBackPressedCallback {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =
getString(R.string.delete_format).format(count, formattedSize)
} }
} }

View file

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

View file

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

View file

@ -1,122 +1,439 @@
package com.lagradost.cloudstream3.ui.download 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) }
.distinctBy { it.id } // Remove duplicates
}
// parentId : bytes private val _isMultiDeleteState = MutableLiveData(false)
val totalBytesUsedByChild = HashMap<Int, Long>() val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
// parentId : bytes
val currentBytesUsedByChild = HashMap<Int, Long>()
// parentId : downloadsCount
val totalDownloads = HashMap<Int, Int>()
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
// Gets all children downloads private var previousVisual: List<VisualDownloadCached>? = null
withContext(Dispatchers.IO) {
for (c in children) {
val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue
if (childFile.fileLength <= 1) continue fun setIsMultiDeleteState(value: Boolean) {
val len = childFile.totalBytes _isMultiDeleteState.postValue(value)
val flen = childFile.fileLength }
totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len fun addSelected(itemId: Int) {
currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen updateSelectedItems { it.add(itemId) }
totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1 }
fun removeSelected(itemId: Int) {
updateSelectedItems { it.remove(itemId) }
}
fun selectAllItems() {
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
}
fun clearSelectedItems() {
// We need this to be done immediately
// so we can't use postValue
_selectedItemIds.value = mutableSetOf()
updateSelectedItems { it.clear() }
}
fun isAllSelected(): Boolean {
val currentSelected = selectedItemIds.value ?: return false
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
}
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
val currentSelected = selectedItemIds.value ?: mutableSetOf()
action(currentSelected)
_selectedItemIds.postValue(currentSelected)
updateSelectedBytes()
updateSelectedCards()
}
private fun updateSelectedBytes() = viewModelScope.launchSafe {
val selectedItemsList = getSelectedItemsData() ?: return@launchSafe
val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes }
_selectedBytes.postValue(totalSelectedBytes)
}
private fun updateSelectedCards() = viewModelScope.launchSafe {
val currentSelected = selectedItemIds.value ?: return@launchSafe
headerCards.value?.let { headers ->
headers.forEach { header ->
header.isSelected = header.data.id in currentSelected
} }
_headerCards.postValue(headers)
} }
val cached = withContext(Dispatchers.IO) { // wont fetch useless keys childCards.value?.let { children ->
totalDownloads.entries.filter { it.value > 0 }.mapNotNull { children.forEach { child ->
context.getKey<VideoDownloadHelper.DownloadHeaderCached>( child.isSelected = child.data.id in currentSelected
DOWNLOAD_HEADER_CACHE,
it.key.toString()
)
} }
_childCards.postValue(children)
} }
}
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) { val visual = withContext(Dispatchers.IO) {
cached.mapNotNull { // TODO FIX val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
val downloads = totalDownloads[it.id] ?: 0 .mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
val bytes = totalBytesUsedByChild[it.id] ?: 0 .distinctBy { it.id } // Remove duplicates
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
val movieEpisode = calculateDownloadStats(context, children)
if (!it.type.isMovieType()) null
else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>( val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
DOWNLOAD_EPISODE_CACHE, .mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
getFolderName(it.id.toString(), it.id.toString())
) createVisualDownloadList(
VisualDownloadHeaderCached( context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
0, )
downloads,
bytes,
currentBytes,
it,
movieEpisode
)
}.sortedBy {
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0)
} // episode sorting by episode, lowest to highest
} }
if (visual != previousVisual) {
previousVisual = visual
updateStorageStats(visual)
_headerCards.postValue(visual)
}
}
private fun calculateDownloadStats(
context: Context,
children: List<VideoDownloadHelper.DownloadEpisodeCached>
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
// parentId : bytes
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount
val totalDownloads = mutableMapOf<Int, Int>()
children.forEach { child ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes
val flen = childFile.fileLength
totalBytesUsedByChild.merge(child.parentId, len, Long::plus)
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads.merge(child.parentId, 1, Int::plus)
}
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
}
private fun createVisualDownloadList(
context: Context,
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>,
currentBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
): List<VisualDownloadCached.Header> {
return cached.mapNotNull {
val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString())
)
VisualDownloadCached.Header(
currentBytes = currentBytes,
totalBytes = bytes,
data = it,
child = movieEpisode,
currentOngoingDownloads = 0,
totalDownloads = downloads,
isSelected = isSelected,
)
// Prevent order being almost completely random,
// making things difficult to find.
}.sortedWith(compareBy<VisualDownloadCached.Header> {
// Sort by isEpisodeBased() ascending. We put those that
// are episode based at the bottom for UI purposes and to
// make it easier to find by grouping them together.
it.data.type.isEpisodeBased()
}.thenBy {
// Then we sort alphabetically by name (case-insensitive).
// Again, we do this to make things easier to find.
it.data.name.lowercase()
})
}
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key ->
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull {
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
VisualDownloadCached.Child(
currentBytes = info.fileLength,
totalBytes = info.totalBytes,
isSelected = isSelected,
data = it,
)
}
}.sortedWith(compareBy(
// Sort by season first, and then by episode number,
// to ensure sorting is consistent.
{ it.data.season ?: 0 },
{ it.data.episode }
))
if (previousVisual != visual) {
previousVisual = visual
_childCards.postValue(visual)
}
}
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
_headerCards.postValue(updatedHeaders)
_childCards.postValue(updatedChildren)
}
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
try { 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,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
@ -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,37 +74,45 @@ 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
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
/*lastRequest = savedData.uriRequest ioSafe {
files = savedData.files val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
var totalBytes: Long = 0 mainWork {
var downloadedBytes: Long = 0 if (savedData != null) {
for (file in savedData.files) { val downloadedBytes = savedData.fileLength
downloadedBytes += file.completedLength val totalBytes = savedData.totalBytes
totalBytes += file.length
}*/ setProgress(downloadedBytes, totalBytes)
setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes)
// some extra padding for just in case } else run { resetView() }
val status = VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) DownloadStatusTell.IsDone else DownloadStatusTell.IsPaused
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
} }
setStatus(status)
} ?: run {
resetView()
} }
} }
abstract fun setStatus(status: VideoDownloadManager.DownloadType?) abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
// some extra padding for just in case
return VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) {
DownloadStatusTell.IsDone
} else DownloadStatusTell.IsPaused
}
fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) {
val status = getStatus(id, downloadedBytes, totalBytes)
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
}
setStatus(status)
}
open fun setProgress(downloadedBytes: Long, totalBytes: Long) { open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
isZeroBytes = downloadedBytes == 0L isZeroBytes = downloadedBytes == 0L
progressBar.post { progressBar.post {
@ -124,13 +138,16 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
if (isZeroBytes) { if (isZeroBytes) {
progressText?.isVisible = false progressText?.isVisible = false
} else { } else {
progressText?.apply { if (doSetProgress) {
val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes) progressText?.apply {
val totalMbString = Formatter.formatShortFileSize(context, totalBytes) val currentFormattedSizeString =
text = formatShortFileSize(context, downloadedBytes)
//if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
context?.getString(R.string.download_size_format) text =
?.format(currentMbString, totalMbString) // if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentFormattedSizeString, totalFormattedSizeString)
}
} }
} }
@ -167,8 +184,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
override fun onAttachedToWindow() { 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
@ -182,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()
@ -198,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)

View file

@ -1,7 +1,6 @@
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.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log import android.util.Log
@ -13,6 +12,7 @@ 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.mvvm.logError
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
@ -25,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) {
@ -44,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 {
@ -56,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() {}
@ -114,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
@ -128,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]
) )
@ -167,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),
@ -195,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))
} }
} }
} }
@ -203,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
} }
} }
@ -216,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 -> {
@ -242,10 +245,10 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
else -> {} else -> {}
} }
} }
}*/ } */
@MainThread @MainThread
private fun setStatusInternal(status : DownloadStatusTell?) { private fun setStatusInternal(status: DownloadStatusTell?) {
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)
@ -260,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
@ -278,12 +282,12 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
override fun setStatus(status: DownloadStatusTell?) { override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status currentStatus = status
// runs on the main thread, but also instant if it already is // Runs on the main thread, but also instant if it already is
if (Looper.myLooper() == Looper.getMainLooper()) { if (Looper.myLooper() == Looper.getMainLooper()) {
try { try {
setStatusInternal(status) setStatusInternal(status)
} catch (t : Throwable) { } catch (t: Throwable) {
logError(t) // just in case setStatusInternal throws because thread logError(t) // Just in case setStatusInternal throws because thread
progressBarBackground.post { progressBarBackground.post {
setStatusInternal(status) setStatusInternal(status)
} }
@ -299,6 +303,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
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
} }
@ -322,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 else -> iconInit
null -> iconInit }.takeIf { it != 0 }
}
if (drawableInt == 0) {
return null
}
return ContextCompat.getDrawable(this.context, drawableInt)
}
} }

View file

@ -15,7 +15,7 @@ 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.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(view) { class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(view) {
@ -54,7 +54,7 @@ class HomeChildItemAdapter(
var hasNext: Boolean = false var hasNext: Boolean = false
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> { 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)
@ -133,7 +133,6 @@ class HomeChildItemAdapter(
item, item,
position, position,
holder.itemView, holder.itemView,
null, // nextFocusBehavior,
nextFocusUp, nextFocusUp,
nextFocusDown nextFocusDown
) )

View file

@ -17,7 +17,6 @@ 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
@ -25,8 +24,6 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
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.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
@ -46,11 +43,13 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable 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.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
@ -234,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?,

View file

@ -1,6 +1,8 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.os.Build
import android.os.Bundle 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
@ -22,7 +24,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
class LoadClickCallback( class LoadClickCallback(
val action: Int = 0, val action: Int = 0,
@ -53,16 +55,20 @@ open class ParentItemAdapter(
"value", "value",
recyclerView?.layoutManager?.onSaveInstanceState() recyclerView?.layoutManager?.onSaveInstanceState()
) )
(recyclerView?.adapter as? BaseAdapter<*,*>)?.save(recyclerView) (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView)
} }
override fun restore(state: Bundle) { override fun restore(state: Bundle) {
(binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
state.getParcelable("value") state.getSafeParcelable<Parcelable>("value")
) )
} }
} }
override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
super.submitList(list?.sortedBy { it.list.list.isEmpty() })
}
override fun onUpdateContent( override fun onUpdateContent(
holder: ViewHolderState<Bundle>, holder: ViewHolderState<Bundle>,
item: HomeViewModel.ExpandableHomepageList, item: HomeViewModel.ExpandableHomepageList,
@ -166,3 +172,8 @@ open class ParentItemAdapter(
.toMutableList()) .toMutableList())
} }
} }
@Suppress("DEPRECATION")
inline fun <reified T> Bundle.getSafeParcelable(key: String): T? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key)
else getParcelable(key, T::class.java)

View file

@ -16,7 +16,6 @@ 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.ChipGroup import com.google.android.material.chip.ChipGroup
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.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
@ -36,6 +35,7 @@ import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.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
@ -117,15 +117,12 @@ class HomeParentItemAdapterPreview(
} }
override fun restore(state: Bundle) { override fun restore(state: Bundle) {
state.getParcelable<Parcelable>("resumeRecyclerView")?.let { recycle -> state.getSafeParcelable<Parcelable>("resumeRecyclerView")?.let { recycle ->
resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle) resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
} }
state.getParcelable<Parcelable>("bookmarkRecyclerView")?.let { recycle -> state.getSafeParcelable<Parcelable>("bookmarkRecyclerView")?.let { recycle ->
bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle) bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
} }
//state.getInt("previewViewpager").let { recycle ->
// previewViewpager.setCurrentItem(recycle,true)
//}
} }
val previewAdapter = HomeScrollAdapter(fragment = fragment) val previewAdapter = HomeScrollAdapter(fragment = fragment)

View file

@ -6,9 +6,6 @@ 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
@ -36,8 +33,11 @@ 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.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppUtils.loadResult 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
@ -152,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) {
@ -387,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!!)
} }
@ -397,9 +399,7 @@ 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)
} }
} }
@ -516,7 +516,7 @@ class HomeViewModel : ViewModel() {
} else { } else {
_page.postValue(Resource.Loading()) _page.postValue(Resource.Loading())
if (preferredApiName != null) if (preferredApiName != null)
_apiName.postValue(preferredApiName) _apiName.postValue(preferredApiName!!)
} }
} else { } else {
// if the api is found, then set it to it and save key // if the api is found, then set it to it and save key

View file

@ -53,9 +53,9 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
@ -600,8 +600,4 @@ class LibraryFragment : Fragment() {
} }
} }
class MenuSearchView(context: Context) : SearchView(context) { class MenuSearchView(context: Context) : SearchView(context)
override fun onActionViewCollapsed() {
super.onActionViewCollapsed()
}
}

View file

@ -23,6 +23,8 @@ 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"

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

@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.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.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -32,7 +33,7 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding)
} }
override fun restore(state: Bundle) { override fun restore(state: Bundle) {
state.getParcelable<Parcelable>("pageRecyclerview")?.let { recycle -> state.getSafeParcelable<Parcelable>("pageRecyclerview")?.let { recycle ->
binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle)
} }
} }

View file

@ -1,7 +1,10 @@
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
@ -45,8 +48,8 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs 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.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
@ -217,7 +220,7 @@ 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 )], source = PlayerEventSource.UI
@ -259,7 +262,7 @@ 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())
} }
} }
@ -442,6 +445,9 @@ abstract class AbstractPlayerFragment(
is VideoEndedEvent -> { is VideoEndedEvent -> {
context?.let { ctx -> context?.let { ctx ->
// Resets subtitle delay on ended video
player.setSubtitleOffset(0)
// Only play next episode if autoplay is on (default) // Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(ctx) if (PreferenceManager.getDefaultSharedPreferences(ctx)
?.getBoolean( ?.getBoolean(
@ -601,12 +607,12 @@ 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") @SuppressLint("UnsafeOptInUsageError")

View file

@ -9,7 +9,11 @@ import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.media3.common.C.* import androidx.annotation.OptIn
import androidx.media3.common.C.TIME_UNSET
import androidx.media3.common.C.TRACK_TYPE_AUDIO
import androidx.media3.common.C.TRACK_TYPE_TEXT
import androidx.media3.common.C.TRACK_TYPE_VIDEO
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
@ -19,9 +23,10 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize import androidx.media3.common.VideoSize
import androidx.media3.common.util.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
@ -57,17 +62,15 @@ 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.AppUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.DrmExtractorLink
import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
import java.io.File import java.io.File
import java.lang.IllegalArgumentException
import java.util.UUID import java.util.UUID
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@ -85,7 +88,7 @@ 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
@ -258,7 +261,6 @@ class CS3IPlayer : IPlayer {
private var currentSubtitles: SubtitleData? = null private var currentSubtitles: SubtitleData? = null
@SuppressLint("UnsafeOptInUsageError")
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
// This beast of an expression does: // This beast of an expression does:
@ -343,7 +345,6 @@ class CS3IPlayer : IPlayer {
}.flatten() }.flatten()
} }
@SuppressLint("UnsafeOptInUsageError")
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> { private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
return (0 until this.mediaTrackGroup.length).mapNotNull { i -> return (0 until this.mediaTrackGroup.length).mapNotNull { i ->
if (this.isSupported) if (this.isSupported)
@ -372,7 +373,6 @@ class CS3IPlayer : IPlayer {
) )
} }
@SuppressLint("UnsafeOptInUsageError")
override fun getVideoTracks(): CurrentTracks { override fun getVideoTracks(): CurrentTracks {
val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList()
val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO }
@ -392,7 +392,6 @@ class CS3IPlayer : IPlayer {
/** /**
* @return True if the player should be reloaded * @return True if the player should be reloaded
* */ * */
@SuppressLint("UnsafeOptInUsageError")
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
Log.i(TAG, "setPreferredSubtitles init $subtitle") Log.i(TAG, "setPreferredSubtitles init $subtitle")
currentSubtitles = subtitle currentSubtitles = subtitle
@ -452,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
@ -460,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? {
@ -471,7 +470,6 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
override fun getAspectRatio(): Rational? { override fun getAspectRatio(): Rational? {
return exoPlayer?.videoFormat?.let { format -> return exoPlayer?.videoFormat?.let { format ->
Rational(format.width, format.height) Rational(format.width, format.height)
@ -482,14 +480,13 @@ class CS3IPlayer : IPlayer {
subtitleHelper.setSubStyle(style) subtitleHelper.setSubStyle(style)
} }
@SuppressLint("UnsafeOptInUsageError")
override fun saveData() { override fun saveData() {
Log.i(TAG, "saveData") Log.i(TAG, "saveData")
updatedTime() updatedTime()
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
playbackPosition = exo.currentPosition playbackPosition = exo.currentPosition
currentWindow = exo.currentWindowIndex currentWindow = exo.currentMediaItemIndex
isPlaying = exo.isPlaying isPlaying = exo.isPlaying
} }
} }
@ -501,7 +498,7 @@ class CS3IPlayer : IPlayer {
updatedTime() updatedTime()
exoPlayer?.apply { exoPlayer?.apply {
setPlayWhenReady(false) playWhenReady = false
stop() stop()
release() release()
} }
@ -564,7 +561,6 @@ class CS3IPlayer : IPlayer {
var requestSubtitleUpdate: (() -> Unit)? = null var requestSubtitleUpdate: (() -> Unit)? = null
@SuppressLint("UnsafeOptInUsageError")
private fun createOnlineSource(headers: Map<String, String>): HttpDataSource.Factory { private fun createOnlineSource(headers: Map<String, String>): HttpDataSource.Factory {
val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT)
return source.apply { return source.apply {
@ -572,7 +568,6 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory {
val provider = getApiFromNameNull(link.source) val provider = getApiFromNameNull(link.source)
val interceptor = provider?.getVideoInterceptor(link) val interceptor = provider?.getVideoInterceptor(link)
@ -605,53 +600,10 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
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)
}*/
@SuppressLint("UnsafeOptInUsageError")
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)
@ -683,7 +635,6 @@ class CS3IPlayer : IPlayer {
return getMediaItemBuilder(mimeType).setUri(url).build() return getMediaItemBuilder(mimeType).setUri(url).build()
} }
@SuppressLint("UnsafeOptInUsageError")
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 = trackSelector.buildUponParameters() trackSelector.parameters = trackSelector.buildUponParameters()
@ -697,7 +648,6 @@ class CS3IPlayer : IPlayer {
var currentTextRenderer: CustomTextRenderer? = null var currentTextRenderer: CustomTextRenderer? = null
@SuppressLint("UnsafeOptInUsageError")
private fun buildExoPlayer( private fun buildExoPlayer(
context: Context, context: Context,
mediaItemSlices: List<MediaItemSlice>, mediaItemSlices: List<MediaItemSlice>,
@ -737,7 +687,7 @@ class CS3IPlayer : IPlayer {
textRendererOutput, textRendererOutput,
eventHandler.looper, eventHandler.looper,
CustomSubtitleDecoderFactory() CustomSubtitleDecoderFactory()
).also { this.currentTextRenderer = it } ).also { renderer -> this.currentTextRenderer = renderer }
currentTextRenderer currentTextRenderer
} else it } else it
}.toTypedArray() }.toTypedArray()
@ -913,7 +863,11 @@ class CS3IPlayer : IPlayer {
} }
CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source)
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source)
CSPlayerEvent.Restart -> seekTo(0, source)
CSPlayerEvent.NextEpisode -> event( CSPlayerEvent.NextEpisode -> event(
EpisodeSeekEvent( EpisodeSeekEvent(
offset = 1, offset = 1,
@ -1030,7 +984,7 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError") //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 ->
event( event(
@ -1118,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(
@ -1163,7 +1120,6 @@ class CS3IPlayer : IPlayer {
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList() private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
@SuppressLint("UnsafeOptInUsageError")
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) { override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
lastTimeStamps = timeStamps lastTimeStamps = timeStamps
timeStamps.forEach { timestamp -> timeStamps.forEach { timestamp ->
@ -1181,7 +1137,6 @@ class CS3IPlayer : IPlayer {
updatedTime(source = PlayerEventSource.Player) updatedTime(source = PlayerEventSource.Player)
} }
@SuppressLint("UnsafeOptInUsageError")
fun onRenderFirst() { fun onRenderFirst() {
if (hasUsedFirstRender) { // this insures that we only call this once per player load if (hasUsedFirstRender) { // this insures that we only call this once per player load
return return
@ -1248,7 +1203,6 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun getSubSources( private fun getSubSources(
onlineSourceFactory: HttpDataSource.Factory?, onlineSourceFactory: HttpDataSource.Factory?,
offlineSourceFactory: DataSource.Factory?, offlineSourceFactory: DataSource.Factory?,

View file

@ -2,6 +2,7 @@ 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
@ -31,7 +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.
**/ **/
@UnstableApi @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) {
@ -72,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
@ -262,7 +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 */
@UnstableApi @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,11 +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.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
@UnstableApi @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
@ -50,10 +55,6 @@ class DownloadFileGenerator(
return null return null
} }
fun cleanDisplayName(name: String): String {
return name.substringBeforeLast('.').trim()
}
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
type: LoadType, type: LoadType,
@ -62,7 +63,21 @@ class DownloadFileGenerator(
offset: Int offset: Int
): Boolean { ): Boolean {
val meta = episodes[currentIndex + offset] val meta = episodes[currentIndex + offset]
callback(null to meta)
if (meta.uri == Uri.EMPTY) {
// We do this here so that we only load it when
// we actually need it as it can be more expensive.
val info = meta.id?.let { id ->
activity?.let { act ->
getDownloadFileInfoAndUpdateSettings(act, id)
}
}
if (info != null) {
val newMeta = meta.copy(uri = info.path)
callback(null to newMeta)
} else callback(null to meta)
} else callback(null to meta)
val ctx = context ?: return true val ctx = context ?: return true
val relative = meta.relativePath ?: return true val relative = meta.relativePath ?: return true
@ -70,28 +85,9 @@ class DownloadFileGenerator(
val cleanDisplay = cleanDisplayName(display) val cleanDisplay = cleanDisplayName(display)
VideoDownloadManager.getFolder(ctx, relative, meta.basePath) getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) {
// only these files are allowed, so no videos as subtitles
if (listOf(
".vtt",
".srt",
".txt",
".ass",
".ttml",
".sbv",
".dfxp"
).none { name.contains(it, true) }
) return@forEach
// cant have the exact same file as a subtitle
if (name.equals(display, true)) return@forEach
val cleanName = cleanDisplayName(name) val cleanName = cleanDisplayName(name)
// we only want files with the approx same name
if (!cleanName.startsWith(cleanDisplay, true)) return@forEach
val realName = cleanName.removePrefix(cleanDisplay) val realName = cleanName.removePrefix(cleanDisplay)
subtitleCallback( subtitleCallback(
@ -105,6 +101,7 @@ class DownloadFileGenerator(
) )
) )
} }
}
return true return true
} }

View file

@ -1,23 +1,21 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.content.ContentUris
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.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
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.safefile.SafeFile import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
const val DTAG = "PlayerActivity"
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
} }
@ -35,53 +33,18 @@ class DownloadedPlayerActivity : AppCompatActivity() {
CommonActivity.onUserLeaveHint(this) CommonActivity.onUserLeaveHint(this)
} }
private fun playLink(url: String) {
this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
LinkGenerator(
listOf(
BasicLink(url)
)
)
)
)
}
private fun playUri(uri: Uri) {
val name = SafeFile.fromUri(this, uri)?.name()
this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(
listOf(
ExtractorUri(
uri = uri,
name = name ?: getString(R.string.downloaded_file),
// well not the same as a normal id, but we take it as users may want to
// play downloaded files and save the location
id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode()
)
)
)
)
)
}
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
@ -89,32 +52,25 @@ 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
} }
onBackPressedDispatcher.addCallback( attachBackPressedCallback { finish() }
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
finish()
}
}
)
} }
override fun onResume() { override fun onResume() {

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

View file

@ -25,19 +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.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.google.android.material.button.MaterialButton
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.screenHeight
import com.lagradost.cloudstream3.CommonActivity.screenWidth 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
@ -46,12 +52,10 @@ 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.ui.settings.Globals
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.SettingsFragment import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.DataStoreHelper 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
@ -64,7 +68,11 @@ 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.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
@ -83,7 +91,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected open var isFullScreenPlayer = true protected open var isFullScreenPlayer = true
protected var playerBinding: PlayerCustomLayoutBinding? = null protected var playerBinding: PlayerCustomLayoutBinding? = null
private var durationMode : Boolean by UserPreferenceDelegate("duration_mode", false) 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
protected var isLocked = false protected var isLocked = false
@ -115,6 +124,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var doubleTapPauseEnabled = true protected var doubleTapPauseEnabled = true
protected var playerRotateEnabled = false protected var playerRotateEnabled = false
protected var autoPlayerRotateEnabled = false protected var autoPlayerRotateEnabled = false
private var hideControlsNames = false
protected var subtitleDelay protected var subtitleDelay
set(value) = try { set(value) = try {
@ -184,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()
@ -236,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) {
@ -249,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 {
@ -290,44 +300,42 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.getCurrentPreferredSubtitle() == null player.getCurrentPreferredSubtitle() == null
} }
private fun restoreOrientationWithSensor(activity: Activity){ private fun restoreOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation val currentOrientation = activity.resources.configuration.orientation
var orientation = 0 val orientation = when (currentOrientation) {
when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE -> Configuration.ORIENTATION_LANDSCAPE ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED ->
orientation = dynamicOrientation()
Configuration.ORIENTATION_PORTRAIT -> Configuration.ORIENTATION_PORTRAIT ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> dynamicOrientation()
} }
activity.requestedOrientation = orientation activity.requestedOrientation = orientation
} }
private fun toggleOrientationWithSensor(activity: Activity){ private fun toggleOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation val currentOrientation = activity.resources.configuration.orientation
var orientation = 0 val orientation: Int = when (currentOrientation) {
when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE -> Configuration.ORIENTATION_LANDSCAPE ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED ->
orientation = dynamicOrientation()
Configuration.ORIENTATION_PORTRAIT -> Configuration.ORIENTATION_PORTRAIT ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else -> dynamicOrientation()
} }
activity.requestedOrientation = orientation activity.requestedOrientation = orientation
} }
open fun lockOrientation(activity: Activity) { open fun lockOrientation(activity: Activity) {
val display = @Suppress("DEPRECATION")
val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
else activity.display!!
val rotation = display.rotation val rotation = display.rotation
val currentOrientation = activity.resources.configuration.orientation val currentOrientation = activity.resources.configuration.orientation
var orientation = 0 val orientation: Int
when (currentOrientation) { when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE -> Configuration.ORIENTATION_LANDSCAPE ->
orientation = orientation =
@ -336,27 +344,25 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
else else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED ->
orientation = dynamicOrientation()
Configuration.ORIENTATION_PORTRAIT -> Configuration.ORIENTATION_PORTRAIT ->
orientation = orientation =
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
else -> orientation = dynamicOrientation()
} }
activity.requestedOrientation = orientation activity.requestedOrientation = orientation
} }
private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) { private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) {
activity?.apply { activity?.apply {
if(lockRotation) { if (lockRotation) {
if(isLocked) { if (isLocked) {
lockOrientation(this) lockOrientation(this)
} } else {
else { if (ignoreDynamicOrientation) {
if(ignoreDynamicOrientation){
// restore when lock is disabled // restore when lock is disabled
restoreOrientationWithSensor(this) restoreOrientationWithSensor(this)
} else { } else {
@ -498,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)
@ -729,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()) {
@ -950,7 +970,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} }
else -> { else -> {
player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) player.handleEvent(
CSPlayerEvent.PlayPauseToggle,
PlayerEventSource.UI
)
} }
} }
} else if (doubleTapEnabled && isFullScreenPlayer) { } else if (doubleTapEnabled && isFullScreenPlayer) {
@ -1143,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()
@ -1159,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()
@ -1225,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
@ -1298,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)
} }
@ -1393,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
@ -1413,12 +1445,34 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
playerSpeedBtt.isVisible = playBackSpeedEnabled playerSpeedBtt.isVisible = playBackSpeedEnabled
playerResizeBtt.isVisible = playerResizeEnabled playerResizeBtt.isVisible = playerResizeEnabled
playerRotateBtt.isVisible = playerRotateEnabled 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)
@ -1462,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()
@ -1517,7 +1581,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} }
} }
// cs3 is peak media center // cs3 is peak media center
setRemainingTimeCounter(durationMode || Globals.isLayout(Globals.TV)) setRemainingTimeCounter(durationMode || isLayout(TV))
playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ ->
updateRemainingTime() updateRemainingTime()
} }
@ -1536,6 +1600,22 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
} }
} }
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) { override fun playerDimensionsLoaded(width: Int, height: Int) {
isVerticalOrientation = height > width isVerticalOrientation = height > width
updateOrientation() updateOrientation()
@ -1555,7 +1635,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private fun setRemainingTimeCounter(showRemaining: Boolean) { private fun setRemainingTimeCounter(showRemaining: Boolean) {
durationMode = showRemaining durationMode = showRemaining
playerBinding?.exoDuration?.isInvisible= showRemaining playerBinding?.exoDuration?.isInvisible = showRemaining
playerBinding?.timeLeft?.isVisible = showRemaining playerBinding?.timeLeft?.isVisible = showRemaining
} }

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,10 +23,15 @@ 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 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.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.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding
@ -39,13 +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.Globals
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY 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
@ -58,6 +66,7 @@ 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 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
@ -154,6 +163,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
override fun playerStatusChanged() { override fun playerStatusChanged() {
super.playerStatusChanged()
if (player.getIsPlaying()) { if (player.getIsPlaying()) {
viewModel.forceClearCache = false viewModel.forceClearCache = false
} }
@ -228,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(
@ -258,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 {
@ -284,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
@ -360,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
@ -372,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
@ -424,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 },
@ -511,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(
@ -633,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
@ -643,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()
} }
} }
@ -782,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
@ -1085,8 +1099,15 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
playerBinding?.playerSkipOp?.isVisible = isOpVisible playerBinding?.playerSkipOp?.isVisible = isOpVisible
playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true when {
isLayout(PHONE) ->
playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true
else ->
playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true
}
if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) {
viewModel.preLoadNextLinks() viewModel.preLoadNextLinks()
@ -1242,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 {
"" ""
} }
@ -1253,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 {
@ -1270,7 +1291,7 @@ class GeneratorPlayer : FullScreenPlayer() {
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"))
} }
} }
@ -1278,7 +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
layout = if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player layout =
if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player
viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java]
sync = ViewModelProvider(this)[SyncViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java]
@ -1440,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
@ -1486,3 +1508,6 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
} }
} }
@Suppress("DEPRECATION")
inline fun <reified T : Serializable> Bundle.getSafeSerializable(key: String) : T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable(key, T::class.java)

View file

@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri
enum class LoadType { enum class LoadType {
Unknown, Unknown,
@ -10,7 +9,8 @@ enum class LoadType {
InAppDownload, InAppDownload,
ExternalApp, ExternalApp,
Browser, Browser,
Chromecast Chromecast,
Fcast
} }
fun LoadType.toSet() : Set<ExtractorLinkType> { fun LoadType.toSet() : Set<ExtractorLinkType> {
@ -29,17 +29,23 @@ fun LoadType.toSet() : Set<ExtractorLinkType> {
ExtractorLinkType.VIDEO, ExtractorLinkType.VIDEO,
ExtractorLinkType.M3U8 ExtractorLinkType.M3U8
) )
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.values().toSet() LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.entries.toSet()
LoadType.Chromecast -> setOf( LoadType.Chromecast -> setOf(
ExtractorLinkType.VIDEO, ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH, ExtractorLinkType.DASH,
ExtractorLinkType.M3U8 ExtractorLinkType.M3U8
) )
LoadType.Fcast -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
} }
} }
interface IGenerator { interface IGenerator {
val hasCache: Boolean val hasCache: Boolean
val canSkipLoading: Boolean
fun hasNext(): Boolean fun hasNext(): Boolean
fun hasPrev(): Boolean fun hasPrev(): Boolean

View file

@ -6,10 +6,8 @@ import android.util.Rational
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
enum class PlayerEventType(val value: Int) { enum class PlayerEventType(val value: Int) {
//Stop(-1),
Pause(0), Pause(0),
Play(1), Play(1),
SeekForward(2), SeekForward(2),
@ -27,6 +25,7 @@ enum class PlayerEventType(val value: Int) {
Resize(13), Resize(13),
SearchSubtitlesOnline(14), SearchSubtitlesOnline(14),
SkipOp(15), SkipOp(15),
Restart(16),
} }
enum class CSPlayerEvent(val value: Int) { enum class CSPlayerEvent(val value: Int) {
@ -40,6 +39,7 @@ enum class CSPlayerEvent(val value: Int) {
PrevEpisode(6), PrevEpisode(6),
PlayPauseToggle(7), PlayPauseToggle(7),
ToggleMute(8), ToggleMute(8),
Restart(9),
} }
enum class CSPlayerLoading { enum class CSPlayerLoading {

View file

@ -1,9 +1,29 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.net.Uri
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.INFER_TYPE
import java.net.URI import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.loadExtractor
import com.lagradost.cloudstream3.utils.unshortenLinkSafe
data class ExtractorUri(
val uri: Uri,
val name: String,
val basePath: String? = null,
val relativePath: String? = null,
val displayName: String? = null,
val id: Int? = null,
val parentId: Int? = null,
val episode: Int? = null,
val season: Int? = null,
val headerName: String? = null,
val tvType: TvType? = null,
)
/** /**
* Used to open the player more easily with the LinkGenerator * Used to open the player more easily with the LinkGenerator
@ -19,6 +39,7 @@ class LinkGenerator(
private val isM3u8: Boolean? = null private val isM3u8: Boolean? = null
) : 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

View file

@ -29,6 +29,7 @@ import android.os.Message;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.text.Cue; import androidx.media3.common.text.Cue;
@ -66,7 +67,7 @@ import java.util.stream.Collectors;
* obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s * obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s
* is delegated to a {@link TextOutput}. * is delegated to a {@link TextOutput}.
*/ */
@UnstableApi @OptIn(markerClass = UnstableApi.class)
public class NonFinalTextRenderer extends BaseRenderer implements Callback { public class NonFinalTextRenderer extends BaseRenderer implements Callback {
private static final String TAG = "TextRenderer"; private static final String TAG = "TextRenderer";
@ -74,7 +75,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
/** /**
* @param trackType The track type that the renderer handles. One of the {@link C} {@code * @param trackType The track type that the renderer handles. One of the {@link C} {@code
* TRACK_TYPE_*} constants. * TRACK_TYPE_*} constants.
* @param outputHandler * @param outputHandler todo description
*/ */
public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) { public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) {
super(trackType); super(trackType);
@ -416,13 +417,11 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Override @Override
public boolean handleMessage(Message msg) { public boolean handleMessage(Message msg) {
switch (msg.what) { if (msg.what == MSG_UPDATE_OUTPUT) {
case MSG_UPDATE_OUTPUT: invokeUpdateOutputInternal((List<Cue>) msg.obj);
invokeUpdateOutputInternal((List<Cue>) msg.obj); return true;
return true;
default:
throw new IllegalStateException();
} }
throw new IllegalStateException();
} }
private void invokeUpdateOutputInternal(List<Cue> cues) { private void invokeUpdateOutputInternal(List<Cue> cues) {
@ -441,7 +440,6 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
} }
).collect(Collectors.toList()); ).collect(Collectors.toList());
output.onCues(fixedCues);
output.onCues(new CueGroup(fixedCues, 0L)); output.onCues(new CueGroup(fixedCues, 0L));
} }

View file

@ -0,0 +1,43 @@
package com.lagradost.cloudstream3.ui.player
import android.app.Activity
import android.content.ContentUris
import android.net.Uri
import androidx.core.content.ContextCompat.getString
import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.safefile.SafeFile
object OfflinePlaybackHelper {
fun playLink(activity: Activity, url: String) {
activity.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
LinkGenerator(
listOf(
BasicLink(url)
)
)
)
)
}
fun playUri(activity: Activity, uri: Uri) {
val name = SafeFile.fromUri(activity, uri)?.name()
activity.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(
listOf(
ExtractorUri(
uri = uri,
name = name ?: getString(activity, R.string.downloaded_file),
// well not the same as a normal id, but we take it as users may want to
// play downloaded files and save the location
id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode()
)
)
)
)
)
}
}

View file

@ -5,6 +5,7 @@ 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.LoadResponse
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
@ -14,13 +15,12 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class PlayerGeneratorViewModel : ViewModel() { class PlayerGeneratorViewModel : ViewModel() {
companion object { companion object {
val TAG = "PlayViewGen" const val TAG = "PlayViewGen"
} }
private var generator: IGenerator? = null private var generator: IGenerator? = null
@ -111,6 +111,9 @@ class PlayerGeneratorViewModel : ViewModel() {
} }
} }
} }
fun getLoadResponse(): LoadResponse? {
return normalSafeApiCall { (generator as? RepoLinkGenerator?)?.page }
}
fun getMeta(): Any? { fun getMeta(): Any? {
return normalSafeApiCall { generator?.getCurrent() } return normalSafeApiCall { generator?.getCurrent() }

View file

@ -4,7 +4,9 @@ import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat
@ -47,6 +49,7 @@ data class SubtitleData(
} }
} }
@OptIn(UnstableApi::class)
class PlayerSubtitleHelper { class PlayerSubtitleHelper {
private var activeSubtitles: Set<SubtitleData> = emptySet() private var activeSubtitles: Set<SubtitleData> = emptySet()
private var allSubtitles: Set<SubtitleData> = emptySet() private var allSubtitles: Set<SubtitleData> = emptySet()

View file

@ -8,15 +8,15 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.settings.Globals
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.SettingsFragment
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.M3u8Helper
import com.lagradost.cloudstream3.utils.M3u8Helper2 import com.lagradost.cloudstream3.utils.M3u8Helper2
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -65,8 +65,12 @@ interface IPreviewGenerator {
companion object { companion object {
fun new(): IPreviewGenerator { fun new(): IPreviewGenerator {
val userDisabled = AcraApplication.context?.let { ctx ->
PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean(
ctx.getString(R.string.preview_seekbar_key), true) == false
} ?: false
/** because TV has low ram + not show we disable this for now */ /** because TV has low ram + not show we disable this for now */
return if (isLayout(TV)) { return if (isLayout(TV) || userDisabled) {
empty() empty()
} else { } else {
PreviewGenerator() PreviewGenerator()
@ -242,7 +246,11 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG
// generated images 1:1 to idx of hsl // generated images 1:1 to idx of hsl
private var images: Array<Bitmap?> = arrayOf() private var images: Array<Bitmap?> = arrayOf()
private val TAG = "PreviewImgM3u8" companion object {
private const val TAG = "PreviewImgM3u8"
}
// prefixSum[i] = sum(hsl.ts[0..i].time) // prefixSum[i] = sum(hsl.ts[0..i].time)
// where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b // where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b
@ -391,13 +399,6 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG
logError(t) logError(t)
continue continue
} }
/*
val buffer = hsl.resolveLinkSafe(index) ?: continue
tmpFile?.writeBytes(buffer)
val buff = FileOutputStream(tmpFile)
retriever.setDataSource(buff.fd)
val frame = retriever.getFrameAtTime(0L)*/
} }
} }
@ -415,14 +416,16 @@ private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGe
null null
} }
companion object {
private const val TAG = "PreviewImgMp4"
}
override fun hasPreview(): Boolean { override fun hasPreview(): Boolean {
synchronized(images) { synchronized(images) {
return loadedLod >= MIN_LOD return loadedLod >= MIN_LOD
} }
} }
val TAG = "PreviewImgMp4"
override fun getPreviewImage(fraction: Float): Bitmap? { override fun getPreviewImage(fraction: Float): Bitmap? {
synchronized(images) { synchronized(images) {
if (loadedLod < MIN_LOD) { if (loadedLod < MIN_LOD) {
@ -527,7 +530,7 @@ private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGe
val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat()))
Log.i(TAG, "Generating preview for ${fraction * 100}%") Log.i(TAG, "Generating preview for ${fraction * 100}%")
val frame = durationUs * fraction val frame = durationUs * fraction
val img = retriever.image(frame.toLong(), params); val img = retriever.image(frame.toLong(), params)
if (!scope.isActive) return if (!scope.isActive) return
if (img == null || img.width <= 1 || img.height <= 1) continue if (img == null || img.width <= 1 || img.height <= 1) continue
synchronized(images) { synchronized(images) {

View file

@ -1,13 +1,13 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.util.Log import android.util.Log
import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -29,6 +29,7 @@ class RepoLinkGenerator(
} }
override val hasCache = true override val hasCache = true
override val canSkipLoading = true
override fun hasNext(): Boolean { override fun hasNext(): Boolean {
return currentIndex < episodes.size - 1 return currentIndex < episodes.size - 1

View file

@ -4,7 +4,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppContextUtils
data class SourcePriority<T>( data class SourcePriority<T>(
val data: T, val data: T,
@ -13,11 +13,10 @@ data class SourcePriority<T>(
) )
class PriorityAdapter<T>(override val items: MutableList<SourcePriority<T>>) : class PriorityAdapter<T>(override val items: MutableList<SourcePriority<T>>) :
AppUtils.DiffAdapter<SourcePriority<T>>(items) { AppContextUtils.DiffAdapter<SourcePriority<T>>(items) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return PriorityViewHolder( return PriorityViewHolder(
PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false), PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false),
//LayoutInflater.from(parent.context).inflate(R.layout.player_prioritize_item, parent, false)
) )
} }
@ -31,10 +30,6 @@ class PriorityAdapter<T>(override val items: MutableList<SourcePriority<T>>) :
val binding: PlayerPrioritizeItemBinding, val binding: PlayerPrioritizeItemBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun <T> bind(item: SourcePriority<T>) { fun <T> bind(item: SourcePriority<T>) {
/* val plusButton: ImageView = itemView.add_button
val subtractButton: ImageView = itemView.subtract_button
val priorityText: TextView = itemView.priority_text
val priorityNumber: TextView = itemView.priority_number*/
binding.priorityText.text = item.name binding.priorityText.text = item.name
fun updatePriority() { fun updatePriority() {

View file

@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding
import com.lagradost.cloudstream3.ui.result.UiImage import com.lagradost.cloudstream3.ui.result.UiImage
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.setImage
class ProfilesAdapter( class ProfilesAdapter(
@ -21,7 +21,7 @@ class ProfilesAdapter(
val usedProfile: Int, val usedProfile: Int,
val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit,
) : ) :
AppUtils.DiffAdapter<QualityDataHelper.QualityProfile>( AppContextUtils.DiffAdapter<QualityDataHelper.QualityProfile>(
items, items,
comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile -> comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile ->
first.id == second.id first.id == second.id
@ -29,8 +29,6 @@ class ProfilesAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ProfilesViewHolder( return ProfilesViewHolder(
PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false)
//LayoutInflater.from(parent.context)
// .inflate(R.layout.player_quality_profile_item, parent, false)
) )
} }

View file

@ -1,6 +1,5 @@
package com.lagradost.cloudstream3.ui.player.source_priority package com.lagradost.cloudstream3.ui.player.source_priority
import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
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
@ -104,7 +103,7 @@ object QualityDataHelper {
* Must under all circumstances at least return one profile * Must under all circumstances at least return one profile
**/ **/
fun getProfiles(): List<QualityProfile> { fun getProfiles(): List<QualityProfile> {
val availableTypes = QualityProfileType.values().toMutableList() val availableTypes = QualityProfileType.entries.toMutableList()
val profiles = (1..PROFILE_COUNT).map { profileNumber -> val profiles = (1..PROFILE_COUNT).map { profileNumber ->
// Get the real type // Get the real type
val type = getQualityProfileType(profileNumber) val type = getQualityProfileType(profileNumber)
@ -140,12 +139,12 @@ object QualityDataHelper {
} }
} }
QualityProfileType.values().forEach { QualityProfileType.entries.forEach {
if (it.unique) insertType(profiles, it) if (it.unique) insertType(profiles, it)
} }
debugAssert({ debugAssert({
!QualityProfileType.values().all { type -> !QualityProfileType.entries.all { type ->
!type.unique || profiles.any { it.type == type } !type.unique || profiles.any { it.type == type }
} }
}, { "All unique quality types do not exist" }) }, { "All unique quality types do not exist" })

View file

@ -65,7 +65,7 @@ class QualityProfileDialog(
setDefaultBtt.setOnClickListener { setDefaultBtt.setOnClickListener {
val currentProfile = getCurrentProfile() ?: return@setOnClickListener val currentProfile = getCurrentProfile() ?: return@setOnClickListener
val choices = QualityDataHelper.QualityProfileType.values() val choices = QualityDataHelper.QualityProfileType.entries
.filter { it != QualityDataHelper.QualityProfileType.None } .filter { it != QualityDataHelper.QualityProfileType.None }
val choiceNames = choices.map { txt(it.stringRes).asString(context) } val choiceNames = choices.map { txt(it.stringRes).asString(context) }

View file

@ -47,7 +47,7 @@ class SourcePriorityDialog(
) )
qualitiesRecyclerView.adapter = PriorityAdapter( qualitiesRecyclerView.adapter = PriorityAdapter(
Qualities.values().mapNotNull { Qualities.entries.mapNotNull {
SourcePriority( SourcePriority(
it, it,
Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null },

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