Compare commits

..

78 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
378 changed files with 9668 additions and 4264 deletions

View file

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

View file

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

View file

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

6
.github/locales.py vendored
View file

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

View file

@ -14,7 +14,6 @@ plugins {
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
var isLibraryDebug = false
fun String.execute() = ByteArrayOutputStream().use { baot ->
if (project.exec {
@ -43,8 +42,8 @@ android {
}*/
signingConfigs {
create("prerelease") {
if (prereleaseStoreFile != null) {
create("prerelease") {
storeFile = file(prereleaseStoreFile)
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
@ -61,8 +60,8 @@ android {
minSdk = 21
targetSdk = 33 /* Android 14 is Fu*ked
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
versionCode = 63
versionName = "4.3.2"
versionCode = 64
versionName = "4.4.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
@ -105,7 +104,6 @@ android {
)
}
debug {
isLibraryDebug = true
isDebuggable = true
applicationIdSuffix = ".debug"
proguardFiles(
@ -126,7 +124,11 @@ android {
resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease")
} else {
logger.warn("No prerelease signing config!")
}
versionNameSuffix = "-PRE"
versionCode = (System.currentTimeMillis() / 60000).toInt()
}
@ -159,16 +161,16 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test:core")
implementation("androidx.test.ext:junit-ktx:1.1.5")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// Android Core & Lifecycle
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.lifecycle:lifecycle-livedata-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
// Design & UI
@ -184,9 +186,9 @@ dependencies {
implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
// For KSP -> Official Annotation Processors are Not Yet Supported for KSP
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.1.0")
implementation("com.google.guava:guava:33.2.0-android")
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.1.0")
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
implementation("com.google.guava:guava:33.2.1-android")
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
// Media 3 (ExoPlayer)
implementation("androidx.media3:media3-ui:1.1.1")
@ -202,9 +204,9 @@ dependencies {
// PlayBack
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
implementation("com.github.teamnewpipe:NewPipeExtractor:fafd471") /* For Trailers
implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers
^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
implementation("com.github.albfernandez:juniversalchardet:2.4.0") // Subtitle Decoding
implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
// Crash Reports (AcraApplication.kt)
implementation("ch.acra:acra-core:5.11.3")
@ -217,14 +219,15 @@ dependencies {
implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
// Extensions & Other Libs
implementation("org.mozilla:rhino:1.7.15") // run JavaScript
implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.10.0") // TMDB API v3 Wrapper Made with RetroFit
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser
^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
Level 25 or Less. */
@ -235,7 +238,14 @@ dependencies {
implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
implementation(project(":library") {
this.extra.set("isDebug", isLibraryDebug)
// There does not seem to be a good way of getting the android flavor.
val isDebug = gradle.startParameter.taskRequests.any { task ->
task.args.any { arg ->
arg.contains("debug", true)
}
}
this.extra.set("isDebug", isDebug)
})
}
@ -257,6 +267,8 @@ tasks.register<Copy>("copyJar") {
// 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"),

View file

@ -154,7 +154,7 @@ class ExampleInstrumentedTest {
fun providerCorrectHomepage() {
runBlocking {
getAllProviders().toList().amap { api ->
TestingUtils.testHomepage(api, ::println)
TestingUtils.testHomepage(api, TestingUtils.Logger())
}
}
println("Done providerCorrectHomepage")
@ -166,7 +166,6 @@ class ExampleInstrumentedTest {
TestingUtils.getDeferredProviderTests(
this,
getAllProviders(),
::println
) { _, _ -> }
}
}

View file

@ -97,7 +97,7 @@
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"

View file

@ -8,13 +8,14 @@ import android.content.Intent
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
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.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
@ -34,6 +35,7 @@ import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.system.exitProcess
@ -80,14 +82,8 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
ACRA.errorReporter.handleException(error)
try {
PrintStream(errorFile).use { ps ->
ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}"))
ps.println(
String.format(
"Fatal exception on thread %s (%d)",
thread.name,
thread.id
)
)
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
error.printStackTrace(ps)
}
} catch (ignored: FileNotFoundException) {
@ -105,7 +101,6 @@ class AcraApplication : Application() {
override fun onCreate() {
super.onCreate()
//NativeCrashHandler.initCrashHandler()
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component))
@ -151,6 +146,7 @@ class AcraApplication : Application() {
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {

View file

@ -5,6 +5,7 @@ import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
import android.util.DisplayMetrics
@ -36,7 +37,7 @@ import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.settings.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.Event
import com.lagradost.cloudstream3.utils.UIHelper
@ -164,7 +165,7 @@ object CommonActivity {
val toast = Toast(act)
toast.duration = duration ?: Toast.LENGTH_SHORT
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
toast.show()
@ -276,12 +277,35 @@ object CommonActivity {
}
}
fun updateTheme(act: Activity) {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
if (settingsManager
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadThemes(act)
}
}
private fun mapSystemTheme(act: Activity): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val currentNightMode =
act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return when (currentNightMode) {
Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme
else -> R.style.AppTheme // Night mode is active, we're using dark theme
}
} else {
return R.style.AppTheme
}
}
fun loadThemes(act: Activity?) {
if (act == null) return
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
"System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
@ -464,20 +488,6 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
//println("Keycode: $keyCode")
//showToast(
// this,
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
// Tested keycodes on remote:
// KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
// KeyEvent.KEYCODE_MEDIA_REWIND
// KeyEvent.KEYCODE_MENU
// KeyEvent.KEYCODE_MEDIA_NEXT
// KeyEvent.KEYCODE_MEDIA_PREVIOUS
// KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
// 149 keycode_numpad 5
when (keyCode) {

View file

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

View file

@ -44,9 +44,6 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper
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.Session
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.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
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.removeKey
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.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale
import com.lagradost.cloudstream3.CommonActivity.updateTheme
import com.lagradost.cloudstream3.databinding.ActivityMainBinding
import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding
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.receivers.VideoDownloadRestartReceiver
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.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.localListApi
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
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.GeneratorPlayer
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.setImage
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.search.SearchFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
@ -122,19 +121,23 @@ import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
import com.lagradost.cloudstream3.utils.ApkInstaller
import com.lagradost.cloudstream3.utils.AppUtils.html
import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppUtils.isLtr
import com.lagradost.cloudstream3.utils.AppUtils.isNetworkAvailable
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.utils.AppUtils.loadCache
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr
import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
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.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.isAuthEnabled
@ -150,6 +153,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
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.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -162,8 +166,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import com.lagradost.cloudstream3.utils.fcast.FcastManager
import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ResponseParser
import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -186,8 +188,7 @@ import kotlin.system.exitProcess
//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225
class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
BiometricAuthenticator.BiometricAuthCallback {
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object {
const val VLC_PACKAGE = "org.videolan.vlc"
const val MPV_PACKAGE = "is.xyz.mpv"
@ -350,7 +351,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
println("Repository url: $realUrl")
loadRepository(realUrl)
return true
} else if (str.contains(appString)) {
} else if (str.contains(APP_STRING)) {
for (api in OAuth2Apis) {
if (str.contains("/${api.redirectUrl}")) {
ioSafe {
@ -380,15 +381,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
}
// 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
if (str == "$appString:") {
if (str == "$APP_STRING:") {
PluginManager.hotReloadAllLocalPlugins(activity)
}
} else if (safeURI(str)?.scheme == appStringRepo) {
val url = str.replaceFirst(appStringRepo, "https")
} else if (safeURI(str)?.scheme == APP_STRING_REPO) {
val url = str.replaceFirst(APP_STRING_REPO, "https")
loadRepository(url)
return true
} else if (safeURI(str)?.scheme == appStringSearch) {
val query = str.substringAfter("$appStringSearch://")
} else if (safeURI(str)?.scheme == APP_STRING_SEARCH) {
val query = str.substringAfter("$APP_STRING_SEARCH://")
nextSearchQuery =
try {
URLDecoder.decode(query, "UTF-8")
@ -402,7 +403,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
R.id.navigation_search
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search
} else if (safeURI(str)?.scheme == appStringPlayer) {
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
val uri = Uri.parse(str)
val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8")
@ -416,9 +417,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
)
)
)
} else if (safeURI(str)?.scheme == appStringResumeWatching) {
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
val id =
str.substringAfter("$appStringResumeWatching://").toIntOrNull()
str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull()
?: return false
ioSafe {
val resumeWatchingCard =
@ -472,7 +473,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
) DubStatus.Dubbed else DubStatus.Subbed, null
)
} else {
viewModel.loadSmall(this, result)
viewModel.loadSmall(result)
}
}
@ -487,6 +488,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateLocale() // android fucks me by chaining lang when rotating the phone
updateTheme(this) // Update if system theme
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
@ -571,23 +573,44 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
false
}
}
binding?.apply {
navView.isVisible = isNavVisible && !landscape
navRailView.isVisible = isNavVisible && landscape
navView.isVisible = isNavVisible && !landscape
// Hide library on TV since it is not supported yet :(
//val isTrueTv = isTrueTvSettings()
//navView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
//navRailView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
// Hide downloads on TV
//navView.menu.findItem(R.id.navigation_downloads)?.isVisible = !isTrueTv
//navRailView.menu.findItem(R.id.navigation_downloads)?.isVisible = !isTrueTv
/**
* We need to make sure if we return to a sub-fragment,
* the correct navigation item is selected so that it does not
* highlight the wrong one in UI.
*/
when (destination.id) {
in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
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
lateinit var mSessionManager: SessionManager
var mSessionManager: SessionManager? = null
private val mSessionManagerListener: SessionManagerListener<Session> by lazy { SessionManagerListenerImpl() }
private inner class SessionManagerListenerImpl : SessionManagerListener<Session> {
@ -627,8 +650,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
setActivityInstance(this)
try {
if (isCastApiAvailable()) {
//mCastSession = mSessionManager.currentCastSession
mSessionManager.addSessionManagerListener(mSessionManagerListener)
mSessionManager?.addSessionManagerListener(mSessionManagerListener)
}
} catch (e: Exception) {
logError(e)
@ -644,7 +666,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
}
try {
if (isCastApiAvailable()) {
mSessionManager.removeSessionManagerListener(mSessionManagerListener)
mSessionManager?.removeSessionManagerListener(mSessionManagerListener)
//mCastSession = null
}
} catch (e: Exception) {
@ -748,7 +770,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
list.forEach { custom ->
allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
?.let {
allProviders.add(it.javaClass.newInstance().apply {
allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply {
name = custom.name
lang = custom.lang
mainUrl = custom.url.trimEnd('/')
@ -771,14 +793,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
lateinit var viewModel: ResultViewModel2
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*/
var isLocalList: Boolean = false
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
viewModel =
ViewModelProvider(this)[ResultViewModel2::class.java]
syncViewModel =
ViewModelProvider(this)[SyncViewModel::class.java]
viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java]
return super.onCreateView(name, context, attrs)
}
@ -1129,7 +1151,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
super.onCreate(savedInstanceState)
try {
if (isCastApiAvailable()) {
mSessionManager = CastContext.getSharedInstance(this).sessionManager
CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager }
}
} catch (t: Throwable) {
logError(t)
@ -1235,17 +1257,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
this.setKey(getString(R.string.jsdelivr_proxy_key), false)
} else {
this.setKey(getString(R.string.jsdelivr_proxy_key), true)
val parentView: View = findViewById(android.R.id.content)
Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG)
.let { snackbar ->
snackbar.setAction(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()
}
showSnackbar(
this@MainActivity,
R.string.jsdelivr_enabled,
Snackbar.LENGTH_LONG,
R.string.revert
) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
}
}
}
@ -1416,7 +1433,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
resultviewPreviewMetaDuration.setText(d.durationText)
resultviewPreviewMetaRating.setText(d.ratingText)
resultviewPreviewDescription.setText(d.plotText)
resultviewPreviewDescription.setTextHtml(d.plotText)
resultviewPreviewPoster.setImage(
d.posterImage ?: d.posterBackgroundImage
)
@ -1431,13 +1448,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
val value = viewModel.watchStatus.value ?: WatchType.NONE
this@MainActivity.showBottomDialog(
WatchType.values().map { getString(it.stringRes) }.toList(),
WatchType.entries.map { getString(it.stringRes) }.toList(),
value.ordinal,
this@MainActivity.getString(R.string.action_add_to_bookmarks),
showApply = false,
{}) {
viewModel.updateWatchStatus(
WatchType.values()[it],
WatchType.entries[it],
this@MainActivity
)
}
@ -1447,12 +1464,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
?: SyncWatchType.NONE
this@MainActivity.showBottomDialog(
SyncWatchType.values().map { getString(it.stringRes) }.toList(),
SyncWatchType.entries.map { getString(it.stringRes) }.toList(),
value.ordinal,
this@MainActivity.getString(R.string.action_add_to_bookmarks),
showApply = false,
{}) {
syncViewModel.setStatus(SyncWatchType.values()[it].internalId)
syncViewModel.setStatus(SyncWatchType.entries[it].internalId)
syncViewModel.publishUserData()
}
}
@ -1533,6 +1550,26 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
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)
@ -1564,7 +1601,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
if (isLayout(TV or EMULATOR)) {
if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback()
attachBackPressedCallback {
showConfirmExitDialog()
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
} else detachBackPressedCallback()
}
}
@ -1809,28 +1851,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
finish()
}
private var backPressedCallback: OnBackPressedCallback? = null
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 {
return try {
app.get(

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,65 +0,0 @@
package com.lagradost.cloudstream3.extractors
import android.util.Base64
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import java.net.URLDecoder
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
class VidSrcTo : ExtractorApi() {
override val name = "VidSrcTo"
override val mainUrl = "https://vidsrc.to"
override val requiresReferer = true
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val mediaId = app.get(url).document.selectFirst("ul.episodes li a")?.attr("data-id") ?: return
val res = app.get("$mainUrl/ajax/embed/episode/$mediaId/sources").parsedSafe<VidsrctoEpisodeSources>() ?: return
if (res.status != 200) return
res.result?.amap { source ->
val embedRes = app.get("$mainUrl/ajax/embed/source/${source.id}").parsedSafe<VidsrctoEmbedSource>() ?: return@amap
val finalUrl = DecryptUrl(embedRes.result.encUrl)
if(finalUrl.equals(embedRes.result.encUrl)) return@amap
when (source.title) {
"Vidplay" -> AnyVidplay(finalUrl.substringBefore("/e/")).getUrl(finalUrl, referer, subtitleCallback, callback)
"Filemoon" -> FileMoon().getUrl(finalUrl, referer, subtitleCallback, callback)
}
}
}
private fun DecryptUrl(encUrl: String): String {
var data = encUrl.toByteArray()
data = Base64.decode(data, Base64.URL_SAFE)
val rc4Key = SecretKeySpec("WXrUARXb1aDLaZjI".toByteArray(), "RC4")
val cipher = Cipher.getInstance("RC4")
cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters)
data = cipher.doFinal(data)
return URLDecoder.decode(data.toString(Charsets.UTF_8), "utf-8")
}
data class VidsrctoEpisodeSources(
@JsonProperty("status") val status: Int,
@JsonProperty("result") val result: List<VidsrctoResult>?
)
data class VidsrctoResult(
@JsonProperty("id") val id: String,
@JsonProperty("title") val title: String
)
data class VidsrctoEmbedSource(
@JsonProperty("status") val status: Int,
@JsonProperty("result") val result: VidsrctoUrl
)
data class VidsrctoUrl(@JsonProperty("url") val encUrl: String)
}

View file

@ -1,120 +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 AnyVidplay(hostUrl: String) : Vidplay() {
override val mainUrl = hostUrl
}
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", referer = url).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

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

View file

@ -1,18 +1,39 @@
package com.lagradost.cloudstream3.metaproviders
import android.net.Uri
import com.lagradost.cloudstream3.*
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.util.Locale
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.roundToInt
open class TraktProvider : MainAPI() {
@ -25,7 +46,8 @@ open class TraktProvider : MainAPI() {
TvType.Anime,
)
private val traktClientId = base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
private val traktClientId =
base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
override val mainPage = mainPageOf(
@ -77,7 +99,8 @@ open class TraktProvider : MainAPI() {
}
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 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()
@ -85,6 +108,7 @@ open class TraktProvider : MainAPI() {
return results
}
override suspend fun load(url: String): LoadResponse {
val data = parseJson<Data>(url)
@ -94,7 +118,8 @@ open class TraktProvider : MainAPI() {
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 resActor =
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full")
val actors = parseJson<People>(resActor).cast?.map {
ActorData(
@ -106,12 +131,15 @@ open class TraktProvider : MainAPI() {
)
}
val resRelated = getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20")
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 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"
@ -163,21 +191,14 @@ open class TraktProvider : MainAPI() {
}
} else {
val resSeasons = getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
val resSeasons =
getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
val episodes = mutableListOf<Episode>()
val seasons = parseJson<List<Seasons>>(resSeasons)
val seasonsNames = mutableListOf<SeasonData>()
var nextAir: NextAiring? = null
seasons.forEach { season ->
seasonsNames.add(
SeasonData(
season.number!!,
season.title
)
)
season.episodes?.map { episode ->
val linkData = LinkData(
@ -215,9 +236,10 @@ open class TraktProvider : MainAPI() {
posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
rating = episode.rating?.times(10)?.roundToInt(),
description = episode.overview,
runTime = episode.runtime
).apply {
this.addDate(episode.firstAired)
if (nextAir == null && this.date != null && this.date!! > unixTimeMS) {
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),
@ -250,7 +272,6 @@ open class TraktProvider : MainAPI() {
this.comingSoon = isUpcoming(mediaDetails.released)
//posterHeaders
this.nextAiring = nextAir
this.seasonNames = seasonsNames
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
this.contentRating = mediaDetails.certification
addTrailer(mediaDetails.trailer)
@ -275,7 +296,7 @@ open class TraktProvider : MainAPI() {
return try {
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val dateTime = dateString?.let { format.parse(it)?.time } ?: return false
APIHolder.unixTimeMS < dateTime
unixTimeMS < dateTime
} catch (t: Throwable) {
logError(t)
false

View file

@ -9,7 +9,10 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests.Companion.await
import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.URI

View file

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

View file

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

View file

@ -1,24 +1,25 @@
package com.lagradost.cloudstream3.plugins
import android.Manifest
import android.app.*
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Resources
import android.os.Build
import android.os.Environment
import android.util.Log
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.Gson
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
@ -34,6 +35,7 @@ import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -164,7 +166,7 @@ object PluginManager {
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
public var currentlyLoading: String? = null
var currentlyLoading: String? = null
// Maps filepath to plugin
val plugins: MutableMap<String, Plugin> =
@ -340,7 +342,7 @@ object PluginManager {
//Omit non-NSFW if mode is set to NSFW only
if (mode == AutoDownloadMode.NsfwOnly) {
if (tvtypes.contains(TvType.NSFW.name) == false) {
if (!tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
}
@ -505,10 +507,12 @@ object PluginManager {
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
Log.d(TAG, "No manifest version for ${data.internalName}")
}
@Suppress("UNCHECKED_CAST")
val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
val pluginInstance: Plugin =
pluginClass.newInstance() as Plugin
pluginClass.getDeclaredConstructor().newInstance() as Plugin
// Sets with the proper version
setPluginData(data.copy(version = version))
@ -518,14 +522,16 @@ object PluginManager {
return true
}
pluginInstance.__filename = fileName
pluginInstance.filename = file.absolutePath
if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
val assets = AssetManager::class.java.newInstance()
val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
val addAssetPath =
AssetManager::class.java.getMethod("addAssetPath", String::class.java)
addAssetPath.invoke(assets, file.absolutePath)
@Suppress("DEPRECATION")
pluginInstance.resources = Resources(
assets,
context.resources.displayMetrics,
@ -567,14 +573,14 @@ object PluginManager {
// remove all registered apis
synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
}
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename }
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
classLoaders.values.removeIf { v -> v == plugin }
@ -721,9 +727,14 @@ object PluginManager {
}
val notification = builder.build()
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define
notify((System.currentTimeMillis() / 1000).toInt(), notification)
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
NotificationManagerCompat.from(context)
.notify((System.currentTimeMillis() / 1000).toInt(), notification)
}
return notification
} catch (e: Exception) {

View file

@ -73,7 +73,7 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String {

View file

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

View file

@ -10,7 +10,7 @@ import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
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.UIHelper.colorFromAttribute
import java.util.concurrent.TimeUnit

View file

@ -10,13 +10,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.*
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions

View file

@ -59,7 +59,7 @@ class SubtitleResource {
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>>()
ZipInputStream(file.inputStream()).use { zipInputStream ->

View file

@ -1,6 +1,5 @@
package com.lagradost.cloudstream3.subtitles
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.TvType
class AbstractSubtitleEntities {

View file

@ -3,18 +3,26 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.*
import java.util.concurrent.TimeUnit
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
companion object {
val malApi = MALApi(0)
val aniListApi = AniListApi(0)
val malApi = MALApi(0).also { api ->
LoadResponse.Companion.malIdPrefix = api.idPrefix
}
val aniListApi = AniListApi(0).also { api ->
LoadResponse.Companion.aniListIdPrefix = api.idPrefix
}
val simklApi = SimklApi(0).also { api ->
LoadResponse.Companion.simklIdPrefix = api.idPrefix
}
val openSubtitlesApi = OpenSubtitlesApi(0)
val simklApi = SimklApi(0)
val addic7ed = Addic7ed()
val subDlApi = SubDlApi(0)
val localListApi = LocalList()
val subSourceApi = SubSourceApi()
// used to login via app intent
val OAuth2Apis
@ -44,25 +52,26 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
get() = listOf(
openSubtitlesApi,
addic7ed,
subDlApi
subDlApi,
subSourceApi
)
const val appString = "cloudstreamapp"
const val appStringRepo = "cloudstreamrepo"
const val appStringPlayer = "cloudstreamplayer"
const val APP_STRING = "cloudstreamapp"
const val APP_STRING_REPO = "cloudstreamrepo"
const val APP_STRING_PLAYER = "cloudstreamplayer"
// Instantly start the search given a query
const val appStringSearch = "cloudstreamsearch"
const val APP_STRING_SEARCH = "cloudstreamsearch"
// Instantly resume watching a show
const val appStringResumeWatching = "cloudstreamcontinuewatching"
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
val unixTime: Long
get() = System.currentTimeMillis() / 1000L
val unixTimeMs: Long
get() = System.currentTimeMillis()
const val maxStale = 60 * 10
const val MAX_STALE = 60 * 10
fun secondsToReadable(seconds: Int, completedValue: String): String {
var secondsLong = seconds.toLong()

View file

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

View file

@ -2,19 +2,10 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch
enum class SyncIdName {
Anilist,
MyAnimeList,
Trakt,
Imdb,
Simkl,
LocalList,
}
import java.util.Date
interface SyncAPI : OAuth2API {
/**
@ -134,6 +125,8 @@ interface SyncAPI : OAuth2API {
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
else -> items
}
}
@ -168,9 +161,10 @@ interface SyncAPI : OAuth2API {
override var posterUrl: String?,
override var posterHeaders: Map<String, String>?,
override var quality: SearchQuality?,
val releaseDate: Date?,
override var id: Int? = null,
val plot : String? = null,
val rating: Int? = null,
val tags: List<String>? = null,
val tags: List<String>? = null
) : SearchResponse
}

View file

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

View file

@ -16,15 +16,16 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import java.net.URL
import java.net.URLEncoder
import java.util.*
import java.util.Locale
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "AniList"
@ -32,6 +33,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist"
override var requireLibraryRefresh = true
override val supportDeviceAuth = false
override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon
override val requiresLogin = false
@ -62,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
val token = sanitizer["access_token"]!!
val expiresIn = sanitizer["expires_in"]!!
@ -86,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(name) ?: return null
return data.data?.Page?.media?.map {
return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,
this.name,
@ -100,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getResult(id: String): SyncAPI.SyncResult {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
val season = getSeason(internalId).data.Media
val season = getSeason(internalId).data.media
return SyncAPI.SyncResult(
season.id.toString(),
@ -300,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
val shows = searchShows(name.replace(blackListRegex, ""))
shows?.data?.Page?.media?.find {
shows?.data?.page?.media?.find {
(malId ?: "NONE") == it.idMal.toString()
}?.let { return it }
val filtered =
shows?.data?.Page?.media?.filter {
shows?.data?.page?.media?.filter {
(((it.startDate.year ?: year.toString()) == year.toString()
|| year == null))
}
@ -495,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q, true)
val d = parseJson<GetDataRoot>(data ?: return null)
val main = d.data?.Media
val main = d.data?.media
if (main?.mediaListEntry != null) {
return AniListTitleHolder(
title = main.title,
@ -535,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
headers = mapOf(
"Authorization" to "Bearer " + (getAuth()
?: return@suspendSafeApiCall null),
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
),
cacheTime = 0,
data = mapOf(
@ -630,8 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
?: this.media.coverImage.medium,
null,
null,
this.media.seasonYear.toYear(),
null,
plot = this.media.description
plot = this.media.description,
)
}
}
@ -646,7 +649,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class Data(
@JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
)
private fun getAniListListCached(): Array<Lists>? {
@ -658,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
if (checkToken()) return null
return if (requireLibraryRefresh) {
val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray()
val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) {
setKey(ANILIST_CACHED_LIST, list)
}
@ -677,7 +680,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
// To fill empty lists when AniList does not return them
val baseMap =
AniListStatusType.values().filter { it.value >= 0 }.associate {
AniListStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>()
}
@ -688,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
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 */
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = null)
data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId(
@ -786,7 +791,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
"""
val response = postApi(idQuery)
val listId =
tryParseJson<MediaListItemRoot>(response)?.data?.MediaList?.id ?: return false
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
"""
mutation(${'$'}id: Int = $listId) {
DeleteMediaListEntry(id: ${'$'}id) {
@ -835,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q)
if (data.isNullOrBlank()) return null
val userData = parseJson<AniListRoot>(data)
val u = userData.data?.Viewer
val u = userData.data?.viewer
val user = AniListUser(
u?.id,
u?.name,
@ -857,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
suspend fun getSeasonRecursive(id: Int) {
val season = getSeason(id)
seasons.add(season)
if (season.data.Media.format?.startsWith("TV") == true) {
season.data.Media.relations?.edges?.forEach {
if (season.data.media.format?.startsWith("TV") == true) {
season.data.media.relations?.edges?.forEach {
if (it.node?.format != null) {
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
getSeasonRecursive(it.node.id)
@ -877,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class SeasonData(
@JsonProperty("Media") val Media: SeasonMedia,
@JsonProperty("Media") val media: SeasonMedia,
)
data class SeasonMedia(
@ -1049,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class AniListData(
@JsonProperty("Viewer") val Viewer: AniListViewer?,
@JsonProperty("Viewer") val viewer: AniListViewer?,
)
data class AniListRoot(
@ -1089,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class LikeData(
@JsonProperty("Viewer") val Viewer: LikeViewer?,
@JsonProperty("Viewer") val viewer: LikeViewer?,
)
data class LikeRoot(
@ -1129,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class GetDataData(
@JsonProperty("Media") val Media: GetDataMedia?,
@JsonProperty("Media") val media: GetDataMedia?,
)
data class GetDataRoot(
@ -1162,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class GetSearchPage(
@JsonProperty("Page") val Page: GetSearchData?,
@JsonProperty("Page") val page: GetSearchData?,
)
data class GetSearchData(

View file

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

View file

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

View file

@ -29,10 +29,10 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
companion object {
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
const val host = "https://api.opensubtitles.com/api/v1"
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
const val HOST = "https://api.opensubtitles.com/api/v1"
const val TAG = "OPENSUBS"
const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
var currentCoolDown: Long = 0L
var currentSession: SubtitleOAuthEntity? = null
}
@ -48,7 +48,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
chain.request().newBuilder()
.removeHeader("user-agent")
.addHeader("user-agent", userAgent)
.addHeader("Api-Key", apiKey)
.addHeader("Api-Key", API_KEY)
.build()
)
}
@ -65,7 +65,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
}
private fun throwGotTooManyRequests() {
currentCoolDown = unixTimeMs + coolDownDuration
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
throw ErrorLoadingException("Too many requests")
}
@ -114,7 +114,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
private suspend fun initLogin(username: String, password: String): Boolean {
//Log.i(TAG, "DATA = [$username] [$password]")
val response = app.post(
url = "$host/login",
url = "$HOST/login",
headers = mapOf(
"Content-Type" to "application/json",
),
@ -133,7 +133,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
SubtitleOAuthEntity(
user = username,
pass = password,
access_token = token.token ?: run {
accessToken = token.token ?: run {
return false
})
)
@ -196,8 +196,8 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid
true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
}
val req = app.get(
@ -232,7 +232,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
val isHearingImpaired = attr.hearing_impaired ?: false
val isHearingImpaired = attr.hearingImpaired ?: false
//Log.i(TAG, "Result id/name => ${item.id} / $name")
item.attributes?.files?.forEach { file ->
val resultData = file.fileId?.toString() ?: ""
@ -265,11 +265,11 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
throwIfCantDoRequest()
val req = app.post(
url = "$host/download",
url = "$HOST/download",
headers = mapOf(
Pair(
"Authorization",
"Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}"
"Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
),
Pair("Content-Type", "application/json"),
Pair("Accept", "*/*")
@ -298,7 +298,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
data class SubtitleOAuthEntity(
var user: String,
var pass: String,
var access_token: String,
var accessToken: String,
)
data class OAuthToken(
@ -323,7 +323,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
@JsonProperty("url") var url: String? = null,
@JsonProperty("files") var files: List<ResultFiles>? = listOf(),
@JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
@JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null,
@JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null,
)
data class ResultFiles(

View file

@ -12,7 +12,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
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.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import okhttp3.Interceptor
import okhttp3.Response
import java.math.BigInteger
@ -36,6 +39,7 @@ import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration
import kotlin.time.DurationUnit
@ -45,6 +49,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "Simkl"
override val key = "simkl-key"
override val redirectUrl = "simkl"
override val supportDeviceAuth = true
override val idPrefix = "simkl"
override var requireLibraryRefresh = true
override var mainUrl = "https://api.simkl.com"
@ -141,8 +146,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
}
companion object {
private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID
private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET
private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID
private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET
private var lastLoginState = ""
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"
/** 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? {
return try {
SimpleDateFormat(simklDateFormat).apply {
SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply {
this.timeZone = TimeZone.getTimeZone("UTC")
}.parse(
string ?: return null
@ -168,7 +173,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
/** 1409562611 -> 2014-09-01T09:10:11Z */
fun getDateTime(unixTime: Long?): String? {
return try {
SimpleDateFormat(simklDateFormat).apply {
SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply {
this.timeZone = TimeZone.getTimeZone("UTC")
}.format(
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 {
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 {
fun fromString(string: String): SimklListStatusType? {
return SimklListStatusType.values().firstOrNull {
return SimklListStatusType.entries.firstOrNull {
it.originalName == string
}
}
@ -242,17 +221,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class TokenRequest(
@JsonProperty("code") val code: String,
@JsonProperty("client_id") val client_id: String = clientId,
@JsonProperty("client_secret") val client_secret: String = clientSecret,
@JsonProperty("redirect_uri") val redirect_uri: String = "$appString://simkl",
@JsonProperty("grant_type") val grant_type: String = "authorization_code"
@JsonProperty("client_id") val clientId: String = CLIENT_ID,
@JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET,
@JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl",
@JsonProperty("grant_type") val grantType: String = "authorization_code"
)
data class TokenResponse(
/** No expiration date */
val access_token: String,
val token_type: String,
val scope: String
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("token_type") val tokenType: 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(
val all: String?,
val tv_shows: UpdatedAt,
val anime: UpdatedAt,
val movies: UpdatedAt,
@JsonProperty("all") val all: String?,
@JsonProperty("tv_shows") val tvShows: UpdatedAt,
@JsonProperty("anime") val anime: UpdatedAt,
@JsonProperty("movies") val movies: UpdatedAt,
) {
data class UpdatedAt(
val all: String?,
val removed_from_list: String?,
val rated_at: String?,
@JsonProperty("all") val all: String?,
@JsonProperty("removed_from_list") val removedFromList: String?,
@JsonProperty("rated_at") val ratedAt: String?,
)
}
@ -316,7 +310,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("title") val title: String?,
@JsonProperty("year") val year: Int?,
@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("poster") val poster: 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,
) {
companion object {
fun fromMap(map: Map<SyncServices, String>): Ids {
fun fromMap(map: Map<SimklSyncServices, String>): Ids {
return Ids(
simkl = map[SyncServices.Simkl]?.toIntOrNull(),
imdb = map[SyncServices.Imdb],
tmdb = map[SyncServices.Tmdb],
mal = map[SyncServices.Mal],
anilist = map[SyncServices.AniList]
simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(),
imdb = map[SimklSyncServices.Imdb],
tmdb = map[SimklSyncServices.Tmdb],
mal = map[SimklSyncServices.Mal],
anilist = map[SimklSyncServices.AniList]
)
}
}
@ -548,7 +542,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
}
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 {
val cacheTime =
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("episodes") episodes: List<Season.Episode>? = 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)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -575,7 +569,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@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)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -584,7 +578,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?,
@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)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -639,24 +633,24 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
}
interface Metadata {
val last_watched_at: String?
val lastWatchedAt: String?
val status: String?
val user_rating: Int?
val last_watched: String?
val watched_episodes_count: Int?
val total_episodes_count: Int?
val userRating: Int?
val lastWatched: String?
val watchedEpisodesCount: Int?
val totalEpisodesCount: Int?
fun getIds(): ShowMetadata.Show.Ids
fun toLibraryItem(): SyncAPI.LibraryItem
}
data class MovieMetadata(
override val last_watched_at: String?,
override val status: String,
override val user_rating: Int?,
override val last_watched: String?,
override val watched_episodes_count: Int?,
override val total_episodes_count: Int?,
@JsonProperty("last_watched_at") override val lastWatchedAt: String?,
@JsonProperty("status") override val status: String,
@JsonProperty("user_rating") override val userRating: Int?,
@JsonProperty("last_watched") override val lastWatched: String?,
@JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?,
@JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?,
val movie: ShowMetadata.Show
) : Metadata {
override fun getIds(): ShowMetadata.Show.Ids {
@ -668,27 +662,28 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
this.movie.title,
"https://simkl.com/tv/${movie.ids.simkl}",
movie.ids.simkl.toString(),
this.watched_episodes_count,
this.total_episodes_count,
this.user_rating?.times(10),
getUnixTime(last_watched_at) ?: 0,
this.watchedEpisodesCount,
this.totalEpisodesCount,
this.userRating?.times(10),
getUnixTime(lastWatchedAt) ?: 0,
"Simkl",
TvType.Movie,
this.movie.poster?.let { getPosterUrl(it) },
null,
null,
movie.ids.simkl,
this.movie.year?.toYear(),
movie.ids.simkl
)
}
}
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("user_rating") override val user_rating: Int?,
@JsonProperty("last_watched") override val last_watched: String?,
@JsonProperty("watched_episodes_count") override val watched_episodes_count: Int?,
@JsonProperty("total_episodes_count") override val total_episodes_count: Int?,
@JsonProperty("user_rating") override val userRating: Int?,
@JsonProperty("last_watched") override val lastWatched: String?,
@JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?,
@JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?,
@JsonProperty("show") val show: Show
) : Metadata {
override fun getIds(): Show.Ids {
@ -700,15 +695,16 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
this.show.title,
"https://simkl.com/tv/${show.ids.simkl}",
show.ids.simkl.toString(),
this.watched_episodes_count,
this.total_episodes_count,
this.user_rating?.times(10),
getUnixTime(last_watched_at) ?: 0,
this.watchedEpisodesCount,
this.totalEpisodesCount,
this.userRating?.times(10),
getUnixTime(lastWatchedAt) ?: 0,
"Simkl",
TvType.Anime,
this.show.poster?.let { getPosterUrl(it) },
null,
null,
this.show.year?.toYear(),
show.ids.simkl
)
}
@ -732,13 +728,13 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("anilist") val anilist: String?,
@JsonProperty("traktslug") val traktslug: String?
) {
fun matchesId(database: SyncServices, id: String): Boolean {
fun matchesId(database: SimklSyncServices, id: String): Boolean {
return when (database) {
SyncServices.Simkl -> this.simkl == id.toIntOrNull()
SyncServices.AniList -> this.anilist == id
SyncServices.Mal -> this.mal == id
SyncServices.Tmdb -> this.tmdb == id
SyncServices.Imdb -> this.imdb == id
SimklSyncServices.Simkl -> this.simkl == id.toIntOrNull()
SimklSyncServices.AniList -> this.anilist == id
SimklSyncServices.Mal -> this.mal == id
SimklSyncServices.Tmdb -> this.tmdb == id
SimklSyncServices.Imdb -> this.imdb == id
}
}
}
@ -757,7 +753,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
chain.request()
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("simkl-api-key", clientId)
.addHeader("simkl-api-key", CLIENT_ID)
.build()
)
}
@ -818,7 +814,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
val episodeConstructor = SimklEpisodeConstructor(
searchResult.ids?.simkl,
searchResult.type,
searchResult.total_episodes,
searchResult.totalEpisodes,
searchResult.hasEnded()
)
@ -840,12 +836,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
)
}
?: return null,
score = foundItem.user_rating,
watchedEpisodes = foundItem.watched_episodes_count,
maxEpisodes = searchResult.total_episodes,
score = foundItem.userRating,
watchedEpisodes = foundItem.watchedEpisodesCount,
maxEpisodes = searchResult.totalEpisodes,
episodeConstructor = episodeConstructor,
oldEpisodes = foundItem.watched_episodes_count ?: 0,
oldScore = foundItem.user_rating,
oldEpisodes = foundItem.watchedEpisodesCount ?: 0,
oldScore = foundItem.userRating,
oldStatus = foundItem.status
)
} else {
@ -853,7 +849,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
score = 0,
watchedEpisodes = 0,
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.total_episodes,
maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
episodeConstructor = episodeConstructor,
oldEpisodes = 0,
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 */
suspend fun searchByIds(serviceMap: Map<SyncServices, String>): Array<MediaObject>? {
private suspend fun searchByIds(serviceMap: Map<SimklSyncServices, String>): Array<MediaObject>? {
if (serviceMap.isEmpty()) return emptyArray()
return app.get(
"$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
}
).parsedSafe()
@ -912,14 +908,14 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
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() }
}
override fun authenticate(activity: FragmentActivity?) {
lastLoginState = BigInteger(130, SecureRandom()).toString(32)
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)
}
@ -969,15 +965,15 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
val activities = getActivities()
val lastCacheUpdate = getKey<Long>(accountId, SIMKL_CACHED_LIST_TIME)
val lastRemoval = listOf(
activities?.tv_shows?.removed_from_list,
activities?.anime?.removed_from_list,
activities?.movies?.removed_from_list
activities?.tvShows?.removedFromList,
activities?.anime?.removedFromList,
activities?.movies?.removedFromList
).maxOf {
getUnixTime(it) ?: -1
}
val lastRealUpdate =
listOf(
activities?.tv_shows?.all,
activities?.tvShows?.all,
activities?.anime?.all,
activities?.movies?.all,
).maxOf {
@ -1034,6 +1030,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
@ -1045,6 +1043,44 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
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 {
val uri = url.toUri()
val state = uri.getQueryParameter("state")
@ -1058,7 +1094,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
).parsedSafe<TokenResponse>() ?: return false
switchToNewAccount()
setKey(accountId, SIMKL_TOKEN_KEY, token.access_token)
setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken)
val user = getUser()
if (user == null) {

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

@ -50,7 +50,7 @@ class APIRepository(val api: MainAPI) {
private val cache = threadSafeListOf<SavedLoadResponse>()
private var cacheIndex: Int = 0
const val cacheSize = 20
const val CACHE_SIZE = 20
}
private fun afterPluginsLoaded(forceReload: Boolean) {
@ -94,9 +94,9 @@ class APIRepository(val api: MainAPI) {
val add = SavedLoadResponse(unixTime, response, lookingForHash)
synchronized(cache) {
if (cache.size > cacheSize) {
if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache
cacheIndex = (cacheIndex + 1) % cacheSize
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
} else {
cache.add(add)
}

View file

@ -112,6 +112,7 @@ abstract class BaseAdapter<
holder.onViewDetachedFromWindow()
}
@Suppress("UNCHECKED_CAST")
fun save(recyclerView: RecyclerView) {
for (child in recyclerView.children) {
val holder =
@ -124,6 +125,7 @@ abstract class BaseAdapter<
stateViewModel.layoutManagerStates[id]?.clear()
}
@Suppress("UNCHECKED_CAST")
private fun getState(holder: ViewHolderState<S>): 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.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.media3.common.util.UnstableApi
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
@ -23,13 +24,13 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.sortSubs
import com.lagradost.cloudstream3.sortUrls
import com.lagradost.cloudstream3.ui.player.LoadType
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
@ -263,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
var isLoadingMore = false
override fun onMediaStatusUpdated() {
super.onMediaStatusUpdated()
val meta = getCurrentMetaData()

View file

@ -8,8 +8,8 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
class GrdLayoutManager(val context: Context, _spanCount: Int) :
GridLayoutManager(context, _spanCount) {
class GrdLayoutManager(val context: Context, spanCount: Int) :
GridLayoutManager(context, spanCount) {
override fun onFocusSearchFailed(
focused: View,
focusDirection: Int,

View file

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

View file

@ -15,7 +15,7 @@ open class NonFinalAdapterListUpdateCallback
/**
* 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<*>) :
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);
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);
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.WebView
import android.webkit.WebViewClient
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : Fragment() {
@ -29,6 +31,7 @@ class WebviewFragment : Fragment() {
}
binding?.webView?.webViewClient = object : WebViewClient() {
@OptIn(UnstableApi::class)
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?

View file

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

View file

@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.BiometricAuthenticator
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
@ -33,7 +34,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
class AccountSelectActivity : AppCompatActivity(), BiometricAuthenticator.BiometricAuthCallback {
class AccountSelectActivity : AppCompatActivity(), BiometricCallback {
lateinit var viewModel: AccountViewModel

View file

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

View file

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

View file

@ -1,94 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5
data class VisualDownloadChildCached(
val currentBytes: Long,
val totalBytes: Long,
val data: VideoDownloadHelper.DownloadEpisodeCached,
)
data class DownloadClickEvent(val action: Int, val data: 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,30 +1,33 @@
package com.lagradost.cloudstream3.ui.download
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
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.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadChildFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentChildDownloadsBinding? = null
companion object {
fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply {
@ -35,57 +38,54 @@ class DownloadChildFragment : Fragment() {
}
override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
detachBackPressedCallback()
binding = null
super.onDestroyView()
}
var binding: FragmentChildDownloadsBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_child_downloads, container, false)
return localBinding.root
}
private fun updateList(folder: String) = main {
context?.let { ctx ->
val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) }
val eps = withContext(Dispatchers.IO) {
data.mapNotNull { key ->
context?.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull {
val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
?: return@mapNotNull null
VisualDownloadChildCached(info.fileLength, info.totalBytes, it)
}
}.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
if (eps.isEmpty()) {
activity?.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?) {
super.onViewCreated(view, savedInstanceState)
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
val folder = arguments?.getString("folder")
val name = arguments?.getString("name")
if (folder == null) {
activity?.onBackPressedDispatcher?.onBackPressed() // TODO FIX
activity?.onBackPressedDispatcher?.onBackPressed()
return
}
fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply {
title = name
@ -98,31 +98,110 @@ class DownloadChildFragment : Fragment() {
setAppBarNoScrollFlagsOnTV()
}
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
DownloadChildAdapter(
ArrayList(),
) { click ->
handleDownloadClick(click)
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.childCards) {
if (it.isEmpty()) {
activity?.onBackPressedDispatcher?.onBackPressed()
return@observe
}
downloadDeleteEventListener = { id: Int ->
val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList
if (list != null) {
if (list.any { it.data.id == id }) {
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)
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
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?.adapter = adapter
binding?.downloadChildList?.setLinearListLayout(
binding?.downloadChildList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF,
nextRight = FOCUS_SELF
)//layoutManager = GridLayoutManager(context, 1)
)
}
updateList(folder)
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,56 +1,62 @@
package com.lagradost.cloudstream3.ui.download
import android.app.Activity
import android.app.Dialog
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.isMovieType
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentDownloadsBinding? = null
private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams(
@ -61,95 +67,175 @@ class DownloadFragment : Fragment() {
this.layoutParams = param
}
private fun setList(list: List<VisualDownloadHeaderCached>) {
main {
(binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list
binding?.downloadList?.adapter?.notifyDataSetChanged()
}
}
override fun onDestroyView() {
if (downloadDeleteEventListener != null) {
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
downloadDeleteEventListener = null
}
detachBackPressedCallback()
binding = null
super.onDestroyView()
}
var binding: FragmentDownloadsBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
downloadsViewModel =
ViewModelProvider(this)[DownloadViewModel::class.java]
): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_downloads, container, false)
return localBinding.root
}
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
hideKeyboard()
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.noDownloadsText) {
binding?.textNoDownloads?.text = it
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
observe(downloadsViewModel.headerCards) {
setList(it)
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding?.downloadLoading?.isVisible = false
binding?.textNoDownloads?.isVisible = it.isEmpty()
}
observe(downloadsViewModel.availableBytes) {
binding?.downloadFreeTxt?.text =
getString(R.string.storage_size_format).format(
getString(R.string.free_storage),
formatShortFileSize(view.context, it)
updateStorageInfo(
view.context,
it,
R.string.free_storage,
binding?.downloadFreeTxt,
binding?.downloadFree
)
binding?.downloadFree?.setLayoutWidth(it)
}
observe(downloadsViewModel.usedBytes) {
binding?.apply {
downloadUsedTxt.text =
getString(R.string.storage_size_format).format(
getString(R.string.used_storage),
formatShortFileSize(view.context, it)
updateStorageInfo(
view.context,
it,
R.string.used_storage,
binding?.downloadUsedTxt,
binding?.downloadUsed
)
downloadUsed.setLayoutWidth(it)
downloadStorageAppbar.isVisible = it > 0
}
// Prevent race condition and make sure
// we don't display it early
if (
downloadsViewModel.isMultiDeleteState.value == null ||
downloadsViewModel.isMultiDeleteState.value == false
) binding?.downloadStorageAppbar?.isVisible = it > 0
}
observe(downloadsViewModel.downloadBytes) {
binding?.apply {
downloadAppTxt.text =
getString(R.string.storage_size_format).format(
getString(R.string.app_storage),
formatShortFileSize(view.context, it)
updateStorageInfo(
view.context,
it,
R.string.app_storage,
binding?.downloadAppTxt,
binding?.downloadApp
)
downloadApp.setLayoutWidth(it)
}
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
detachBackPressedCallback()
downloadsViewModel.clearSelectedItems()
// Prevent race condition and make sure
// we don't display it early
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
binding?.downloadStorageAppbar?.isVisible = true
}
}
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
{ click -> handleItemClick(click) },
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
}
)
binding?.downloadList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF,
)
}
binding?.apply {
openLocalVideoButton.apply {
isGone = isLayout(TV)
setOnClickListener { openLocalVideo() }
}
downloadStreamButton.apply {
isGone = isLayout(TV)
setOnClickListener { showStreamInputDialog(it.context) }
}
}
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
DownloadHeaderAdapter(
ArrayList(),
{ click ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
handleScroll(scrollY - oldScrollY)
}
}
context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot)
}
private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) {
0 -> {
if (click.data.type.isMovieType()) {
//wont be called
} else {
val folder = DataStore.getFolderName(
DOWNLOAD_EPISODE_CACHE,
click.data.id.toString()
)
DOWNLOAD_ACTION_GO_TO_CHILD -> {
if (click.data.type.isEpisodeBased()) {
val folder =
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)
@ -157,86 +243,101 @@ class DownloadFragment : Fragment() {
}
}
1 -> {
(activity as AppCompatActivity?)?.loadResult(
click.data.url,
click.data.apiName
)
DOWNLOAD_ACTION_LOAD_RESULT -> {
activity?.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) {
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.updateList(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)
)
downloadDeleteEventListener = { id ->
val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList
if (list != null) {
if (list.any { it.data.id == id }) {
context?.let { ctx ->
setList(ArrayList())
downloadsViewModel.updateList(ctx)
}
}
}
view?.setLayoutWidth(bytes)
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
binding?.downloadList?.apply {
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF
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)
)
//layoutManager = GridLayoutManager(context, 1)
)
}
}
// Should be visible in emulator layout
binding?.downloadStreamButton?.isGone = isLayout(TV)
binding?.downloadStreamButton?.setOnClickListener {
val dialog =
Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom)
private fun showStreamInputDialog(context: Context) {
val dialog = Dialog(context, 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
}
fun activateSwitchOnHls(text: String?) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
}
binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true }
binding.streamReferer.doOnTextChanged { text, _, _, _ ->
if (!preventAutoSwitching)
activateSwitchOnHls(text?.toString())
if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
}
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt(
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
0
)?.text?.toString()?.let { copy ->
val fixedText = copy.trim()
binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText)
activateSwitchOnHls(fixedText, binding)
}
binding.applyBtt.setOnClickListener {
@ -245,7 +346,6 @@ class DownloadFragment : Fragment() {
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(
@ -257,7 +357,6 @@ class DownloadFragment : Fragment() {
)
)
)
dialog.dismissSafe(activity)
}
}
@ -266,18 +365,27 @@ class DownloadFragment : Fragment() {
dialog.dismissSafe(activity)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val dy = scrollY - oldScrollY
if (dy > 0) { //check for scroll down
binding?.downloadStreamButton?.shrink() // hide
} else if (dy < -5) {
binding?.downloadStreamButton?.extend() // show
}
}
}
downloadsViewModel.updateList(requireContext())
fixPaddingStatusbar(binding?.downloadRoot)
private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
}
private fun handleScroll(dy: Int) {
if (dy > 0) {
binding?.downloadStreamButton?.shrink()
} else if (dy < -5) {
binding?.downloadStreamButton?.extend()
}
}
// Open local video from files using content provider x safeFile
private val videoResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
}
}

View file

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

View file

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

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.text.format.Formatter
import android.text.format.Formatter.formatShortFileSize
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
@ -9,6 +9,8 @@ import androidx.annotation.LayoutRes
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.VideoDownloadManager
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
@ -52,12 +54,16 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
}
init {
@Suppress("LeakingThis")
resetViewData()
}
var doSetProgress = true
open fun resetViewData() {
// lastRequest = null
isZeroBytes = true
doSetProgress = true
persistentId = null
}
@ -68,23 +74,36 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
persistentId = id
currentMetaData.id = id
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)?.let { savedData ->
if (!doSetProgress) return
ioSafe {
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
mainWork {
if (savedData != null) {
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
/*lastRequest = savedData.uriRequest
files = savedData.files
var totalBytes: Long = 0
var downloadedBytes: Long = 0
for (file in savedData.files) {
downloadedBytes += file.completedLength
totalBytes += file.length
}*/
setProgress(downloadedBytes, totalBytes)
applyMetaData(id, downloadedBytes, totalBytes)
} else run { resetView() }
}
}
}
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
// some extra padding for just in case
val status = VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) DownloadStatusTell.IsDone else DownloadStatusTell.IsPaused
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
@ -92,12 +111,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
this.status = status
}
setStatus(status)
} ?: run {
resetView()
}
}
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
isZeroBytes = downloadedBytes == 0L
@ -124,13 +138,16 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
if (isZeroBytes) {
progressText?.isVisible = false
} else {
if (doSetProgress) {
progressText?.apply {
val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes)
val totalMbString = Formatter.formatShortFileSize(context, totalBytes)
val currentFormattedSizeString =
formatShortFileSize(context, downloadedBytes)
val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
text =
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentMbString, totalMbString)
?.format(currentFormattedSizeString, totalFormattedSizeString)
}
}
}
@ -198,5 +215,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
* Get a clean slate again, might be useful in recyclerview?
* */
abstract fun resetView()
}

View file

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

View file

@ -1,7 +1,6 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
@ -13,7 +12,6 @@ import androidx.annotation.MainThread
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
@ -29,7 +27,6 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) {
@ -47,6 +44,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
private var iconPaused: Int = 0
private var hideWhenIcon: Boolean = true
var progressDrawable: Int = 0
var overrideLayout: Int? = null
companion object {
@ -59,7 +58,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}
private var progressBarBackground: View
private var statusView: ImageView
var statusView: ImageView
open fun onInflate() {}
@ -131,7 +130,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
val progressDrawable = getResourceId(
progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
)
@ -264,7 +263,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
progressBarBackground.background =
ContextCompat.getDrawable(context, progressDrawable)
val drawable = getDrawableFromStatus(status)
val drawable =
getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) }
statusView.setImageDrawable(drawable)
val isDrawable = drawable != null
@ -282,12 +282,12 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status
// runs on the main thread, but also instant if it already is
// Runs on the main thread, but also instant if it already is
if (Looper.myLooper() == Looper.getMainLooper()) {
try {
setStatusInternal(status)
} catch (t: Throwable) {
logError(t) // just in case setStatusInternal throws because thread
logError(t) // Just in case setStatusInternal throws because thread
progressBarBackground.post {
setStatusInternal(status)
}
@ -303,6 +303,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
setStatus(null)
currentMetaData = DownloadMetadata(0, 0, 0, null)
isZeroBytes = true
doSetProgress = true
progressBar.progress = 0
}
@ -326,19 +327,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}
}
open fun getDrawableFromStatus(status: DownloadStatusTell?): Drawable? {
val drawableInt = when (status) {
open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) {
DownloadStatusTell.IsPaused -> iconPaused
DownloadStatusTell.IsPending -> iconWaiting
DownloadStatusTell.IsDownloading -> iconActive
DownloadStatusTell.IsFailed -> iconError
DownloadStatusTell.IsDone -> iconComplete
DownloadStatusTell.IsStopped -> iconRemoved
null -> iconInit
}
if (drawableInt == 0) {
return null
}
return ContextCompat.getDrawable(this.context, drawableInt)
}
else -> iconInit
}.takeIf { it != 0 }
}

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.settings.Globals.TV
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
class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(view) {
@ -54,7 +54,7 @@ class HomeChildItemAdapter(
var hasNext: Boolean = false
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 root = LayoutInflater.from(parent.context).inflate(layout, parent, false)
@ -133,7 +133,6 @@ class HomeChildItemAdapter(
item,
position,
holder.itemView,
null, // nextFocusBehavior,
nextFocusUp,
nextFocusDown
)

View file

@ -17,7 +17,6 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.*
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -25,8 +24,6 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.chip.Chip
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppUtils.ownHide
import com.lagradost.cloudstream3.utils.AppUtils.ownShow
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
@ -234,7 +233,7 @@ class HomeFragment : Fragment() {
return bottomSheetDialogBuilder
}
fun getPairList(
private fun getPairList(
anime: Chip?,
cartoons: Chip?,
tvs: Chip?,

View file

@ -1,6 +1,8 @@
package com.lagradost.cloudstream3.ui.home
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
class LoadClickCallback(
val action: Int = 0,
@ -58,7 +60,7 @@ open class ParentItemAdapter(
override fun restore(state: Bundle) {
(binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
state.getParcelable("value")
state.getSafeParcelable<Parcelable>("value")
)
}
}
@ -170,3 +172,8 @@ open class ParentItemAdapter(
.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 com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.lagradost.cloudstream3.APIHolder.getId
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList
@ -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.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.getId
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
@ -117,15 +117,12 @@ class HomeParentItemAdapterPreview(
}
override fun restore(state: Bundle) {
state.getParcelable<Parcelable>("resumeRecyclerView")?.let { recycle ->
state.getSafeParcelable<Parcelable>("resumeRecyclerView")?.let { recycle ->
resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
}
state.getParcelable<Parcelable>("bookmarkRecyclerView")?.let { recycle ->
state.getSafeParcelable<Parcelable>("bookmarkRecyclerView")?.let { recycle ->
bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle)
}
//state.getInt("previewViewpager").let { recycle ->
// previewViewpager.setCurrentItem(recycle,true)
//}
}
val previewAdapter = HomeScrollAdapter(fragment = fragment)

View file

@ -6,9 +6,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
@ -36,8 +33,11 @@ import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppContextUtils.filterHomePageListByFilmQuality
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStoreHelper
@ -152,7 +152,7 @@ class HomeViewModel : ViewModel() {
}
}?.distinctBy { it.first } ?: return@launchSafe
val length = WatchType.values().size
val length = WatchType.entries.size
val currentWatchTypes = mutableSetOf<WatchType>()
for (watch in watchStatusIds) {
@ -387,7 +387,9 @@ class HomeViewModel : ViewModel() {
}
is Resource.Failure -> {
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
_page.postValue(data!!)
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
_preview.postValue(data!!)
}
@ -397,9 +399,7 @@ class HomeViewModel : ViewModel() {
}
fun click(callback: SearchClickCallback) {
if (callback.action == SEARCH_ACTION_FOCUSED) {
//focusCallback(callback.card)
} else {
if (callback.action != SEARCH_ACTION_FOCUSED) {
SearchHelper.handleSearchClickCallback(callback)
}
}
@ -516,7 +516,7 @@ class HomeViewModel : ViewModel() {
} else {
_page.postValue(Resource.Loading())
if (preferredApiName != null)
_apiName.postValue(preferredApiName)
_apiName.postValue(preferredApiName!!)
}
} else {
// if the api is found, then set it to it and save key

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

View file

@ -23,6 +23,8 @@ enum class ListSorting(@StringRes val stringRes: Int) {
UpdatedOld(R.string.sort_updated_old),
AlphabeticalA(R.string.sort_alphabetical_a),
AlphabeticalZ(R.string.sort_alphabetical_z),
ReleaseDateNew(R.string.sort_release_date_new),
ReleaseDateOld(R.string.sort_release_date_old),
}
const val LAST_SYNC_API_KEY = "last_sync_api"

View file

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

View file

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

View file

@ -27,7 +27,11 @@ import androidx.fragment.app.Fragment
import androidx.media3.common.PlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.ui.*
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar
import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.github.rubensousa.previewseekbar.PreviewBar
@ -44,8 +48,8 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.UIHelper
@ -216,7 +220,7 @@ abstract class AbstractPlayerFragment(
return
}
player.handleEvent(
CSPlayerEvent.values()[intent.getIntExtra(
CSPlayerEvent.entries[intent.getIntExtra(
EXTRA_CONTROL_TYPE,
0
)], source = PlayerEventSource.UI
@ -258,7 +262,7 @@ abstract class AbstractPlayerFragment(
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.requestLocalAudioFocus(AppUtils.getFocusRequest())
activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
}
}
@ -603,12 +607,12 @@ abstract class AbstractPlayerFragment(
}
fun nextResize() {
resizeMode = (resizeMode + 1) % PlayerResize.values().size
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
resize(resizeMode, true)
}
fun resize(resize: Int, showToast: Boolean) {
resize(PlayerResize.values()[resize], showToast)
resize(PlayerResize.entries[resize], showToast)
}
@SuppressLint("UnsafeOptInUsageError")

View file

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

View file

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

View file

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

View file

@ -1,11 +1,15 @@
package com.lagradost.cloudstream3.ui.player
import android.net.Uri
import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
import kotlin.math.max
import kotlin.math.min
@ -14,6 +18,7 @@ class DownloadFileGenerator(
private var currentIndex: Int = 0
) : IGenerator {
override val hasCache = false
override val canSkipLoading = false
override fun hasNext(): Boolean {
return currentIndex < episodes.size - 1
@ -50,10 +55,6 @@ class DownloadFileGenerator(
return null
}
fun cleanDisplayName(name: String): String {
return name.substringBeforeLast('.').trim()
}
override suspend fun generateLinks(
clearCache: Boolean,
type: LoadType,
@ -62,7 +63,21 @@ class DownloadFileGenerator(
offset: Int
): Boolean {
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 relative = meta.relativePath ?: return true
@ -70,28 +85,9 @@ class DownloadFileGenerator(
val cleanDisplay = cleanDisplayName(display)
VideoDownloadManager.getFolder(ctx, relative, meta.basePath)
?.forEach { (name, uri) ->
// 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
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
if (isMatchingSubtitle(name, display, cleanDisplay)) {
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)
subtitleCallback(
@ -105,6 +101,7 @@ class DownloadFileGenerator(
)
)
}
}
return true
}

View file

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

View file

@ -1,13 +1,13 @@
package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
class ExtractorLinkGenerator(
private val links: List<ExtractorLink>,
private val subtitles: List<SubtitleData>,
) : IGenerator {
override val hasCache = false
override val canSkipLoading = true
override fun getCurrentId(): Int? {
return null

View file

@ -14,20 +14,31 @@ import android.os.Bundle
import android.provider.Settings
import android.text.Editable
import android.text.format.DateUtils
import android.view.*
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.Surface
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import androidx.annotation.OptIn
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.core.view.children
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.media3.common.util.UnstableApi
import androidx.preference.PreferenceManager
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.screenHeight
@ -41,11 +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.result.setText
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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -58,7 +68,11 @@ import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UserPreferenceDelegate
import com.lagradost.cloudstream3.utils.Vector2
import kotlin.math.*
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking
const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage
@ -78,6 +92,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var playerBinding: PlayerCustomLayoutBinding? = null
private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false)
// state of player UI
protected var isShowing = false
protected var isLocked = false
@ -109,6 +124,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var doubleTapPauseEnabled = true
protected var playerRotateEnabled = false
protected var autoPlayerRotateEnabled = false
private var hideControlsNames = false
protected var subtitleDelay
set(value) = try {
@ -230,6 +246,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
fadeAnimation.duration = 100
fadeAnimation.fillAfter = true
@OptIn(UnstableApi::class)
val sView = subView
val sStyle = subStyle
if (sView != null && sStyle != null) {
@ -243,7 +260,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat()
playerBinding?.apply {
playerOpenSource.let {
ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply {
@ -286,42 +302,40 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private fun restoreOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation
var orientation = 0
when (currentOrientation) {
val orientation = when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED ->
orientation = dynamicOrientation()
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
Configuration.ORIENTATION_PORTRAIT ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> dynamicOrientation()
}
activity.requestedOrientation = orientation
}
private fun toggleOrientationWithSensor(activity: Activity) {
val currentOrientation = activity.resources.configuration.orientation
var orientation = 0
when (currentOrientation) {
val orientation: Int = when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED ->
orientation = dynamicOrientation()
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
Configuration.ORIENTATION_PORTRAIT ->
orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else -> dynamicOrientation()
}
activity.requestedOrientation = orientation
}
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
else activity.display!!
val rotation = display.rotation
val currentOrientation = activity.resources.configuration.orientation
var orientation = 0
val orientation: Int
when (currentOrientation) {
Configuration.ORIENTATION_LANDSCAPE ->
orientation =
@ -330,15 +344,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED ->
orientation = dynamicOrientation()
Configuration.ORIENTATION_PORTRAIT ->
orientation =
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
else -> orientation = dynamicOrientation()
}
activity.requestedOrientation = orientation
}
@ -348,8 +361,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
if (lockRotation) {
if (isLocked) {
lockOrientation(this)
}
else {
} else {
if (ignoreDynamicOrientation) {
// restore when lock is disabled
restoreOrientationWithSensor(this)
@ -728,6 +740,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
private var currentTapIndex = 0
protected fun autoHide() {
currentTapIndex++
delayHide()
}
override fun playerStatusChanged() {
super.playerStatusChanged()
delayHide()
}
private fun delayHide() {
val index = currentTapIndex
playerBinding?.playerHolder?.postDelayed({
if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) {
@ -949,7 +970,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
else -> {
player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI)
player.handleEvent(
CSPlayerEvent.PlayPauseToggle,
PlayerEventSource.UI
)
}
}
} else if (doubleTapEnabled && isFullScreenPlayer) {
@ -1142,6 +1166,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
return true
}
@SuppressLint("GestureBackNavigation")
private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean {
if (hasNavigated) {
autoHide()
@ -1225,6 +1250,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
// if nothing has loaded these buttons should not be visible
playerBinding?.apply {
playerSkipEpisode.isVisible = false
playerGoForward.isVisible = false
playerTracksBtt.isVisible = false
playerSkipOp.isVisible = false
shadowOverlay.isVisible = false
@ -1298,6 +1324,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.handleEvent(CSPlayerEvent.SeekBack)
}
PlayerEventType.Restart -> {
player.handleEvent(CSPlayerEvent.Restart)
}
PlayerEventType.ToggleMute -> {
player.handleEvent(CSPlayerEvent.ToggleMute)
}
@ -1393,6 +1423,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
false
)
hideControlsNames = settingsManager.getBoolean(ctx.getString(R.string.hide_player_control_names_key), false)
val profiles = QualityDataHelper.getProfiles()
val type = if (ctx.isUsingMobileData())
QualityDataHelper.QualityProfileType.Data
@ -1413,12 +1445,34 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
playerSpeedBtt.isVisible = playBackSpeedEnabled
playerResizeBtt.isVisible = playerResizeEnabled
playerRotateBtt.isVisible = playerRotateEnabled
if (hideControlsNames) {
hideControlsNames()
}
}
} catch (e: Exception) {
logError(e)
}
playerBinding?.apply {
if (isLayout(TV or EMULATOR)) {
mapOf(
playerGoBack to playerGoBackText,
playerRestart to playerRestartText,
playerGoForward to playerGoForwardText
).forEach { (button, text) ->
button.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
text.isSelected = false
text.isVisible = false
return@setOnFocusChangeListener
}
text.isSelected = true
text.isVisible = true
}
}
}
playerPausePlay.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.PlayPauseToggle)
@ -1462,6 +1516,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player.handleEvent(CSPlayerEvent.NextEpisode)
}
playerGoForward.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.NextEpisode)
}
playerRestart.setOnClickListener {
autoHide()
player.handleEvent(CSPlayerEvent.Restart)
}
playerLock.setOnClickListener {
autoHide()
toggleLock()
@ -1517,7 +1581,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
}
// cs3 is peak media center
setRemainingTimeCounter(durationMode || Globals.isLayout(Globals.TV))
setRemainingTimeCounter(durationMode || isLayout(TV))
playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ ->
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) {
isVerticalOrientation = height > width
updateOrientation()

View file

@ -6,6 +6,7 @@ import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@ -13,6 +14,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.core.animation.addListener
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
@ -21,6 +23,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.media3.common.Format.NO_VALUE
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
@ -44,11 +47,13 @@ import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
import com.lagradost.cloudstream3.ui.result.*
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1
import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
@ -61,6 +66,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.Job
import java.io.Serializable
import java.util.*
import kotlin.math.abs
@ -157,6 +163,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
override fun playerStatusChanged() {
super.playerStatusChanged()
if (player.getIsPlaying()) {
viewModel.forceClearCache = false
}
@ -231,7 +238,7 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun closestQuality(target: Int?): Qualities {
if (target == null) return Qualities.Unknown
return Qualities.values().minBy { abs(it.value - target) }
return Qualities.entries.minBy { abs(it.value - target) }
}
private fun getLinkPriority(
@ -364,8 +371,6 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE
binding.subtitleAdapter.adapter = arrayAdapter
val adapter =
binding.subtitleAdapter.adapter as? ArrayAdapter<AbstractSubtitleEntities.SubtitleEntity>
binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ ->
currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener
@ -376,8 +381,8 @@ class GeneratorPlayer : FullScreenPlayer() {
fun setSubtitlesList(list: List<AbstractSubtitleEntities.SubtitleEntity>) {
currentSubtitles = list
adapter?.clear()
adapter?.addAll(currentSubtitles)
arrayAdapter.clear()
arrayAdapter.addAll(currentSubtitles)
}
val currentTempMeta = getMetaData()
@ -519,7 +524,7 @@ class GeneratorPlayer : FullScreenPlayer() {
//TODO: Set year text from currently loaded movie on Player
//dialog.subtitles_search_year?.setText(currentTempMeta.year)
}
@OptIn(UnstableApi::class)
private fun openSubPicker() {
try {
subsPathPicker.launch(
@ -792,7 +797,6 @@ class GeneratorPlayer : FullScreenPlayer() {
settingsManager.edit().putString(
ctx.getString(R.string.subtitles_encoding_key), prefValues[it]
).apply()
updateForcedEncoding(ctx)
dismiss()
player.seekTime(-1) // to update subtitles, a dirty trick
@ -1095,9 +1099,16 @@ class GeneratorPlayer : FullScreenPlayer() {
}
playerBinding?.playerSkipOp?.isVisible = isOpVisible
when {
isLayout(PHONE) ->
playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true
else ->
playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true
}
if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) {
viewModel.preLoadNextLinks()
}
@ -1252,7 +1263,7 @@ class GeneratorPlayer : FullScreenPlayer() {
fun setPlayerDimen(widthHeight: Pair<Int, Int>?) {
val extra = if (widthHeight != null) {
val (width, height) = widthHeight
"${width}x${height}"
"- ${width}x${height}"
} else {
""
}
@ -1263,7 +1274,7 @@ class GeneratorPlayer : FullScreenPlayer() {
0 -> ""
1 -> extra
2 -> source
3 -> "$source - $extra"
3 -> "$source $extra"
else -> ""
}
playerBinding?.playerVideoTitleRez?.apply {
@ -1280,7 +1291,7 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun unwrapBundle(savedInstanceState: Bundle?) {
Log.i(TAG, "unwrapBundle = $savedInstanceState")
savedInstanceState?.let { bundle ->
sync.addSyncs(bundle.getSerializable("syncData") as? HashMap<String, String>?)
sync.addSyncs(bundle.getSafeSerializable<HashMap<String, String>>("syncData"))
}
}
@ -1288,7 +1299,8 @@ class GeneratorPlayer : FullScreenPlayer() {
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
// this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason
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]
sync = ViewModelProvider(this)[SyncViewModel::class.java]
@ -1450,7 +1462,7 @@ class GeneratorPlayer : FullScreenPlayer() {
observe(viewModel.currentLinks) {
currentLinks = it
val turnVisible = it.isNotEmpty()
val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true
val wasGone = binding?.overlayLoadingSkipButton?.isGone == true
binding?.overlayLoadingSkipButton?.isVisible = turnVisible
@ -1496,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.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri
enum class LoadType {
Unknown,
@ -46,6 +45,7 @@ fun LoadType.toSet() : Set<ExtractorLinkType> {
interface IGenerator {
val hasCache: Boolean
val canSkipLoading: Boolean
fun hasNext(): 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.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
enum class PlayerEventType(val value: Int) {
//Stop(-1),
Pause(0),
Play(1),
SeekForward(2),
@ -27,6 +25,7 @@ enum class PlayerEventType(val value: Int) {
Resize(13),
SearchSubtitlesOnline(14),
SkipOp(15),
Restart(16),
}
enum class CSPlayerEvent(val value: Int) {
@ -40,6 +39,7 @@ enum class CSPlayerEvent(val value: Int) {
PrevEpisode(6),
PlayPauseToggle(7),
ToggleMute(8),
Restart(9),
}
enum class CSPlayerLoading {

View file

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

View file

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

@ -15,13 +15,12 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class PlayerGeneratorViewModel : ViewModel() {
companion object {
val TAG = "PlayViewGen"
const val TAG = "PlayViewGen"
}
private var generator: IGenerator? = null

View file

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

View file

@ -8,15 +8,15 @@ import android.os.Build
import android.util.Log
import androidx.annotation.WorkerThread
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.ui.settings.Globals
import com.lagradost.cloudstream3.ui.settings.Globals.TV
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.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.M3u8Helper
import com.lagradost.cloudstream3.utils.M3u8Helper2
import kotlinx.coroutines.CoroutineScope
@ -65,8 +65,12 @@ interface IPreviewGenerator {
companion object {
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 */
return if (isLayout(TV)) {
return if (isLayout(TV) || userDisabled) {
empty()
} else {
PreviewGenerator()
@ -242,7 +246,11 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG
// generated images 1:1 to idx of hsl
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)
// 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)
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
}
companion object {
private const val TAG = "PreviewImgMp4"
}
override fun hasPreview(): Boolean {
synchronized(images) {
return loadedLod >= MIN_LOD
}
}
val TAG = "PreviewImgMp4"
override fun getPreviewImage(fraction: Float): Bitmap? {
synchronized(images) {
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()))
Log.i(TAG, "Generating preview for ${fraction * 100}%")
val frame = durationUs * fraction
val img = retriever.image(frame.toLong(), params);
val img = retriever.image(frame.toLong(), params)
if (!scope.isActive) return
if (img == null || img.width <= 1 || img.height <= 1) continue
synchronized(images) {

View file

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

View file

@ -4,7 +4,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppContextUtils
data class SourcePriority<T>(
val data: T,
@ -13,11 +13,10 @@ data class 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 {
return PriorityViewHolder(
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,
) : RecyclerView.ViewHolder(binding.root) {
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
fun updatePriority() {

View file

@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding
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
class ProfilesAdapter(
@ -21,7 +21,7 @@ class ProfilesAdapter(
val usedProfile: Int,
val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit,
) :
AppUtils.DiffAdapter<QualityDataHelper.QualityProfile>(
AppContextUtils.DiffAdapter<QualityDataHelper.QualityProfile>(
items,
comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile ->
first.id == second.id
@ -29,8 +29,6 @@ class ProfilesAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ProfilesViewHolder(
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
import android.content.Context
import androidx.annotation.StringRes
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
@ -104,7 +103,7 @@ object QualityDataHelper {
* Must under all circumstances at least return one profile
**/
fun getProfiles(): List<QualityProfile> {
val availableTypes = QualityProfileType.values().toMutableList()
val availableTypes = QualityProfileType.entries.toMutableList()
val profiles = (1..PROFILE_COUNT).map { profileNumber ->
// Get the real type
val type = getQualityProfileType(profileNumber)
@ -140,12 +139,12 @@ object QualityDataHelper {
}
}
QualityProfileType.values().forEach {
QualityProfileType.entries.forEach {
if (it.unique) insertType(profiles, it)
}
debugAssert({
!QualityProfileType.values().all { type ->
!QualityProfileType.entries.all { type ->
!type.unique || profiles.any { it.type == type }
}
}, { "All unique quality types do not exist" })

View file

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

View file

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

View file

@ -17,8 +17,6 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList
@ -34,12 +32,13 @@ import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.search.SearchViewModel
import com.lagradost.cloudstream3.ui.settings.Globals
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.ownShow
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount

View file

@ -3,8 +3,6 @@ package com.lagradost.cloudstream3.ui.result
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.IdRes
import androidx.annotation.LayoutRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
@ -70,8 +68,7 @@ class ActorAdaptor(
}
}
private inner class CardViewHolder
constructor(
private inner class CardViewHolder(
val binding: CastItemBinding,
private val focusCallback: (View?) -> Unit = {}
) :
@ -138,7 +135,7 @@ class ActorAdaptor(
voiceActorImageHolder.isVisible = false
voiceActorName.isVisible = false
} else {
voiceActorName.text = actor.voiceActor.name
voiceActorName.text = actor.voiceActor?.name
voiceActorImageHolder.isVisible = voiceActorImage.setImage(vaImage)
}
}

View file

@ -21,13 +21,14 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppUtils.html
import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.Locale
const val ACTION_PLAY_EPISODE_IN_PLAYER = 1
const val ACTION_PLAY_EPISODE_IN_VLC_PLAYER = 2
@ -57,8 +58,8 @@ const val ACTION_PLAY_EPISODE_IN_MPV = 17
const val ACTION_MARK_AS_WATCHED = 18
const val ACTION_FCAST = 19
const val TV_EP_SIZE_LARGE = 400
const val TV_EP_SIZE_SMALL = 300
const val TV_EP_SIZE = 400
data class EpisodeClickEvent(val action: Int, val data: ResultEpisode)
class EpisodeAdapter(
@ -168,8 +169,7 @@ class EpisodeAdapter(
return cardList.size
}
class EpisodeCardViewHolderLarge
constructor(
class EpisodeCardViewHolderLarge(
val binding: ResultEpisodeLargeBinding,
private val hasDownloadSupport: Boolean,
private val clickCallback: (EpisodeClickEvent) -> Unit,
@ -181,7 +181,7 @@ class EpisodeAdapter(
fun bind(card: ResultEpisode) {
localCard = card
val setWidth =
if (isLayout(TV or EMULATOR)) TV_EP_SIZE_LARGE.toPx else ViewGroup.LayoutParams.MATCH_PARENT
if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT
binding.episodeLinHolder.layoutParams.width = setWidth
binding.episodeHolderLarge.layoutParams.width = setWidth
@ -192,15 +192,15 @@ class EpisodeAdapter(
downloadButton.isVisible = hasDownloadSupport
downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached(
card.name,
card.poster,
card.episode,
card.season,
card.id,
card.parentId,
card.rating,
card.description,
System.currentTimeMillis(),
name = card.name,
poster = card.poster,
episode = card.episode,
season = card.season,
id = card.id,
parentId = card.parentId,
rating = card.rating,
description = card.description,
cacheTime = System.currentTimeMillis(),
), null
) {
when (it.action) {
@ -275,7 +275,10 @@ class EpisodeAdapter(
episodeDate.setText(
txt(
R.string.episode_upcoming_format,
secondsToReadable(card.airDate.minus(unixTimeMS).div(1000).toInt(), "")
secondsToReadable(
card.airDate.minus(unixTimeMS).div(1000).toInt(),
""
)
)
)
} else {
@ -293,6 +296,12 @@ class EpisodeAdapter(
episodeDate.isVisible = false
}
episodeRuntime.setText(
txt(
card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") }
)
)
if (isLayout(EMULATOR or PHONE)) {
episodePoster.setOnClickListener {
clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card))
@ -325,8 +334,7 @@ class EpisodeAdapter(
}
}
class EpisodeCardViewHolderSmall
constructor(
class EpisodeCardViewHolderSmall(
val binding: ResultEpisodeBinding,
private val hasDownloadSupport: Boolean,
private val clickCallback: (EpisodeClickEvent) -> Unit,
@ -336,22 +344,22 @@ class EpisodeAdapter(
fun bind(card: ResultEpisode) {
binding.episodeHolder.layoutParams.apply {
width =
if (isLayout(TV or EMULATOR)) TV_EP_SIZE_SMALL.toPx else ViewGroup.LayoutParams.MATCH_PARENT
if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT
}
binding.apply {
downloadButton.isVisible = hasDownloadSupport
downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached(
card.name,
card.poster,
card.episode,
card.season,
card.id,
card.parentId,
card.rating,
card.description,
System.currentTimeMillis(),
name = card.name,
poster = card.poster,
episode = card.episode,
season = card.season,
id = card.id,
parentId = card.parentId,
rating = card.rating,
description = card.description,
cacheTime = System.currentTimeMillis(),
), null
) {
when (it.action) {

View file

@ -8,18 +8,6 @@ import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
/*
class ImageAdapter(context: Context, val resource: Int) : ArrayAdapter<Int>(context, resource) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val newConvertView = convertView ?: run {
val mInflater = context
.getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
mInflater.inflate(resource, null)
}
getItem(position)?.let { (newConvertView as? ImageView?)?.setImageResource(it) }
return newConvertView
}
}*/
const val IMAGE_CLICK = 0
const val IMAGE_LONG_CLICK = 1
@ -66,8 +54,7 @@ class ImageAdapter(
diffResult.dispatchUpdatesTo(this)
}
class ImageViewHolder
constructor(val binding: ResultMiniImageBinding) :
class ImageViewHolder(val binding: ResultMiniImageBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
img: Int,

View file

@ -3,12 +3,12 @@ package com.lagradost.cloudstream3.ui.result
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
@ -51,6 +51,7 @@ data class ResultEpisode(
/** Sum of all previous season episode counts + episode */
val totalEpisodeIndex: Int? = null,
val airDate: Long? = null,
val runTime: Int? = null,
)
fun ResultEpisode.getRealPosition(): Long {
@ -87,6 +88,7 @@ fun buildResultEpisode(
parentId: Int,
totalEpisodeIndex: Int? = null,
airDate: Long? = null,
runTime: Int? = null,
): ResultEpisode {
val posDur = getViewPos(id)
val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None
@ -111,6 +113,7 @@ fun buildResultEpisode(
videoWatchState,
totalEpisodeIndex,
airDate,
runTime,
)
}

View file

@ -23,13 +23,13 @@ import androidx.core.widget.NestedScrollView
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.ViewModelProvider
import com.discord.panels.OverlappingPanelsLayout
import com.discord.panels.PanelState
import com.discord.panels.PanelsChildGestureRegionObserver
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.LoadResponse
@ -57,10 +57,11 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData
import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppUtils.loadCache
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
@ -78,7 +79,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
open class ResultFragmentPhone : FullScreenPlayer() {
private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener {
private val gestureRegionsListener =
object : PanelsChildGestureRegionObserver.GestureRegionsListener {
override fun onGestureRegionsUpdate(gestureRegions: List<Rect>) {
binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions)
}
@ -117,6 +119,14 @@ open class ResultFragmentPhone : FullScreenPlayer() {
return root
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
PanelsChildGestureRegionObserver.Provider.get().apply {
resultBinding?.resultCastItems?.let { register(it) }
}
}
var currentTrailers: List<ExtractorLink> = emptyList()
var currentTrailerIndex = 0
@ -185,8 +195,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
}
binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer
}
//player_view?.apply {
//alpha = 0.0f
//ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply {
@ -201,8 +209,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
//}
//startAnimation(fadeIn)
//}
}
private fun setTrailers(trailers: List<ExtractorLink>?) {
@ -213,9 +219,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
}
override fun onDestroyView() {
//somehow this still leaks and I dont know why????
// todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt
PanelsChildGestureRegionObserver.Provider.get().let { obs ->
resultBinding?.resultCastItems?.let {
obs.unregister(it)
@ -332,14 +335,18 @@ open class ResultFragmentPhone : FullScreenPlayer() {
syncModel.addFromUrl(storedData.url)
val api = APIHolder.getApiFromNameNull(storedData.apiName)
// This may not be 100% reliable, and may delay for small period
// before resultCastItems will be scrollable again, but this does work
// most of the time.
binding?.resultOverlappingPanels?.registerEndPanelStateListeners(
object : OverlappingPanelsLayout.PanelStateListener {
override fun onPanelStateChange(panelState: PanelState) {
PanelsChildGestureRegionObserver.Provider.get().apply {
resultBinding?.resultCastItems?.let {
register(it)
resultBinding?.resultCastItems?.let { register(it) }
}
addGestureRegionsUpdateListener(gestureRegionsListener)
}
}
)
// ===== ===== =====
@ -477,8 +484,16 @@ open class ResultFragmentPhone : FullScreenPlayer() {
if (act.isCastApiAvailable()) {
try {
CastButtonFactory.setUpMediaRouteButton(act, this)
val castContext = CastContext.getSharedInstance(act.applicationContext)
isGone = castContext.castState == CastState.NO_DEVICES_AVAILABLE
CastContext.getSharedInstance(act.applicationContext) {
it.run()
}.addOnCompleteListener {
isGone = if (it.isSuccessful) {
it.result.castState == CastState.NO_DEVICES_AVAILABLE
} else {
true
}
}
// this shit leaks for some reason
//castContext.addCastStateListener { state ->
// media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE
@ -630,15 +645,15 @@ open class ResultFragmentPhone : FullScreenPlayer() {
}
downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached(
ep.name,
ep.poster,
0,
null,
ep.id,
ep.id,
null,
null,
System.currentTimeMillis(),
name = ep.name,
poster = ep.poster,
episode = 0,
season = null,
id = ep.id,
parentId = ep.id,
rating = null,
description = null,
cacheTime = System.currentTimeMillis(),
),
null
) { click ->
@ -670,6 +685,9 @@ open class ResultFragmentPhone : FullScreenPlayer() {
observe(viewModel.page) { data ->
if (data == null) return@observe
resultBinding?.apply {
PanelsChildGestureRegionObserver.Provider.get().apply {
register(resultCastItems)
}
(data as? Resource.Success)?.value?.let { d ->
resultVpn.setText(d.vpnText)
resultInfo.setText(d.metaText)
@ -965,12 +983,12 @@ open class ResultFragmentPhone : FullScreenPlayer() {
setOnClickListener { fab ->
activity?.showBottomDialog(
WatchType.values().map { fab.context.getString(it.stringRes) }.toList(),
WatchType.entries.map { fab.context.getString(it.stringRes) }.toList(),
watchType.ordinal,
fab.context.getString(R.string.action_add_to_bookmarks),
showApply = false,
{}) {
viewModel.updateWatchStatus(WatchType.values()[it], context)
viewModel.updateWatchStatus(WatchType.entries[it], context)
}
}
}
@ -1050,7 +1068,7 @@ open class ResultFragmentPhone : FullScreenPlayer() {
text?.asStringNull(ctx) ?: return@mapNotNull null
)
}) {
viewModel.changeDubStatus(DubStatus.values()[itemId])
viewModel.changeDubStatus(DubStatus.entries[itemId])
}
}
}
@ -1107,7 +1125,8 @@ open class ResultFragmentPhone : FullScreenPlayer() {
override fun onPause() {
super.onPause()
PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(gestureRegionsListener)
PanelsChildGestureRegionObserver.Provider.get()
.addGestureRegionsUpdateListener(gestureRegionsListener)
}
private fun setRecommendations(rec: List<SearchResponse>?, validApiName: String?) {

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