Compare commits

...

224 Commits

Author SHA1 Message Date
Arkadiusz Fal
792a2c1c6c Fix method access 2023-05-29 16:50:40 +02:00
Arkadiusz Fal
12ef9e091c Bump build number to 152 2023-05-29 16:42:20 +02:00
Arkadiusz Fal
101ecb6892 Update CHANGELOG 2023-05-29 16:42:20 +02:00
Arkadiusz Fal
be2e4acedd Simplify channels view 2023-05-29 16:42:20 +02:00
Arkadiusz Fal
c6cff4dee4 Add loading status to vertical cells 2023-05-29 16:42:19 +02:00
Arkadiusz Fal
eaeaa45422 Fix #479 2023-05-29 16:42:19 +02:00
Arkadiusz Fal
6ddf1113bf Fix fullscreen exit 2023-05-29 16:42:19 +02:00
Arkadiusz Fal
15f3e11a78 Simplify playlists view 2023-05-29 16:42:19 +02:00
Arkadiusz Fal
713570dfd6 Fix navigatable in favorite item view 2023-05-29 16:13:13 +02:00
Arkadiusz Fal
50eb0be1d7 Change subscriptions picker labels to text 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
3adbea1897 Fix #443 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
80a644eb7a Fix Invidious trending categories 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
b1637c5ef1 Fix buttons on tvOS 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
166482601d Fix home settings buttons colors 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
1d61dec8eb Revert "Minor improvements"
This reverts commit 24241d3485.
2023-05-29 16:13:12 +02:00
Arkadiusz Fal
ca7195caba Add remove item to search recents items 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
947f216fac Fix closing video on error 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
a054d343a9 Fix music mode in AVPlayer 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
48263ae7db Add tap on search to focus search field on iOS 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
562df2d9ba Add advanced setting "Show video context menu options to force selected backend" 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
e5f137a2d2 Add setting "Keep channels with unwatched videos on top of subscriptions list" 2023-05-29 16:13:12 +02:00
Arkadiusz Fal
6856506834 Merge pull request #478 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-29 16:12:54 +02:00
maboroshin
a604382a3d Translated using Weblate (Japanese)
Currently translated at 99.4% (521 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-05-29 09:48:10 +02:00
Bharathi
0bbbf0907d Translated using Weblate (German)
Currently translated at 100.0% (524 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-05-29 09:48:09 +02:00
mere
921987be5d Translated using Weblate (Romanian)
Currently translated at 100.0% (524 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-05-27 06:50:18 +02:00
joaooliva
b47156ba5e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (524 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-05-27 06:50:18 +02:00
Ophiushi
5ab06d0e09 Translated using Weblate (French)
Currently translated at 100.0% (524 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-27 06:50:17 +02:00
Arkadiusz Fal
af632d7943 Fix property access 2023-05-25 19:03:59 +02:00
Arkadiusz Fal
56993de1c2 Bump build number to 151 2023-05-25 18:55:40 +02:00
Arkadiusz Fal
ca61f0d8e5 Update packages 2023-05-25 18:55:26 +02:00
Arkadiusz Fal
6e89538623 Update CHANGELOG 2023-05-25 18:54:21 +02:00
Arkadiusz Fal
d167ff575d Hide buttons in channels menu on list view 2023-05-25 18:54:12 +02:00
Arkadiusz Fal
c7d6253739 Localizations fixes 2023-05-25 18:53:17 +02:00
Arkadiusz Fal
24241d3485 Minor improvements 2023-05-25 18:36:04 +02:00
Arkadiusz Fal
5cfcffc885 Show loading video 2023-05-25 18:36:04 +02:00
Arkadiusz Fal
9d2e6f117d Fix url handling 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
7c24a86a6a Fix updating watch on AVPlayer 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
4d70c8a3c3 Change subscriptions view picker 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
59f48c739a Add "Startup section" setting
Fix #103
2023-05-25 18:36:03 +02:00
Arkadiusz Fal
ae144ea82f Minor player fix 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
2583be9401 Fix music mode controls 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
0061bd8c20 Home Settings 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
12afb31c03 Fix issue with navigation links and lists 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
35867ba14a Home changes 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
50e1491990 Refactor hide shorts 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
1e23809359 Improve history view performance 2023-05-25 18:36:03 +02:00
Arkadiusz Fal
eed9330c0c Add buttons for hiding watched videos
Fix #448
2023-05-25 18:36:02 +02:00
Arkadiusz Fal
1e2d6cf72f Move "Show toggle watch status button" to History settings 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
ac7dad2ab8 Fix #450 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
e1f03bc025 Remove "Rotate to portrait when exiting fullscreen" setting
Now it is automatically decided depending on device type
2023-05-25 18:36:02 +02:00
Arkadiusz Fal
0dee8310ce Fix #468 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
b16eae3d88 Fix #467 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
02a29e5d07 Fix #446 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
22bbf731e9 Fix #466 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
c0053cf837 Fix PiP close on Mac 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
16fb7087e3 Fix playlists view on iOS 2023-05-25 18:36:02 +02:00
Arkadiusz Fal
8f48da93d8 Merge pull request #475 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-25 18:35:24 +02:00
Arkadiusz Fal
f3eac03b58 Translated using Weblate (Polish)
Currently translated at 100.0% (524 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-05-25 18:35:11 +02:00
Anonymous
86b8c7384c Translated using Weblate (English)
Currently translated at 100.0% (524 of 524 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-25 18:35:10 +02:00
Arkadiusz Fal
1502d02184 Merge pull request #472 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-25 18:30:30 +02:00
maboroshin
a0dc280f22 Translated using Weblate (Japanese)
Currently translated at 97.7% (510 of 522 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-05-25 18:29:52 +02:00
Francesco
91ec2f39b0 Translated using Weblate (Italian)
Currently translated at 98.0% (512 of 522 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/it/
2023-05-25 18:29:51 +02:00
Robert Kleinschuster
1d85f92087 Translated using Weblate (German)
Currently translated at 98.0% (512 of 522 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-05-25 18:29:50 +02:00
Arkadiusz Fal
ce2f9cee99 Translated using Weblate (Polish)
Currently translated at 100.0% (522 of 522 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-05-25 18:29:49 +02:00
Anonymous
63fe52695c Translated using Weblate (English)
Currently translated at 100.0% (522 of 522 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-25 18:29:49 +02:00
Lionel Vallet
9524991c5f Translated using Weblate (French)
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-23 08:49:32 +02:00
Bharathi
78f2c681a7 Translated using Weblate (German)
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-05-23 08:49:32 +02:00
mere
8f8abe7bb1 Translated using Weblate (Romanian)
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-05-22 05:45:25 +02:00
joaooliva
0a6beabae8 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-05-22 05:45:25 +02:00
Ophiushi
873bbf90bb Translated using Weblate (French)
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-22 05:45:24 +02:00
Arkadiusz Fal
450a4b42f7 Fix property access 2023-05-21 19:37:22 +02:00
Arkadiusz Fal
db4b3115b1 Bump build number to 150 2023-05-21 19:27:30 +02:00
Arkadiusz Fal
fba465a22a Update CHANGELOG 2023-05-21 19:27:30 +02:00
Arkadiusz Fal
d996069a20 Rotation fixes 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
d7a2564617 Localization fix 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
d5f8e35430 Fix channel videos horizontal layout 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
8f9de6d1be Disable mac beta lane in release workflow 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
0fc6f7fdb7 Remove padding from search suggestions 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
d5f88a73f8 Fix Add to playlist actionable 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
97af5a6e0c Fix opening channels and playlists 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
4c5ef920b4 Fix drag gesture 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
a55683e6bf Fix orientation 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
739ca007e8 Fix tests 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
7d7bd40a89 Add option "Close player on end of video"
Fix #442
2023-05-21 19:13:42 +02:00
Arkadiusz Fal
c6798be167 Show stream opening status with AVPlayer 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
2b7ccc4b03 Orientation fixes 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
08ce572b9e Fix actions buttons text 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
1cc66fdc10 Fix various issues 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
34a05433d5 Fix issue with streams list duplicates 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
5383cf0e90 AVPlayer system controls on iOS 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
a4fdd50388 Update packages 2023-05-21 19:13:42 +02:00
Arkadiusz Fal
f67b1d4feb Improve orientation and safe area handling
Fix #369
Fix #382
2023-05-21 19:13:42 +02:00
Arkadiusz Fal
b53b5eac56 Localization fix 2023-05-21 19:13:19 +02:00
Arkadiusz Fal
6c5b8ef3ec Bump build number to 149 2023-05-21 19:13:18 +02:00
Arkadiusz Fal
226da4d2be Bump version number to 1.4.5 2023-05-21 19:13:18 +02:00
Arkadiusz Fal
49ffffae53 Fix crash 2023-05-21 19:13:18 +02:00
Arkadiusz Fal
848a43ce7f Merge pull request #464 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-21 19:12:56 +02:00
Arkadiusz Fal
6ca0e82feb Translated using Weblate (Polish)
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-05-21 19:12:46 +02:00
Anonymous
f7f53c6417 Translated using Weblate (English)
Currently translated at 100.0% (512 of 512 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-21 19:12:45 +02:00
Arkadiusz Fal
55a0b2dee6 Merge pull request #462 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-21 18:41:07 +02:00
Lionel Vallet
9f0700d2bf Translated using Weblate (French)
Currently translated at 99.0% (503 of 508 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-21 18:40:36 +02:00
Arkadiusz Fal
ab0c2e7b84 Translated using Weblate (Polish)
Currently translated at 100.0% (508 of 508 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-05-21 18:40:35 +02:00
Anonymous
d603ef7431 Translated using Weblate (English)
Currently translated at 100.0% (508 of 508 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-21 18:40:34 +02:00
mere
281e0510cd Translated using Weblate (Romanian)
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-05-21 11:04:56 +02:00
joaooliva
837c9a3f75 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-05-21 11:04:55 +02:00
Ophiushi
bcec9d09ab Translated using Weblate (French)
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-21 11:04:55 +02:00
Lionel Vallet
82a09d1584 Translated using Weblate (French)
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-21 11:04:54 +02:00
Bharathi
dae667fa8a Translated using Weblate (German)
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-05-21 11:04:54 +02:00
Arkadiusz Fal
0f802684f2 Translated using Weblate (Polish)
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-05-21 11:04:53 +02:00
Anonymous
59b49c2e2f Translated using Weblate (English)
Currently translated at 100.0% (503 of 503 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-21 11:04:53 +02:00
Arkadiusz Fal
3a8d6aed76 Bump build number to 148 2023-05-19 10:54:02 +02:00
Arkadiusz Fal
2952e10359 Update CHANGELOG 2023-05-19 10:54:02 +02:00
Arkadiusz Fal
59f84ec129 Merge pull request #460 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-19 10:53:07 +02:00
Dan
a76dae6656 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (500 of 500 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/uk/
2023-05-19 10:52:56 +02:00
Arkadiusz Fal
a208ef9147 Bump build number to 147 2023-05-19 09:59:31 +02:00
Arkadiusz Fal
7a998d2d69 Update CHANGELOG 2023-05-19 09:59:31 +02:00
Arkadiusz Fal
f3659904dc Fix keyboard issue with account/instance form on iOS 2023-05-19 09:55:25 +02:00
Arkadiusz Fal
72246448f1 Localizations fixes 2023-05-19 09:55:04 +02:00
Arkadiusz Fal
6617ad5fc6 Fix player width on macOS 2023-05-19 09:54:35 +02:00
Arkadiusz Fal
65b3eb60d9 Merge pull request #459 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-19 09:54:19 +02:00
Arkadiusz Fal
0174d2f8a0 Translated using Weblate (Polish)
Currently translated at 100.0% (500 of 500 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-05-19 09:54:00 +02:00
Anonymous
8f340586a6 Translated using Weblate (English)
Currently translated at 100.0% (500 of 500 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-19 09:54:00 +02:00
Anonymous
23e07baa7a Translated using Weblate (English)
Currently translated at 100.0% (499 of 499 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-05-19 09:48:46 +02:00
Arkadiusz Fal
e0e0352238 Bump build number to 146 2023-05-18 11:34:44 +02:00
Arkadiusz Fal
543a7c0da6 Update CHANGELOG 2023-05-18 11:34:44 +02:00
Arkadiusz Fal
c78a0dc8c3 Fix playback settings sheet height 2023-05-18 11:32:45 +02:00
Arkadiusz Fal
0051b3ab74 Minor change 2023-05-18 11:32:45 +02:00
Arkadiusz Fal
1f0a2d25e9 Localization fix 2023-05-18 11:32:45 +02:00
Arkadiusz Fal
5d8e8483d1 Minor performance improvement 2023-05-18 11:32:45 +02:00
Arkadiusz Fal
7972498f2c Merge pull request #454 from mikhailokarpenko/localization/ukrainian-translation
cover more ukrainian translation
2023-05-18 11:31:14 +02:00
Arkadiusz Fal
703ac90a33 Merge pull request #455 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-18 11:30:26 +02:00
maboroshin
265c4cd95c Translated using Weblate (Japanese)
Currently translated at 98.9% (491 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-05-15 00:47:45 +02:00
ButterflyOfFire
b7929465a7 Translated using Weblate (Arabic)
Currently translated at 86.8% (431 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2023-05-15 00:47:45 +02:00
Francesco
669f7d5aa6 Translated using Weblate (Italian)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/it/
2023-05-15 00:47:44 +02:00
Ophiushi
3136d6328d Translated using Weblate (French)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-15 00:47:44 +02:00
Mike
32b19d8cd5 cover more ukrainian translation 2023-05-14 12:12:42 -04:00
Arkadiusz Fal
2efb3ec334 Bump build number to 145 2023-05-07 22:30:38 +02:00
Arkadiusz Fal
16c580b1f3 Update CHANGELOG 2023-05-07 22:06:51 +02:00
Arkadiusz Fal
5cef7d40ff Bump build number to 144 2023-05-07 22:06:51 +02:00
Arkadiusz Fal
7b9aa8ce99 Fix settings height on macOS 2023-05-07 22:06:51 +02:00
Arkadiusz Fal
a83657b8c6 Fix crash 2023-05-07 22:06:51 +02:00
Arkadiusz Fal
287bd25360 Switch seek duration +/- buttons
Fix #438
2023-05-07 22:06:51 +02:00
Arkadiusz Fal
7c45f3286b Update packages 2023-05-07 22:06:51 +02:00
Arkadiusz Fal
a9e3e81567 Remove unused package 2023-05-07 22:06:50 +02:00
Arkadiusz Fal
f45001da78 Codestyle fixes 2023-05-07 22:06:50 +02:00
Arkadiusz Fal
3779b7ed1f Add scrollbars in video details and button to scroll comments to top
Added player settings to disable scroll to top button

Fix #439
2023-05-07 22:06:50 +02:00
Arkadiusz Fal
55517fd44d Bump build number to 143 2023-05-07 22:04:22 +02:00
Arkadiusz Fal
77c40226eb Bump version number to 1.4.4 2023-05-07 22:04:22 +02:00
Arkadiusz Fal
5424d5168a Merge pull request #437 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-05-07 13:40:07 +02:00
tototof
2c7fce011f Translated using Weblate (French)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-06 14:49:33 +02:00
Ophiushi
6911fb8e08 Translated using Weblate (French)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-05-06 14:49:33 +02:00
Leonardo Barone
a73a030d92 Translated using Weblate (Italian)
Currently translated at 94.3% (468 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/it/
2023-05-02 16:48:08 +02:00
Bharathi
930cefc29e Translated using Weblate (German)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-04-29 19:49:16 +02:00
Arkadiusz Fal
02d9b34fb0 Merge pull request #435 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-26 13:53:40 +02:00
Louis Vignier
0a8d9dfceb Translated using Weblate (French)
Currently translated at 84.2% (418 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-04-25 15:50:34 +02:00
Arkadiusz Fal
84db321b70 Update CHANGELOG 2023-04-24 13:00:57 +02:00
Arkadiusz Fal
10c1fbd503 Bump build number to 142 2023-04-24 12:58:24 +02:00
Arkadiusz Fal
c7b64c973d Fix player overlay opacity 2023-04-24 12:57:31 +02:00
Arkadiusz Fal
40097de1fd Fix localizations 2023-04-24 12:57:20 +02:00
Arkadiusz Fal
8eac32078b Fix placeholders 2023-04-24 12:57:06 +02:00
Arkadiusz Fal
8271feb77a Fix actions actionable 2023-04-24 12:16:45 +02:00
Arkadiusz Fal
77fde219e0 Fix chapters layout 2023-04-24 12:16:45 +02:00
Arkadiusz Fal
383bb32215 Merge pull request #434 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-24 12:16:28 +02:00
joaooliva
6a4f031cca Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-04-23 23:03:50 +02:00
Arkadiusz Fal
1e65f6d807 Bump build number to 141 2023-04-23 14:11:59 +02:00
Arkadiusz Fal
b4d5322ac6 Update CHANGELOG 2023-04-23 14:11:39 +02:00
Arkadiusz Fal
a5bfabed0c Fix clipped controls
Fix #431
2023-04-23 14:10:44 +02:00
Arkadiusz Fal
f6569db418 Fix crash 2023-04-23 14:10:44 +02:00
Arkadiusz Fal
9cc9c74f97 Merge pull request #433 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-23 14:10:39 +02:00
mere
5dbc211c95 Translated using Weblate (Romanian)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-04-23 14:10:20 +02:00
joaooliva
03b27280f4 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.5% (494 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-04-23 14:10:20 +02:00
Arkadiusz Fal
913ac37991 Translated using Weblate (Polish)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-04-23 14:10:20 +02:00
Arkadiusz Fal
7cfbe5ae5a Add missing bundle platform 2023-04-22 23:47:56 +02:00
Arkadiusz Fal
bf80c4024c Bump build number to 140 2023-04-22 23:39:44 +02:00
Arkadiusz Fal
e70808c463 Update fastlane 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
ce68c0f5b4 Update CHANGELOG 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
8e829ed3b1 Localizations fixes 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
5c0cf7452c Remove unused code 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
2d02d9b472 Fix possible crashes 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
72a98314c1 Fix accounts switcher padding on tvOS 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
ea997ffdb9 Fix #425 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
83dfdd6c0e tvOS filters for all views
Vertical list for trending, popular, playlists, search

Fix #413, #415
2023-04-22 23:39:43 +02:00
Arkadiusz Fal
6596a440a5 New chapters layout 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
d52ccf2ce6 Add video description expanding 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
b19918e219 Disable offset animation on hiding player 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
2fe211edb4 Add setting for toggle video watch status 2023-04-22 23:39:43 +02:00
Arkadiusz Fal
f852782f5e Fix handling watch statuses 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
c8feeca41f Add playback mode to playback settings 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
9a594b4a8d Account name handling fix 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
c48301c788 Performance fix 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
9936d9dd9e Fix details reload 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
8f9fb7ba82 New actions buttons 2023-04-22 23:39:42 +02:00
Arkadiusz Fal
a7763c5802 Fix controls buttons settings 2023-04-22 23:10:28 +02:00
Arkadiusz Fal
160ea86298 Fix url opening 2023-04-22 23:10:28 +02:00
Arkadiusz Fal
a9e9fa3a6d Code style changes 2023-04-22 23:10:28 +02:00
Arkadiusz Fal
afa0049333 Improve placeholders 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
28f346dee2 Remove Watch Next 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
67690bc435 Video details changes and channel sheet 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
5db74a3997 Code style fixes 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
8ef016d792 Minor performance improvement 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
b59baa6fab Update packages 2023-04-22 23:10:27 +02:00
Arkadiusz Fal
3657d732d9 Merge pull request #430 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-22 23:09:29 +02:00
Arkadiusz Fal
309e4a3281 Translated using Weblate (Polish)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-04-22 23:08:38 +02:00
Anonymous
f6e5486412 Translated using Weblate (English)
Currently translated at 100.0% (496 of 496 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-04-22 23:08:38 +02:00
Anonymous
d58a68cd66 Translated using Weblate (English)
Currently translated at 100.0% (476 of 476 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-04-22 22:48:23 +02:00
Arkadiusz Fal
faa7d82b8f Merge pull request #418 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-04-22 09:47:22 +02:00
jonnysemon
3dd9ff837e Translated using Weblate (Arabic)
Currently translated at 90.6% (429 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2023-04-06 16:48:37 +02:00
oguska
73a62ea76e Translated using Weblate (Turkish)
Currently translated at 50.1% (237 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2023-04-01 12:37:58 +02:00
Lachtan
7ce37fd5dd Translated using Weblate (Czech)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/cs/
2023-03-30 21:39:47 +02:00
Ophiushi
25ca69f17d Translated using Weblate (French)
Currently translated at 83.7% (396 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-03-30 21:39:47 +02:00
maboroshin
578c5a8a61 Translated using Weblate (Japanese)
Currently translated at 97.8% (463 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-03-13 11:37:38 +01:00
ssantos
c3e81d1b67 Translated using Weblate (Portuguese)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt/
2023-03-13 11:37:38 +01:00
Arkadiusz Fal
a65db8555b Bump build number to 139 2023-03-12 22:56:23 +01:00
Arkadiusz Fal
afc1a25c26 Update changelog 2023-03-12 22:56:23 +01:00
Arkadiusz Fal
4851db4879 Fix loading channels data in Favorites 2023-03-12 22:52:54 +01:00
Arkadiusz Fal
912e1d1a23 Add Japanese localization 2023-03-12 22:46:36 +01:00
maboroshin
acc880fd47 Translated using Weblate (Japanese)
Currently translated at 97.6% (462 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-03-12 22:45:10 +01:00
maboroshin
30e587ea97 Added translation using Weblate (Japanese) 2023-03-12 22:45:10 +01:00
mere
d723b2d6f1 Translated using Weblate (Romanian)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-03-12 22:45:10 +01:00
joaooliva
f7c0f8dd34 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-03-12 22:45:10 +01:00
mere
5f53515bdd Translated using Weblate (Romanian)
Currently translated at 98.3% (465 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-03-12 22:45:10 +01:00
Bharathi
ad4920e0f2 Translated using Weblate (German)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-03-12 22:45:10 +01:00
Arkadiusz Fal
e467b21c9d Translated using Weblate (Polish)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-03-01 23:39:45 +01:00
Anonymous
f94a00a7bb Translated using Weblate (English)
Currently translated at 100.0% (473 of 473 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-03-01 23:39:45 +01:00
166 changed files with 5650 additions and 3075 deletions

View File

@@ -23,7 +23,9 @@ jobs:
testflight:
strategy:
matrix:
lane: ['mac beta', 'ios beta', 'tvos beta']
# disabled mac beta lane
# lane: ['mac beta', 'ios beta', 'tvos beta']
lane: ['ios beta', 'tvos beta']
name: Releasing ${{ matrix.lane }} version to TestFlight
runs-on: macos-latest
steps:

View File

@@ -0,0 +1,21 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
if #available(iOS 16, *) {
content
.toolbarBackground(color, for: .navigationBar)
} else {
content
}
}
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
if #available(iOS 16, *) {
content
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
} else {
content
}
}
}

View File

@@ -0,0 +1,12 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
if #available(iOS 16, *) {
content
.toolbarColorScheme(colorScheme, for: .navigationBar)
} else {
content
}
}
}

View File

@@ -1,11 +1,52 @@
## Build 138
* Added pagination/infinite scroll for channel contents (Invidious and Piped)
* Added support for channel tabs for Invidious (previously available only for Piped)
* New browsing setting: "Show unwatched feed badges"
* Other minor changes and improvements
### Previous Builds
* Added filter to hide Short videos, available via view menu/toolbar button
* Added localizations: Arabic, Portugese, Portuguese (Brazil)
## Build 152
* Tapping second time on search tab button focuses the input field and selects entered query text (iOS)
* Added Browsing setting "Keep channels with unwatched videos on top of subscriptions list"
* Improved buttons and layout on tvOS
* Fixed issue with trending categories (Invidious) not working when using non-English language
* Fixed issue with search query suggestions not being displayed properly in some languages
* Changed subscriptions page picker label from icon to text
* Views will display information if there is no videos to show instead of always showing placeholders
* Fixed AVPlayer issue with music mode playing video track
* Added remove context menu option for all types of recent items in Search
* Added advanced setting "Show video context menu options to force selected backend"
* Fixed reported crashes
* Other minor changes and improvements
* Other minor fixes and improvements
## Previous Builds
* Improved Home
- Added menu with view options on iOS and toolbar buttons on macOS/tvOS
- Added Home Settings
- Moved settings from Browsing to Home Settings
- Enhanced Favorites management: select listing type and videos limit for each element
- Select listing type for History just like for Favorites
* Added view option to hide watched videos
* Added Browsing setting "Startup section"
* Added feed/channels list segmented picker in Subscriptions and moved view options menu on iOS
* Thumbnails in list view respect "Round corners" setting
* Added watching progress indicator to list view
* Moved "Show toggle watch status button" to History settings
* Removed "Rotate to portrait when exiting fullscreen" setting - it is instead automatically decided depending on device type
* Fixed channels view layout on tvOS
* Fixed channels and playlists navigation on tvOS
* Fixed issue where controls were not visible when music mode was enabled
* Fixed issue with closing Picture in Picture on macOS
* Fixed issue where playing video with AVPlayer would cause it to be immediately marked as watched
* Fixed issue with playlists view showing duplicated buttons when "Show cache status" is enabled
* Fixed issue where navigating to channel from list view in Playlists and Search would immediately go back
* Fixed issue where first URL would fail to open
* Added support for AVPlayer native system controls on iOS and macOS
- Use system features such as AirPlay, subtitles switching (Piped with HLS), text detection and copy and more
- Added Controls setting: "Use system controls with AVPlayer"
* Player rotates for landscape videos on entering full screen on iOS
- Player > Orientation setting: "Rotate when entering fullscreen on landscape video"
* Added Player > Playback setting: "Close video and player on end"
* Added reporting for opening stream in OSD for AVPlayer
* Fixed issue with opening channels and playlists links
* Fixed issues where controls/player layout could break (e.g., when going to background and back)
* Fixed issue where stream picker would show duplicate entries
* Fixed issue where search suggestions would show unnecessary bottom padding
* Fixed landscape channel sheet layout in player
* Fixed reported crashes
* Localization updates and fixes
* Other minor fixes and improvements

View File

@@ -0,0 +1,11 @@
import AVKit
extension AVPlayerViewController {
func enterFullScreen(animated: Bool) {
perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
}
func exitFullScreen(animated: Bool) {
perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
}
}

View File

@@ -0,0 +1,14 @@
import CoreData
extension NSManagedObjectContext {
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
///
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
/// - Throws: An error if anything went wrong executing the batch deletion.
func executeAndMergeChanges(_ batchDeleteRequest: NSBatchDeleteRequest) throws {
batchDeleteRequest.resultType = .resultTypeObjectIDs
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
extension String {
var replacingHTMLEntities: String {
do {
return try NSAttributedString(data: Data(utf8), options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
], documentAttributes: nil).string
} catch {
return self
}
}
}

View File

@@ -3,21 +3,21 @@ GEM
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.1)
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.716.0)
aws-sdk-core (3.170.0)
aws-partitions (1.769.0)
aws-sdk-core (3.173.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.62.0)
aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.119.1)
aws-sdk-s3 (1.122.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
@@ -65,8 +65,8 @@ GEM
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.212.1)
fastimage (2.2.7)
fastlane (2.213.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -90,7 +90,7 @@ GEM
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
@@ -106,8 +106,8 @@ GEM
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.34.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-androidpublisher_v3 (0.42.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
@@ -119,8 +119,8 @@ GEM
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
@@ -128,7 +128,7 @@ GEM
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -137,7 +137,7 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.3.0)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@@ -155,7 +155,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.0.0)
multipart-post (2.3.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
@@ -211,6 +211,7 @@ GEM
PLATFORMS
arm64-darwin-21
x86_64-darwin-19
x86_64-darwin-20
x86_64-linux
DEPENDENCIES

View File

@@ -60,29 +60,13 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
instanceID.isNil
}
var shortUsername: String {
let (username, _) = credentials
guard let username,
username.count > 10
else {
return username ?? ""
}
let index = username.index(username.startIndex, offsetBy: 11)
return String(username[..<index])
}
var description: String {
guard !isPublic else {
return name
}
guard !name.isEmpty else {
return shortUsername
}
return name
let (username, _) = credentials
return username ?? name
}
var urlHost: String {

View File

@@ -13,7 +13,7 @@ struct AccountsBridge: Defaults.Bridge {
return [
"id": value.id,
"instanceID": value.instanceID ?? "",
"name": value.name ?? "",
"name": value.name,
"apiURL": value.urlString,
"username": value.username,
"password": value.password ?? ""

View File

@@ -24,7 +24,7 @@ final class AccountsModel: ObservableObject {
return nil
}
return AccountsModel.find(id)
return Self.find(id)
}
var any: Account? {
@@ -140,15 +140,4 @@ final class AccountsModel: ObservableObject {
KeychainModel.shared.getAccountKey(account, "password")
)
}
static func removeDefaultsCredentials(_ account: Account) {
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
var account = Defaults[.accounts][accountIndex]
account.name = ""
account.username = ""
account.password = nil
Defaults[.accounts][accountIndex] = account
}
}
}

View File

@@ -13,7 +13,7 @@ final class InstancesModel: ObservableObject {
return nil
}
return InstancesModel.shared.find(id)
return Self.shared.find(id)
}
var lastUsed: Instance? {
@@ -21,7 +21,7 @@ final class InstancesModel: ObservableObject {
return nil
}
return InstancesModel.shared.find(id)
return Self.shared.find(id)
}
func find(_ id: Instance.ID?) -> Instance? {

View File

@@ -79,7 +79,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
if let suggestions = content.json.dictionaryValue["suggestions"] {
return suggestions.arrayValue.map(String.init)
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
}
return []
@@ -236,7 +236,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func trending(country: Country, category: TrendingCategory?) -> Resource {
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
.withParam("type", category?.name)
.withParam("type", category?.type)
.withParam("region", country.rawValue)
}
@@ -579,8 +579,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
let nextPage = json.dictionaryValue["continuation"]?.string
var contentItems = [ContentItem]()
var items = [ContentItem]()
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
let items = json.dictionaryValue[key]
{

View File

@@ -4,7 +4,7 @@ import Logging
import SwiftyJSON
struct BaseCacheModel {
static var shared = BaseCacheModel()
static var shared = Self()
static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() }
static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) }

View File

@@ -4,7 +4,7 @@ import Logging
import SwiftyJSON
struct BookmarksCacheModel {
static var shared = BookmarksCacheModel()
static var shared = Self()
let logger = Logger(label: "stream.yattee.cache")
static let bookmarksGroup = "group.stream.yattee.app.bookmarks"

View File

@@ -4,7 +4,7 @@ import Logging
import SwiftyJSON
struct ChannelPlaylistsCacheModel: CacheModel {
static let shared = ChannelPlaylistsCacheModel()
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.channel-playlists")
static let diskConfig = DiskConfig(name: "channel-playlists")

View File

@@ -4,7 +4,7 @@ import Logging
import SwiftyJSON
struct ChannelsCacheModel: CacheModel {
static let shared = ChannelsCacheModel()
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.channels")
static let diskConfig = DiskConfig(name: "channels")

View File

@@ -5,7 +5,7 @@ import Logging
import SwiftyJSON
struct FeedCacheModel: CacheModel {
static let shared = FeedCacheModel()
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.feed")
static let diskConfig = DiskConfig(name: "feed")

View File

@@ -4,7 +4,7 @@ import Logging
import SwiftyJSON
struct PlaylistsCacheModel: CacheModel {
static let shared = PlaylistsCacheModel()
static let shared = Self()
static let limit = 30
let logger = Logger(label: "stream.yattee.cache.playlists")

View File

@@ -23,6 +23,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
@Published var error: RequestError?
var accounts: AccountsModel { .shared }
var unwatchedFeedCount: UnwatchedFeedCountModel { .shared }
var resource: Resource? {
accounts.api.subscriptions
@@ -32,6 +33,19 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
var allByUnwatchedCount: [Channel] {
if let account = accounts.current {
return all.sorted { c1, c2 in
let c1HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c1.id] ?? -1) > 0
let c2HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c2.id] ?? -1) > 0
let nameIncreasing = c1.name.lowercased() < c2.name.lowercased()
return c1HasUnwatched ? (c2HasUnwatched ? nameIncreasing : true) : (c2HasUnwatched ? false : nameIncreasing)
}
}
return all
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
accounts.api.subscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)

View File

@@ -4,7 +4,7 @@ import Logging
import SwiftyJSON
struct VideosCacheModel: CacheModel {
static let shared = VideosCacheModel()
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.videos")
static let diskConfig = DiskConfig(name: "videos")

View File

@@ -33,21 +33,6 @@ struct Channel: Identifiable, Hashable {
}
}
var contentItemType: ContentItem.ContentType {
switch self {
case .videos:
return .video
case .playlists:
return .playlist
case .livestreams:
return .video
case .shorts:
return .video
case .channels:
return .channel
}
}
var systemImage: String {
switch self {
case .videos:
@@ -91,7 +76,9 @@ struct Channel: Identifiable, Hashable {
var subscriptionsText: String?
var totalViews: Int?
var verified: Bool? // swiftlint:disable discouraged_optional_boolean
// swiftlint:disable discouraged_optional_boolean
var verified: Bool?
// swiftlint:enable discouraged_optional_boolean
var videos = [Video]()
var tabs = [Tab]()

View File

@@ -25,7 +25,7 @@ struct ChannelPlaylist: Identifiable {
}
static func from(_ json: JSON) -> Self {
ChannelPlaylist(
Self(
id: json["id"].stringValue,
title: json["title"].stringValue,
thumbnailURL: json["thumbnailURL"].url,

View File

@@ -42,7 +42,9 @@ final class CommentsModel: ObservableObject {
firstPage = page.isNil || page!.isEmpty
player.playerAPI(video)?.comments(video.videoID, page: page)?
player
.playerAPI(video)?
.comments(video.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {

View File

@@ -31,15 +31,15 @@ struct ContentItem: Identifiable {
var id: String = UUID().uuidString
static func array(of videos: [Video]) -> [ContentItem] {
videos.map { ContentItem(video: $0) }
videos.map { Self(video: $0) }
}
static func array(of playlists: [ChannelPlaylist]) -> [ContentItem] {
playlists.map { ContentItem(playlist: $0) }
playlists.map { Self(playlist: $0) }
}
static func array(of channels: [Channel]) -> [ContentItem] {
channels.map { ContentItem(channel: $0) }
channels.map { Self(channel: $0) }
}
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {

View File

@@ -234,6 +234,8 @@ extension Country {
}
}
// swiftlint:enable switch_case_on_newline
var flag: String {
let unicodeScalars = rawValue
.unicodeScalars

View File

@@ -3,6 +3,7 @@ import Foundation
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
enum Section: Codable, Equatable, Defaults.Serializable {
case history
case subscriptions
case popular
case trending(String, String?)
@@ -13,6 +14,8 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
var label: String {
switch self {
case .history:
return "History"
case .subscriptions:
return "Subscriptions"
case .popular:
@@ -50,4 +53,8 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
var id = UUID().uuidString
var section: Section
var widgetSettingsKey: String {
"favorites-\(id)"
}
}

View File

@@ -2,10 +2,11 @@ import Defaults
import Foundation
struct FavoritesModel {
static let shared = FavoritesModel()
static let shared = Self()
@Default(.showFavoritesInHome) var showFavoritesInHome
@Default(.favorites) var all
@Default(.widgetsSettings) var widgetsSettings
var isEnabled: Bool {
showFavoritesInHome
@@ -74,9 +75,47 @@ struct FavoritesModel {
func addableItems() -> [FavoriteItem] {
let allItems = [
FavoriteItem(section: .subscriptions),
FavoriteItem(section: .popular)
FavoriteItem(section: .popular),
FavoriteItem(section: .history)
]
return allItems.filter { item in !all.contains { $0.section == item.section } }
}
func listingStyle(_ item: FavoriteItem) -> WidgetListingStyle {
widgetSettings(item).listingStyle
}
func limit(_ item: FavoriteItem) -> Int {
min(WidgetSettings.maxLimit(listingStyle(item)), widgetSettings(item).limit)
}
func setListingStyle(_ style: WidgetListingStyle, _ item: FavoriteItem) {
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
var settings = widgetsSettings[index]
settings.listingStyle = style
widgetsSettings[index] = settings
} else {
let settings = WidgetSettings(id: item.widgetSettingsKey, listingStyle: style)
widgetsSettings.append(settings)
}
}
func setLimit(_ limit: Int, _ item: FavoriteItem) {
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
var settings = widgetsSettings[index]
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
settings.limit = limit
widgetsSettings[index] = settings
} else {
var settings = WidgetSettings(id: item.widgetSettingsKey, limit: limit)
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
settings.limit = limit
widgetsSettings.append(settings)
}
}
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
}
}

View File

@@ -11,6 +11,7 @@ final class FeedModel: ObservableObject, CacheModel {
@Published var isLoading = false
@Published var videos = [Video]()
@Published private var page = 1
@Published var watchedUUID = UUID()
private var feedCount = UnwatchedFeedCountModel.shared
private var cacheModel = FeedCacheModel.shared
@@ -115,7 +116,7 @@ final class FeedModel: ObservableObject, CacheModel {
}
func calculateUnwatchedFeed() {
guard let account = accounts.current, accounts.signedIn, Defaults[.showUnwatchedFeedBadges] else { return }
guard let account = accounts.current, accounts.signedIn else { return }
let feed = cacheModel.retrieveFeed(account: account)
backgroundContext.perform { [weak self] in
guard let self else { return }
@@ -132,20 +133,15 @@ final class FeedModel: ObservableObject, CacheModel {
let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
self.feedCount.unwatchedByChannel[account] = byChannel
self.watchedUUID = UUID()
}
}
}
func markAllFeedAsWatched() {
guard let account = accounts.current, accounts.signedIn else { return }
let mark = { [weak self] in
self?.backgroundContext.perform { [weak self] in
guard let self else { return }
self.videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) }
self.calculateUnwatchedFeed()
}
guard let self else { return }
self.markVideos(self.videos, watched: true, watchedAt: Date(timeIntervalSince1970: 0))
}
if videos.isEmpty {
@@ -211,14 +207,14 @@ final class FeedModel: ObservableObject, CacheModel {
}
}
func markVideos(_ videos: [Video], watched: Bool) {
func markVideos(_ videos: [Video], watched: Bool, watchedAt: Date? = nil) {
guard accounts.signedIn, let account = accounts.current else { return }
backgroundContext.perform { [weak self] in
guard let self else { return }
if watched {
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) }
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, watchedAt: watchedAt, context: self.backgroundContext) }
} else {
let watches = self.watchFetchRequestResult(videos, context: self.backgroundContext)
watches.forEach { self.backgroundContext.delete($0) }
@@ -227,6 +223,7 @@ final class FeedModel: ObservableObject, CacheModel {
try? self.backgroundContext.save()
self.calculateUnwatchedFeed()
WatchModel.shared.watchesChanged()
}
}
@@ -264,6 +261,10 @@ final class FeedModel: ObservableObject, CacheModel {
return (feedCount.unwatched[account] ?? 0) > 0
}
var watchedId: String {
watchedUUID.uuidString
}
var feedTime: Date? {
if let account = accounts.current {
return cacheModel.getFeedTime(account: account)

View File

@@ -10,18 +10,21 @@ extension PlayerModel {
historyVideos.first { $0.videoID == id }
}
func loadHistoryVideoDetails(_ watch: Watch) {
func loadHistoryVideoDetails(_ watch: Watch, onCompletion: @escaping () -> Void = {}) {
guard historyVideo(watch.videoID).isNil else {
onCompletion()
return
}
if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) {
historyVideos.append(.local(url))
onCompletion()
return
}
if let video = VideosCacheModel.shared.retrieveVideo(watch.video.cacheKey) {
historyVideos.append(video)
onCompletion()
return
}
@@ -35,6 +38,7 @@ extension PlayerModel {
if let video: Video = response.typedContent() {
VideosCacheModel.shared.storeVideo(video)
self.historyVideos.append(video)
onCompletion()
}
}
.onCompletion { _ in
@@ -65,7 +69,7 @@ extension PlayerModel {
let watch: Watch!
let duration = self.playerTime.duration.seconds
let duration = self.activeBackend == .mpv ? self.playerTime.duration.seconds : self.avPlayerBackend.playerItemDuration?.seconds ?? 0
if results?.isEmpty ?? true {
watch = Watch(context: self.backgroundContext)
@@ -107,13 +111,19 @@ extension PlayerModel {
try? self.context.save()
FeedModel.shared.calculateUnwatchedFeed()
WatchModel.shared.watchesChanged()
}
}
func removeAllWatches() {
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
_ = try? context.execute(deleteRequest)
_ = try? context.save()
do {
try context.executeAndMergeChanges(deleteRequest)
try context.save()
} catch let error as NSError {
logger.info(.init(stringLiteral: error.localizedDescription))
}
}
}

View File

@@ -2,12 +2,14 @@ import Foundation
import KeychainAccess
struct KeychainModel {
static var shared = KeychainModel()
static var shared = Self()
var keychain = Keychain(service: "stream.yattee.app")
func updateAccountKey(_ account: Account, _ key: String, _ value: String) {
keychain[accountKey(account, key)] = value
DispatchQueue.global(qos: .background).async {
keychain[accountKey(account, key)] = value
}
}
func getAccountKey(_ account: Account, _ key: String) -> String? {
@@ -19,8 +21,10 @@ struct KeychainModel {
}
func removeAccountKeys(_ account: Account) {
try? keychain.remove(accountKey(account, "token"))
try? keychain.remove(accountKey(account, "username"))
try? keychain.remove(accountKey(account, "password"))
DispatchQueue.global(qos: .background).async {
try? keychain.remove(accountKey(account, "token"))
try? keychain.remove(accountKey(account, "username"))
try? keychain.remove(accountKey(account, "password"))
}
}
}

View File

@@ -46,7 +46,7 @@ final class NavigationModel: ObservableObject {
case .search:
return "search"
#if os(tvOS)
case .settings: // swiftlint:disable:this switch_case_alignment
case .settings:
return "settings"
#endif
default:
@@ -63,7 +63,9 @@ final class NavigationModel: ObservableObject {
}
}
@Published var tabSelection: TabSelection!
@Published var tabSelection: TabSelection! { didSet {
if oldValue == tabSelection { multipleTapHandler() }
}}
@Published var presentingAddToPlaylist = false
@Published var videoToAddToPlaylist: Video!
@@ -83,6 +85,10 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingAccounts = false
@Published var presentingWelcomeScreen = false
@Published var presentingHomeSettings = false
@Published var presentingChannelSheet = false
@Published var channelPresentedInSheet: Channel!
@Published var presentingShareSheet = false
@Published var shareURL: URL?
@@ -103,7 +109,6 @@ final class NavigationModel: ObservableObject {
hideKeyboard()
let presentingPlayer = player.presentingPlayer
player.hide()
presentingChannel = false
#if os(macOS)
@@ -113,20 +118,34 @@ final class NavigationModel: ObservableObject {
let recent = RecentItem(from: channel)
recents.add(RecentItem(from: channel))
if navigationStyle == .sidebar {
sidebarSectionChanged.toggle()
tabSelection = .recentlyOpened(recent.tag)
} else {
var delay = 0.0
let navigateToChannel = {
#if os(iOS)
if presentingPlayer { delay = 1.0 }
self.player.hide()
#endif
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
if navigationStyle == .sidebar {
self.sidebarSectionChanged.toggle()
self.tabSelection = .recentlyOpened(recent.tag)
} else {
withAnimation(Constants.overlayAnimation) {
self.presentingChannel = true
}
}
}
#if os(iOS)
if presentingPlayer {
presentChannelInSheet(channel)
} else {
navigateToChannel()
}
#elseif os(tvOS)
Delay.by(0.01) {
navigateToChannel()
}
#else
navigateToChannel()
#endif
}
func openChannelPlaylist(_ playlist: ChannelPlaylist, navigationStyle: NavigationStyle) {
@@ -273,6 +292,20 @@ final class NavigationModel: ObservableObject {
shareURL = url
presentingShareSheet = true
}
func presentChannelInSheet(_ channel: Channel) {
channelPresentedInSheet = channel
presentingChannelSheet = true
}
func multipleTapHandler() {
switch tabSelection {
case .search:
self.search.focused = true
default:
print("not implemented")
}
}
}
typealias TabSelection = NavigationModel.TabSelection

View File

@@ -37,7 +37,7 @@ struct OpenVideosModel {
}
}
static let shared = OpenVideosModel()
static let shared = Self()
var player: PlayerModel! = .shared
var logger = Logger(label: "stream.yattee.open-videos")
@@ -107,7 +107,7 @@ struct OpenVideosModel {
prepending: playbackMode == .playNow || playbackMode == .playNext
)
WatchNextViewModel.shared.hide()
NavigationModel.shared.presentingChannelSheet = false
if playbackMode == .playNow || playbackMode == .shuffleAll {
#if os(iOS)

View File

@@ -1,10 +1,10 @@
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static let shared = Self()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let result = Self(inMemory: true)
let viewContext = result.container.viewContext
do {

View File

@@ -1,4 +1,4 @@
import AVFoundation
import AVKit
import Defaults
import Foundation
import Logging
@@ -6,6 +6,7 @@ import MediaPlayer
#if !os(macOS)
import UIKit
#endif
import SwiftUI
final class AVPlayerBackend: PlayerBackend {
static let assetKeysToLoad = ["tracks", "playable", "duration"]
@@ -37,9 +38,7 @@ final class AVPlayerBackend: PlayerBackend {
!avPlayer.currentItem.isNil
}
var isLoadingVideo: Bool {
model.currentItem == nil || model.time == nil || !model.time!.isValid
}
var isLoadingVideo = false
var isPlaying: Bool {
avPlayer.timeControlStatus == .playing
@@ -84,6 +83,10 @@ final class AVPlayerBackend: PlayerBackend {
private(set) var playerLayer = AVPlayerLayer()
#if os(tvOS)
var controller: AppleAVPlayerViewController?
#elseif os(iOS)
var controller = AVPlayerViewController() { didSet {
controller.player = avPlayer
}}
#endif
var startPictureInPictureOnPlay = false
var startPictureInPictureOnSwitch = false
@@ -108,6 +111,9 @@ final class AVPlayerBackend: PlayerBackend {
addPlayerTimeControlStatusObserver()
playerLayer.player = avPlayer
#if os(iOS)
controller.player = avPlayer
#endif
}
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
@@ -130,6 +136,8 @@ final class AVPlayerBackend: PlayerBackend {
preservingTime: Bool,
upgrading _: Bool
) {
isLoadingVideo = true
if let url = stream.singleAssetURL {
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
@@ -325,6 +333,10 @@ final class AVPlayerBackend: PlayerBackend {
return
}
if self.model.musicMode {
self.startMusicMode()
}
if !preservingTime,
!self.model.transitioningToPiP,
let segment = self.model.sponsorBlock.segments.first,
@@ -467,12 +479,10 @@ final class AVPlayerBackend: PlayerBackend {
return
}
self.isLoadingVideo = false
switch playerItem.status {
case .readyToPlay:
if self.model.playingInPictureInPicture {
self.startPictureInPictureOnSwitch = false
self.startPictureInPictureOnPlay = false
}
if self.model.activeBackend == .appleAVPlayer,
self.isAutoplaying(playerItem)
{
@@ -487,17 +497,21 @@ final class AVPlayerBackend: PlayerBackend {
self.model.play()
}
} else if self.startPictureInPictureOnPlay {
self.startPictureInPictureOnPlay = false
self.model.stream = self.stream
self.model.streamSelection = self.stream
if self.model.activeBackend != .appleAVPlayer {
self.startPictureInPictureOnSwitch = true
let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0
self.seek(to: seconds, seekType: .backendSync) { _ in
self.seek(to: seconds, seekType: .backendSync) { finished in
guard finished else { return }
DispatchQueue.main.async {
self.model.pause()
self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false)
Delay.by(3) {
self.startPictureInPictureOnPlay = false
}
}
}
}
@@ -688,7 +702,6 @@ final class AVPlayerBackend: PlayerBackend {
func didChangeTo() {
if startPictureInPictureOnSwitch {
startPictureInPictureOnSwitch = false
tryStartingPictureInPicture()
} else if model.musicMode {
startMusicMode()
@@ -697,6 +710,10 @@ final class AVPlayerBackend: PlayerBackend {
}
}
var isStartingPiP: Bool {
startPictureInPictureOnPlay || startPictureInPictureOnSwitch
}
func tryStartingPictureInPicture() {
guard let controller = model.pipController else { return }
@@ -712,6 +729,32 @@ final class AVPlayerBackend: PlayerBackend {
}
}
}
Delay.by(5) {
self.startPictureInPictureOnSwitch = false
}
}
func setPlayerInLayer(_ playerIsPresented: Bool) {
if playerIsPresented {
bindPlayerToLayer()
} else {
removePlayerFromLayer()
}
}
func removePlayerFromLayer() {
playerLayer.player = nil
#if os(iOS)
controller.player = nil
#endif
}
func bindPlayerToLayer() {
playerLayer.player = avPlayer
#if os(iOS)
controller.player = avPlayer
#endif
}
func getTimeUpdates() {}

View File

@@ -222,7 +222,9 @@ final class MPVBackend: PlayerBackend {
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
#if !os(macOS)
if model.presentingPlayer {
UIApplication.shared.isIdleTimerDisabled = true
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true
}
}
#endif
@@ -280,7 +282,7 @@ final class MPVBackend: PlayerBackend {
self.stop()
DispatchQueue.main.async { [weak self] in
guard let self else {
guard let self, let client = self.client else {
return
}
@@ -296,7 +298,7 @@ final class MPVBackend: PlayerBackend {
}
}
self.client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
}
} else {
@@ -308,7 +310,7 @@ final class MPVBackend: PlayerBackend {
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
self.client?.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
self?.pause()
}

View File

@@ -318,14 +318,14 @@ final class MPVClient: ObservableObject {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let model = self.backend.model
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
var insets = 0.0
#if os(iOS)
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeAreaModel.shared.safeArea.bottom : 0
#endif
let offsetY = max(0, model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0)
UIView.animate(withDuration: 0.2, animations: {
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
var insets = 0.0
#if os(iOS)
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeArea.insets.bottom : 0
#endif
let offsetY = model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0
self.glView?.frame = CGRect(x: 0, y: offsetY, width: roundedWidth, height: height)
}) { completion in
if completion {

View File

@@ -105,12 +105,12 @@ extension PlayerBackend {
return
}
let action = {
switch model.playbackMode {
case .queue, .shuffle:
model.prepareCurrentItemForHistory(finished: true)
switch model.playbackMode {
case .queue, .shuffle:
model.prepareCurrentItemForHistory(finished: true)
if model.queue.isEmpty {
if model.queue.isEmpty {
if Defaults[.closeVideoOnEOF] {
#if os(tvOS)
if model.activeBackend == .appleAVPlayer {
model.avPlayerBackend.controller?.dismiss(animated: false)
@@ -118,34 +118,16 @@ extension PlayerBackend {
#endif
model.resetQueue()
model.hide()
} else {
model.advanceToNextItem()
}
case .loopOne:
loopAction()
case .related:
guard let item = model.autoplayItem else { return }
model.resetAutoplay()
model.advanceToItem(item)
}
}
let actionAndHideWatchNext: (Bool) -> Void = { delay in
WatchNextViewModel.shared.hide()
if delay {
Delay.by(0.3) {
action()
}
} else {
action()
model.advanceToNextItem()
}
}
if Defaults[.openWatchNextOnFinishedWatching], model.presentingPlayer {
let timer = Delay.by(TimeInterval(Defaults[.openWatchNextOnFinishedWatchingDelay]) ?? 5.0) {
actionAndHideWatchNext(true)
}
WatchNextViewModel.shared.finishedWatching(model.currentItem, timer: timer)
} else {
actionAndHideWatchNext(false)
case .loopOne:
loopAction()
case .related:
guard let item = model.autoplayItem else { return }
model.resetAutoplay()
model.advanceToItem(item)
}
}

View File

@@ -4,7 +4,7 @@ import Foundation
import SwiftUI
final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
var player: PlayerModel!
var player: PlayerModel { .shared }
func pictureInPictureController(
_: AVPictureInPictureController,
@@ -16,29 +16,31 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {}
func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) {
guard let player else { return }
player.play()
player.playingInPictureInPicture = true
player.avPlayerBackend.startPictureInPictureOnPlay = false
player.avPlayerBackend.startPictureInPictureOnSwitch = false
player.controls.objectWillChange.send()
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { player.hide() } }
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } }
}
func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) {
guard let player else { return }
player.playingInPictureInPicture = false
player.controls.objectWillChange.send()
}
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {}
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {
player.show()
}
func pictureInPictureController(
_: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
let wasPlaying = player.isPlaying
var delay = 0.0
#if os(iOS)
if !player.presentingPlayer {
@@ -50,7 +52,7 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
#endif
if !player.currentItem.isNil, !player.musicMode {
player?.show()
player.show()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
@@ -58,6 +60,11 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
self?.player.playingInPictureInPicture = false
}
if wasPlaying {
Delay.by(1) {
self?.player.play()
}
}
completionHandler(true)
}
}

View File

@@ -49,13 +49,13 @@ final class PlayerModel: ObservableObject {
let logger = Logger(label: "stream.yattee.app")
var avPlayerView = AppleAVPlayerView()
var playerItem: AVPlayerItem?
var mpvPlayerView = MPVPlayerView()
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var activeBackend = PlayerBackendType.mpv
@Published var forceBackendOnPlay: PlayerBackendType?
var avPlayerBackend = AVPlayerBackend()
var mpvBackend = MPVBackend()
@@ -128,7 +128,7 @@ final class PlayerModel: ObservableObject {
#if os(iOS)
@Published var lockedOrientation: UIInterfaceOrientationMask?
@Default(.rotateToPortraitOnExitFullScreen) private var rotateToPortraitOnExitFullScreen
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
#endif
var accounts: AccountsModel { .shared }
@@ -152,6 +152,9 @@ final class PlayerModel: ObservableObject {
@Published var playingInPictureInPicture = false
var pipController: AVPictureInPictureController?
var pipDelegate = PiPDelegate()
#if !os(macOS)
var appleAVPlayerViewControllerDelegate = AppleAVPlayerViewControllerDelegate()
#endif
var playerError: Error? { didSet {
if let error = playerError {
@@ -163,6 +166,7 @@ final class PlayerModel: ObservableObject {
@Default(.saveLastPlayed) var saveLastPlayed
@Default(.lastPlayed) var lastPlayed
@Default(.qualityProfiles) var qualityProfiles
@Default(.avPlayerUsesSystemControls) var avPlayerUsesSystemControls
@Default(.forceAVPlayerForLiveStreams) var forceAVPlayerForLiveStreams
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closePiPOnNavigation) var closePiPOnNavigation
@@ -186,16 +190,17 @@ final class PlayerModel: ObservableObject {
mpvBackend.client = mpvController.client
#endif
Defaults[.activeBackend] = .mpv
playbackMode = Defaults[.playbackMode]
guard pipController.isNil else { return }
pipController = .init(playerLayer: avPlayerBackend.playerLayer)
let pipDelegate = PiPDelegate()
pipDelegate.player = self
self.pipDelegate = pipDelegate
pipController = .init(playerLayer: avPlayerBackend.playerLayer)
pipController?.delegate = pipDelegate
#if os(iOS)
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
}
#endif
currentRate = playerRate
}
@@ -231,16 +236,11 @@ final class PlayerModel: ObservableObject {
}
DispatchQueue.main.async { [weak self] in
self?.exitFullScreen(showControls: false)
Delay.by(0.3) {
self?.exitFullScreen(showControls: false)
}
}
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
Orientation.lockOrientation(.allButUpsideDown)
}
#endif
#if os(macOS)
Windows.player.hide()
#endif
@@ -334,7 +334,7 @@ final class PlayerModel: ObservableObject {
pause()
videoBeingOpened = video
WatchNextViewModel.shared.hide()
navigation.presentingChannelSheet = false
var changeBackendHandler: (() -> Void)?
@@ -410,6 +410,10 @@ final class PlayerModel: ObservableObject {
upgrading: upgrading
)
DispatchQueue.main.async {
self.forceBackendOnPlay = nil
}
if !upgrading {
updateCurrentArtwork()
}
@@ -444,7 +448,7 @@ final class PlayerModel: ObservableObject {
return
}
if let backend = (live && forceAVPlayerForLiveStreams) ? PlayerBackendType.appleAVPlayer : qualityProfile?.backend,
if let backend = forceBackendOnPlay ?? ((live && forceAVPlayerForLiveStreams) ? PlayerBackendType.appleAVPlayer : qualityProfile?.backend),
backend != activeBackend,
backend == .appleAVPlayer || !(avPlayerBackend.startPictureInPictureOnPlay || playingInPictureInPicture)
{
@@ -459,18 +463,29 @@ final class PlayerModel: ObservableObject {
return
}
streamSelection = stream
playStream(
stream,
of: currentVideo,
preservingTime: !currentItem.playbackTime.isNil
)
DispatchQueue.global(qos: .userInitiated).async {
DispatchQueue.main.async {
self.streamSelection = stream
}
self.playStream(
stream,
of: currentVideo,
preservingTime: !self.currentItem.playbackTime.isNil
)
}
}
private func handlePresentationChange() {
backend.setNeedsDrawing(presentingPlayer)
#if os(iOS)
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
#endif
controls.hide()
controls.hideOverlays()
#if !os(macOS)
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
@@ -487,6 +502,18 @@ final class PlayerModel: ObservableObject {
self?.pause()
}
}
if !presentingPlayer {
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
Orientation.lockOrientation(.allButUpsideDown)
}
OrientationModel.shared.stopOrientationUpdates()
#endif
}
}
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
@@ -537,10 +564,15 @@ final class PlayerModel: ObservableObject {
self.stream = stream
streamSelection = stream
self.upgradeToStream(stream, force: true)
return
}
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
if !backend.canPlay(stream) ||
(to == .mpv && stream.isHLS) ||
(to == .appleAVPlayer && !stream.isHLS)
{
guard let preferredStream = streamByQualityProfile else {
return
}
@@ -585,6 +617,8 @@ final class PlayerModel: ObservableObject {
func closeCurrentItem(finished: Bool = false) {
pause()
videoBeingOpened = nil
advancing = false
forceBackendOnPlay = nil
closing = true
controls.presentingControls = false
@@ -626,8 +660,8 @@ final class PlayerModel: ObservableObject {
if avPlayerBackend.video == video {
if activeBackend != .appleAVPlayer {
avPlayerBackend.startPictureInPictureOnSwitch = true
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
}
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
} else {
avPlayerBackend.startPictureInPictureOnPlay = true
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
@@ -664,6 +698,46 @@ final class PlayerModel: ObservableObject {
backend.closePiP()
}
var pipImage: String {
transitioningToPiP ? "pip.fill" : pipController?.isPictureInPictureActive ?? false ? "pip.exit" : "pip.enter"
}
var fullscreenImage: String {
playingFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
}
func toggleFullScreenAction() {
toggleFullscreen(playingFullScreen, showControls: false)
}
func togglePiPAction() {
(pipController?.isPictureInPictureActive ?? false) ? closePiP() : startPiP()
}
#if os(iOS)
var lockOrientationImage: String {
lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation"
}
func lockOrientationAction() {
if lockedOrientation.isNil {
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
lockedOrientation = orientationMask
let orientation = OrientationTracker.shared.currentInterfaceOrientation
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
// iOS 16 workaround
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
} else {
lockedOrientation = nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
}
}
#endif
func replayAction() {
backend.seek(to: 0.0, seekType: .userInteracted)
}
func handleQueueChange() {
Defaults[.queue] = queue
@@ -837,7 +911,7 @@ final class PlayerModel: ObservableObject {
#else
func handleEnterForeground() {
setNeedsDrawing(presentingPlayer)
avPlayerBackend.playerLayer.player = avPlayerBackend.avPlayer
avPlayerBackend.bindPlayerToLayer()
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
@@ -851,7 +925,7 @@ final class PlayerModel: ObservableObject {
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
pause()
} else if !playingInPictureInPicture {
avPlayerBackend.playerLayer.player = nil
avPlayerBackend.removePlayerFromLayer()
}
}
#endif
@@ -874,6 +948,13 @@ final class PlayerModel: ObservableObject {
#if os(tvOS)
guard activeBackend == .mpv else { return }
#endif
#if os(iOS)
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
return
}
#endif
guard let video = currentItem?.video else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = .none
return
@@ -937,24 +1018,34 @@ final class PlayerModel: ObservableObject {
Windows.player.toggleFullScreen()
#endif
playingFullScreen = !isFullScreen
#if os(iOS)
if !playingFullScreen {
playingFullScreen = true
Orientation.lockOrientation(.allButUpsideDown)
} else {
let rotationOrientation = rotateToPortraitOnExitFullScreen ? UIInterfaceOrientation.portrait : nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
// TODO: rework to move view before rotating
if SafeArea.insets.left > 0 {
Delay.by(0.15) {
self.playingFullScreen = false
}
} else {
self.playingFullScreen = false
if playingFullScreen {
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
avPlayerBackend.controller.enterFullScreen(animated: true)
return
}
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
if currentVideoIsLandscape {
let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
// not sure why but first rotation call is ignore so doing rotate to same orientation first
Delay.by(delay) {
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
}
}
} else {
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
avPlayerBackend.controller.exitFullScreen(animated: true)
avPlayerBackend.controller.dismiss(animated: true)
return
}
let rotationOrientation = Constants.isIPhone ? UIInterfaceOrientation.portrait : nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
}
#else
playingFullScreen = !isFullScreen
#endif
}
@@ -992,6 +1083,12 @@ final class PlayerModel: ObservableObject {
#endif
}
var currentVideoIsLandscape: Bool {
guard currentVideo != nil else { return false }
return aspectRatio > 1
}
var formattedSize: String {
guard let videoWidth = backend?.videoWidth, let videoHeight = backend?.videoHeight else { return "unknown" }
return "\(String(format: "%.2f", videoWidth))\u{d7}\(String(format: "%.2f", videoHeight))"

View File

@@ -10,11 +10,12 @@ extension PlayerModel {
}
var videoForDisplay: Video? {
videoBeingOpened ?? (closing ? nil : currentVideo)
videoBeingOpened ?? currentVideo
}
func play(_ videos: [Video], shuffling: Bool = false) {
WatchNextViewModel.shared.hide()
navigation.presentingChannelSheet = false
playbackMode = shuffling ? .shuffle : .queue
videos.forEach { enqueueVideo($0, loadDetails: false) }
@@ -33,6 +34,8 @@ extension PlayerModel {
}
func playNow(_ video: Video, at time: CMTime? = nil) {
navigation.presentingChannelSheet = false
if playingInPictureInPicture, closePiPOnNavigation {
closePiP()
}
@@ -55,7 +58,7 @@ extension PlayerModel {
comments.reset()
stream = nil
WatchNextViewModel.shared.hide()
navigation.presentingChannelSheet = false
withAnimation {
aspectRatio = VideoPlayerView.defaultAspectRatio
@@ -85,7 +88,7 @@ extension PlayerModel {
guard let playerInstance = self.playerInstance else { return }
let streamsInstance = video.streams.compactMap(\.instance).first
if video.streams.isEmpty || streamsInstance != playerInstance {
if video.streams.isEmpty || streamsInstance.isNil || streamsInstance!.apiURLString != playerInstance.apiURLString {
self.loadAvailableStreams(video) { [weak self] _ in
self?.videoBeingOpened = nil
}
@@ -175,7 +178,7 @@ extension PlayerModel {
remove(newItem)
WatchNextViewModel.shared.hide()
navigation.presentingChannelSheet = false
currentItem = newItem
currentItem.playbackTime = time
@@ -219,9 +222,11 @@ extension PlayerModel {
let item = PlayerQueueItem(video, playbackTime: atTime)
if play {
navigation.presentingChannelSheet = false
withAnimation {
aspectRatio = VideoPlayerView.defaultAspectRatio
WatchNextViewModel.shared.hide()
navigation.presentingChannelSheet = false
currentItem = item
}
videoBeingOpened = video
@@ -359,10 +364,7 @@ extension PlayerModel {
message: Text(message),
primaryButton: .cancel { [weak self] in
guard let self else { return }
self.advancing = false
self.videoBeingOpened = nil
self.currentItem = nil
self.hide()
self.closeCurrentItem()
},
secondaryButton: retryButton
)

View File

@@ -32,6 +32,17 @@ extension PlayerModel {
}
}
var playerItemEndTimeWithSegments: CMTime? {
if let duration = playerItemDuration,
let segment = sponsorBlock.segments.last,
segment.endTime.seconds >= duration.seconds - 3
{
return segment.endTime
}
return playerItemDuration
}
private func skip(_ segment: Segment, at time: CMTime) {
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
@@ -95,6 +106,8 @@ extension PlayerModel {
func resetSegments() {
resetLastSegment()
restoredSegments = []
DispatchQueue.main.async { [weak self] in
self?.restoredSegments = []
}
}
}

View File

@@ -41,7 +41,7 @@ extension PlayerModel {
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
return
}
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
} else {
self.logger.critical("no streams available from \(instance.description)")
}

View File

@@ -2,7 +2,7 @@ import Foundation
import IOKit.pwr_mgt
struct ScreenSaverManager {
static var shared = ScreenSaverManager()
static var shared = Self()
var noSleepAssertion: IOPMAssertionID = 0
var noSleepReturn: IOReturn?

View File

@@ -6,7 +6,7 @@ import Foundation
#endif
struct QualityProfilesModel {
static let shared = QualityProfilesModel()
static let shared = Self()
#if os(tvOS)
var tvOSProfile: QualityProfile? {

View File

@@ -39,7 +39,9 @@ final class RecentsModel: ObservableObject {
func addQuery(_ query: String) {
if !query.isEmpty {
NavigationModel.shared.tabSelection = .search
if NavigationModel.shared.tabSelection != .search {
NavigationModel.shared.tabSelection = .search
}
add(.init(from: query))
}
}

View File

@@ -16,9 +16,23 @@ final class SearchModel: ObservableObject {
@Published var querySuggestions = [String]()
private var suggestionsDebouncer = Debouncer(.milliseconds(200))
@Published var focused = false
var accounts: AccountsModel { .shared }
private var resource: Resource!
init() {
#if os(iOS)
addKeyboardDidHideNotificationObserver()
#endif
}
deinit {
#if os(iOS)
removeKeyboardDidHideNotificationObserver()
#endif
}
var isLoading: Bool {
resource?.isLoading ?? false
}
@@ -136,4 +150,18 @@ final class SearchModel: ObservableObject {
}
}
}
#if os(iOS)
private func addKeyboardDidHideNotificationObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
}
@objc func onKeyboardDidHide() {
focused = false
}
private func removeKeyboardDidHideNotificationObserver() {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
}
#endif
}

View File

@@ -54,13 +54,13 @@ final class SponsorBlockAPI: ObservableObject {
"still frame or clip which are also seen in other videos by the same creator.").localized()
case "outro":
return ("Typically near or at the end of the video when the credits pop up and/or endcards are shown.").localized()
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
case "interaction":
return ("Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).").localized()
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
case "music_offtopic":
return ("For videos which feature music as the primary content.").localized()
return "For videos which feature music as the primary content.".localized()
default:
return nil

View File

@@ -176,6 +176,10 @@ class Stream: Equatable, Hashable, Identifiable {
localURL != nil
}
var isHLS: Bool {
hlsURL != nil
}
var quality: String {
guard localURL.isNil else { return "Opened File" }
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
@@ -229,8 +233,14 @@ class Stream: Equatable, Hashable, Identifiable {
}
func hash(into hasher: inout Hasher) {
hasher.combine(videoAsset?.url)
hasher.combine(audioAsset?.url)
hasher.combine(hlsURL)
if let url = videoAsset?.url {
hasher.combine(url)
}
if let url = audioAsset?.url {
hasher.combine(url)
}
if let url = hlsURL {
hasher.combine(url)
}
}
}

View File

@@ -41,4 +41,8 @@ enum TrendingCategory: String, CaseIterable, Identifiable, Defaults.Serializable
var controlLabel: String {
id == "default" ? "All".localized() : title
}
var type: String {
rawValue.capitalized
}
}

View File

@@ -3,7 +3,7 @@ import Logging
struct URLBookmarkModel {
static let bookmarkPrefix = "urlbookmark-"
static var shared = URLBookmarkModel()
static var shared = Self()
var logger = Logger(label: "stream.yattee.url-bookmark")

View File

@@ -32,4 +32,5 @@ final class UnwatchedFeedCountModel: ObservableObject {
}
return nil
}
// swiftlint:enable empty_count
}

View File

@@ -117,7 +117,7 @@ struct Video: Identifiable, Equatable, Hashable {
}
static func local(_ url: URL) -> Video {
Video(
Self(
app: .local,
videoID: url.absoluteString,
streams: [.init(localURL: url)]
@@ -167,7 +167,7 @@ struct Video: Identifiable, Equatable, Hashable {
static func from(_ json: JSON) -> Self {
let dateFormatter = ISO8601DateFormatter()
return Video(
return Self(
instanceID: json["instanceID"].stringValue,
app: .init(rawValue: json["app"].stringValue) ?? AccountsModel.shared.current.app ?? .local,
instanceURL: URL(string: json["instanceURL"].stringValue) ?? AccountsModel.shared.current.instance.apiURL,
@@ -295,8 +295,8 @@ struct Video: Identifiable, Equatable, Hashable {
}
var localStreamIsRemoteURL: Bool {
guard let localStream else { return false }
return !localStream.localURL.isFileURL
guard let url = localStream?.localURL else { return false }
return url.isFileURL
}
var localStreamIsDirectory: Bool {

View File

@@ -7,6 +7,7 @@ import Foundation
final class Watch: NSManagedObject, Identifiable {
@Default(.watchedThreshold) private var watchedThreshold
@Default(.saveHistory) private var saveHistory
@Default(.showWatchingProgress) private var showWatchingProgress
}
extension Watch {
@@ -14,7 +15,7 @@ extension Watch {
NSFetchRequest<Watch>(entityName: "Watch")
}
@nonobjc class func markAsWatched(videoID: String, account: Account, duration: Double, context: NSManagedObjectContext) {
@nonobjc class func markAsWatched(videoID: String, account: Account, duration: Double, watchedAt: Date? = nil, context: NSManagedObjectContext) {
let watchFetchRequest = Watch.fetchRequest()
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", videoID as String)
@@ -36,7 +37,7 @@ extension Watch {
watch.videoDuration = duration
watch.stoppedAt = duration
watch.watchedAt = Date()
watch.watchedAt = watchedAt ?? .init()
try? context.save()
}
@@ -51,7 +52,7 @@ extension Watch {
@NSManaged var appName: String?
@NSManaged var instanceURL: URL?
var app: VideosApp! {
var app: VideosApp? {
guard let appName else { return nil }
return .init(rawValue: appName)
}
@@ -102,4 +103,8 @@ extension Watch {
return Video(app: app ?? AccountsModel.shared.current?.app ?? .local, instanceURL: instanceURL, videoID: videoID)
}
var isShowingProgress: Bool {
saveHistory && showWatchingProgress && (finished || progress > 0)
}
}

11
Model/WatchModel.swift Normal file
View File

@@ -0,0 +1,11 @@
import SwiftUI
final class WatchModel: ObservableObject {
static let shared = WatchModel()
@Published var historyToken = UUID()
func watchesChanged() {
historyToken = UUID()
}
}

View File

@@ -1,205 +0,0 @@
import Combine
import Defaults
import Foundation
import SwiftUI
final class WatchNextViewModel: ObservableObject {
enum Page: String, CaseIterable {
case queue
case related
case history
var title: String {
rawValue.capitalized.localized()
}
var systemImageName: String {
switch self {
case .queue:
return "list.and.film"
case .related:
return "rectangle.stack.fill"
case .history:
return "clock"
}
}
}
enum PresentationReason {
case userInteracted
case finishedWatching
case closed
}
static let animation = Animation.easeIn(duration: 0.25)
static let shared = WatchNextViewModel()
@Published var item: PlayerQueueItem?
@Published private(set) var isPresenting = false
@Published var reason: PresentationReason?
@Published var page = Page.queue
@Published var countdown = 0.0
var countdownTimer: Timer?
var player = PlayerModel.shared
var autoplayTimer: Timer?
var isAutoplaying: Bool {
reason == .finishedWatching
}
var isHideable: Bool {
reason == .userInteracted
}
var isRestartable: Bool {
player.currentItem != nil && reason != .userInteracted
}
var canAutoplay: Bool {
switch player.playbackMode {
case .shuffle:
return !player.queue.isEmpty
default:
return nextFromTheQueue != nil
}
}
func userInteractedOpen(_ item: PlayerQueueItem?) {
self.item = item
open(reason: .userInteracted)
}
func finishedWatching(_ item: PlayerQueueItem?, timer: Timer? = nil) {
if canAutoplay {
countdown = TimeInterval(Defaults[.openWatchNextOnFinishedWatchingDelay]) ?? 5.0
resetCountdownTimer()
autoplayTimer?.invalidate()
autoplayTimer = timer
} else {
timer?.invalidate()
}
self.item = item
open(reason: .finishedWatching)
}
func resetCountdownTimer() {
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
guard self.countdown > 0 else {
timer.invalidate()
return
}
self.countdown = max(0, self.countdown - 1)
}
}
func closed(_ item: PlayerQueueItem) {
self.item = item
open(reason: .closed)
}
func keepFromAutoplaying() {
userInteractedOpen(item)
cancelAutoplay()
}
func cancelAutoplay() {
autoplayTimer?.invalidate()
countdownTimer?.invalidate()
}
func restart() {
cancelAutoplay()
guard player.currentItem != nil else { return }
if reason == .closed {
hide()
return
}
player.backend.seek(to: .zero, seekType: .loopRestart) { _ in
self.hide()
self.player.play()
}
}
private func open(reason: PresentationReason) {
self.reason = reason
setPageAfterOpening()
guard !isPresenting else { return }
withAnimation(Self.animation) {
isPresenting = true
}
}
private func setPageAfterOpening() {
let firstAvailable = Page.allCases.first { isAvailable($0) } ?? .history
switch reason {
case .finishedWatching:
page = player.playbackMode == .related ? .queue : firstAvailable
case .closed:
page = player.playbackMode == .related ? .queue : firstAvailable
default:
page = firstAvailable
}
}
func close() {
let close = {
self.player.closeCurrentItem()
self.player.hide()
Delay.by(0.5) {
self.isPresenting = false
}
}
if reason == .closed {
close()
return
}
if canAutoplay {
cancelAutoplay()
hide()
} else {
close()
}
}
func hide() {
guard isPresenting else { return }
withAnimation(Self.animation) {
isPresenting = false
}
}
func resetItem() {
item = nil
}
func isAvailable(_ page: Page) -> Bool {
switch page {
case .queue:
return !player.queue.isEmpty
case .related:
guard let video = item?.video else { return false }
return !video.related.isEmpty
case .history:
return true
}
}
var nextFromTheQueue: PlayerQueueItem? {
if player.playbackMode == .related {
return player.autoplayItem
} else if player.playbackMode == .queue {
return player.queue.first
}
return nil
}
}

View File

@@ -42,7 +42,7 @@ final class ShareViewController: SLComposeServiceViewController {
self.open(url: url)
}
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
}
}

View File

@@ -37,12 +37,6 @@ struct ChannelCell: View {
.buttonStyle(.plain)
}
var label: some View {
labelContent
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.contentShape(RoundedRectangle(cornerRadius: 12))
}
var labelContent: some View {
VStack {
WebImage(url: channel.thumbnailURL, options: [.lowPriority])

View File

@@ -6,19 +6,29 @@ struct ChannelPlaylistCell: View {
@Environment(\.navigationStyle) private var navigationStyle
var navigation = NavigationModel.shared
var body: some View {
if navigationStyle == .tab {
NavigationLink(destination: ChannelPlaylistView(playlist: playlist)) { cell }
} else {
Button {
NavigationModel.shared.openChannelPlaylist(playlist, navigationStyle: navigationStyle)
} label: {
cell
#if os(tvOS)
button
#else
if navigationStyle == .tab {
navigationLink
} else {
button
}
.buttonStyle(.plain)
#endif
}
var navigationLink: some View {
NavigationLink(destination: ChannelPlaylistView(playlist: playlist)) { cell }
}
var button: some View {
Button {
NavigationModel.shared.openChannelPlaylist(playlist, navigationStyle: navigationStyle)
} label: {
cell
}
.buttonStyle(.plain)
}
var cell: some View {

View File

@@ -3,81 +3,68 @@ import Siesta
import SwiftUI
struct ChannelPlaylistView: View {
var playlist: ChannelPlaylist?
var playlist: ChannelPlaylist
var showCloseButton = false
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@StateObject private var store = Store<ChannelPlaylist>()
@Environment(\.colorScheme) private var colorScheme
@Environment(\.navigationStyle) private var navigationStyle
@Default(.channelPlaylistListingStyle) private var channelPlaylistListingStyle
@Default(.hideShorts) private var hideShorts
@ObservedObject private var accounts = AccountsModel.shared
var player = PlayerModel.shared
@ObservedObject private var recents = RecentsModel.shared
@State private var isLoading = false
private var items: [ContentItem] {
ContentItem.array(of: store.item?.videos ?? [])
}
private var presentedPlaylist: ChannelPlaylist? {
playlist ?? recents.presentedPlaylist
}
private var resource: Resource? {
guard let playlist = presentedPlaylist else {
return nil
}
let resource = accounts.api.channelPlaylist(playlist.id)
resource?.addObserver(store)
return resource
accounts.api.channelPlaylist(playlist.id)
}
var body: some View {
VStack(alignment: .leading) {
#if os(tvOS)
HStack {
if let playlist = presentedPlaylist {
ThumbnailView(url: store.item?.thumbnailURL ?? playlist.thumbnailURL)
.frame(width: 140, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 2))
ThumbnailView(url: store.item?.thumbnailURL ?? playlist.thumbnailURL)
.frame(width: 140, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 2))
Text(playlist.title)
.font(.headline)
.frame(alignment: .leading)
.lineLimit(1)
Text(playlist.title)
.font(.headline)
.frame(alignment: .leading)
.lineLimit(1)
Spacer()
Spacer()
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
.labelStyle(.iconOnly)
}
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
.labelStyle(.iconOnly)
playButtons
.labelStyle(.iconOnly)
}
#endif
VerticalCells(items: items)
VerticalCells(items: items, isLoading: isLoading)
.environment(\.inChannelPlaylistView, true)
}
.environment(\.listingStyle, channelPlaylistListingStyle)
.onAppear {
if let playlist = presentedPlaylist,
let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(playlist)
{
if let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(playlist) {
store.replace(cache)
}
resource?.loadIfNeeded()?.onSuccess { response in
if let playlist: ChannelPlaylist = response.typedContent() {
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
isLoading = true
resource?
.load()
.onSuccess { response in
if let playlist: ChannelPlaylist = response.typedContent() {
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
store.replace(playlist)
}
}
}
.onCompletion { _ in isLoading = false }
}
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
@@ -89,7 +76,6 @@ struct ChannelPlaylistView: View {
}
}
#endif
#if os(macOS)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if showCloseButton {
@@ -101,11 +87,14 @@ struct ChannelPlaylistView: View {
.buttonStyle(.plain)
}
}
}
#if os(macOS)
.toolbar {
ToolbarItem(placement: playlistButtonsPlacement) {
HStack {
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
HideShortsButtons(hide: $hideShorts)
HideWatchedButtons()
HideShortsButtons()
ShareButton(contentItem: contentItem)
favoriteButton
@@ -114,14 +103,12 @@ struct ChannelPlaylistView: View {
}
}
}
.navigationTitle(label)
.navigationTitle(playlist.title)
#endif
}
@ViewBuilder private var favoriteButton: some View {
if let playlist = presentedPlaylist {
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
}
FavoriteButton(item: FavoriteItem(section: .channelPlaylist(accounts.app.appType.rawValue, playlist.id, playlist.title)))
}
#if os(iOS)
@@ -134,7 +121,8 @@ struct ChannelPlaylistView: View {
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
Section {
HideShortsButtons(hide: $hideShorts)
HideWatchedButtons()
HideShortsButtons()
}
Section {
@@ -142,13 +130,13 @@ struct ChannelPlaylistView: View {
}
} label: {
HStack(spacing: 12) {
if let url = store.item?.thumbnailURL ?? playlist?.thumbnailURL {
if let url = store.item?.thumbnailURL ?? playlist.thumbnailURL {
ThumbnailView(url: url)
.frame(width: 60, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
Text(label)
Text(playlist.title)
.font(.headline)
.foregroundColor(.primary)
@@ -162,10 +150,6 @@ struct ChannelPlaylistView: View {
}
#endif
private var label: String {
presentedPlaylist?.title ?? ""
}
private var playlistButtonsPlacement: ToolbarItemPlacement {
#if os(iOS)
.navigationBarTrailing

View File

@@ -4,8 +4,9 @@ import Siesta
import SwiftUI
struct ChannelVideosView: View {
var channel: Channel?
var channel: Channel
var showCloseButton = false
var inNavigationView = true
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@@ -20,10 +21,6 @@ struct ChannelVideosView: View {
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@ObservedObject private var accounts = AccountsModel.shared
@ObservedObject private var feed = FeedModel.shared
@ObservedObject private var navigation = NavigationModel.shared
@@ -33,14 +30,13 @@ struct ChannelVideosView: View {
@Default(.channelPlaylistListingStyle) private var channelPlaylistListingStyle
@Default(.expandChannelDescription) private var expandChannelDescription
@Default(.hideShorts) private var hideShorts
var presentedChannel: Channel? {
store.item?.channel ?? channel ?? recents.presentedChannel
store.item?.channel ?? channel
}
var contentItems: [ContentItem] {
return contentTypeItems.collection
contentTypeItems.collection
}
var body: some View {
@@ -69,7 +65,7 @@ struct ChannelVideosView: View {
.frame(maxWidth: .infinity)
#endif
VerticalCells(items: contentItems) {
VerticalCells(items: contentItems, isLoading: resource?.isLoading ?? false, edgesIgnoringSafeArea: verticalCellsEdgesIgnoringSafeArea) {
if let description = presentedChannel?.description, !description.isEmpty {
Button {
withAnimation(.spring()) {
@@ -101,7 +97,6 @@ struct ChannelVideosView: View {
.environment(\.loadMoreContentHandler) { loadNextPage() }
.environment(\.inChannelView, true)
.environment(\.listingStyle, channelPlaylistListingStyle)
.environment(\.hideShorts, hideShorts)
#if os(tvOS)
.prefersDefaultFocus(in: focusNamespace)
#endif
@@ -119,51 +114,50 @@ struct ChannelVideosView: View {
Button {
withAnimation(Constants.overlayAnimation) {
navigation.presentingChannel = false
navigation.presentingChannelSheet = false
}
} label: {
Label("Close", systemImage: "xmark")
}
#if !os(macOS)
.buttonStyle(.plain)
#endif
}
}
#if !os(iOS)
#if os(macOS)
ToolbarItem(placement: .navigation) {
thumbnail
}
ToolbarItem {
ToolbarItemGroup {
if !inNavigationView {
Text(navigationTitle)
.fontWeight(.bold)
}
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
}
ToolbarItem {
HideShortsButtons(hide: $hideShorts)
}
ToolbarItem {
HideWatchedButtons()
HideShortsButtons()
contentTypePicker
}
ToolbarItem {
ToolbarItemGroup {
HStack(spacing: 3) {
subscriptionsLabel
viewsLabel
}
}
ToolbarItem {
if let contentItem = presentedChannel?.contentItem {
ShareButton(contentItem: contentItem)
}
}
ToolbarItem {
subscriptionToggleButton
.layoutPriority(2)
}
ToolbarItem {
favoriteButton
}
.labelStyle(.iconOnly)
ToolbarItem {
toggleWatchedButton
.labelStyle(.iconOnly)
}
#endif
}
@@ -171,10 +165,7 @@ struct ChannelVideosView: View {
.onAppear {
descriptionExpanded = expandChannelDescription
if let channel,
let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey),
store.item.isNil
{
if let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey), store.item.isNil {
store.replace(cache)
}
@@ -205,6 +196,14 @@ struct ChannelVideosView: View {
}
}
var verticalCellsEdgesIgnoringSafeArea: Edge.Set {
#if os(tvOS)
return .horizontal
#else
return .init()
#endif
}
@ViewBuilder var favoriteButton: some View {
if let presentedChannel {
FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, presentedChannel.id, presentedChannel.name)))
@@ -234,14 +233,14 @@ struct ChannelVideosView: View {
Group {
if let subscribers = store.item?.channel?.subscriptionsString {
HStack(spacing: 0) {
Text(subscribers)
Image(systemName: "person.2.fill")
Text(subscribers)
}
} else if store.item.isNil {
HStack(spacing: 0) {
Image(systemName: "person.2.fill")
Text("1234")
.redacted(reason: .placeholder)
Image(systemName: "person.2.fill")
}
}
}
@@ -252,10 +251,10 @@ struct ChannelVideosView: View {
var viewsLabel: some View {
HStack(spacing: 0) {
if let views = store.item?.channel?.totalViewsString {
Text(views)
Image(systemName: "eye.fill")
.imageScale(.small)
Text(views)
}
}
.foregroundColor(.secondary)
@@ -278,7 +277,8 @@ struct ChannelVideosView: View {
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
Section {
HideShortsButtons(hide: $hideShorts)
HideWatchedButtons()
HideShortsButtons()
}
}
} label: {
@@ -320,7 +320,7 @@ struct ChannelVideosView: View {
private var contentTypePicker: some View {
Picker("Content type", selection: $contentType) {
if let channel = presentedChannel {
if presentedChannel != nil {
ForEach(Channel.ContentType.allCases, id: \.self) { type in
if typeAvailable(type) {
Label(type.description, systemImage: type.systemImage).tag(type)
@@ -328,6 +328,7 @@ struct ChannelVideosView: View {
}
}
}
.labelsHidden()
}
private func typeAvailable(_ type: Channel.ContentType) -> Bool {
@@ -424,18 +425,20 @@ struct ChannelVideosView: View {
}
func load() {
resource?.load().onSuccess { response in
if let page: ChannelPage = response.typedContent() {
if let channel = page.channel {
ChannelsCacheModel.shared.store(channel)
resource?
.load()
.onSuccess { response in
if let page: ChannelPage = response.typedContent() {
if let channel = page.channel {
ChannelsCacheModel.shared.store(channel)
}
self.page = page
self.contentTypeItems.replace(page.results)
}
self.page = page
self.contentTypeItems.replace(page.results)
}
}
.onFailure { error in
navigation.presentAlert(title: "Could not load channel data", message: error.userMessage)
}
.onFailure { error in
navigation.presentAlert(title: "Could not load channel data", message: error.userMessage)
}
}
func loadNextPage() {
@@ -463,7 +466,7 @@ struct ChannelVideosView: View {
struct ChannelVideosView_Previews: PreviewProvider {
static var previews: some View {
#if os(macOS)
ChannelVideosView(channel: Video.fixture.channel)
ChannelVideosView(channel: Video.fixture.channel, showCloseButton: true, inNavigationView: false)
.environment(\.navigationStyle, .sidebar)
#else
NavigationView {

View File

@@ -13,6 +13,14 @@ struct Constants {
#endif
}
static var isIPad: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .pad
#else
false
#endif
}
static var progressViewScale: Double {
#if os(macOS)
0.4
@@ -53,14 +61,6 @@ struct Constants {
#endif
}
static var nextSystemImage: String {
if #available(iOS 16, macOS 13, tvOS 16, *) {
return "film.stack"
} else {
return "list.and.film"
}
}
static func seekIcon(_ type: String, _ interval: TimeInterval) -> String {
let interval = Int(interval)
let allVersions = [10, 15, 30, 45, 60, 75, 90]

View File

@@ -28,7 +28,6 @@ extension Defaults.Keys {
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
#if os(iOS)
static let showDocuments = Key<Bool>("showDocuments", default: false)
static let homeRecentDocumentsItems = Key<Int>("homeRecentDocumentsItems", default: 3)
#endif
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
@@ -52,6 +51,8 @@ extension Defaults.Keys {
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
#endif
static let showUnwatchedFeedBadges = Key<Bool>("showUnwatchedFeedBadges", default: false)
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
@@ -71,10 +72,12 @@ extension Defaults.Keys {
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
hd2160pMPVProfile,
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile,
sd360pAVPlayerProfile
] : [
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile,
sd360pAVPlayerProfile
]
@@ -87,6 +90,7 @@ extension Defaults.Keys {
static let qualityProfilesDefault = [
hd2160pMPVProfile,
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
@@ -97,6 +101,7 @@ extension Defaults.Keys {
static let qualityProfilesDefault = [
hd2160pMPVProfile,
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
@@ -127,14 +132,22 @@ extension Defaults.Keys {
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
static let showKeywords = Key<Bool>("showKeywords", default: false)
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
#endif
#if os(iOS)
static let expandVideoDescriptionDefault = Constants.isIPad
#else
static let expandVideoDescriptionDefault = true
#endif
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
#if os(tvOS)
static let pauseOnHidingPlayerDefault = true
#else
@@ -145,6 +158,7 @@ extension Defaults.Keys {
#if !os(macOS)
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
#endif
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
#if !os(macOS)
@@ -172,14 +186,19 @@ extension Defaults.Keys {
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
static let startupSection = Key<StartupSection>("startupSection", default: .home)
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let rotateToPortraitOnExitFullScreen = Key<Bool>("rotateToPortraitOnExitFullScreen", default: UIDevice.current.userInterfaceIdiom == .phone)
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
"rotateToLandscapeOnEnterFullScreen",
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
)
#endif
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
#if os(macOS)
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
@@ -199,10 +218,14 @@ extension Defaults.Keys {
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
static let actionButtonNextEnabled = Key<Bool>("actionButtonNextEnabled", default: true)
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
static let actionButtonNextQueueCountEnabled = Key<Bool>("actionButtonNextQueueCountEnabled", default: true)
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
#if os(iOS)
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
@@ -217,8 +240,7 @@ extension Defaults.Keys {
static let playerControlsRestartEnabled = Key<Bool>("playerControlsRestartEnabled", default: false)
static let playerControlsAdvanceToNextEnabled = Key<Bool>("playerControlsAdvanceToNextEnabled", default: false)
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
static let playerControlsNextEnabled = Key<Bool>("playerControlsNextEnabled", default: true)
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: true)
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
@@ -232,15 +254,13 @@ extension Defaults.Keys {
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
static let popularListingStyle = Key<ListingStyle>("popularListingStyle", default: .cells)
static let trendingListingStyle = Key<ListingStyle>("trendingListingStyle", default: .cells)
static let playlistListingStyle = Key<ListingStyle>("playlistListingStyle", default: .cells)
static let playlistListingStyle = Key<ListingStyle>("playlistListingStyle", default: .list)
static let channelPlaylistListingStyle = Key<ListingStyle>("channelPlaylistListingStyle", default: .cells)
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
static let openWatchNextOnFinishedWatching = Key<Bool>("openWatchNextOnFinishedWatching", default: true)
static let openWatchNextOnClose = Key<Bool>("openWatchNextOnClose", default: false)
static let openWatchNextOnFinishedWatchingDelay = Key<String>("openWatchNextOnFinishedWatchingDelay", default: "5")
static let hideShorts = Key<Bool>("hideShorts", default: false)
static let hideWatched = Key<Bool>("hideWatched", default: false)
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@@ -323,6 +343,31 @@ enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
}
}
enum StartupSection: String, CaseIterable, Defaults.Serializable {
case home, subscriptions, popular, trending, playlists, search
var label: String {
rawValue.capitalized.localized()
}
var tabSelection: TabSelection {
switch self {
case .home:
return .home
case .subscriptions:
return .subscriptions
case .popular:
return .popular
case .trending:
return .trending
case .playlists:
return .playlists
case .search:
return .search
}
}
}
enum WatchedVideoStyle: String, Defaults.Serializable {
case nothing, badge, decreasedOpacity, both
@@ -343,12 +388,6 @@ enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
case `continue`, restart
}
#if !os(tvOS)
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
case info, separate
}
#endif
enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
case iconOnly, iconAndText
@@ -411,3 +450,83 @@ enum PlayerTapGestureAction: String, CaseIterable, Defaults.Serializable {
}
}
}
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
case disabled
case landscapeLeft
case landscapeRight
#if os(iOS)
var interaceOrientation: UIInterfaceOrientation {
switch self {
case .landscapeLeft:
return .landscapeLeft
case .landscapeRight:
return .landscapeRight
default:
return .portrait
}
}
#endif
var isRotating: Bool {
self != .disabled
}
}
struct WidgetSettings: Defaults.Serializable {
static let defaultLimit = 10
static let maxLimit: [WidgetListingStyle: Int] = [
.horizontalCells: 50,
.list: 50
]
static var bridge = WidgetSettingsBridge()
var id: String
var listingStyle = WidgetListingStyle.horizontalCells
var limit = Self.defaultLimit
var viewID: String {
"\(id)-\(listingStyle.rawValue)-\(limit)"
}
static func maxLimit(_ style: WidgetListingStyle) -> Int {
Self.maxLimit[style] ?? Self.defaultLimit
}
}
struct WidgetSettingsBridge: Defaults.Bridge {
typealias Value = WidgetSettings
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value else { return nil }
return [
"id": value.id,
"listingStyle": value.listingStyle.rawValue,
"limit": String(value.limit)
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard let object, let id = object["id"], !id.isEmpty else { return nil }
var listingStyle = WidgetListingStyle.horizontalCells
if let style = object["listingStyle"] {
listingStyle = WidgetListingStyle(rawValue: style) ?? .horizontalCells
}
let limit = Int(object["limit"] ?? "\(WidgetSettings.defaultLimit)") ?? WidgetSettings.defaultLimit
return Value(
id: id,
listingStyle: listingStyle,
limit: limit
)
}
}
enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
case horizontalCells
case list
}

View File

@@ -16,7 +16,7 @@ struct DocumentsView: View {
Group {
if model.isDirectory(standardizedURL) {
NavigationLink(destination: DocumentsView(directoryURL: url)) {
NavigationLink(destination: Self(directoryURL: url)) {
VideoBanner(video: video)
}
} else {

View File

@@ -62,14 +62,6 @@ private struct LoadMoreContentHandler: EnvironmentKey {
static let defaultValue: LoadMoreContentHandlerType = {}
}
private struct ScrollViewBottomPaddingKey: EnvironmentKey {
static let defaultValue: Double = 30
}
private struct HideShortsKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var inChannelView: Bool {
get { self[InChannelViewKey.self] }
@@ -101,11 +93,6 @@ extension EnvironmentValues {
set { self[LoadMoreContentHandler.self] = newValue }
}
var scrollViewBottomPadding: Double {
get { self[ScrollViewBottomPaddingKey.self] }
set { self[ScrollViewBottomPaddingKey.self] = newValue }
}
var listingStyle: ListingStyle {
get { self[ListingStyleKey.self] }
set { self[ListingStyleKey.self] = newValue }
@@ -125,9 +112,4 @@ extension EnvironmentValues {
get { self[NoListingDividersKey.self] }
set { self[NoListingDividersKey.self] = newValue }
}
var hideShorts: Bool {
get { self[HideShortsKey.self] }
set { self[HideShortsKey.self] = newValue }
}
}

View File

@@ -9,12 +9,21 @@ struct FavoriteItemView: View {
@Environment(\.navigationStyle) private var navigationStyle
@StateObject private var store = FavoriteResourceObserver()
@Default(.favorites) private var favorites
@ObservedObject private var accounts = AccountsModel.shared
private var playlists = PlaylistsModel.shared
private var favoritesModel = FavoritesModel.shared
private var navigation = NavigationModel.shared
@ObservedObject private var player = PlayerModel.shared
@ObservedObject private var watchModel = WatchModel.shared
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
var watches: FetchedResults<Watch>
@State private var visibleWatches = [Watch]()
@Default(.hideShorts) private var hideShorts
@Default(.hideWatched) private var hideWatched
@Default(.widgetsSettings) private var widgetsSettings
@Default(.visibleSections) private var visibleSections
init(item: FavoriteItem) {
self.item = item
@@ -25,13 +34,7 @@ struct FavoriteItemView: View {
if isVisible {
VStack(alignment: .leading, spacing: 2) {
itemControl
.contextMenu {
Button {
favoritesModel.remove(item)
} label: {
Label("Remove from Favorites", systemImage: "trash")
}
}
.contextMenu { contextMenu }
.contentShape(Rectangle())
#if os(tvOS)
.padding(.leading, 40)
@@ -39,20 +42,148 @@ struct FavoriteItemView: View {
.padding(.leading, 15)
#endif
HorizontalCells(items: store.contentItems)
if limitedItems.isEmpty {
EmptyItems(isLoading: resource?.isLoading ?? false) { reloadVisibleWatches() }
.padding(.vertical, 10)
#if os(tvOS)
.padding(.horizontal, 40)
#else
.padding(.horizontal, 15)
#endif
} else {
Group {
switch widgetListingStyle {
case .horizontalCells:
HorizontalCells(items: limitedItems)
case .list:
ListView(items: limitedItems)
.padding(.vertical, 10)
#if os(tvOS)
.padding(.leading, 40)
#else
.padding(.leading, 15)
#endif
}
}
.environment(\.inChannelView, inChannelView)
}
}
.contentShape(Rectangle())
.onAppear {
resource?.addObserver(store)
loadCacheAndResource()
if item.section == .history {
reloadVisibleWatches()
} else {
resource?.addObserver(store)
loadCacheAndResource()
}
}
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
.onChange(of: hideShorts) { _ in reloadVisibleWatches() }
.onChange(of: hideWatched) { _ in reloadVisibleWatches() }
}
}
.id(watchModel.historyToken)
.onChange(of: accounts.current) { _ in
resource?.addObserver(store)
loadCacheAndResource(force: true)
}
.onChange(of: watchModel.historyToken) { _ in
Delay.by(0.5) {
reloadVisibleWatches()
}
}
.onAppear {
Defaults.observe(.widgetsSettings) { _ in
watchModel.watchesChanged()
}
.tieToLifetime(of: accounts)
}
}
var contextMenu: some View {
Group {
if item.section == .history {
Section {
Button {
navigation.presentAlert(
Alert(
title: Text("Are you sure you want to clear history of watched videos?"),
message: Text("This cannot be reverted"),
primaryButton: .destructive(Text("Clear All")) {
PlayerModel.shared.removeHistory()
visibleWatches = []
},
secondaryButton: .cancel()
)
)
} label: {
Label("Clear History", systemImage: "trash")
}
}
}
Button {
favoritesModel.remove(item)
} label: {
Label("Remove from Favorites", systemImage: "trash")
}
#if os(tvOS)
Button("Cancel", role: .cancel) {}
#endif
}
}
func reloadVisibleWatches() {
guard item.section == .history else { return }
visibleWatches = []
let watches = Array(
watches
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
.prefix(favoritesModel.limit(item))
)
let last = watches.last
watches.forEach { watch in
player.loadHistoryVideoDetails(watch) {
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
visibleWatches.append(watch)
guard watch == last else { return }
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
}
}
}
var limitedItems: [ContentItem] {
var items: [ContentItem]
if item.section == .history {
items = visibleWatches.map { ContentItem(video: player.historyVideo($0.videoID) ?? $0.video) }
} else {
items = store.contentItems.filter { itemVisible($0) }
}
return Array(items.prefix(favoritesModel.limit(item)))
}
func itemVisible(_ item: ContentItem) -> Bool {
if hideWatched, watch(item)?.finished ?? false {
return false
}
guard hideShorts, item.contentType == .video, let video = item.video else {
return true
}
return !video.short
}
func watch(_ item: ContentItem) -> Watch? {
watches.first { $0.videoID == item.video.videoID }
}
var widgetListingStyle: WidgetListingStyle {
favoritesModel.listingStyle(item)
}
func loadCacheAndResource(force: Bool = false) {
@@ -88,6 +219,12 @@ struct FavoriteItemView: View {
channel.videos = videos
ChannelsCacheModel.shared.store(channel)
store.contentItems = ContentItem.array(of: videos)
} else if let channelPage: ChannelPage = response.typedContent() {
if let channel = channelPage.channel {
ChannelsCacheModel.shared.store(channel)
}
store.contentItems = channelPage.results
}
}
case let .channelPlaylist(_, id, title):
@@ -123,6 +260,21 @@ struct FavoriteItemView: View {
}
}
var navigatableItem: Bool {
switch item.section {
case .history:
return false
case .trending:
return visibleSections.contains(.trending)
case .subscriptions:
return visibleSections.contains(.subscriptions) && accounts.signedIn
case .popular:
return visibleSections.contains(.popular) && accounts.app.supportsPopular
default:
return true
}
}
var inChannelView: Bool {
switch item.section {
case .channel:
@@ -134,15 +286,20 @@ struct FavoriteItemView: View {
var itemControl: some View {
VStack {
#if os(tvOS)
itemButton
#else
if itemIsNavigationLink {
itemNavigationLink
} else {
if navigatableItem {
#if os(tvOS)
itemButton
}
#endif
#else
if itemIsNavigationLink {
itemNavigationLink
} else {
itemButton
}
#endif
} else {
itemLabel
.foregroundColor(.secondary)
}
}
}
@@ -151,7 +308,9 @@ struct FavoriteItemView: View {
itemLabel
.foregroundColor(.accentColor)
}
#if !os(tvOS)
.buttonStyle(.plain)
#endif
}
var itemNavigationLink: some View {
@@ -216,6 +375,8 @@ struct FavoriteItemView: View {
navigation.openSearchQuery(text)
case let .playlist(_, id):
navigation.tabSelection = .playlist(id)
case .history:
print("should not happen")
}
}
@@ -223,8 +384,10 @@ struct FavoriteItemView: View {
HStack {
Text(label)
.font(.title3.bold())
Image(systemName: "chevron.right")
.imageScale(.small)
if navigatableItem {
Image(systemName: "chevron.right")
.imageScale(.small)
}
}
.lineLimit(1)
.padding(.trailing, 10)
@@ -251,6 +414,8 @@ struct FavoriteItemView: View {
private var resource: Resource? {
switch item.section {
case .history:
return nil
case .subscriptions:
if accounts.app.supportsSubscriptions {
return accounts.api.feed(1)

View File

@@ -8,6 +8,12 @@ struct HistoryView: View {
@ObservedObject private var player = PlayerModel.shared
@State private var visibleWatches = [Watch]()
init(limit: Int = 10) {
self.limit = limit
}
var body: some View {
LazyVStack {
if visibleWatches.isEmpty {
@@ -18,26 +24,19 @@ struct HistoryView: View {
}.foregroundColor(.secondary)
}
} else {
ForEach(visibleWatches, id: \.videoID) { watch in
let video = player.historyVideo(watch.videoID) ?? watch.video
ContentItemView(item: .init(video: video))
.environment(\.listingStyle, .list)
.contextMenu {
VideoContextMenuView(video: video)
}
}
ListView(items: contentItems, limit: limit)
}
}
.animation(nil, value: visibleWatches)
.onAppear {
visibleWatches
.forEach(player.loadHistoryVideoDetails)
}
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
}
private var visibleWatches: [Watch] {
Array(watches.filter { $0.videoID != player.currentVideo?.videoID }.prefix(limit))
var contentItems: [ContentItem] {
visibleWatches.map { .init(video: player.historyVideo($0.videoID) ?? $0.video) }
}
func reloadVisibleWatches() {
visibleWatches = Array(watches.filter { $0.videoID != player.currentVideo?.videoID }.prefix(limit))
}
}

View File

@@ -6,7 +6,7 @@ import UniformTypeIdentifiers
struct HomeView: View {
@ObservedObject private var accounts = AccountsModel.shared
@State private var presentingEditFavorites = false
@State private var presentingHomeSettings = false
@State private var favoritesChanged = false
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
@@ -20,9 +20,7 @@ struct HomeView: View {
#if !os(tvOS)
@Default(.favorites) private var favorites
#endif
#if os(iOS)
@Default(.homeRecentDocumentsItems) private var homeRecentDocumentsItems
@Default(.widgetsSettings) private var widgetsSettings
#endif
@Default(.homeHistoryItems) private var homeHistoryItems
@Default(.showFavoritesInHome) private var showFavoritesInHome
@@ -33,33 +31,45 @@ struct HomeView: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
HStack {
#if os(tvOS)
Group {
if showOpenActionsInHome {
AccentButton(text: "Open Video", imageSystemName: "globe") {
NavigationModel.shared.presentingOpenVideos = true
VStack {
HStack {
#if os(tvOS)
Group {
if showOpenActionsInHome {
AccentButton(text: "Open Video", imageSystemName: "globe") {
NavigationModel.shared.presentingOpenVideos = true
}
}
AccentButton(text: "Locations", imageSystemName: "globe") {
NavigationModel.shared.presentingAccounts = true
}
AccentButton(text: "Settings", imageSystemName: "gear") {
NavigationModel.shared.presentingSettings = true
}
}
AccentButton(text: "Locations", imageSystemName: "globe") {
NavigationModel.shared.presentingAccounts = true
#else
if showOpenActionsInHome {
AccentButton(text: "Files", imageSystemName: "folder") {
NavigationModel.shared.presentingFileImporter = true
}
AccentButton(text: "Paste", imageSystemName: "doc.on.clipboard.fill") {
OpenVideosModel.shared.openURLsFromClipboard(playbackMode: .playNow)
}
AccentButton(imageSystemName: "ellipsis") {
NavigationModel.shared.presentingOpenVideos = true
}
.frame(maxWidth: 40)
}
AccentButton(text: "Settings", imageSystemName: "gear") {
NavigationModel.shared.presentingSettings = true
}
}
#else
if showOpenActionsInHome {
AccentButton(text: "Files", imageSystemName: "folder") {
NavigationModel.shared.presentingFileImporter = true
}
AccentButton(text: "Paste", imageSystemName: "doc.on.clipboard.fill") {
OpenVideosModel.shared.openURLsFromClipboard(playbackMode: .playNow)
}
AccentButton(imageSystemName: "ellipsis") {
NavigationModel.shared.presentingOpenVideos = true
}
.frame(maxWidth: 40)
#endif
}
#if os(tvOS)
HStack {
Spacer()
HideWatchedButtons()
HideShortsButtons()
HomeSettingsButton()
}
#endif
}
@@ -80,7 +90,7 @@ struct HomeView: View {
}
if !accounts.current.isNil, showFavoritesInHome {
LazyVStack(alignment: .leading) {
VStack(alignment: .leading) {
#if os(tvOS)
ForEach(Defaults[.favorites]) { item in
FavoriteItemView(item: item)
@@ -96,87 +106,6 @@ struct HomeView: View {
}
}
#if os(iOS)
if homeRecentDocumentsItems > 0 {
VStack {
HStack {
NavigationLink(destination: DocumentsView()) {
HStack {
Text("Documents")
.font(.title3.bold())
Image(systemName: "chevron.right")
.imageScale(.small)
}
.lineLimit(1)
}
.padding(.leading, 15)
Spacer()
Button {
recentDocumentsID = UUID()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
.font(.headline)
.labelStyle(.iconOnly)
.foregroundColor(.secondary)
}
}
RecentDocumentsView(limit: homeRecentDocumentsItems)
.id(recentDocumentsID)
}
.frame(maxWidth: .infinity, alignment: .leading)
#if os(tvOS)
.padding(.trailing, 40)
#else
.padding(.trailing, 15)
#endif
}
#endif
if homeHistoryItems > 0 {
VStack {
HStack {
sectionLabel("History")
Spacer()
Button {
navigation.presentAlert(
Alert(
title: Text("Are you sure you want to clear history of watched videos?"),
message: Text("It cannot be reverted"),
primaryButton: .destructive(Text("Clear All")) {
PlayerModel.shared.removeHistory()
historyID = UUID()
},
secondaryButton: .cancel()
)
)
} label: {
Label("Clear History", systemImage: "trash")
.font(.headline)
.labelStyle(.iconOnly)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
#if os(tvOS)
.padding(.trailing, 40)
#else
.padding(.trailing, 15)
#endif
HistoryView(limit: homeHistoryItems)
#if os(tvOS)
.padding(.horizontal, 40)
#else
.padding(.horizontal, 15)
#endif
.id(historyID)
}
}
#if !os(tvOS)
Color.clear.padding(.bottom, 60)
#endif
@@ -186,6 +115,10 @@ struct HomeView: View {
favoritesChanged.toggle()
}
.tieToLifetime(of: accounts)
Defaults.observe(.widgetsSettings) { _ in
favoritesChanged.toggle()
}
.tieToLifetime(of: accounts)
}
.redrawOn(change: favoritesChanged)
@@ -198,9 +131,21 @@ struct HomeView: View {
#if os(macOS)
.background(Color.secondaryBackground)
.frame(minWidth: 360)
.toolbar {
ToolbarItemGroup(placement: .automatic) {
HideWatchedButtons()
HideShortsButtons()
HomeSettingsButton()
}
}
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
homeMenu
}
}
#endif
#if !os(macOS)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
@@ -220,16 +165,47 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.secondary)
}
#if os(iOS)
var homeMenu: some View {
Menu {
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
Button {
navigation.presentingHomeSettings = true
} label: {
Label("Home Settings", systemImage: "gear")
}
}
} label: {
HStack(spacing: 12) {
Text("Home")
.foregroundColor(.primary)
.font(.headline)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.small)
}
.transaction { t in t.animation = nil }
}
}
#endif
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
TabView {
HomeView()
.injectFixtureEnvironmentObjects()
.tabItem {
Label("Home", systemImage: "house")
}
NavigationView {
HomeView()
.injectFixtureEnvironmentObjects()
.tabItem {
Label("Home", systemImage: "house")
}
}
}
}
}

View File

@@ -28,15 +28,8 @@ struct QueueView: View {
}
.buttonStyle(.plain)
LazyVStack(alignment: .leading) {
ForEach(limitedItems) { item in
ContentItemView(item: .init(video: item.video))
.environment(\.listingStyle, .list)
.environment(\.inQueueListing, true)
.environment(\.noListingDividers, limit == 1)
.transition(.opacity)
}
}
ListView(items: items, limit: limit)
.environment(\.inQueueListing, true)
}
}
.padding(.vertical, items.isEmpty ? 0 : 15)
@@ -44,22 +37,14 @@ struct QueueView: View {
var label: String {
if items.count < 2 {
return "Next in Queue"
return "Next in Queue".localized()
}
return "Next in Queue (\(items.count))"
return "Next in Queue".localized() + " (\(items.count))"
}
var limitedItems: [ContentItem] {
if let limit {
return Array(items.prefix(limit).map(\.contentItem))
}
return items.map(\.contentItem)
}
var items: [PlayerQueueItem] {
player.queue
var items: [ContentItem] {
player.queue.map(\.contentItem)
}
var limit: Int? {

View File

@@ -30,10 +30,16 @@ struct PlayerOverlayModifier: ViewModifier {
}
}
.animation(.easeIn, value: player.videoForDisplay)
.opacity(player.videoForDisplay == nil ? 0 : 1)
.opacity(opacity)
}
}
var opacity: Double {
guard !player.closing else { return 0 }
return player.videoForDisplay == nil ? 0 : 1
}
var maxWidth: Double {
playerBarMaxWidth == "0" ? .infinity : (Double(playerBarMaxWidth) ?? 600)
}

View File

@@ -5,10 +5,8 @@ struct AccountViewButton: View {
@ObservedObject private var model = AccountsModel.shared
private var navigation = NavigationModel.shared
@Default(.accounts) private var accounts
@Default(.instances) private var instances
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
@Default(.accountPickerDisplaysAnonymousAccounts) private var accountPickerDisplaysAnonymousAccounts
@ViewBuilder var body: some View {
if !instances.isEmpty {

View File

@@ -84,6 +84,9 @@ struct AccountsView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if os(tvOS)
.padding(.horizontal, 50)
#endif
}
var closeButton: some View {

View File

@@ -29,8 +29,4 @@ final class AccountsViewModel: ObservableObject {
var currentAccount: Account? { AccountsModel.shared.current }
var instances: [Instance] { InstancesModel.shared.all }
func accountsOfInstance(_ instance: Instance) -> [Account] {
accounts.filter { $0.instance.apiURL == instance.apiURL }.sorted { $0.name < $1.name }
}
}

View File

@@ -5,7 +5,6 @@ import SwiftUI
#endif
struct AppSidebarNavigation: View {
@ObservedObject private var accounts = AccountsModel.shared
private var navigation: NavigationModel { .shared }
#if os(iOS)

View File

@@ -2,7 +2,6 @@ import Defaults
import SwiftUI
struct AppSidebarRecents: View {
@ObservedObject private var navigation = NavigationModel.shared
var recents = RecentsModel.shared
@Default(.recentlyOpened) private var recentItems

View File

@@ -6,7 +6,6 @@ struct AppSidebarSubscriptions: View {
@ObservedObject private var feed = FeedModel.shared
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
@ObservedObject private var accounts = AccountsModel.shared
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges

View File

@@ -6,8 +6,8 @@ struct AppTabNavigation: View {
@ObservedObject private var navigation = NavigationModel.shared
private var player = PlayerModel.shared
@ObservedObject private var feed = FeedModel.shared
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
private var recents = RecentsModel.shared
@Default(.showHome) private var showHome
@Default(.showDocuments) private var showDocuments
@@ -176,9 +176,9 @@ struct AppTabNavigation: View {
}
@ViewBuilder private var channelView: some View {
if navigation.presentingChannel {
if navigation.presentingChannel, let channel = recents.presentedChannel {
NavigationView {
ChannelVideosView(showCloseButton: true)
ChannelVideosView(channel: channel, showCloseButton: true)
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.inChannelView, true)
@@ -190,9 +190,9 @@ struct AppTabNavigation: View {
}
@ViewBuilder private var playlistView: some View {
if navigation.presentingPlaylist {
if navigation.presentingPlaylist, let playlist = recents.presentedPlaylist {
NavigationView {
ChannelPlaylistView(showCloseButton: true)
ChannelPlaylistView(playlist: playlist, showCloseButton: true)
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.id("channelPlaylist")

View File

@@ -8,36 +8,43 @@ import Siesta
import SwiftUI
struct ContentView: View {
@ObservedObject private var accounts = AccountsModel.shared
@ObservedObject private var navigation = NavigationModel.shared
@ObservedObject private var player = PlayerModel.shared
private var playlists = PlaylistsModel.shared
private var subscriptions = SubscribedChannelsModel.shared
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
var playerControls: PlayerControlsModel { .shared }
let persistenceController = PersistenceController.shared
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
var body: some View {
Group {
#if os(iOS)
if Constants.isIPhone {
AppTabNavigation()
} else {
if horizontalSizeClass == .compact {
AppTabNavigation()
} else {
AppSidebarNavigation()
GeometryReader { proxy in
Group {
#if os(iOS)
Group {
if Constants.isIPhone {
AppTabNavigation()
} else {
if horizontalSizeClass == .compact {
AppTabNavigation()
} else {
AppSidebarNavigation()
}
}
}
}
#elseif os(macOS)
AppSidebarNavigation()
#elseif os(tvOS)
TVNavigationView()
#elseif os(macOS)
AppSidebarNavigation()
#elseif os(tvOS)
TVNavigationView()
#endif
}
#if !os(macOS)
.onAppear {
SafeAreaModel.shared.safeArea = proxy.safeAreaInsets
}
.onChange(of: proxy.safeAreaInsets) { newValue in
SafeAreaModel.shared.safeArea = newValue
}
#endif
}
#if os(iOS)
@@ -66,6 +73,37 @@ struct ContentView: View {
AccountsView()
}
)
.background(
EmptyView().sheet(isPresented: $navigation.presentingHomeSettings) {
#if os(macOS)
VStack(alignment: .leading) {
Button("Done") {
navigation.presentingHomeSettings = false
}
.padding()
.keyboardShortcut(.cancelAction)
HomeSettings()
}
.frame(width: 500, height: 800)
#else
NavigationView {
HomeSettings()
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
navigation.presentingHomeSettings = false
} label: {
Text("Done")
}
}
}
#endif
}
#endif
}
)
#if !os(tvOS)
.fileImporter(
isPresented: $navigation.presentingFileImporter,
@@ -95,7 +133,7 @@ struct ContentView: View {
}
.onOpenURL { url in
URLBookmarkModel.shared.saveBookmark(url)
OpenURLHandler.shared.handle(url)
OpenURLHandler(navigationStyle: navigationStyle).handle(url)
}
.background(
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
@@ -120,7 +158,19 @@ struct ContentView: View {
OpenVideosView()
}
)
#if !os(macOS)
.background(
EmptyView().sheet(isPresented: $navigation.presentingChannelSheet) {
NavigationView {
ChannelVideosView(channel: navigation.channelPresentedInSheet, showCloseButton: true)
}
}
)
#endif
.alert(isPresented: $navigation.presentingAlert) { navigation.alert }
#if os(iOS)
.statusBarHidden(player.playingFullScreen)
#endif
}
var navigationStyle: NavigationStyle {
@@ -138,9 +188,11 @@ struct ContentView: View {
playerView
.transition(.asymmetric(insertion: .identity, removal: .opacity))
.zIndex(3)
} else if player.activeBackend == .appleAVPlayer {
} else if player.activeBackend == .appleAVPlayer,
avPlayerUsesSystemControls || player.avPlayerBackend.isStartingPiP
{
#if os(iOS)
playerView.offset(y: UIScreen.main.bounds.height)
AppleAVPlayerLayerView().offset(y: UIScreen.main.bounds.height)
#endif
}
}

View File

@@ -4,7 +4,6 @@ import Siesta
struct OpenURLHandler {
static var firstHandle = true
static var shared = OpenURLHandler()
static let yatteeProtocol = "yattee://"
var accounts: AccountsModel { .shared }
@@ -12,9 +11,16 @@ struct OpenURLHandler {
var recents: RecentsModel { .shared }
var player: PlayerModel { .shared }
var search: SearchModel { .shared }
var navigationStyle = NavigationStyle.sidebar
var navigationStyle: NavigationStyle
func handle(_ url: URL) {
if Self.firstHandle {
Self.firstHandle = false
Delay.by(1) { handle(url) }
return
}
if accounts.current.isNil {
accounts.setCurrent(accounts.any)
}
@@ -100,7 +106,8 @@ struct OpenURLHandler {
#endif
let video = Video(app: accounts.current.app!, videoID: id)
player.videoBeingOpened = video
player.videoBeingOpened = .init(app: accounts.current.app!, videoID: id, title: "Loading video...")
player.show()
player
.playerAPI(video)?
@@ -109,8 +116,9 @@ struct OpenURLHandler {
.onSuccess { response in
if let video: Video = response.typedContent() {
let time = parser.time.isNil ? nil : CMTime.secondsInDefaultTimescale(TimeInterval(parser.time!))
self.player.playNow(video, at: time)
self.player.show()
Delay.by(0.5) {
self.player.playNow(video, at: time)
}
} else {
navigation.presentAlert(title: "Error", message: "This video could not be opened")
}
@@ -163,7 +171,9 @@ struct OpenURLHandler {
resource
.load()
.onSuccess { response in
if let channel: Channel = response.typedContent() {
if let page: ChannelPage = response.typedContent(),
let channel = page.channel
{
DispatchQueue.main.async {
NavigationModel.shared.openChannel(
channel,

View File

@@ -2,16 +2,133 @@ import AVKit
import Defaults
import SwiftUI
#if !os(macOS)
final class AppleAVPlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate {
#if os(iOS)
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
#endif
var player: PlayerModel { .shared }
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
false
}
func playerViewController(_: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator) {
#if os(iOS)
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
if PlayerModel.shared.currentVideoIsLandscape {
let delay = PlayerModel.shared.activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
// not sure why but first rotation call is ignore so doing rotate to same orientation first
Delay.by(delay) {
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
}
}
#endif
}
func playerViewController(_: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
let wasPlaying = player.isPlaying
coordinator.animate(alongsideTransition: nil) { context in
#if os(iOS)
if wasPlaying {
self.player.play()
}
#endif
if !context.isCancelled {
#if os(iOS)
self.player.lockedOrientation = nil
if Constants.isIPhone {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
if wasPlaying {
self.player.play()
}
self.player.playingFullScreen = false
#endif
}
}
}
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {}
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {}
func playerViewControllerDidStartPictureInPicture(_: AVPlayerViewController) {
player.playingInPictureInPicture = true
player.avPlayerBackend.startPictureInPictureOnPlay = false
player.avPlayerBackend.startPictureInPictureOnSwitch = false
player.controls.objectWillChange.send()
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } }
}
func playerViewControllerDidStopPictureInPicture(_: AVPlayerViewController) {
player.playingInPictureInPicture = false
player.controls.objectWillChange.send()
}
func playerViewController(_: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
player.presentingPlayer = true
withAnimation(.linear(duration: 0.3)) {
self.player.playingInPictureInPicture = false
Delay.by(0.5) {
completionHandler(true)
Delay.by(0.2) {
self.player.play()
}
}
}
}
func playerViewController(_: AVPlayerViewController, restoreUserInterfaceForFullScreenExitWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
withAnimation(nil) {
player.presentingPlayer = true
}
completionHandler(true)
}
}
#endif
#if os(iOS)
struct AppleAVPlayerView: UIViewRepresentable {
struct AppleAVPlayerView: UIViewControllerRepresentable {
@State private var controller = AVPlayerViewController()
func makeUIViewController(context _: Context) -> AVPlayerViewController {
setupController()
return controller
}
func updateUIViewController(_: AVPlayerViewController, context _: Context) {
setupController()
}
func setupController() {
controller.delegate = PlayerModel.shared.appleAVPlayerViewControllerDelegate
controller.allowsPictureInPicturePlayback = true
if #available(iOS 14.2, *) {
controller.canStartPictureInPictureAutomaticallyFromInline = true
}
PlayerModel.shared.avPlayerBackend.controller = controller
}
}
struct AppleAVPlayerLayerView: UIViewRepresentable {
func makeUIView(context _: Context) -> some UIView {
let playerLayerView = PlayerLayerView(frame: .zero)
return playerLayerView
PlayerLayerView(frame: .zero)
}
func updateUIView(_: UIViewType, context _: Context) {}
}
#else
#elseif os(tvOS)
struct AppleAVPlayerView: UIViewControllerRepresentable {
func makeUIViewController(context _: Context) -> AppleAVPlayerViewController {
let controller = AppleAVPlayerViewController()
@@ -24,4 +141,25 @@ import SwiftUI
PlayerModel.shared.rebuildTVMenu()
}
}
#else
struct AppleAVPlayerView: NSViewRepresentable {
func makeNSView(context _: Context) -> some NSView {
let view = AVPlayerView()
view.player = PlayerModel.shared.avPlayerBackend.avPlayer
view.showsFullScreenToggleButton = true
view.allowsPictureInPicturePlayback = true
view.pictureInPictureDelegate = MacOSPiPDelegate.shared
return view
}
func updateNSView(_: NSViewType, context _: Context) {}
}
struct AppleAVPlayerLayerView: NSViewRepresentable {
func makeNSView(context _: Context) -> some NSView {
PlayerLayerView(frame: .zero)
}
func updateNSView(_: NSViewType, context _: Context) {}
}
#endif

View File

@@ -6,10 +6,6 @@ struct Buffering: View {
var reason = "Buffering stream...".localized()
var state: String?
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@ObservedObject private var player = PlayerModel.shared
@Default(.playerControlsLayout) private var regularPlayerControlsLayout

View File

@@ -24,7 +24,7 @@ struct OpeningStream: View {
if let selection = player.streamSelection {
if selection.isLocal {
return "Opening file..."
return "Opening file...".localized()
} else {
return String(format: "Opening %@ stream...".localized(), selection.shortQuality)
}

View File

@@ -13,6 +13,7 @@ struct PlayerControls: View {
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
#elseif os(tvOS)
enum Field: Hashable {
case seekOSD
@@ -28,7 +29,6 @@ struct PlayerControls: View {
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
@Default(.openWatchNextOnClose) private var openWatchNextOnClose
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
@@ -40,9 +40,10 @@ struct PlayerControls: View {
@Default(.playerControlsRestartEnabled) private var playerControlsRestartEnabled
@Default(.playerControlsAdvanceToNextEnabled) private var playerControlsAdvanceToNextEnabled
@Default(.playerControlsPlaybackModeEnabled) private var playerControlsPlaybackModeEnabled
@Default(.playerControlsNextEnabled) private var playerControlsNextEnabled
@Default(.playerControlsMusicModeEnabled) private var playerControlsMusicModeEnabled
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
private let controlsOverlayModel = ControlOverlaysModel.shared
private var navigation = NavigationModel.shared
@@ -50,22 +51,28 @@ struct PlayerControls: View {
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
var showControls: Bool {
player.activeBackend == .mpv || !avPlayerUsesSystemControls || player.musicMode
}
var body: some View {
ZStack(alignment: .topLeading) {
Seek()
.zIndex(4)
.transition(.opacity)
.frame(maxWidth: .infinity, alignment: .topLeading)
#if os(tvOS)
.focused($focusedField, equals: .seekOSD)
.onChange(of: player.seek.lastSeekTime) { _ in
if !model.presentingControls {
focusedField = .seekOSD
if showControls {
Seek()
.zIndex(4)
.transition(.opacity)
.frame(maxWidth: .infinity, alignment: .topLeading)
#if os(tvOS)
.focused($focusedField, equals: .seekOSD)
.onChange(of: player.seek.lastSeekTime) { _ in
if !model.presentingControls {
focusedField = .seekOSD
}
}
}
#else
#else
.offset(y: 2)
#endif
#endif
}
VStack {
ZStack {
@@ -79,109 +86,108 @@ struct PlayerControls: View {
}
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
Section {
#if !os(tvOS)
HStack {
seekBackwardButton
Spacer()
togglePlayButton
Spacer()
seekForwardButton
}
.font(.system(size: playerControlsLayout.bigButtonFontSize))
#endif
ZStack(alignment: .bottom) {
VStack(spacing: 4) {
#if !os(tvOS)
buttonsBar
HStack {
if !player.currentVideo.isNil, player.playingFullScreen {
Button {
withAnimation(Self.animation) {
model.presentingDetailsOverlay = true
}
} label: {
ControlsBar(fullScreen: $model.presentingDetailsOverlay, expansionState: .constant(.full), presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
.clipShape(RoundedRectangle(cornerRadius: 4))
.frame(maxWidth: 300, alignment: .leading)
}
.buttonStyle(.plain)
}
Spacer()
}
#endif
Spacer()
if playerControlsLayout.displaysTitleLine {
VStack(alignment: .leading) {
Text(player.videoForDisplay?.displayTitle ?? "Not Playing")
.shadow(radius: 10)
.font(.system(size: playerControlsLayout.titleLineFontSize).bold())
.lineLimit(1)
Text(player.currentVideo?.displayAuthor ?? "")
.fontWeight(.semibold)
.shadow(radius: 10)
.foregroundColor(.init(white: 0.8))
.font(.system(size: playerControlsLayout.authorLineFontSize))
.lineLimit(1)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.offset(y: -40)
}
timeline
.padding(.bottom, 2)
}
.zIndex(1)
.padding(.top, 2)
.transition(.opacity)
HStack(spacing: playerControlsLayout.buttonsSpacing) {
#if os(tvOS)
togglePlayButton
if showControls {
Section {
#if !os(tvOS)
HStack {
seekBackwardButton
Spacer()
togglePlayButton
Spacer()
seekForwardButton
#endif
if playerControlsAdvanceToNextEnabled {
restartVideoButton
}
if playerControlsAdvanceToNextEnabled {
advanceToNextItemButton
}
Spacer()
#if os(tvOS)
if playerControlsSettingsEnabled {
settingsButton
.font(.system(size: playerControlsLayout.bigButtonFontSize))
#endif
ZStack(alignment: .bottom) {
VStack(spacing: 4) {
#if !os(tvOS)
buttonsBar
HStack {
if !player.currentVideo.isNil, player.playingFullScreen {
Button {
withAnimation(Self.animation) {
model.presentingDetailsOverlay = true
}
} label: {
ControlsBar(fullScreen: $model.presentingDetailsOverlay, expansionState: .constant(.full), presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
.clipShape(RoundedRectangle(cornerRadius: 4))
.frame(maxWidth: 300, alignment: .leading)
}
.buttonStyle(.plain)
}
Spacer()
}
#endif
Spacer()
if playerControlsLayout.displaysTitleLine {
VStack(alignment: .leading) {
Text(player.videoForDisplay?.displayTitle ?? "Not Playing")
.shadow(radius: 10)
.font(.system(size: playerControlsLayout.titleLineFontSize).bold())
.lineLimit(1)
Text(player.currentVideo?.displayAuthor ?? "")
.fontWeight(.semibold)
.shadow(radius: 10)
.foregroundColor(.init(white: 0.8))
.font(.system(size: playerControlsLayout.authorLineFontSize))
.lineLimit(1)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.offset(y: -40)
}
#endif
if playerControlsPlaybackModeEnabled {
playbackModeButton
timeline
.padding(.bottom, 2)
}
if playerControlsNextEnabled {
watchNextButton
.zIndex(1)
.padding(.top, 2)
.transition(.opacity)
HStack(spacing: playerControlsLayout.buttonsSpacing) {
#if os(tvOS)
togglePlayButton
seekBackwardButton
seekForwardButton
#endif
if playerControlsRestartEnabled {
restartVideoButton
}
if playerControlsAdvanceToNextEnabled {
advanceToNextItemButton
}
Spacer()
#if os(tvOS)
if playerControlsSettingsEnabled {
settingsButton
}
#endif
if playerControlsPlaybackModeEnabled {
playbackModeButton
}
#if os(tvOS)
closeVideoButton
#else
if playerControlsMusicModeEnabled {
musicModeButton
}
#endif
}
.zIndex(0)
#if os(tvOS)
closeVideoButton
.offset(y: -playerControlsLayout.timelineHeight - 30)
#else
if playerControlsMusicModeEnabled {
musicModeButton
}
.offset(y: -playerControlsLayout.timelineHeight - 5)
#endif
}
.zIndex(0)
#if os(tvOS)
.offset(y: -playerControlsLayout.timelineHeight - 30)
#else
.offset(y: -playerControlsLayout.timelineHeight - 5)
#endif
}
.opacity(model.presentingControls && !player.availableStreams.isEmpty ? 1 : 0)
}
.opacity(model.presentingControls ? 1 : 0)
}
}
.frame(maxWidth: .infinity)
@@ -234,7 +240,7 @@ struct PlayerControls: View {
guard player.playerSize.height.isFinite else { return 200 }
var inset = 0.0
#if os(iOS)
inset = SafeArea.insets.bottom
inset = safeAreaModel.safeArea.bottom
#endif
return [player.playerSize.height - inset, 500].min()!
}
@@ -334,7 +340,7 @@ struct PlayerControls: View {
var fullscreenButton: some View {
button(
"Fullscreen",
systemImage: player.playingFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
systemImage: player.fullscreenImage
) {
player.toggleFullscreen(player.playingFullScreen, showControls: false)
}
@@ -360,12 +366,7 @@ struct PlayerControls: View {
private var closeVideoButton: some View {
button("Close", systemImage: "xmark") {
if openWatchNextOnClose {
player.pause()
WatchNextViewModel.shared.closed(player.currentItem)
} else {
player.closeCurrentItem()
}
player.closeCurrentItem()
}
#if os(tvOS)
.focused($focusedField, equals: .close)
@@ -377,28 +378,13 @@ struct PlayerControls: View {
}
private var pipButton: some View {
let image = player.transitioningToPiP ? "pip.fill" : player.pipController?.isPictureInPictureActive ?? false ? "pip.exit" : "pip.enter"
return button("PiP", systemImage: image) {
(player.pipController?.isPictureInPictureActive ?? false) ? player.closePiP() : player.startPiP()
}
.disabled(!player.pipPossible)
button("PiP", systemImage: player.pipImage, action: player.togglePiPAction)
.disabled(!player.pipPossible)
}
#if os(iOS)
private var lockOrientationButton: some View {
button("Lock Rotation", systemImage: player.lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation", active: !player.lockedOrientation.isNil) {
if player.lockedOrientation.isNil {
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
player.lockedOrientation = orientationMask
let orientation = OrientationTracker.shared.currentInterfaceOrientation
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
// iOS 16 workaround
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
} else {
player.lockedOrientation = nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
}
}
button("Lock Rotation", systemImage: player.lockOrientationImage, active: !player.lockedOrientation.isNil, action: player.lockOrientationAction)
}
#endif
@@ -409,12 +395,6 @@ struct PlayerControls: View {
}
}
var watchNextButton: some View {
button("Watch Next", systemImage: Constants.nextSystemImage) {
WatchNextViewModel.shared.userInteractedOpen(player.currentItem)
}
}
var seekBackwardButton: some View {
var foregroundColor: Color?
var fontSize: Double?
@@ -472,9 +452,7 @@ struct PlayerControls: View {
}
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5) {
player.backend.seek(to: 0.0, seekType: .userInteracted)
}
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5, action: player.replayAction)
}
private var togglePlayButton: some View {

View File

@@ -13,7 +13,8 @@ struct ProgressBar: View {
Rectangle().frame(width: min(Double(self.value) * geometry.size.width, geometry.size.width), height: geometry.size.height)
.foregroundColor(Color.accentColor)
.animation(.linear)
}.cornerRadius(45.0)
}
.cornerRadius(45.0)
}
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct TVControls: UIViewRepresentable {
var model: PlayerControlsModel!
var player: PlayerModel { .shared }
var safeArea: SafeAreaModel { .shared }
var thumbnails: ThumbnailsModel { .shared }
@State private var direction = ""
@@ -32,10 +33,10 @@ struct TVControls: UIViewRepresentable {
let controls = UIHostingController(rootView: PlayerControls())
controls.view.frame = .init(
origin: .init(x: SafeArea.insets.left, y: SafeArea.insets.top),
origin: .init(x: safeArea.safeArea.leading, y: safeArea.safeArea.top),
size: .init(
width: UIScreen.main.bounds.width - SafeArea.horizontalInsets,
height: UIScreen.main.bounds.height - SafeArea.verticalInset
width: UIScreen.main.bounds.width - safeArea.horizontalInsets,
height: UIScreen.main.bounds.height - safeArea.verticalInsets
)
)

View File

@@ -5,8 +5,9 @@ struct VideoDetailsOverlay: View {
@ObservedObject private var controls = PlayerControlsModel.shared
var body: some View {
VideoDetails(video: controls.player.videoForDisplay, fullScreen: fullScreenBinding)
VideoDetails(video: controls.player.videoForDisplay, fullScreen: fullScreenBinding, sidebarQueue: .constant(false))
.clipShape(RoundedRectangle(cornerRadius: 4))
.id(controls.player.currentVideo?.cacheKey)
}
var fullScreenBinding: Binding<Bool> {

View File

@@ -63,7 +63,14 @@ struct PlaybackSettings: View {
}
HStack {
controlsHeader("Rate")
controlsHeader("Playback Mode".localized())
Spacer()
playbackModeControl
}
.padding(.vertical, 10)
HStack {
controlsHeader("Rate".localized())
Spacer()
HStack(spacing: rateButtonsSpacing) {
decreaseRateButton
@@ -77,10 +84,9 @@ struct PlaybackSettings: View {
#endif
}
}
if player.activeBackend == .mpv {
HStack {
controlsHeader("Captions")
controlsHeader("Captions".localized())
Spacer()
captionsButton
#if os(tvOS)
@@ -281,6 +287,40 @@ struct PlaybackSettings: View {
#endif
}
@ViewBuilder var playbackModeControl: some View {
#if os(tvOS)
Button {
player.playbackMode = player.playbackMode.next()
} label: {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
.transaction { t in t.animation = nil }
.frame(minWidth: 350)
}
#elseif os(macOS)
playbackModePicker
.modifier(SettingsPickerModifier())
#if os(macOS)
.frame(maxWidth: 150)
#endif
#else
Menu {
playbackModePicker
} label: {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
}
.transaction { t in t.animation = .none }
#endif
}
var playbackModePicker: some View {
Picker("Playback Mode", selection: $player.playbackMode) {
ForEach(PlayerModel.PlaybackMode.allCases, id: \.rawValue) { mode in
Label(mode.description.localized(), systemImage: mode.systemImage).tag(mode)
}
}
.labelsHidden()
}
@ViewBuilder private var qualityProfileButton: some View {
#if os(macOS)
qualityProfilePicker
@@ -397,7 +437,7 @@ struct PlaybackSettings: View {
@ViewBuilder private var captionsPicker: some View {
let captions = player.currentVideo?.captions ?? []
Picker("Captions", selection: $player.captions) {
Picker("Captions".localized(), selection: $player.captions) {
if captions.isEmpty {
Text("Not available")
} else {

View File

@@ -5,7 +5,7 @@ extension Backport where Content: View {
@ViewBuilder func playbackSettingsPresentationDetents() -> some View {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
content
.presentationDetents([.height(350), .large])
.presentationDetents([.height(400), .large])
} else {
content
}

View File

@@ -1,11 +1,15 @@
import Defaults
import SwiftUI
struct PlayerBackendView: View {
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
#endif
@ObservedObject private var player = PlayerModel.shared
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
var body: some View {
ZStack(alignment: .top) {
Group {
@@ -15,13 +19,24 @@ struct PlayerBackendView: View {
case .mpv:
player.mpvPlayerView
case .appleAVPlayer:
player.avPlayerView
#if os(tvOS)
AppleAVPlayerView()
#else
if avPlayerUsesSystemControls,
!player.playingInPictureInPicture,
!player.avPlayerBackend.isStartingPiP
{
AppleAVPlayerView()
} else if !avPlayerUsesSystemControls ||
player.playingInPictureInPicture ||
player.avPlayerBackend.isStartingPiP
{
AppleAVPlayerLayerView()
}
#endif
}
}
.zIndex(0)
ControlsGradientView()
.zIndex(1)
}
}
.overlay(GeometryReader { proxy in
@@ -30,12 +45,11 @@ struct PlayerBackendView: View {
.onChange(of: proxy.size) { _ in player.playerSize = proxy.size }
.onChange(of: player.controls.presentingOverlays) { _ in player.playerSize = proxy.size }
})
#if os(iOS)
.padding(.top, player.playingFullScreen && verticalSizeClass == .regular ? 20 : 0)
#endif
#if !os(tvOS)
PlayerGestures()
if player.activeBackend == .mpv || !avPlayerUsesSystemControls {
PlayerGestures()
}
PlayerControls()
#if os(iOS)
.padding(.top, controlsTopPadding)
@@ -55,19 +69,17 @@ struct PlayerBackendView: View {
guard player.playingFullScreen else { return 0 }
if UIDevice.current.userInterfaceIdiom != .pad {
return verticalSizeClass == .compact ? SafeArea.insets.top : 0
return verticalSizeClass == .compact ? safeAreaModel.safeArea.top : 0
} else {
return SafeArea.insets.top.isZero ? SafeArea.insets.bottom : SafeArea.insets.top
return safeAreaModel.safeArea.top.isZero ? safeAreaModel.safeArea.bottom : safeAreaModel.safeArea.top
}
}
var controlsBottomPadding: Double {
guard player.playingFullScreen else { return 0 }
if UIDevice.current.userInterfaceIdiom != .pad {
return player.playingFullScreen && verticalSizeClass == .compact ? SafeArea.insets.bottom : 0
return player.playingFullScreen || verticalSizeClass == .compact ? safeAreaModel.safeArea.bottom : 0
} else {
return player.playingFullScreen ? SafeArea.insets.bottom : 0
return player.playingFullScreen ? safeAreaModel.safeArea.bottom : 0
}
}
#endif

View File

@@ -3,7 +3,7 @@ import SwiftUI
extension VideoPlayerView {
var playerDragGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
DragGesture(minimumDistance: 30, coordinateSpace: .global)
#if os(iOS)
.updating($dragGestureOffset) { value, state, _ in
guard isVerticalDrag else { return }
@@ -17,7 +17,8 @@ extension VideoPlayerView {
}
.onChanged { value in
guard player.presentingPlayer,
!controlsOverlayModel.presenting else { return }
!controlsOverlayModel.presenting,
dragGestureState else { return }
if player.controls.presentingControls, !player.musicMode {
player.controls.presentingControls = false
@@ -36,7 +37,12 @@ extension VideoPlayerView {
}
#endif
if !isVerticalDrag, horizontalPlayerGestureEnabled, abs(horizontalDrag) > seekGestureSensitivity, !isHorizontalDrag {
if !isVerticalDrag,
horizontalPlayerGestureEnabled,
abs(horizontalDrag) > seekGestureSensitivity,
!isHorizontalDrag,
player.activeBackend == .mpv || !avPlayerUsesSystemControls
{
isHorizontalDrag = true
player.seek.onSeekGestureStart()
viewDragOffset = 0
@@ -63,7 +69,7 @@ extension VideoPlayerView {
{
player.exitFullScreen(showControls: false)
#if os(iOS)
if Defaults[.rotateToPortraitOnExitFullScreen] {
if Constants.isIPhone {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
#endif
@@ -80,6 +86,16 @@ extension VideoPlayerView {
player.seek.onSeekGestureEnd()
}
if viewDragOffset > 60,
player.playingFullScreen
{
#if os(iOS)
player.lockedOrientation = nil
#endif
player.exitFullScreen(showControls: false)
viewDragOffset = 0
return
}
isVerticalDrag = false
guard player.presentingPlayer,

View File

@@ -5,42 +5,50 @@ struct PlayerGestures: View {
private var player = PlayerModel.shared
@ObservedObject private var model = PlayerControlsModel.shared
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
var showGestures: Bool {
player.activeBackend == .mpv || !avPlayerUsesSystemControls
}
var body: some View {
HStack(spacing: 0) {
gestureRectangle
.tapRecognizer(
tapSensitivity: 0.2,
doubleTapAction: {
model.presentingControls = false
let interval = TimeInterval(Defaults[.gestureBackwardSeekDuration]) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
},
anyTapAction: {
singleTapAction()
model.update()
}
)
if showGestures {
gestureRectangle
.tapRecognizer(
tapSensitivity: 0.2,
doubleTapAction: {
model.presentingControls = false
let interval = TimeInterval(Defaults[.gestureBackwardSeekDuration]) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
},
anyTapAction: {
singleTapAction()
model.update()
}
)
gestureRectangle
.tapRecognizer(
tapSensitivity: 0.2,
doubleTapAction: {
model.presentingControls = false
player.backend.togglePlay()
},
anyTapAction: singleTapAction
)
gestureRectangle
.tapRecognizer(
tapSensitivity: 0.2,
doubleTapAction: {
model.presentingControls = false
player.backend.togglePlay()
},
anyTapAction: singleTapAction
)
gestureRectangle
.tapRecognizer(
tapSensitivity: 0.2,
doubleTapAction: {
model.presentingControls = false
let interval = TimeInterval(Defaults[.gestureForwardSeekDuration]) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
},
anyTapAction: singleTapAction
)
gestureRectangle
.tapRecognizer(
tapSensitivity: 0.2,
doubleTapAction: {
model.presentingControls = false
let interval = TimeInterval(Defaults[.gestureForwardSeekDuration]) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
},
anyTapAction: singleTapAction
)
}
}
}

View File

@@ -7,22 +7,17 @@ struct PlayerQueueRow: View {
let item: PlayerQueueItem
var history = false
var autoplay = false
var watch: Watch?
private var player = PlayerModel.shared
@Default(.closePiPOnNavigation) var closePiPOnNavigation
@FetchRequest private var watchRequest: FetchedResults<Watch>
init(item: PlayerQueueItem, history: Bool = false, autoplay: Bool = false) {
init(item: PlayerQueueItem, history: Bool = false, autoplay: Bool = false, watch: Watch? = nil) {
self.item = item
self.history = history
self.autoplay = autoplay
_watchRequest = FetchRequest<Watch>(
entity: Watch.entity(),
sortDescriptors: [],
predicate: NSPredicate(format: "videoID = %@", item.videoID)
)
self.watch = watch
}
var body: some View {
@@ -80,10 +75,6 @@ struct PlayerQueueRow: View {
#endif
}
private var watch: Watch? {
watchRequest.first
}
private var watchStoppedAt: CMTime? {
guard let seconds = watch?.stoppedAt else {
return nil

View File

@@ -5,9 +5,9 @@ struct RelatedView: View {
@ObservedObject private var player = PlayerModel.shared
var body: some View {
List {
if let related = player.currentVideo?.related {
Section(header: Text("Related")) {
LazyVStack {
if let related = player.videoForDisplay?.related {
Section(header: header) {
ForEach(related) { video in
PlayerQueueRow(item: PlayerQueueItem(video))
.listRowBackground(Color.clear)
@@ -34,6 +34,15 @@ struct RelatedView: View {
.listStyle(.plain)
#endif
}
var header: some View {
Text("Related")
#if !os(macOS)
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
}
struct RelatedView_Previews: PreviewProvider {

View File

@@ -11,6 +11,21 @@ struct ChapterView: View {
Button {
player.backend.seek(to: chapter.start, seekType: .userInteracted)
} label: {
Group {
#if os(tvOS)
horizontalChapter
#else
verticalChapter
#endif
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
#if os(tvOS)
var horizontalChapter: some View {
HStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
@@ -25,10 +40,26 @@ struct ChapterView: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
#else
var verticalChapter: some View {
VStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.lineLimit(2)
.multilineTextAlignment(.leading)
.font(.headline)
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
.frame(maxWidth: Self.thumbnailWidth, alignment: .leading)
}
}
#endif
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image, options: [.lowPriority])
@@ -37,21 +68,20 @@ struct ChapterView: View {
ProgressView()
}
.indicator(.activity)
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
#if os(tvOS)
.frame(width: thumbnailWidth, height: 140)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: thumbnailWidth, height: 60)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
private var thumbnailWidth: Double {
#if os(tvOS)
250
#else
100
#endif
static var thumbnailWidth: Double {
250
}
static var thumbnailHeight: Double {
thumbnailWidth / 1.7777
}
}

View File

@@ -5,24 +5,45 @@ import SwiftUI
struct ChaptersView: View {
@ObservedObject private var player = PlayerModel.shared
var chapters: [Chapter] {
player.videoForDisplay?.chapters ?? []
}
var chaptersHaveImages: Bool {
chapters.allSatisfy { $0.image != nil }
}
var body: some View {
if let chapters = player.currentVideo?.chapters, !chapters.isEmpty {
List {
Section {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
if !chapters.isEmpty {
#if os(tvOS)
List {
Section {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
}
}
.listRowBackground(Color.clear)
}
.listRowBackground(Color.clear)
}
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.grouped)
.backport
.scrollContentBackground(false)
.listStyle(.plain)
#else
.listStyle(.plain)
if chaptersHaveImages {
ScrollView(.horizontal) {
LazyHStack(spacing: 20) {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
}
}
.padding(.horizontal, 15)
}
.frame(minHeight: ChapterView.thumbnailHeight + 100)
} else {
Section {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
}
}
.padding(.horizontal)
}
#endif
} else {
NoCommentsView(text: "No chapters information available".localized(), systemImage: "xmark.circle.fill")

View File

@@ -204,7 +204,7 @@ struct CommentView: View {
Group {
let last = comments.replies.last
ForEach(comments.replies) { comment in
CommentView(comment: comment, repliesID: $repliesID)
Self(comment: comment, repliesID: $repliesID)
#if os(tvOS)
.focusable()
#endif

View File

@@ -1,7 +1,6 @@
import SwiftUI
struct CommentsView: View {
var embedInScrollView = false
@State private var repliesID: Comment.ID?
@ObservedObject private var comments = CommentsModel.shared
@@ -16,7 +15,7 @@ struct CommentsView: View {
PlaceholderProgressView()
} else {
let last = comments.all.last
let commentsStack = LazyVStack {
LazyVStack {
ForEach(comments.all) { comment in
CommentView(comment: comment, repliesID: $repliesID)
.onAppear {
@@ -25,16 +24,6 @@ struct CommentsView: View {
.borderBottom(height: comment != last ? 0.5 : 0, color: Color("ControlsBorderColor"))
}
}
.padding(.top, 55)
if embedInScrollView {
ScrollView(.vertical, showsIndicators: false) {
commentsStack
Color.clear.frame(height: 50)
}
} else {
commentsStack
}
}
}
.padding(.horizontal)

View File

@@ -6,7 +6,7 @@ struct InspectorView: View {
@ObservedObject private var player = PlayerModel.shared
var body: some View {
ScrollView {
Section(header: header) {
VStack(alignment: .leading, spacing: 12) {
if let video {
VStack(spacing: 4) {
@@ -15,7 +15,7 @@ struct InspectorView: View {
videoDetailRow("Format", value: player.mpvBackend.videoFormat)
videoDetailRow("Codec", value: player.mpvBackend.videoCodec)
videoDetailRow("Hardware Decoder", value: player.mpvBackend.hwDecoder)
videoDetailRow("Hardware decoder", value: player.mpvBackend.hwDecoder)
videoDetailRow("Driver", value: player.mpvBackend.currentVo)
videoDetailRow("Size", value: player.formattedSize)
videoDetailRow("FPS", value: player.mpvBackend.formattedOutputFps)
@@ -53,10 +53,14 @@ struct InspectorView: View {
NoCommentsView(text: "Not playing", systemImage: "stop.circle.fill")
}
}
.padding(.top, 60)
.padding(.bottom, 50)
}
.padding(.horizontal)
}
var header: some View {
Text("Inspector".localized())
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder func videoDetailGroupHeading(_ heading: String, image systemName: String? = nil) -> some View {

View File

@@ -13,7 +13,7 @@ struct PlayerQueueView: View {
@Default(.saveHistory) private var saveHistory
var body: some View {
List {
Group {
Group {
if player.playbackMode == .related {
autoplaying
@@ -34,15 +34,6 @@ struct PlayerQueueView: View {
.listRowSeparator(false)
}
.environment(\.inNavigationView, false)
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.grouped)
.backport
.scrollContentBackground(false)
#else
.listStyle(.plain)
#endif
}
@ViewBuilder var autoplaying: some View {
@@ -65,6 +56,8 @@ struct PlayerQueueView: View {
var autoplayingHeader: some View {
HStack {
Text("Autoplaying Next")
.foregroundColor(.secondary)
.font(.caption)
Spacer()
Button {
player.setRelatedAutoplayItem()
@@ -78,7 +71,7 @@ struct PlayerQueueView: View {
}
var playingNext: some View {
Section(header: Text("Queue")) {
Section(header: queueHeader) {
if player.queue.isEmpty {
Text("Queue is empty")
.foregroundColor(.secondary)
@@ -96,6 +89,15 @@ struct PlayerQueueView: View {
}
}
var queueHeader: some View {
Text("Queue".localized())
#if !os(macOS)
.foregroundColor(.secondary)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
private var visibleWatches: [Watch] {
watches.filter { $0.videoID != player.currentVideo?.videoID }
}

View File

@@ -6,8 +6,15 @@ struct VideoActions: View {
case share
case addToPlaylist
case subscribe
case fullScreen
case pip
#if os(iOS)
case lockOrientation
#endif
case restart
case advanceToNextItem
case musicMode
case settings
case next
case hide
case close
}
@@ -19,17 +26,20 @@ struct VideoActions: View {
var video: Video?
@Default(.openWatchNextOnClose) private var openWatchNextOnClose
@Default(.playerActionsButtonLabelStyle) private var playerActionsButtonLabelStyle
@Default(.actionButtonShareEnabled) private var actionButtonShareEnabled
@Default(.actionButtonAddToPlaylistEnabled) private var actionButtonAddToPlaylistEnabled
@Default(.actionButtonSubscribeEnabled) private var actionButtonSubscribeEnabled
@Default(.actionButtonSettingsEnabled) private var actionButtonSettingsEnabled
@Default(.actionButtonNextEnabled) private var actionButtonNextEnabled
@Default(.actionButtonFullScreenEnabled) private var actionButtonFullScreenEnabled
@Default(.actionButtonPipEnabled) private var actionButtonPipEnabled
@Default(.actionButtonLockOrientationEnabled) private var actionButtonLockOrientationEnabled
@Default(.actionButtonRestartEnabled) private var actionButtonRestartEnabled
@Default(.actionButtonAdvanceToNextItemEnabled) private var actionButtonAdvanceToNextItemEnabled
@Default(.actionButtonMusicModeEnabled) private var actionButtonMusicModeEnabled
@Default(.actionButtonHideEnabled) private var actionButtonHideEnabled
@Default(.actionButtonCloseEnabled) private var actionButtonCloseEnabled
@Default(.actionButtonNextQueueCountEnabled) private var actionButtonNextQueueCountEnabled
var body: some View {
HStack(spacing: 6) {
@@ -55,8 +65,20 @@ struct VideoActions: View {
return actionButtonSubscribeEnabled
case .settings:
return actionButtonSettingsEnabled
case .next:
return actionButtonNextEnabled
case .fullScreen:
return actionButtonFullScreenEnabled
case .pip:
return actionButtonPipEnabled
#if os(iOS)
case .lockOrientation:
return actionButtonLockOrientationEnabled
#endif
case .restart:
return actionButtonRestartEnabled
case .advanceToNextItem:
return actionButtonAdvanceToNextItemEnabled
case .musicMode:
return actionButtonMusicModeEnabled
case .hide:
return actionButtonHideEnabled
case .close:
@@ -69,11 +91,21 @@ struct VideoActions: View {
case .share:
return video?.isShareable ?? false
case .addToPlaylist:
return !(video?.isLocal ?? true)
return !(video?.isLocal ?? true) && accounts.signedIn
case .subscribe:
return !(video?.isLocal ?? true) && accounts.signedIn && accounts.app.supportsSubscriptions
case .settings:
return video != nil
case .fullScreen:
return video != nil
case .pip:
return video != nil
case .advanceToNextItem:
return player.isAdvanceToNextItemAvailable
case .restart:
return video != nil
case .musicMode:
return video != nil
default:
return true
}
@@ -116,6 +148,23 @@ struct VideoActions: View {
}
}
}
case .fullScreen:
actionButton("Fullscreen", systemImage: player.fullscreenImage, action: player.toggleFullScreenAction)
case .pip:
actionButton("PiP", systemImage: player.pipImage, action: player.togglePiPAction)
#if os(iOS)
case .lockOrientation:
actionButton("Lock", systemImage: player.lockOrientationImage, active: player.lockedOrientation != nil, action: player.lockOrientationAction)
#endif
case .restart:
actionButton("Replay", systemImage: "backward.end.fill", action: player.replayAction)
case .advanceToNextItem:
actionButton("Next", systemImage: "forward.fill") {
player.advanceToNextItem()
}
case .musicMode:
actionButton("Music", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
case .settings:
actionButton("Settings", systemImage: "gear") {
withAnimation(ControlOverlaysModel.animation) {
@@ -126,10 +175,6 @@ struct VideoActions: View {
#endif
}
}
case .next:
actionButton(nextLabel, systemImage: Constants.nextSystemImage) {
WatchNextViewModel.shared.userInteractedOpen(player.currentItem)
}
case .hide:
actionButton("Hide", systemImage: "chevron.down") {
player.hide(animate: true)
@@ -137,12 +182,7 @@ struct VideoActions: View {
case .close:
actionButton("Close", systemImage: "xmark") {
if player.presentingPlayer, openWatchNextOnClose {
player.pause()
WatchNextViewModel.shared.closed(player.currentItem)
} else {
player.closeCurrentItem()
}
player.closeCurrentItem()
}
}
}
@@ -150,28 +190,23 @@ struct VideoActions: View {
}
}
var nextLabel: String {
if actionButtonNextQueueCountEnabled, !player.queue.isEmpty {
return "\("Next".localized())\(player.queue.count)"
}
return "Next".localized()
}
func actionButton(
_ name: String,
systemImage: String,
active: Bool = false,
action: @escaping () -> Void = {}
) -> some View {
Button(action: action) {
VStack(spacing: 3) {
Image(systemName: systemImage)
.frame(width: 20, height: 20)
.foregroundColor(active ? Color("AppRedColor") : .accentColor)
if playerActionsButtonLabelStyle.text {
Text(name.localized())
.foregroundColor(.secondary)
.foregroundColor(active ? Color("AppRedColor") : .secondary)
.font(.caption2)
.allowsTightening(true)
.lineLimit(1)
}
}
.padding(.horizontal, playerActionsButtonLabelStyle.text ? 6 : 12)

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