Compare commits

...

200 Commits

Author SHA1 Message Date
Arkadiusz Fal
8b0c9d3d0a Fix syntax 2024-02-02 11:48:03 +01:00
Arkadiusz Fal
371471ad81 Fix syntax 2024-02-02 11:38:01 +01:00
Arkadiusz Fal
d5464186af Use old Previews syntax 2024-02-02 11:31:34 +01:00
Arkadiusz Fal
f4c310846a Update CHANGELOG 2024-02-02 11:16:27 +01:00
Arkadiusz Fal
2413526d70 Style fixes 2024-02-02 11:16:27 +01:00
Arkadiusz Fal
55f4a4a2a1 Fix possible crash 2024-02-02 11:16:27 +01:00
Arkadiusz Fal
5b35c03bc5 Add MPV deinterlace filter setting
Fix #601
2024-02-02 11:16:27 +01:00
Arkadiusz Fal
93ea943c54 Add action buttons label setting 2024-02-02 11:16:27 +01:00
Arkadiusz Fal
5ae6f321cd Update dependencies 2024-02-02 11:16:27 +01:00
Arkadiusz Fal
2be6f04e37 Import export settings 2024-02-02 11:16:27 +01:00
Arkadiusz Fal
9826ee4d36 Remove old references 2024-02-02 11:06:32 +01:00
Arkadiusz Fal
39a109216b Style fix 2024-02-02 11:06:32 +01:00
Arkadiusz Fal
05b25b65bc Use mpvkit 0.37.0 2024-02-02 11:06:32 +01:00
Mohammed Al Otaibi
195db01602 Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-02-02 11:06:32 +01:00
maboroshin
292af65ea5 Translated using Weblate (Japanese)
Currently translated at 99.6% (535 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2024-02-02 11:06:32 +01:00
Mohammed Al Otaibi
5ee46fe87a Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-02-02 11:06:32 +01:00
Arkadiusz Fal
179b4358ae Bump build number to 177 2024-02-02 11:06:32 +01:00
Arkadiusz Fal
5be8a663e0 Update CHANGELOG 2024-02-02 11:06:32 +01:00
Arkadiusz Fal
1d81f710a9 Bump build number to 176 2024-02-02 11:06:32 +01:00
Arkadiusz Fal
49e051c70d Use mpvkit 0.36.0 2024-02-02 11:06:31 +01:00
Arkadiusz Fal
1efaed4541 Use fastlane fork with tvos certs fix 2024-02-02 11:06:31 +01:00
Arkadiusz Fal
3e96001511 Bump build number to 175 2024-02-02 11:06:31 +01:00
Arkadiusz Fal
6e8fb4a6db Update CHANGELOG 2024-02-02 11:06:31 +01:00
Arkadiusz Fal
446ee0ac8e Update dependencies 2024-02-02 11:06:31 +01:00
Arkadiusz Fal
f6a89c7daf Bump version number to 1.5.2 2024-02-02 11:06:31 +01:00
Karel van Klink
ad5dc8a871 Translated using Weblate (Dutch)
Currently translated at 41.8% (225 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/nl/
2024-01-20 14:02:05 +01:00
Mohammed Al Otaibi
afaeb754ca Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-01-19 07:00:27 +01:00
OneiMoment
21fd92aea4 Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-01-18 06:13:06 +01:00
jonnysemon
02e5749fc9 Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-01-18 06:13:06 +01:00
OneiMoment
d44f80bd53 Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-01-15 13:06:27 +00:00
cdguy
5c87916785 Translated using Weblate (Turkish)
Currently translated at 62.3% (335 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2024-01-15 13:06:26 +00:00
Arkadiusz Fal
043443fb89 Merge pull request #600 from yattee/chore/update-packages
Chore/update packages
2024-01-14 13:14:10 +01:00
Arkadiusz Fal
1bc44afde6 Update dependencies 2024-01-14 12:56:06 +01:00
Arkadiusz Fal
d80101d779 Update CHANGELOG 2024-01-14 12:53:52 +01:00
Arkadiusz Fal
39925c390a Bump build number to 174 2024-01-14 12:53:52 +01:00
Arkadiusz Fal
9c51f24d3f Bump build number to 173 2024-01-13 10:29:48 +01:00
Arkadiusz Fal
c231546b5c Revert "Update packages"
This reverts commit 4d953b0871.

Dependecies issue:

[09:16:19]: ▸ local binary target 'Libshaderc_combined' does not contain expected binary artifact named 'Libshaderc_combined'local binary target 'Libplacebo' does not contain expected binary artifact named 'Libplacebo'2024-01-13 09:16:19.223 xcodebuild[5250:23030] Writing error result bundle to /var/folders/mm/pltwc2yj1jx192t6dzy9zsrr0000gn/T/ResultBundle_2024-13-01_09-16-0019.xcresult
[09:16:20]: ▸ fatalErrorxcodebuild: error: Could not resolve package dependencies:
[09:16:20]: ▸   local binary target 'Libshaderc_combined' does not contain expected binary artifact named 'Libshaderc_combined'
[09:16:20]: ▸   local binary target 'Libplacebo' does not contain expected binary artifact named 'Libplacebo'
[09:16:20]: ▸   fatalError
2024-01-13 10:17:45 +01:00
Arkadiusz Fal
58c43acb2b Update CHANGELOG 2024-01-13 10:12:14 +01:00
Arkadiusz Fal
4d953b0871 Update packages 2024-01-13 10:12:14 +01:00
Arkadiusz Fal
101fee9a37 Revert "Enable external distribution"
This reverts commit 51e5aeec13.
2024-01-13 10:12:14 +01:00
Arkadiusz Fal
a44bbe4cec Merge pull request #599 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-01-13 10:11:58 +01:00
OneiMoment
3f0eec3c54 Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-01-13 00:06:25 +01:00
mrtag-oss
ea0f52ebe0 Translated using Weblate (Russian)
Currently translated at 99.6% (535 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-01-11 19:06:37 +01:00
OneiMoment
a82bdd2a00 Translated using Weblate (Arabic)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-01-11 19:06:37 +01:00
Arkadiusz Fal
b2b8565635 Update fastlane only 2024-01-09 18:14:58 +01:00
Arkadiusz Fal
a061c1c040 Revert "Update packages"
This reverts commit 06aafc1719.
2024-01-09 18:14:24 +01:00
Arkadiusz Fal
0182faceae Bump build number to 172 2024-01-09 17:03:07 +01:00
Arkadiusz Fal
3b4e594fcf Update CHANGELOG 2024-01-09 17:02:55 +01:00
Arkadiusz Fal
51e5aeec13 Enable external distribution 2024-01-09 17:02:55 +01:00
Arkadiusz Fal
06aafc1719 Update packages 2024-01-09 17:02:54 +01:00
Arkadiusz Fal
f468fa6340 Merge pull request #587 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-01-09 16:55:41 +01:00
M K
eb85e3b731 Translated using Weblate (Dutch)
Currently translated at 10.2% (55 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/nl/
2024-01-09 16:55:22 +01:00
M K
935f5cc75e Added translation using Weblate (Dutch) 2024-01-09 16:55:22 +01:00
ssantos
bdbd23d66b Translated using Weblate (Portuguese)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt/
2024-01-09 16:55:22 +01:00
maboroshin
e4796b08b6 Translated using Weblate (Japanese)
Currently translated at 99.2% (533 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2024-01-09 16:55:22 +01:00
Ophiushi
19bc9197ec Translated using Weblate (French)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2024-01-09 16:55:22 +01:00
mere
c513753f59 Translated using Weblate (Romanian)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2024-01-09 16:55:22 +01:00
joaooliva
81b5ae1fa9 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2024-01-09 16:55:22 +01:00
gallegonovato
eddab1980a Translated using Weblate (Spanish)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2024-01-09 16:55:22 +01:00
Arkadiusz Fal
7ff52294a8 Merge pull request #597 from stonerl/regex-for-chapters
more robust regex for chapters from description
2024-01-09 16:55:16 +01:00
Arkadiusz Fal
5ae18a5170 Merge pull request #596 from stonerl/update-defaults
Update defaults to 7.3.1
2024-01-09 16:55:07 +01:00
Toni Förster
ecba91f35d more robust regex for chapters from description
This should reduce the number of falsely matched chapters, e.g., when time-code-like numbers appear in the middle of the text, like 16:9 or sports results.

It also checks for chapters that have an end time and omits the end time code from the title.

Track lists in music videos are now also properly displayed as chapters.
2024-01-01 16:04:40 +01:00
Toni Förster
36ecf63b6c defaults move from .observe() to .updates()
https://github.com/sindresorhus/Defaults/releases/tag/v7.0.0
2023-12-27 17:27:24 +01:00
Toni Förster
0671b6ef9f update Defaults package to 7.3.1 2023-12-27 17:00:18 +01:00
Arkadiusz Fal
39f6319043 Bump build number to 171 2023-12-09 22:08:21 +01:00
Arkadiusz Fal
6a2dc9164e Update CHANGELOG 2023-12-09 22:08:20 +01:00
Arkadiusz Fal
37d3f43596 Add channel view help labels 2023-12-09 22:08:20 +01:00
Arkadiusz Fal
aef0ba6ffd Fix displaying account username 2023-12-09 22:08:20 +01:00
Arkadiusz Fal
1ae12cfa21 Add localizations for fa, es, tr, ru 2023-12-09 22:08:20 +01:00
Arkadiusz Fal
8475669aab Update packages 2023-12-09 22:08:20 +01:00
Arkadiusz Fal
6fec76fcb3 Merge pull request #584 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-12-09 22:08:13 +01:00
Arkadiusz Fal
08b2e7ceac Translated using Weblate (Polish)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-12-09 22:08:00 +01:00
Anonymous
60972f0c7b Translated using Weblate (English)
Currently translated at 100.0% (537 of 537 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-12-09 22:08:00 +01:00
Arkadiusz Fal
a49db76588 Merge pull request #564 from stonerl/collapsible-chapters
make chapters collapsible and highlight current chapter
2023-12-09 21:51:24 +01:00
Arkadiusz Fal
bd0c86060b Merge pull request #583 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-12-09 21:34:14 +01:00
Arkadiusz Fal
9849f31e1f Translated using Weblate (Polish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-12-09 21:33:58 +01:00
Arkadiusz Fal
00ac222af6 Merge pull request #568 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-12-09 21:25:54 +01:00
Vijay
732a8d7385 Translated using Weblate (Hindi)
Currently translated at 65.7% (352 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hi/
2023-12-09 21:03:19 +01:00
Vijay
a009ad7d53 Translated using Weblate (Hindi)
Currently translated at 63.5% (340 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hi/
2023-12-09 21:03:19 +01:00
Kaambiz
91c7c9fc8e Translated using Weblate (Persian)
Currently translated at 78.1% (418 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fa/
2023-12-09 21:03:19 +01:00
Alper Zengintaş
e836f87c88 Translated using Weblate (Turkish)
Currently translated at 49.9% (267 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2023-12-09 21:03:19 +01:00
Kaambiz
1e1a23acd0 Added translation using Weblate (Persian) 2023-12-09 21:03:19 +01:00
Sloom
d8d728a48f Translated using Weblate (Arabic (Najdi))
Currently translated at 0.3% (2 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ars/
2023-12-09 21:03:19 +01:00
maboroshin
067e8a79bf Translated using Weblate (Japanese)
Currently translated at 99.6% (533 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-12-09 21:03:19 +01:00
joaooliva
576f40360d Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (535 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-12-09 21:03:19 +01:00
gallegonovato
c679a52903 Translated using Weblate (Spanish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-12-09 21:03:19 +01:00
Alper Zengintaş
82f109290d Translated using Weblate (Turkish)
Currently translated at 46.7% (250 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2023-12-09 21:03:19 +01:00
Ophiushi
765006b185 Translated using Weblate (French)
Currently translated at 100.0% (535 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-12-09 21:03:19 +01:00
Anonymous
86cddb06e4 Translated using Weblate (English)
Currently translated at 100.0% (535 of 535 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-12-09 21:03:19 +01:00
Sloom
012b5156b7 Added translation using Weblate (Arabic (Najdi)) 2023-12-09 21:03:19 +01:00
Arkadiusz Fal
d6c04540e9 Merge pull request #569 from stonerl/no-180-rotation
disable portraitUpsideDown on iPhones
2023-12-09 21:03:14 +01:00
Arkadiusz Fal
3144b52b55 Merge pull request #578 from stonerl/description-fix
macOS: disable hit testing when not expanded
2023-12-09 21:01:30 +01:00
Toni Förster
b04385ceae macOS: disable hit testing when not expanded
fixes overspilling of text when clicking on collapsed descriptions
2023-12-05 00:36:12 +01:00
Toni Förster
721a97dc41 no need for NotificationCenter 2023-12-05 00:07:36 +01:00
Toni Förster
4ca8adc4dd move onReceive to the chapter header in VideoDetails 2023-12-04 23:00:20 +01:00
Toni Förster
d361ef01d4 move updating the time to PlayerModel
this makes the chapter view much much smoother
2023-12-04 21:58:49 +01:00
Toni Förster
0d9c27319d make time updates work for .mpv and .avp
- time update notifications work for both backends
- only init mpv timers when mpv is the active backend
- move notification extension to playerbackend
2023-12-04 14:47:26 +01:00
Toni Förster
600b8d198b add option to expand vertical chapters by default 2023-12-04 01:04:39 +01:00
Toni Förster
d65224320e chapters can only be clicked if expanded or horizontal 2023-12-04 00:39:29 +01:00
Toni Förster
586cea7d44 subscribe chapters to currentTime notification 2023-12-04 00:07:39 +01:00
Toni Förster
aa5d6733b2 remove superfluous vars and chaptermodel 2023-12-03 20:04:57 +01:00
Toni Förster
982dca1846 highlight current chapter when clicked on it 2023-11-29 00:31:53 +01:00
Toni Förster
7de702ad23 change color to appRed for chapter 2023-11-28 23:06:37 +01:00
Toni Förster
13ef96cd02 separate tvOS View 2023-11-28 20:05:04 +01:00
Toni Förster
4ec6f35c5d highlight current chapter 2023-11-28 16:45:36 +01:00
Toni Förster
7f19f7aa47 disable portraitUpsideDown on iPhones 2023-11-27 18:05:17 +01:00
Toni Förster
fb5cd0f681 vertical chapters are full width and max 3 lines 2023-11-27 14:18:43 +01:00
Toni Förster
3feafc153c remove toggle for vertical chapters 2023-11-27 13:34:18 +01:00
Toni Förster
8f08674527 vertical chapters with images are always collapsed 2023-11-27 12:13:51 +01:00
Toni Förster
a7baaeb485 iOS click on collapsed chapters expands the view 2023-11-26 21:24:52 +01:00
Toni Förster
b0d81cdefd iOS - chapters header is a button now 2023-11-26 21:24:52 +01:00
Toni Förster
a33a1d7658 formatting 2023-11-26 21:24:51 +01:00
Toni Förster
e436dec4ba make chapters collapsible
Chapters are now collapsible by default only the first two chapters are shown. The second will be shown opaque to indicate more chapters.
2023-11-26 21:24:50 +01:00
Arkadiusz Fal
eb1dfe69cd Bump build number to 170 2023-11-26 19:09:45 +01:00
Arkadiusz Fal
9f5720d393 Update CHANGELOG 2023-11-26 19:09:26 +01:00
Arkadiusz Fal
675db6f651 Update packages 2023-11-26 18:59:10 +01:00
Arkadiusz Fal
fb5e86c2cb Merge pull request #559 from stonerl/collapsable-details
make description collapsible
2023-11-26 18:57:27 +01:00
Toni Förster
d384a4c520 iOS click on collapsed description text expands it 2023-11-26 15:42:47 +01:00
Toni Förster
6994271eca iOS - the description header is a button now 2023-11-26 15:42:47 +01:00
Toni Förster
ed5fa8e4aa no preview shows nothing now 2023-11-26 15:42:47 +01:00
Toni Förster
cbdf295d67 fix macOS layout 2023-11-26 15:42:47 +01:00
Toni Förster
5d78946320 add stepper for previewed line (broken on macOS) 2023-11-26 15:42:47 +01:00
Toni Förster
84fdc22861 formatting 2023-11-26 15:42:46 +01:00
Toni Förster
a57645f824 make description collapsible 2023-11-26 15:42:46 +01:00
Arkadiusz Fal
e78f40c555 Merge pull request #560 from stonerl/clickable-links
make links in description clickable
2023-11-26 12:28:58 +01:00
Arkadiusz Fal
23f5fc9575 Merge pull request #562 from stonerl/honor-aspect-ratio
honour the aspect ratio when resizing
2023-11-26 12:27:02 +01:00
Toni Förster
fc7a7b085f fixed styling 2023-11-26 10:33:58 +01:00
Arkadiusz Fal
7ffa34b0f9 Merge pull request #554 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-11-25 22:26:26 +01:00
maboroshin
177e28121b Translated using Weblate (Japanese)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-11-25 22:26:19 +01:00
gallegonovato
68a35b8804 Translated using Weblate (Spanish)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:19 +01:00
ooosssay
9ec9a680a6 Translated using Weblate (Spanish)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:19 +01:00
gallegonovato
7629931747 Translated using Weblate (Spanish)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:19 +01:00
ooosssay
afb22e6c25 Translated using Weblate (Spanish)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:19 +01:00
gallegonovato
052fc86388 Translated using Weblate (Spanish)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:19 +01:00
ooosssay
4de51f29c8 Translated using Weblate (Spanish)
Currently translated at 98.4% (525 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:19 +01:00
gallegonovato
d1a1f4da38 Translated using Weblate (Spanish)
Currently translated at 98.4% (525 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
ooosssay
d1153ed97d Translated using Weblate (Spanish)
Currently translated at 97.5% (520 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
gallegonovato
1c356560d5 Translated using Weblate (Spanish)
Currently translated at 97.5% (520 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
gallegonovato
bbd18d921b Translated using Weblate (Spanish)
Currently translated at 91.5% (488 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
gallegonovato
9e4860d97c Translated using Weblate (Spanish)
Currently translated at 88.7% (473 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
ooosssay
50d42f721f Translated using Weblate (Spanish)
Currently translated at 88.7% (473 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
gallegonovato
9bfccb49e3 Translated using Weblate (Spanish)
Currently translated at 86.3% (460 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
Sergio Varela
919126f9b0 Translated using Weblate (Spanish)
Currently translated at 86.1% (459 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-11-25 22:26:18 +01:00
Toni Förster
d00903569f Translated using Weblate (German)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-11-25 22:26:18 +01:00
Toni Förster
25e7b0d3e1 Translated using Weblate (German)
Currently translated at 99.8% (532 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2023-11-25 22:26:18 +01:00
Arkadiusz Fal
2aafe33417 Merge pull request #565 from stonerl/private-instances
support for private instances
2023-11-25 22:26:13 +01:00
Toni Förster
e525f36824 formatting 2023-11-22 10:24:41 +01:00
Toni Förster
5a5f5a8696 code formatting 2023-11-22 10:21:21 +01:00
Toni Förster
45d2968d9e support for private instances
closes #552

and some formatting
2023-11-21 18:14:17 +01:00
Toni Förster
c3e1465f31 fix broken fullscreen on macOS 2023-11-21 17:58:44 +01:00
Toni Förster
df47ffb013 change default minWidth with hidden sidebar 2023-11-21 17:49:29 +01:00
Toni Förster
8900f96ce7 honour the aspect ratio when resizing
The `defaultMinimumHeightLeft? has been adjusted to make the radio of the video view 16:9 in its initial state. Also on macOS when resizing the window, the aspect ratio of the view now correlates with the video.
2023-11-21 13:47:51 +01:00
Toni Förster
ed69780d52 removed superfluous braces 2023-11-21 01:13:01 +01:00
Toni Förster
691a3305e7 make links in description clickable
Adds clickable links to macOS. I it is a naive approach, but it works.

Probably closes #51
2023-11-21 00:58:40 +01:00
Arkadiusz Fal
987f6dcac8 Bump build number to 169 2023-11-04 11:34:45 +01:00
Arkadiusz Fal
1113a94d67 Update CHANGELOG 2023-11-04 11:34:25 +01:00
Arkadiusz Fal
8c6fc7d561 Update dependencies 2023-11-04 11:33:01 +01:00
Arkadiusz Fal
fb84927fc8 Bump build number to 168 2023-10-24 09:17:22 +02:00
Arkadiusz Fal
c452e66999 Update CHANGELOG 2023-10-24 09:17:22 +02:00
Arkadiusz Fal
7ab91e08f4 Update dependencies 2023-10-24 09:16:37 +02:00
Arkadiusz Fal
bdd18dba4e Merge pull request #543 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-10-24 09:15:59 +02:00
maboroshin
026654d3a2 Translated using Weblate (Japanese)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-10-22 19:40:21 +02:00
mere
e9729bc9b3 Translated using Weblate (Romanian)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2023-10-22 19:40:21 +02:00
Arkadiusz Fal
a04159969e Use AF for Piped token request 2023-10-22 19:39:34 +02:00
Arkadiusz Fal
3624d186bc Bump build number to 167 2023-10-16 15:53:58 +02:00
Arkadiusz Fal
31ea4860cf Bump version number to 1.5.1 2023-10-16 15:53:41 +02:00
Arkadiusz Fal
5a074d7607 Bump build number to 166 2023-10-15 14:10:05 +02:00
Arkadiusz Fal
9d8b20148b Update CHANGELOG 2023-10-15 14:09:37 +02:00
Arkadiusz Fal
0ffb9d0606 Fix date parsing for piped videos 2023-10-15 14:08:38 +02:00
Arkadiusz Fal
8258d8d5b4 Bring back iOS 14 and macOS 11 2023-10-15 14:08:08 +02:00
Arkadiusz Fal
94915b0496 Revert "Drop iOS 14 and macOS 11 support"
This reverts commit dcef7f47ff.
2023-10-15 13:35:23 +02:00
Arkadiusz Fal
053b4a22b8 Fix style 2023-10-15 13:33:46 +02:00
Arkadiusz Fal
e6a9f39477 Bump build number to 165 2023-10-15 00:46:59 +02:00
Arkadiusz Fal
ff4f80b7ef Update CHANGELOG 2023-10-15 00:46:59 +02:00
Arkadiusz Fal
7e1218ce13 Update packages 2023-10-15 00:46:59 +02:00
Arkadiusz Fal
4c707271c3 Add setting to disable chapters and related, add info section in
player settings
2023-10-15 00:46:59 +02:00
Arkadiusz Fal
04e56638ce Fix possible crash 2023-10-15 00:46:59 +02:00
Arkadiusz Fal
674269c4c1 Fix issue with tvOS screensaver 2023-10-15 00:46:58 +02:00
Arkadiusz Fal
2b4627c3d6 Fix lint 2023-10-15 00:46:58 +02:00
Arkadiusz Fal
4260c6d6b5 Merge pull request #539 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-10-15 00:46:13 +02:00
maboroshin
4057021cb9 Translated using Weblate (Japanese)
Currently translated at 99.4% (530 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2023-10-10 15:04:11 +00:00
gallegonovato
151a99c2a3 Translated using Weblate (Spanish)
Currently translated at 84.2% (449 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2023-10-08 12:03:32 +00:00
Radical
bd606a4cf8 Translated using Weblate (Norwegian Bokmål)
Currently translated at 59.6% (318 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/nb_NO/
2023-10-08 12:03:31 +00:00
Ophiushi
4dbf2551d9 Translated using Weblate (French)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2023-09-28 19:02:38 +02:00
Arkadiusz Fal
8fefbccc83 Bump build number to 164 2023-09-26 18:19:49 +02:00
Arkadiusz Fal
65bc1e4efe Update CHANGELOG 2023-09-26 18:19:27 +02:00
Arkadiusz Fal
9a257ec897 Fix quality profile form 2023-09-26 18:19:09 +02:00
Arkadiusz Fal
dd0a966a9f Bump build number to 163 2023-09-26 17:57:19 +02:00
Arkadiusz Fal
cec0327293 Update CHANGELOG 2023-09-26 17:47:18 +02:00
Arkadiusz Fal
5b2c94758b Set window min width on macOS 2023-09-26 17:47:00 +02:00
Arkadiusz Fal
2dd1d2ac56 Fix possible crash 2023-09-26 17:44:01 +02:00
Arkadiusz Fal
3be7a5a69f Fix issue with creating quality profile 2023-09-26 17:44:01 +02:00
Arkadiusz Fal
3e6ea2633e Remove unnecessary frameworks 2023-09-26 17:44:01 +02:00
Arkadiusz Fal
2ef692edb8 Migrate to SwiftUIIntrospect 2023-09-26 17:44:01 +02:00
Arkadiusz Fal
3edfa5dfe7 Set focus on search textfield on macOS
Fix #268
2023-09-26 17:44:01 +02:00
Arkadiusz Fal
8976ef04f6 Localization fix 2023-09-26 17:44:00 +02:00
Arkadiusz Fal
cf81aedb60 Merge pull request #538 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2023-09-26 17:43:48 +02:00
joaooliva
78a225cde4 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2023-09-26 17:43:26 +02:00
Arkadiusz Fal
efdbbbc1f4 Translated using Weblate (Polish)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2023-09-26 17:43:26 +02:00
Anonymous
f82056a358 Translated using Weblate (English)
Currently translated at 100.0% (533 of 533 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2023-09-26 17:43:26 +02:00
137 changed files with 5886 additions and 1079 deletions

View File

@@ -0,0 +1,15 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func badge(_ count: Text?) -> some View {
#if os(tvOS)
content
#else
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.badge(count)
} else {
content
}
#endif
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func listRowSeparator(_ visible: Bool) -> some View {
if #available(iOS 15, macOS 13, *) {
content
#if !os(tvOS)
.listRowSeparator(visible ? .visible : .hidden)
#endif
} else {
content
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func refreshable(action: @Sendable @escaping () async -> Void) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.refreshable(action: action)
} else {
content
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func tint(_ color: Color?) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.tint(color)
} else {
content.foregroundColor(color)
}
}
}

View File

@@ -0,0 +1,97 @@
/*
Copyright © 2020 Apple Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import SwiftUI
#if os(iOS)
public struct VisualEffectBlur<Content: View>: View {
/// Defaults to .systemMaterial
var blurStyle: UIBlurEffect.Style
/// Defaults to nil
var vibrancyStyle: UIVibrancyEffectStyle?
var content: Content
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
self.blurStyle = blurStyle
self.vibrancyStyle = vibrancyStyle
self.content = content()
}
public var body: some View {
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: ZStack { content })
.accessibility(hidden: Content.self == EmptyView.self)
}
}
// MARK: - Representable
extension VisualEffectBlur {
struct Representable<Content: View>: UIViewRepresentable {
var blurStyle: UIBlurEffect.Style
var vibrancyStyle: UIVibrancyEffectStyle?
var content: Content
func makeUIView(context: Context) -> UIVisualEffectView {
context.coordinator.blurView
}
func updateUIView(_: UIVisualEffectView, context: Context) {
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
}
func makeCoordinator() -> Coordinator {
Coordinator(content: content)
}
}
}
// MARK: - Coordinator
extension VisualEffectBlur.Representable {
class Coordinator {
let blurView = UIVisualEffectView()
let vibrancyView = UIVisualEffectView()
let hostingController: UIHostingController<Content>
init(content: Content) {
hostingController = UIHostingController(rootView: content)
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostingController.view.backgroundColor = nil
blurView.contentView.addSubview(vibrancyView)
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
vibrancyView.contentView.addSubview(hostingController.view)
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
hostingController.rootView = content
let blurEffect = UIBlurEffect(style: blurStyle)
blurView.effect = blurEffect
if let vibrancyStyle {
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
} else {
vibrancyView.effect = nil
}
hostingController.view.setNeedsDisplay()
}
}
}
extension VisualEffectBlur where Content == EmptyView {
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
EmptyView()
}
}
}
#endif

View File

@@ -0,0 +1,83 @@
/*
Copyright © 2020 Apple Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import SwiftUI
#if os(macOS)
public struct VisualEffectBlur: View {
private var material: NSVisualEffectView.Material
private var blendingMode: NSVisualEffectView.BlendingMode
private var state: NSVisualEffectView.State
public init(
material: NSVisualEffectView.Material = .headerView,
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow,
state: NSVisualEffectView.State = .followsWindowActiveState
) {
self.material = material
self.blendingMode = blendingMode
self.state = state
}
public var body: some View {
Representable(
material: material,
blendingMode: blendingMode,
state: state
).accessibility(hidden: true)
}
}
// MARK: - Representable
extension VisualEffectBlur {
struct Representable: NSViewRepresentable {
var material: NSVisualEffectView.Material
var blendingMode: NSVisualEffectView.BlendingMode
var state: NSVisualEffectView.State
func makeNSView(context: Context) -> NSVisualEffectView {
context.coordinator.visualEffectView
}
func updateNSView(_: NSVisualEffectView, context: Context) {
context.coordinator.update(material: material)
context.coordinator.update(blendingMode: blendingMode)
context.coordinator.update(state: state)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
class Coordinator {
let visualEffectView = NSVisualEffectView()
init() {
visualEffectView.blendingMode = .withinWindow
}
func update(material: NSVisualEffectView.Material) {
visualEffectView.material = material
}
func update(blendingMode: NSVisualEffectView.BlendingMode) {
visualEffectView.blendingMode = blendingMode
}
func update(state: NSVisualEffectView.State) {
visualEffectView.state = state
}
}
}
#endif

View File

@@ -1,18 +1,13 @@
## Build 162
* Added support for "Podcasts" and "Releases" channel tabs (Invidious)
## Build 177
* Added Settings Import/Export (iOS, macOS)
* Export all settings, instances and accounts
* Import selected elements from the file
* Include unencrypted passwords in the export or provide them during the import
* Added Controls setting "Action button labels" icon or icon and text
* Added Advanced setting for MPV: "deinterlace"
* Updated dependencies
* Updated localizations
* Fixed reported crash
* Other minor changes and improvements
## Previous builds
* Added button in Location settings to add current used public location to "Custom Locations"
* Updated mpv and dependencies (using mpvkit library, mpv 0.36.0, ffmpeg 6.0)
* Increased controls timeline/scrubber gesture area for easier handling
* Fixed handling playlist links
* Fixed issue where Piped videos would use audio for other language version
* Fixed issue with handling opening URLs on macOS
* Fixed issue with "Keep channels with unwatched videos on top of subscriptions list" setting not honored in sidebar
* Fixed performance issues with accounts with large amount of subscribed channels
* Fixed regression with opening videos links
* Fixed issue with deselecting formats in Quality profiles
* Fixed compatibility with iOS 17, macOS 14 and tvOS 17
* Dropped support for iOS 14 and macOS 11
* Fixed reported crashes
* Other minor fixes and improvements
**Big thanks to the past, current and future project contributors!**

View File

@@ -1,6 +1,6 @@
source "https://rubygems.org"
gem "fastlane"
gem 'fastlane', git: 'https://github.com/nekrich/fastlane.git', branch: 'fix/match-tvos-devices-fetch'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

View File

@@ -1,27 +1,74 @@
GIT
remote: https://github.com/nekrich/fastlane.git
revision: d2d51a9af37f9b04a157e78fd25d147cecc89980
branch: fix/match-tvos-devices-fetch
specs:
fastlane (2.219.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.5)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.827.0)
aws-sdk-core (3.183.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-eventstream (1.3.0)
aws-partitions (1.886.0)
aws-sdk-core (3.191.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.71.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.135.0)
aws-sdk-core (~> 3, >= 3.181.0)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
@@ -32,11 +79,10 @@ GEM
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.103.0)
excon (0.109.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -65,51 +111,11 @@ GEM
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.216.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
fastimage (2.3.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.49.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.1)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -117,24 +123,23 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 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)
google-cloud-env (~> 1.0)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
@@ -149,7 +154,7 @@ GEM
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
json (2.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
@@ -157,11 +162,11 @@ GEM
multipart-post (2.3.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
optparse (0.4.0)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.3)
rake (13.0.6)
plist (3.7.1)
public_suffix (5.0.4)
rake (13.1.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -185,17 +190,13 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
webrick (1.8.1)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.23.0)
xcodeproj (1.24.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
@@ -214,7 +215,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane
fastlane!
BUNDLED WITH
2.3.6

View File

@@ -64,6 +64,10 @@ final class AccountsModel: ObservableObject {
)
}
func find(_ id: Account.ID) -> Account? {
all.first { $0.id == id }
}
func configureAccount() {
if let account = lastUsed ??
InstancesModel.shared.lastUsed?.anonymousAccount ??
@@ -108,8 +112,8 @@ final class AccountsModel: ObservableObject {
Defaults[.accounts].first { $0.id == id }
}
static func add(instance: Instance, name: String, username: String, password: String) -> Account {
let account = Account(instanceID: instance.id, name: name, urlString: instance.apiURLString)
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
Defaults[.accounts].append(account)
setCredentials(account, username: username, password: password)

View File

@@ -68,4 +68,8 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
func hash(into hasher: inout Hasher) {
hasher.combine(apiURL)
}
var accounts: [Account] {
AccountsModel.shared.all.filter { $0.instanceID == id }
}
}

View File

@@ -42,15 +42,23 @@ final class InstancesModel: ObservableObject {
Defaults[.accounts].filter { $0.instanceID == id }
}
func add(app: VideosApp, name: String, url: String) -> Instance {
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
let instance = Instance(
app: app, id: UUID().uuidString, name: name, apiURLString: standardizedURL(url)
app: app, id: id, name: name, apiURLString: standardizedURL(url)
)
Defaults[.instances].append(instance)
return instance
}
func insert(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
if let instance = Defaults[.instances].first(where: { $0.apiURL.absoluteString == standardizedURL(url) }) {
return instance
}
return add(id: id, app: app, name: name, url: url)
}
func setFrontendURL(_ instance: Instance, _ url: String) {
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
var instance = Defaults[.instances][index]

View File

@@ -1,3 +1,4 @@
import Alamofire
import AVFoundation
import Foundation
import Siesta
@@ -148,28 +149,44 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return
}
login.request(
.post,
json: ["username": username, "password": password]
AF.request(
login.url,
method: .post,
parameters: ["username": username, "password": password],
encoding: JSONEncoding.default
)
.onSuccess { response in
let token = response.json.dictionaryValue["token"]?.string ?? ""
if let error = response.json.dictionaryValue["error"]?.string {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error
)
} else if !token.isEmpty {
AccountsModel.setToken(self.account, token)
self.objectWillChange.send()
} else {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: "Could not update your token."
)
.responseDecodable(of: JSON.self) { [weak self] response in
guard let self else {
return
}
self.configure()
switch response.result {
case let .success(value):
let json = JSON(value)
let token = json.dictionaryValue["token"]?.string ?? ""
if let error = json.dictionaryValue["error"]?.string {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error
)
} else if !token.isEmpty {
AccountsModel.setToken(self.account, token)
self.objectWillChange.send()
} else {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: "Could not update your token."
)
}
self.configure()
case let .failure(error):
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error.localizedDescription
)
}
}
}
@@ -496,7 +513,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let uploaded = details["uploaded"]?.double
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
if published.isNil {
var publishedAt: Date?
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
if published.isNil,
let date = details["uploadDate"]?.string,
let formattedDate = dateFormatter.date(from: date)
{
publishedAt = formattedDate
} else {
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
}
@@ -526,6 +553,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
thumbnails: thumbnails,
live: live,
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
publishedAt: publishedAt,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),

View File

@@ -144,52 +144,63 @@ extension VideosAPI {
}
func extractChapters(from description: String) -> [Chapter] {
guard let chaptersRegularExpression = try? NSRegularExpression(
pattern: "(?<start>(?:[0-9]+:){1,}(?:[0-9]+))(?:\\s)+(?:- ?)?(?<title>.*)",
options: .caseInsensitive
) else { return [] }
/*
The following chapter patterns are covered:
let chapterLines = chaptersRegularExpression.matches(
in: description,
range: NSRange(description.startIndex..., in: description)
)
start - end - title / start - end: Title / start - end title
start - title / start: title / start title / [start] - title / [start]: title / [start] title
index. title - start / index. title start
title: (start)
return chapterLines.compactMap { line in
let titleRange = line.range(withName: "title")
let startRange = line.range(withName: "start")
The order is important!
*/
let patterns = [
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)"
]
guard let titleSubstringRange = Range(titleRange, in: description),
let startSubstringRange = Range(startRange, in: description) else { return nil }
for pattern in patterns {
guard let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue }
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
let titleCapture = String(description[titleSubstringRange])
let startCapture = String(description[startSubstringRange])
let startComponents = startCapture.components(separatedBy: ":")
guard startComponents.count <= 3 else { return nil }
if !chapterLines.isEmpty {
return chapterLines.compactMap { line in
let titleRange = line.range(withName: "title")
let startRange = line.range(withName: "start")
guard let titleSubstringRange = Range(titleRange, in: description),
let startSubstringRange = Range(startRange, in: description)
else {
return nil
}
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
let startCapture = String(description[startSubstringRange])
let startComponents = startCapture.components(separatedBy: ":")
guard startComponents.count <= 3 else { return nil }
var hours: Double?
var minutes: Double?
var seconds: Double?
var hours: Double?
var minutes: Double?
var seconds: Double?
if startComponents.count == 3 {
hours = Double(startComponents[0])
minutes = Double(startComponents[1])
seconds = Double(startComponents[2])
} else if startComponents.count == 2 {
minutes = Double(startComponents[0])
seconds = Double(startComponents[1])
if startComponents.count == 3 {
hours = Double(startComponents[0])
minutes = Double(startComponents[1])
seconds = Double(startComponents[2])
} else if startComponents.count == 2 {
minutes = Double(startComponents[0])
seconds = Double(startComponents[1])
}
guard var startSeconds = seconds else { return nil }
startSeconds += (minutes ?? 0) * 60
startSeconds += (hours ?? 0) * 60 * 60
return .init(title: titleCapture, start: startSeconds)
}
}
guard var startSeconds = seconds else { return nil }
if let minutes {
startSeconds += 60 * minutes
}
if let hours {
startSeconds += 60 * 60 * hours
}
return .init(title: titleCapture, start: startSeconds)
}
return []
}
}

View File

@@ -25,6 +25,7 @@ struct FavoritesModel {
}
func add(_ item: FavoriteItem) {
if contains(item) { return }
all.append(item)
}
@@ -122,4 +123,12 @@ struct FavoritesModel {
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
}
func updateWidgetSettings(_ settings: WidgetSettings) {
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
widgetsSettings[index] = settings
} else {
widgetsSettings.append(settings)
}
}
}

View File

@@ -0,0 +1,17 @@
import Defaults
import SwiftyJSON
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
"mpvEnableLogging": Defaults[.mpvEnableLogging],
"mpvCacheSecs": Defaults[.mpvCacheSecs],
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
"mpvDeinterlace": Defaults[.mpvDeinterlace],
"showCacheStatus": Defaults[.showCacheStatus],
"feedCacheSize": Defaults[.feedCacheSize]
]
}
}

View File

@@ -0,0 +1,54 @@
import Defaults
import SwiftyJSON
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"showHome": Defaults[.showHome],
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
"showQueueInHome": Defaults[.showQueueInHome],
"showFavoritesInHome": Defaults[.showFavoritesInHome],
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
"startupSection": Defaults[.startupSection].rawValue,
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
"expandChannelDescription": Defaults[.expandChannelDescription],
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
"channelOnThumbnail": Defaults[.channelOnThumbnail],
"timeOnThumbnail": Defaults[.timeOnThumbnail],
"roundedThumbnails": Defaults[.roundedThumbnails],
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
]
}
override var platformJSON: JSON {
var export = JSON()
#if os(iOS)
export["showDocuments"].bool = Defaults[.showDocuments]
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
#endif
#if !os(tvOS)
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
#endif
return export
}
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
var json = JSON()
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
return json
}
}

View File

@@ -0,0 +1,40 @@
import Defaults
import SwiftyJSON
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
"seekGestureSpeed": Defaults[.seekGestureSpeed],
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
]
}
}

View File

@@ -0,0 +1,21 @@
import Defaults
import SwiftyJSON
final class HistorySettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"saveRecents": Defaults[.saveRecents],
"saveHistory": Defaults[.saveHistory],
"showWatchingProgress": Defaults[.showWatchingProgress],
"saveLastPlayed": Defaults[.saveLastPlayed],
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
"watchedThreshold": Defaults[.watchedThreshold],
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
]
}
}

View File

@@ -0,0 +1,56 @@
import Defaults
import SwiftyJSON
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
var includePublicInstances = true
var includeInstances = true
var includeAccounts = true
var includeAccountsUnencryptedPasswords = false
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
self.includePublicInstances = includePublicInstances
self.includeInstances = includeInstances
self.includeAccounts = includeAccounts
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
}
override var globalJSON: JSON {
var json = JSON()
if includePublicInstances {
json["instancesManifest"].string = Defaults[.instancesManifest]
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
}
if includeInstances {
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
}
if includeAccounts {
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
var account = account
let (username, password) = AccountsModel.getCredentials(account)
account.username = username ?? ""
if includeAccountsUnencryptedPasswords {
account.password = password ?? ""
}
return accountJSON(account).dictionaryObject
}
}
return json
}
private func instanceJSON(_ instance: Instance) -> JSON {
var json = JSON()
json.dictionaryObject = InstancesBridge().serialize(instance)
return json
}
private func accountJSON(_ account: Account) -> JSON {
var json = JSON()
json.dictionaryObject = AccountsBridge().serialize(account)
return json
}
}

View File

@@ -0,0 +1,27 @@
import Defaults
import SwiftyJSON
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"lastAccountID": Defaults[.lastAccountID] ?? "",
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
"playerRate": Defaults[.playerRate],
"trendingCategory": Defaults[.trendingCategory].rawValue,
"trendingCountry": Defaults[.trendingCountry].rawValue,
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
"hideShorts": Defaults[.hideShorts],
"hideWatched": Defaults[.hideWatched]
]
}
}

View File

@@ -0,0 +1,44 @@
import Defaults
import SwiftyJSON
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
"expandVideoDescription": Defaults[.expandVideoDescription],
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
"showChapters": Defaults[.showChapters],
"expandChapters": Defaults[.expandChapters],
"showRelated": Defaults[.showRelated],
"showInspector": Defaults[.showInspector].rawValue,
"playerSidebar": Defaults[.playerSidebar].rawValue,
"showKeywords": Defaults[.showKeywords],
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP]
]
}
override var platformJSON: JSON {
var export = JSON()
#if !os(macOS)
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
#endif
#if !os(tvOS)
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
#endif
#if os(iOS)
export["honorSystemOrientationLock"].bool = Defaults[.honorSystemOrientationLock]
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
#endif
return export
}
}

View File

@@ -0,0 +1,21 @@
import Defaults
import SwiftyJSON
final class QualitySettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"batteryCellularProfile": Defaults[.batteryCellularProfile],
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
"chargingCellularProfile": Defaults[.chargingCellularProfile],
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
]
}
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
var json = JSON()
json.dictionaryObject = QualityProfileBridge().serialize(profile)
return json
}
}

View File

@@ -0,0 +1,16 @@
import Defaults
import SwiftyJSON
final class RecentlyOpenedExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
]
}
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
var json = JSON()
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
return json
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
import SwiftyJSON
class SettingsGroupExporter { // swiftlint:disable:this final_class
var globalJSON: JSON {
[]
}
var platformJSON: JSON {
[]
}
var exportJSON: JSON {
var json = globalJSON
if !platformJSON.isEmpty {
try? json.merge(with: platformJSON)
}
return json
}
func jsonFromString(_ string: String?) -> JSON? {
if let data = string?.data(using: .utf8, allowLossyConversion: false),
let json = try? JSON(data: data)
{
return json
}
return nil
}
}

View File

@@ -0,0 +1,11 @@
import Defaults
import SwiftyJSON
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories])
]
}
}

View File

@@ -0,0 +1,193 @@
import Defaults
import Foundation
import SwiftUI
import SwiftyJSON
final class ImportExportSettingsModel: ObservableObject {
static let shared = ImportExportSettingsModel()
static var exportFile: URL {
YatteeApp.settingsExportDirectory
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
}
static var settingsExtension: String {
"yatteesettings"
}
enum ExportGroup: String, Identifiable, CaseIterable {
case browsingSettings
case playerSettings
case controlsSettings
case qualitySettings
case historySettings
case sponsorBlockSettings
case advancedSettings
case locationsSettings
case instances
case accounts
case accountsUnencryptedPasswords
case recentlyOpened
case otherData
static var settingsGroups: [Self] {
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
}
static var locationsGroups: [Self] {
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
}
static var otherGroups: [Self] {
[.recentlyOpened, .otherData]
}
var id: RawValue {
rawValue
}
var label: String {
switch self {
case .browsingSettings:
return "Browsing"
case .playerSettings:
return "Player"
case .controlsSettings:
return "Controls"
case .qualitySettings:
return "Quality"
case .historySettings:
return "History"
case .sponsorBlockSettings:
return "SponsorBlock"
case .locationsSettings:
return "Public Locations"
case .instances:
return "Custom Locations"
case .accounts:
return "Accounts"
case .accountsUnencryptedPasswords:
return "Accounts passwords (unencrypted)"
case .advancedSettings:
return "Advanced"
case .recentlyOpened:
return "Recents"
case .otherData:
return "Other data"
}
}
}
@Published var selectedExportGroups = Set<ExportGroup>()
static var defaultExportGroups = Set<ExportGroup>([
.browsingSettings,
.playerSettings,
.controlsSettings,
.qualitySettings,
.historySettings,
.sponsorBlockSettings,
.locationsSettings,
.instances,
.accounts,
.advancedSettings
])
@Published var isExportInProgress = false
private var navigation = NavigationModel.shared
private var settings = SettingsModel.shared
func toggleExportGroupSelection(_ group: ExportGroup) {
if isGroupSelected(group) {
selectedExportGroups.remove(group)
} else {
selectedExportGroups.insert(group)
}
removeNotEnabledSelectedGroups()
}
func reset() {
isExportInProgress = false
selectedExportGroups = Self.defaultExportGroups
}
func reset(_ model: ImportSettingsFileModel? = nil) {
reset()
guard let model else { return }
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
}
func exportAction() {
DispatchQueue.global(qos: .background).async { [weak self] in
var writingOptions: JSONSerialization.WritingOptions = []
#if DEBUG
writingOptions.insert(.prettyPrinted)
writingOptions.insert(.sortedKeys)
#endif
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
#if os(macOS)
DispatchQueue.main.async { [weak self] in
self?.isExportInProgress = false
}
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
#endif
}
}
private var jsonForExport: JSON? {
[
"metadata": metadataJSON,
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
"locationsSettings": LocationsSettingsGroupExporter(
includePublicInstances: isGroupSelected(.locationsSettings),
includeInstances: isGroupSelected(.instances),
includeAccounts: isGroupSelected(.accounts),
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
).exportJSON,
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
]
}
private var metadataJSON: JSON {
[
"build": YatteeApp.build,
"timestamp": "\(Date().timeIntervalSince1970)",
"platform": Constants.platform
]
}
func isGroupSelected(_ group: ExportGroup) -> Bool {
selectedExportGroups.contains(group)
}
func isGroupEnabled(_ group: ExportGroup) -> Bool {
switch group {
case .accounts:
return selectedExportGroups.contains(.instances)
case .accountsUnencryptedPasswords:
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
default:
return true
}
}
func removeNotEnabledSelectedGroups() {
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
}
var isExportAvailable: Bool {
!selectedExportGroups.isEmpty && !isExportInProgress
}
}

View File

@@ -0,0 +1,135 @@
import Defaults
import Foundation
import SwiftyJSON
struct ImportSettingsFileModel {
let url: URL
var filename: String {
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
}
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
if let locationsSettings = json?.dictionaryValue["locationsSettings"] {
return LocationsSettingsGroupImporter(
json: locationsSettings,
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
includedInstancesIDs: sheetViewModel.selectedInstances,
includedAccountsIDs: sheetViewModel.selectedAccounts,
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
)
}
return nil
}
var importExportModel = ImportExportSettingsModel.shared
var sheetViewModel = ImportSettingsSheetViewModel.shared
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
switch group {
case .locationsSettings:
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
default:
return !groupJSON(group).isEmpty
}
}
var isPublicInstancesSettingsGroupInFile: Bool {
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
}
var instancesOrAccountsInFile: Bool {
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
}
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
json?.dictionaryValue[group.rawValue] ?? .init()
}
func performImport() {
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
}
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
}
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
}
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
}
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
}
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
}
locationsSettingsGroupImporter?.performImport()
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
}
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
}
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
}
}
var json: JSON? {
if let fileContents = try? Data(contentsOf: url),
let json = try? JSON(data: fileContents)
{
return json
}
return nil
}
var metadataBuild: String? {
if let build = json?.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
return build
}
return nil
}
var metadataPlatform: String? {
if let platform = json?.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
return platform
}
return nil
}
var metadataDate: String? {
if let timestamp = json?.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
let date = Date(timeIntervalSince1970: timestamp)
return dateFormatter.string(from: date)
}
return nil
}
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .medium
return formatter
}
}

View File

@@ -0,0 +1,40 @@
import Defaults
import SwiftyJSON
struct AdvancedSettingsGroupImporter {
var json: JSON
func performImport() {
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
}
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
}
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
Defaults[.mpvEnableLogging] = mpvEnableLogging
}
if let mpvCacheSecs = json["mpvCacheSecs"].string {
Defaults[.mpvCacheSecs] = mpvCacheSecs
}
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
}
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
Defaults[.mpvDeinterlace] = mpvDeinterlace
}
if let showCacheStatus = json["showCacheStatus"].bool {
Defaults[.showCacheStatus] = showCacheStatus
}
if let feedCacheSize = json["feedCacheSize"].string {
Defaults[.feedCacheSize] = feedCacheSize
}
}
}

View File

@@ -0,0 +1,144 @@
import Defaults
import SwiftyJSON
struct BrowsingSettingsGroupImporter {
var json: JSON
func performImport() {
if let showHome = json["showHome"].bool {
Defaults[.showHome] = showHome
}
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
}
if let showQueueInHome = json["showQueueInHome"].bool {
Defaults[.showQueueInHome] = showQueueInHome
}
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
Defaults[.showFavoritesInHome] = showFavoritesInHome
}
if let favorites = json["favorites"].array {
favorites.forEach { favoriteJSON in
if let jsonString = favoriteJSON.rawString(options: []),
let item = FavoriteItem.bridge.deserialize(jsonString)
{
FavoritesModel.shared.add(item)
}
}
}
if let widgetsFavorites = json["widgetsSettings"].array {
widgetsFavorites.forEach { widgetJSON in
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = WidgetSettingsBridge().deserialize(dict) {
FavoritesModel.shared.updateWidgetSettings(item)
}
}
}
if let startupSectionString = json["startupSection"].string,
let startupSection = StartupSection(rawValue: startupSectionString)
{
Defaults[.startupSection] = startupSection
}
if let visibleSections = json["visibleSections"].array {
let sections = visibleSections.compactMap { visibleSectionJSON in
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
let section = VisibleSection(rawValue: visibleSectionString)
{
return section
}
return nil
}
Defaults[.visibleSections] = Set(sections)
}
#if os(iOS)
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
}
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
}
#endif
#if !os(tvOS)
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
}
#endif
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
}
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
}
if let expandChannelDescription = json["expandChannelDescription"].bool {
Defaults[.expandChannelDescription] = expandChannelDescription
}
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
}
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
}
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
}
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
{
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
}
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
{
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
}
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
}
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
}
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
}
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
Defaults[.channelOnThumbnail] = channelOnThumbnail
}
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
Defaults[.timeOnThumbnail] = timeOnThumbnail
}
if let roundedThumbnails = json["roundedThumbnails"].bool {
Defaults[.roundedThumbnails] = roundedThumbnails
}
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
{
Defaults[.thumbnailsQuality] = thumbnailsQuality
}
}
}

View File

@@ -0,0 +1,140 @@
import Defaults
import SwiftyJSON
struct ConstrolsSettingsGroupImporter {
var json: JSON
func performImport() {
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
}
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
}
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
}
if let seekGestureSpeed = json["seekGestureSpeed"].double {
Defaults[.seekGestureSpeed] = seekGestureSpeed
}
if let playerControlsLayoutString = json["playerControlsLayout"].string,
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
{
Defaults[.playerControlsLayout] = playerControlsLayout
}
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
{
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
}
if let systemControlsCommandsString = json["systemControlsCommands"].string,
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
{
Defaults[.systemControlsCommands] = systemControlsCommands
}
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
}
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
}
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
}
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
}
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
}
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
}
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
}
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
}
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
}
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
}
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
}
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
{
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
}
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
}
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
}
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
}
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
}
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
}
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
}
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
}
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
}
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
}
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
}
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
}
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
}
}
}

View File

@@ -0,0 +1,54 @@
import Defaults
import SwiftyJSON
struct HistorySettingsGroupImporter {
var json: JSON
func performImport() {
if let saveRecents = json["saveRecents"].bool {
Defaults[.saveRecents] = saveRecents
}
if let saveHistory = json["saveHistory"].bool {
Defaults[.saveHistory] = saveHistory
}
if let showWatchingProgress = json["showWatchingProgress"].bool {
Defaults[.showWatchingProgress] = showWatchingProgress
}
if let saveLastPlayed = json["saveLastPlayed"].bool {
Defaults[.saveLastPlayed] = saveLastPlayed
}
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
{
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
}
if let watchedThreshold = json["watchedThreshold"].int {
Defaults[.watchedThreshold] = watchedThreshold
}
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
}
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
{
Defaults[.watchedVideoStyle] = watchedVideoStyle
}
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
{
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
}
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
}
}
}

View File

@@ -0,0 +1,84 @@
import Defaults
import SwiftyJSON
struct LocationsSettingsGroupImporter {
var json: JSON
var includePublicLocations = true
var includedInstancesIDs = Set<Instance.ID>()
var includedAccountsIDs = Set<Account.ID>()
var includedAccountsPasswords = [Account.ID: String]()
init(
json: JSON,
includePublicLocations: Bool = true,
includedInstancesIDs: Set<Instance.ID> = [],
includedAccountsIDs: Set<Account.ID> = [],
includedAccountsPasswords: [Account.ID: String] = [:]
) {
self.json = json
self.includePublicLocations = includePublicLocations
self.includedInstancesIDs = includedInstancesIDs
self.includedAccountsIDs = includedAccountsIDs
self.includedAccountsPasswords = includedAccountsPasswords
}
var instances: [Instance] {
if let instances = json["instances"].array {
return instances.compactMap { instanceJSON in
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
return InstancesBridge().deserialize(dict)
}
}
return []
}
var accounts: [Account] {
if let accounts = json["accounts"].array {
return accounts.compactMap { accountJSON in
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
return AccountsBridge().deserialize(dict)
}
}
return []
}
func performImport() {
if includePublicLocations {
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
}
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
}
if let accounts = json["accounts"].array {
accounts.forEach { accountJSON in
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
if let account = AccountsBridge().deserialize(dict),
includedAccountsIDs.contains(account.id)
{
var password = account.password
if password?.isEmpty ?? true {
password = includedAccountsPasswords[account.id]
}
if let password,
!password.isEmpty,
let instanceID = account.instanceID,
let instance = InstancesModel.shared.find(instanceID)
{
if !instance.accounts.contains(where: { instanceAccount in
let (username, _) = instanceAccount.credentials
return username == account.username
}) {
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
import Defaults
import SwiftyJSON
struct OtherDataSettingsGroupImporter {
var json: JSON
func performImport() {
if let lastAccountID = json["lastAccountID"].string {
Defaults[.lastAccountID] = lastAccountID
}
if let lastInstanceID = json["lastInstanceID"].string {
Defaults[.lastInstanceID] = lastInstanceID
}
if let playerRate = json["playerRate"].double {
Defaults[.playerRate] = playerRate
}
if let trendingCategoryString = json["trendingCategory"].string,
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
{
Defaults[.trendingCategory] = trendingCategory
}
if let trendingCountryString = json["trendingCountry"].string,
let trendingCountry = Country(rawValue: trendingCountryString)
{
Defaults[.trendingCountry] = trendingCountry
}
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
{
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
}
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
}
if let popularListingStyle = json["popularListingStyle"].string {
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
}
if let trendingListingStyle = json["trendingListingStyle"].string {
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
}
if let playlistListingStyle = json["playlistListingStyle"].string {
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
}
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
}
if let searchListingStyle = json["searchListingStyle"].string {
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
}
if let hideShorts = json["hideShorts"].bool {
Defaults[.hideShorts] = hideShorts
}
if let hideWatched = json["hideWatched"].bool {
Defaults[.hideWatched] = hideWatched
}
}
}

View File

@@ -0,0 +1,100 @@
import Defaults
import SwiftyJSON
struct PlayerSettingsGroupImporter {
var json: JSON
func performImport() {
if let playerInstanceID = json["playerInstanceID"].string {
Defaults[.playerInstanceID] = playerInstanceID
}
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
}
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
}
if let expandVideoDescription = json["expandVideoDescription"].bool {
Defaults[.expandVideoDescription] = expandVideoDescription
}
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
}
if let showChapters = json["showChapters"].bool {
Defaults[.showChapters] = showChapters
}
if let expandChapters = json["expandChapters"].bool {
Defaults[.expandChapters] = expandChapters
}
if let showRelated = json["showRelated"].bool {
Defaults[.showRelated] = showRelated
}
if let showInspectorString = json["showInspector"].string,
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
{
Defaults[.showInspector] = showInspector
}
if let playerSidebarString = json["playerSidebar"].string,
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
{
Defaults[.playerSidebar] = playerSidebar
}
if let showKeywords = json["showKeywords"].bool {
Defaults[.showKeywords] = showKeywords
}
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
}
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
}
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
}
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
}
#if !os(macOS)
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
}
#endif
#if !os(tvOS)
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
}
#endif
#if os(iOS)
if let honorSystemOrientationLock = json["honorSystemOrientationLock"].bool {
Defaults[.honorSystemOrientationLock] = honorSystemOrientationLock
}
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
}
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
{
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
}
#endif
}
}

View File

@@ -0,0 +1,37 @@
import Defaults
import SwiftyJSON
struct QualitySettingsGroupImporter {
var json: JSON
func performImport() {
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
Defaults[.batteryCellularProfile] = batteryCellularProfileString
}
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
}
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
Defaults[.chargingCellularProfile] = chargingCellularProfileString
}
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
}
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
}
if let qualityProfiles = json["qualityProfiles"].array {
qualityProfiles.forEach { qualityProfileJSON in
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = QualityProfileBridge().deserialize(dict) {
QualityProfilesModel.shared.update(item, item)
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
import Defaults
import SwiftyJSON
struct RecentlyOpenedImporter {
var json: JSON
func performImport() {
if let recentlyOpened = json["recentlyOpened"].array {
recentlyOpened.forEach { recentlyOpenedJSON in
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = RecentItemBridge().deserialize(dict) {
RecentsModel.shared.add(item)
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
import Defaults
import SwiftyJSON
struct SponsorBlockSettingsGroupImporter {
var json: JSON
func performImport() {
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
}
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap { $0.string })
}
}
}

View File

@@ -107,6 +107,10 @@ final class NavigationModel: ObservableObject {
@Published var presentingFileImporter = false
@Published var presentingSettingsImportSheet = false
@Published var presentingSettingsFileImporter = false
@Published var settingsImportURL: URL?
func openChannel(_ channel: Channel, navigationStyle: NavigationStyle) {
guard channel.id != Video.fixtureChannelID else {
return
@@ -269,6 +273,8 @@ final class NavigationModel: ObservableObject {
presentingChannel = false
presentingPlaylist = false
presentingOpenVideos = false
presentingFileImporter = false
presentingSettingsImportSheet = false
}
func hideKeyboard() {
@@ -279,8 +285,9 @@ final class NavigationModel: ObservableObject {
func presentAlert(title: String, message: String? = nil) {
let message = message.isNil ? nil : Text(message!)
alert = Alert(title: Text(title), message: message)
presentingAlert = true
let alert = Alert(title: Text(title), message: message)
presentAlert(alert)
}
func presentRequestErrorAlert(_ error: RequestError) {
@@ -289,6 +296,11 @@ final class NavigationModel: ObservableObject {
}
func presentAlert(_ alert: Alert) {
guard !presentingSettings else {
SettingsModel.shared.presentAlert(alert)
return
}
self.alert = alert
presentingAlert = true
}
@@ -311,6 +323,16 @@ final class NavigationModel: ObservableObject {
print("not implemented")
}
}
func presentSettingsImportSheet(_ url: URL, forceSettings: Bool = false) {
guard !presentingSettings, !forceSettings else {
ImportExportSettingsModel.shared.reset()
SettingsModel.shared.presentSettingsImportSheet(url)
return
}
settingsImportURL = url
presentingSettingsImportSheet = true
}
}
typealias TabSelection = NavigationModel.TabSelection

View File

@@ -596,6 +596,8 @@ final class AVPlayerBackend: PlayerBackend {
if self.controlsUpdates {
self.updateControls()
}
self.model.updateTime(self.currentTime!)
}
}
@@ -666,6 +668,10 @@ final class AVPlayerBackend: PlayerBackend {
} else {
ScreenSaverManager.shared.enable()
}
#else
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
}
#endif
self.timeObserverThrottle.execute {

View File

@@ -182,13 +182,21 @@ final class MPVBackend: PlayerBackend {
}
init() {
// swiftlint:disable shorthand_optional_binding
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
self?.getTimeUpdates()
guard let self = self, self.model.activeBackend == .mpv else {
return
}
self.getTimeUpdates()
}
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
self?.updateNetworkState()
guard let self = self, self.model.activeBackend == .mpv else {
return
}
self.updateNetworkState()
}
// swiftlint:enable shorthand_optional_binding
}
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
@@ -432,6 +440,8 @@ final class MPVBackend: PlayerBackend {
timeObserverThrottle.execute {
self.model.updateWatch(time: self.currentTime)
}
self.model.updateTime(self.currentTime!)
}
private func stopClientUpdates() {

View File

@@ -67,6 +67,7 @@ final class MPVClient: ObservableObject {
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
checkError(mpv_initialize(mpv))

View File

@@ -131,6 +131,8 @@ final class PlayerModel: ObservableObject {
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
#endif
@Published var currentChapterIndex: Int?
var accounts: AccountsModel { .shared }
var comments: CommentsModel { .shared }
var controls: PlayerControlsModel { .shared }
@@ -1112,4 +1114,36 @@ final class PlayerModel: ObservableObject {
onPlayStream.forEach { $0(stream) }
onPlayStream.removeAll()
}
func updateTime(_ cmTime: CMTime) {
let time = CMTimeGetSeconds(cmTime)
let newChapterIndex = chapterForTime(time)
if currentChapterIndex != newChapterIndex {
DispatchQueue.main.async {
self.currentChapterIndex = newChapterIndex
}
}
}
private func chapterForTime(_ time: Double) -> Int? {
guard let chapters = self.videoForDisplay?.chapters else {
return nil
}
for (index, chapter) in chapters.enumerated() {
let nextChapterStartTime = index < (chapters.count - 1) ? chapters[index + 1].start : nil
if let nextChapterStart = nextChapterStartTime {
if time >= chapter.start, time < nextChapterStart {
return index
}
} else {
if time >= chapter.start {
return index
}
}
}
return nil
}
}

View File

@@ -105,6 +105,7 @@ extension PlayerModel {
func playerAPI(_ video: Video) -> VideosAPI? {
guard let url = video.instanceURL else { return accounts.api }
if accounts.current?.url == url { return accounts.api }
switch video.app {
case .local:
return nil

View File

@@ -18,6 +18,12 @@ final class SearchModel: ObservableObject {
@Published var focused = false
#if os(iOS)
var textField: UITextField!
#elseif os(macOS)
var textField: NSTextField!
#endif
var accounts: AccountsModel { .shared }
private var resource: Resource!

View File

@@ -7,6 +7,9 @@ final class SettingsModel: ObservableObject {
@Published var presentingAlert = false
@Published var alert = Alert(title: Text("Error"))
@Published var presentingSettingsImportSheet = false
@Published var settingsImportURL: URL?
func presentAlert(title: String, message: String? = nil) {
let message = message.isNil ? nil : Text(message!)
alert = Alert(title: Text(title), message: message)
@@ -17,4 +20,9 @@ final class SettingsModel: ObservableObject {
self.alert = alert
presentingAlert = true
}
func presentSettingsImportSheet(_ url: URL) {
settingsImportURL = url
presentingSettingsImportSheet = true
}
}

View File

@@ -290,8 +290,8 @@ struct Video: Identifiable, Equatable, Hashable {
}
var localStreamIsFile: Bool {
guard let localStream else { return false }
return localStream.localURL.isFileURL
guard let url = localStream?.localURL else { return false }
return url.isFileURL
}
var localStreamIsRemoteURL: Bool {

View File

@@ -40,7 +40,7 @@ struct ChannelVideosView: View {
}
var body: some View {
VStack {
let content = VStack {
#if os(tvOS)
VStack {
HStack(spacing: 24) {
@@ -181,12 +181,19 @@ struct ChannelVideosView: View {
.navigationTitle(navigationTitle)
#endif
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#endif
#if !os(iOS)
.focusScope(focusNamespace)
#endif
return Group {
if #available(macOS 12.0, *) {
content
#if os(tvOS)
.background(Color.background(scheme: colorScheme))
#endif
#if !os(iOS)
.focusScope(focusNamespace)
#endif
} else {
content
}
}
}
var verticalCellsEdgesIgnoringSafeArea: Edge.Set {
@@ -357,6 +364,7 @@ struct ChannelVideosView: View {
}
} label: {
Label("Unsubscribe", systemImage: "xmark.circle")
.help("Unsubscribe")
#if os(iOS)
.labelStyle(.automatic)
#else
@@ -373,6 +381,7 @@ struct ChannelVideosView: View {
}
} label: {
Label("Subscribe", systemImage: "circle")
.help("Subscribe")
#if os(iOS)
.labelStyle(.automatic)
#else
@@ -406,6 +415,7 @@ struct ChannelVideosView: View {
feed.markChannelAsWatched(channel.id)
} label: {
Label("Mark channel feed as watched", systemImage: "checkmark.circle.fill")
.help("Mark channel feed as watched")
}
.disabled(!feed.canMarkAllFeedAsWatched)
}
@@ -416,6 +426,7 @@ struct ChannelVideosView: View {
feed.markChannelAsUnwatched(channel.id)
} label: {
Label("Mark channel feed as unwatched", systemImage: "checkmark.circle")
.help("Mark channel feed as unwatched")
}
}

View File

@@ -2,7 +2,7 @@ import Defaults
import Foundation
import SwiftUI
struct Constants {
enum Constants {
static let yatteeProtocol = "yattee://"
static let overlayAnimation = Animation.linear(duration: 0.2)
static var isIPhone: Bool {
@@ -61,11 +61,38 @@ struct Constants {
#endif
}
static var deviceName: String {
#if os(macOS)
Host().localizedName ?? "Mac"
#else
UIDevice.current.name
#endif
}
static var platform: String {
#if os(macOS)
"macOS"
#elseif os(iOS)
"iOS"
#elseif os(tvOS)
"tvOS"
#else
"unknown"
#endif
}
static func seekIcon(_ type: String, _ interval: TimeInterval) -> String {
let interval = Int(interval)
let allVersions = [5, 10, 15, 30, 45, 60, 75, 90]
let allVersions = [10, 15, 30, 45, 60, 75, 90]
let iOS15 = [5]
let iconName = "go\(type).\(interval)"
if #available(iOS 15, macOS 12, *) {
if iOS15.contains(interval) {
return iconName
}
}
if allVersions.contains(interval) {
return iconName
}

View File

@@ -6,37 +6,22 @@ import SwiftUI
#endif
extension Defaults.Keys {
static let instancesManifest = Key<String>("instancesManifest", default: "")
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
static let instances = Key<[Instance]>("instances", default: [])
static let accounts = Key<[Account]>("accounts", default: [])
static let lastAccountID = Key<Account.ID?>("lastAccountID")
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
// MARK: GROUP - Browsing
static let showHome = Key<Bool>("showHome", default: true)
static let showOpenActionsInHome = Key<Bool>("showOpenActionsInHome", default: true)
static let showQueueInHome = Key<Bool>("showQueueInHome", default: true)
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
static let startupSection = Key<StartupSection>("startupSection", default: .home)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
#if os(iOS)
static let showDocuments = Key<Bool>("showDocuments", default: false)
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
#endif
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .nothing)
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: false)
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: false)
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
#if !os(tvOS)
#if os(macOS)
@@ -46,21 +31,139 @@ extension Defaults.Keys {
#endif
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: accountPickerDisplaysUsernameDefault)
#endif
static let accountPickerDisplaysAnonymousAccounts = Key<Bool>("accountPickerDisplaysAnonymousAccounts", default: true)
#if os(iOS)
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 keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
static let showChannelAvatarInChannelsLists = Key<Bool>("showChannelAvatarInChannelsLists", default: true)
static let showChannelAvatarInVideosListing = Key<Bool>("showChannelAvatarInVideosListing", default: true)
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .nothing)
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: false)
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: false)
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
static let thumbnailsQuality = Key<ThumbnailsQuality>("thumbnailsQuality", default: .highest)
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
// MARK: GROUP - Player
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
#if os(tvOS)
static let pauseOnHidingPlayerDefault = true
#else
static let pauseOnHidingPlayerDefault = false
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
#if !os(macOS)
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", 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)
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
static let showChapters = Key<Bool>("showChapters", default: true)
static let expandChapters = Key<Bool>("expandChapters", default: true)
static let showRelated = Key<Bool>("showRelated", default: true)
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
static let showKeywords = Key<Bool>("showKeywords", default: false)
#if !os(tvOS)
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
#endif
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
"rotateToLandscapeOnEnterFullScreen",
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
)
#endif
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
#if !os(macOS)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
// MARK: GROUP - Controls
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
#if os(iOS)
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
#elseif os(tvOS)
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
#else
static let playerControlsLayoutDefault = PlayerControlsLayout.medium
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.medium
#endif
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
static let buttonBackwardSeekDuration = Key<String>("buttonBackwardSeekDuration", default: "10")
static let buttonForwardSeekDuration = Key<String>("buttonForwardSeekDuration", default: "10")
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
#if os(iOS)
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
#endif
#if os(tvOS)
static let playerControlsSettingsEnabledDefault = true
#else
static let playerControlsSettingsEnabledDefault = false
#endif
static let playerControlsSettingsEnabled = Key<Bool>("playerControlsSettingsEnabled", default: playerControlsSettingsEnabledDefault)
static let playerControlsCloseEnabled = Key<Bool>("playerControlsCloseEnabled", default: true)
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 playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
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 actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", 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)
// MARK: GROUP - Quality
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
@@ -109,149 +212,67 @@ extension Defaults.Keys {
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
#endif
static let playerRate = Key<Double>("playerRate", default: 1.0)
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: qualityProfilesDefault)
static let batteryCellularProfile = Key<QualityProfile.ID>("batteryCellularProfile", default: batteryCellularProfileDefault)
static let batteryNonCellularProfile = Key<QualityProfile.ID>("batteryNonCellularProfile", default: batteryNonCellularProfileDefault)
static let chargingCellularProfile = Key<QualityProfile.ID>("chargingCellularProfile", default: chargingCellularProfileDefault)
static let chargingNonCellularProfile = Key<QualityProfile.ID>("chargingNonCellularProfile", default: chargingNonCellularProfileDefault)
static let forceAVPlayerForLiveStreams = Key<Bool>("forceAVPlayerForLiveStreams", default: true)
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
#if os(iOS)
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
#elseif os(tvOS)
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
#else
static let playerControlsLayoutDefault = PlayerControlsLayout.medium
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.medium
#endif
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: qualityProfilesDefault)
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 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)
static let showChannelAvatarInChannelsLists = Key<Bool>("showChannelAvatarInChannelsLists", default: true)
static let showChannelAvatarInVideosListing = Key<Bool>("showChannelAvatarInVideosListing", default: true)
#if os(tvOS)
static let pauseOnHidingPlayerDefault = true
#else
static let pauseOnHidingPlayerDefault = false
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
#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)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
// MARK: GROUP - History
static let saveRecents = Key<Bool>("saveRecents", default: true)
static let saveHistory = Key<Bool>("saveHistory", default: true)
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
static let saveRecents = Key<Bool>("saveRecents", default: true)
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
// MARK: GROUP - SponsorBlock
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
static let startupSection = Key<StartupSection>("startupSection", default: .home)
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
"rotateToLandscapeOnEnterFullScreen",
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
)
#endif
// MARK: GROUP - Locations
static let instancesManifest = Key<String>("instancesManifest", default: "")
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
static let instances = Key<[Instance]>("instances", default: [])
static let accounts = Key<[Account]>("accounts", default: [])
// MARK: Group - Advanced
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
#if os(macOS)
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
#else
static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? ButtonLabelStyle.iconOnly : .iconAndText
#endif
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
static let buttonBackwardSeekDuration = Key<String>("buttonBackwardSeekDuration", default: "10")
static let buttonForwardSeekDuration = Key<String>("buttonForwardSeekDuration", default: "10")
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
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 actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", 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)
#endif
#if os(tvOS)
static let playerControlsSettingsEnabledDefault = true
#else
static let playerControlsSettingsEnabledDefault = false
#endif
static let playerControlsSettingsEnabled = Key<Bool>("playerControlsSettingsEnabled", default: playerControlsSettingsEnabledDefault)
static let playerControlsCloseEnabled = Key<Bool>("playerControlsCloseEnabled", default: true)
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 playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
static let mpvDeinterlace = Key<Bool>("mpvDeinterlace", default: false)
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
// MARK: GROUP - Other exportable
static let lastAccountID = Key<Account.ID?>("lastAccountID")
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
static let playerRate = Key<Double>("playerRate", default: 1.0)
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
static let subscriptionsViewPage = Key<SubscriptionsView.Page>("subscriptionsViewPage", default: .feed)
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
@@ -262,8 +283,22 @@ extension Defaults.Keys {
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
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: [])
// MARK: GROUP - Not exportable
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
// MARK: LEGACY
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@@ -397,6 +432,15 @@ enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
var text: Bool {
self == .iconAndText
}
var description: String {
switch self {
case .iconOnly:
return "Icon only"
case .iconAndText:
return "Icon and text"
}
}
}
enum ThumbnailsQuality: String, CaseIterable, Defaults.Serializable {

View File

@@ -1,6 +1,6 @@
import Foundation
struct Delay {
enum Delay {
@discardableResult static func by(_ interval: TimeInterval, block: @escaping () -> Void) -> Timer {
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in block() }
}

View File

@@ -34,7 +34,8 @@ struct DocumentsView: View {
}
.navigationTitle(directoryLabel)
.padding(.horizontal)
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
.backport
.refreshable {
DispatchQueue.main.async {
self.refresh()

View File

@@ -22,7 +22,6 @@ struct HomeView: View {
@Default(.favorites) private var favorites
@Default(.widgetsSettings) private var widgetsSettings
#endif
@Default(.homeHistoryItems) private var homeHistoryItems
@Default(.showFavoritesInHome) private var showFavoritesInHome
@Default(.showOpenActionsInHome) private var showOpenActionsInHome
@Default(.showQueueInHome) private var showQueueInHome
@@ -116,14 +115,14 @@ struct HomeView: View {
#endif
}
.onAppear {
Defaults.observe(.favorites) { _ in
favoritesChanged.toggle()
Task {
for await _ in Defaults.updates(.favorites) {
favoritesChanged.toggle()
}
for await _ in Defaults.updates(.widgetsSettings) {
favoritesChanged.toggle()
}
}
.tieToLifetime(of: accounts)
Defaults.observe(.widgetsSettings) { _ in
favoritesChanged.toggle()
}
.tieToLifetime(of: accounts)
}
.redrawOn(change: favoritesChanged)

View File

@@ -15,10 +15,14 @@ struct AccountViewButton: View {
} label: {
HStack(spacing: 6) {
if !accountPickerDisplaysUsername || !(model.current?.isPublic ?? true) {
if let name = model.current?.app?.rawValue.capitalized {
Image(name)
.resizable()
.frame(width: accountImageSize, height: accountImageSize)
if #available(iOS 15, macOS 12, *) {
if let name = model.current?.app?.rawValue.capitalized {
Image(name)
.resizable()
.frame(width: accountImageSize, height: accountImageSize)
} else {
Image(systemName: "globe")
}
} else {
Image(systemName: "globe")
}
@@ -26,7 +30,6 @@ struct AccountViewButton: View {
if accountPickerDisplaysUsername {
label
.labelStyle(.titleOnly)
}
}
}
@@ -43,6 +46,6 @@ struct AccountViewButton: View {
}
private var label: some View {
Label(model.current?.description ?? "Select Account", systemImage: "globe")
Text(model.current?.description ?? "Select Account")
}
}

View File

@@ -1,7 +1,7 @@
import Defaults
import SwiftUI
#if os(iOS)
import Introspect
import SwiftUIIntrospect
#endif
struct AppSidebarNavigation: View {
@@ -15,7 +15,7 @@ struct AppSidebarNavigation: View {
var body: some View {
#if os(iOS)
content.introspectViewController { viewController in
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17)) { viewController in
// workaround for an empty supplementary view on launch
// the supplementary view is determined by the default selection inside the
// primary view, but the primary view is not loaded so its selection is not read

View File

@@ -38,6 +38,7 @@ struct AppSidebarPlaylists: View {
if accounts.app.userPlaylistsEndpointIncludesVideos, !playlist.videos.isEmpty {
label
.backport
.badge(Text("\(playlist.videos.count)"))
} else {
label

View File

@@ -33,6 +33,7 @@ struct AppSidebarSubscriptions: View {
Spacer()
}
.lineLimit(1)
.backport
.badge(showUnwatchedFeedBadges ? feedCount.unwatchedByChannelText(channel) : nil)
}
.contextMenu {

View File

@@ -95,6 +95,7 @@ struct AppTabNavigation: View {
.accessibility(label: Text("Subscriptions"))
}
.tag(TabSelection.subscriptions)
.backport
.badge(showUnwatchedFeedBadges ? feedCount.unwatchedText : nil)
}

View File

@@ -68,6 +68,7 @@ struct ContentView: View {
SettingsView()
}
)
.modifier(ImportSettingsSheetViewModifier(isPresented: $navigation.presentingSettingsImportSheet, settingsFile: $navigation.settingsImportURL))
.background(
EmptyView().sheet(isPresented: $navigation.presentingAccounts) {
AccountsView()
@@ -167,6 +168,9 @@ struct ContentView: View {
#if os(iOS)
.statusBarHidden(player.playingFullScreen)
#endif
#if os(macOS)
.frame(minWidth: 1200)
#endif
}
@ViewBuilder var videoPlayer: some View {

View File

@@ -35,7 +35,9 @@ struct Sidebar: View {
}
}
.onChange(of: navigation.sidebarSectionChanged) { _ in
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
if let tabSelection = navigation.tabSelection {
scrollScrollViewToItem(scrollView: scrollView, for: tabSelection)
}
}
.listStyle(.sidebar)
}
@@ -79,6 +81,7 @@ struct Sidebar: View {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))
}
.backport
.badge(showUnwatchedFeedBadges ? feedCount.unwatchedText : nil)
.contextMenu {
playUnwatchedButton

View File

@@ -14,6 +14,11 @@ struct OpenURLHandler {
var navigationStyle: NavigationStyle
func handle(_ url: URL) {
if url.isFileURL, url.standardizedFileURL.absoluteString.hasSuffix(".\(ImportExportSettingsModel.settingsExtension)") {
navigation.presentSettingsImportSheet(url)
return
}
if Self.firstHandle {
Self.firstHandle = false

View File

@@ -47,7 +47,7 @@ final class AppleAVPlayerViewController: UIViewController {
infoViewControllers.append(infoViewController([.chapters], title: "Chapters"))
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
let queueSections = [NowPlayingView.ViewSection.playingNext]
var queueSections = [NowPlayingView.ViewSection.playingNext]
infoViewControllers.append(contentsOf: [
infoViewController([.related], title: "Related"),

View File

@@ -7,8 +7,19 @@ struct ControlBackgroundModifier: ViewModifier {
func body(content: Content) -> some View {
if enabled {
content
if #available(iOS 15, macOS 12, *) {
content
.background(.thinMaterial)
} else {
content
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(edgesIgnoringSafeArea))
#else
.background(.thinMaterial)
#endif
}
} else {
content
}

View File

@@ -255,6 +255,8 @@ struct PlayerControls: View {
{
ThumbnailView(url: url)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity)
.animation(.default)
} else if player.videoForDisplay == nil {
Color.black
}

View File

@@ -18,9 +18,8 @@ struct RelatedView: View {
Color.clear.padding(.bottom, 50)
.listRowBackground(Color.clear)
#if os(iOS)
.listRowSeparator(.hidden)
#endif
.backport
.listRowSeparator(false)
}
}
}

View File

@@ -2,28 +2,85 @@ import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct ChapterView: View {
var chapter: Chapter
#if !os(tvOS)
struct ChapterView: View {
var chapter: Chapter
var player = PlayerModel.shared
var chapterIndex: Int
@ObservedObject private var player = PlayerModel.shared
var body: some View {
Button {
player.backend.seek(to: chapter.start, seekType: .userInteracted)
} label: {
Group {
#if os(tvOS)
horizontalChapter
#else
verticalChapter
#endif
}
.contentShape(Rectangle())
var isCurrentChapter: Bool {
player.currentChapterIndex == chapterIndex
}
var body: some View {
Button(action: {
player.backend.seek(to: chapter.start, seekType: .userInteracted)
}) {
Group {
verticalChapter
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
var verticalChapter: some View {
VStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.lineLimit(3)
.multilineTextAlignment(.leading)
.font(.headline)
.foregroundColor(isCurrentChapter ? Color("AppRedColor") : .primary)
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
.frame(maxWidth: !chapter.image.isNil ? Self.thumbnailWidth : nil, alignment: .leading)
}
}
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image, options: [.lowPriority])
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
.mask(RoundedRectangle(cornerRadius: 6))
}
static var thumbnailWidth: Double {
250
}
static var thumbnailHeight: Double {
thumbnailWidth / 1.7777
}
.buttonStyle(.plain)
}
#if os(tvOS)
#else
struct ChapterViewTVOS: View {
var chapter: Chapter
var player = PlayerModel.shared
var body: some View {
Button {
player.backend.seek(to: chapter.start, seekType: .userInteracted)
} label: {
Group {
horizontalChapter
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
var horizontalChapter: some View {
HStack(spacing: 12) {
@@ -41,53 +98,36 @@ struct ChapterView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
var verticalChapter: some View {
VStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image, options: [.lowPriority])
.resizable()
.placeholder {
ProgressView()
}
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)
}
.indicator(.activity)
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
.mask(RoundedRectangle(cornerRadius: 12))
}
#endif
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image, options: [.lowPriority])
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
#if os(tvOS)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
static var thumbnailWidth: Double {
250
}
static var thumbnailWidth: Double {
250
static var thumbnailHeight: Double {
thumbnailWidth / 1.7777
}
}
static var thumbnailHeight: Double {
thumbnailWidth / 1.7777
}
}
#endif
struct ChapterView_Preview: PreviewProvider {
static var previews: some View {
ChapterView(chapter: .init(title: "Chapter", start: 30))
.injectFixtureEnvironmentObjects()
#if os(tvOS)
ChapterViewTVOS(chapter: .init(title: "Chapter", start: 30))
.injectFixtureEnvironmentObjects()
#else
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0)
.injectFixtureEnvironmentObjects()
#endif
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct ChaptersView: View {
@ObservedObject private var player = PlayerModel.shared
@Binding var expand: Bool
var chapters: [Chapter] {
player.videoForDisplay?.chapters ?? []
@@ -15,45 +16,71 @@ struct ChaptersView: View {
var body: some View {
if !chapters.isEmpty {
#if os(tvOS)
List {
Section {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
}
}
.listRowBackground(Color.clear)
}
.listStyle(.plain)
#else
if chaptersHaveImages {
ScrollView(.horizontal) {
LazyHStack(spacing: 20) {
if chaptersHaveImages {
#if os(tvOS)
List {
Section {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
ChapterViewTVOS(chapter: chapter)
}
}
.padding(.horizontal, 15)
.listRowBackground(Color.clear)
}
.frame(minHeight: ChapterView.thumbnailHeight + 100)
} else {
.listStyle(.plain)
#else
ScrollView(.horizontal) {
LazyHStack(spacing: 20) { chapterViews(for: chapters[...]) }.padding(.horizontal, 15)
}
#endif
} else if expand {
#if os(tvOS)
Section {
ForEach(chapters) { chapter in
ChapterView(chapter: chapter)
ChapterViewTVOS(chapter: chapter)
}
}
.padding(.horizontal)
}
#endif
} else {
NoCommentsView(text: "No chapters information available".localized(), systemImage: "xmark.circle.fill")
#else
Section { chapterViews(for: chapters[...]) }.padding(.horizontal)
#endif
} else {
#if os(iOS)
Button(action: {
self.expand.toggle()
}) {
Section {
chapterViews(for: chapters.prefix(3), opacity: 0.3, clickable: false)
}.padding(.horizontal)
}
#elseif os(macOS)
Section {
chapterViews(for: chapters.prefix(3), opacity: 0.3, clickable: false)
}.padding(.horizontal)
#else
Section {
ForEach(chapters) { chapter in
ChapterViewTVOS(chapter: chapter)
}
}
#endif
}
}
}
#if !os(tvOS)
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true) -> some View {
ForEach(Array(chaptersToShow.indices), id: \.self) { index in
let chapter = chaptersToShow[index]
ChapterView(chapter: chapter, chapterIndex: index)
.opacity(index == 0 ? 1.0 : opacity)
.allowsHitTesting(clickable)
}
}
#endif
}
struct ChaptersView_Previews: PreviewProvider {
static var previews: some View {
ChaptersView()
ChaptersView(expand: .constant(false))
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -100,7 +100,7 @@ struct CommentView: View {
}
private var authorAvatar: some View {
WebImage(url: URL(string: comment.authorAvatarURL)!, options: [.lowPriority])
WebImage(url: URL(string: comment.authorAvatarURL), options: [.lowPriority])
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
@@ -219,17 +219,25 @@ struct CommentView: View {
}
private var commentText: some View {
Text(comment.text)
#if !os(tvOS)
.textSelection(.enabled)
#endif
#if os(macOS)
.font(.system(size: 14))
#elseif os(iOS)
.font(.system(size: 15))
#endif
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
Group {
let text = Text(comment.text)
#if os(macOS)
.font(.system(size: 14))
#elseif os(iOS)
.font(.system(size: 15))
#endif
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
if #available(iOS 15.0, macOS 12.0, *) {
text
#if !os(tvOS)
.textSelection(.enabled)
#endif
} else {
text
}
}
}
private func openChannelAction() {

View File

@@ -32,8 +32,13 @@ struct CommentsView: View {
struct CommentsView_Previews: PreviewProvider {
static var previews: some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
CommentsView()
.previewInterfaceOrientation(.landscapeRight)
.injectFixtureEnvironmentObjects()
}
CommentsView()
.previewInterfaceOrientation(.landscapeRight)
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -79,10 +79,15 @@ struct InspectorView: View {
Text(detail.localized())
.foregroundColor(.secondary)
Spacer()
Text(value).lineLimit(1)
#if !os(tvOS)
let value = Text(value).lineLimit(1)
if #available(iOS 15.0, macOS 12.0, *) {
value
#if !os(tvOS)
.textSelection(.enabled)
#endif
#endif
} else {
value
}
}
.font(.caption)
}

View File

@@ -30,9 +30,8 @@ struct PlayerQueueView: View {
#endif
Color.clear.padding(.bottom, 50)
.listRowBackground(Color.clear)
#if os(iOS)
.listRowSeparator(.hidden)
#endif
.backport
.listRowSeparator(false)
}
.environment(\.inNavigationView, false)
}

View File

@@ -6,11 +6,10 @@ import Foundation
import SwiftUI
struct VideoDescription: View {
static let collapsedLines = 5
private var search: SearchModel { .shared }
@Default(.showKeywords) private var showKeywords
@Default(.expandVideoDescription) private var expandVideoDescription
@Default(.collapsedLinesDescription) private var collapsedLinesDescription
var video: Video
var detailsSize: CGSize?
@@ -21,56 +20,90 @@ struct VideoDescription: View {
}
var body: some View {
Group {
if !expandVideoDescription && !expand {
Button {
expand = true
} label: {
descriptionView
}
.buttonStyle(.plain)
} else {
descriptionView
descriptionView.id(video.videoID)
}
@ViewBuilder var descriptionView: some View {
if !expand && collapsedLinesDescription == 0 {
EmptyView()
} else {
VStack {
#if os(iOS)
ActiveLabelDescriptionRepresentable(
description: description,
detailsSize: detailsSize,
expand: expand
)
#else
textDescription
#endif
keywords
}
.contentShape(Rectangle())
.overlay(
Group {
#if canImport(UIKit)
if !expand {
Button(action: { expand.toggle() }) {
Rectangle()
.foregroundColor(.clear)
}
}
#endif
}
)
}
.id(video.videoID)
}
var descriptionView: some View {
VStack {
#if os(iOS)
ActiveLabelDescriptionRepresentable(
description: description,
detailsSize: detailsSize,
expand: shouldExpand
)
#else
textDescription
#endif
keywords
}
.contentShape(Rectangle())
}
var shouldExpand: Bool {
expandVideoDescription || expand
}
@ViewBuilder var textDescription: some View {
#if !os(iOS)
Text(description)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(shouldExpand ? 500 : Self.collapsedLines)
#if !os(tvOS)
.textSelection(.enabled)
#endif
.multilineTextAlignment(.leading)
.font(.system(size: 14))
.lineSpacing(3)
#if canImport(AppKit)
Group {
if #available(macOS 12, *) {
DescriptionWithLinks(description: description, detailsSize: detailsSize)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(expand ? 500 : collapsedLinesDescription)
.textSelection(.enabled)
} else {
Text(description)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(expand ? 500 : collapsedLinesDescription)
}
}
.multilineTextAlignment(.leading)
.font(.system(size: 14))
.lineSpacing(3)
.allowsHitTesting(expand)
#endif
}
// If possibe convert URLs to clickable links
#if canImport(AppKit)
@available(macOS 12, *)
struct DescriptionWithLinks: View {
let description: String
let detailsSize: CGSize?
let separators = CharacterSet(charactersIn: " \n")
var formattedString: AttributedString {
var attrString = AttributedString(description)
let words = description.unicodeScalars.split(whereSeparator: separators.contains).map(String.init)
words.forEach { word in
if word.hasPrefix("https://") || word.hasPrefix("http://"), let url = URL(string: String(word)) {
if let range = attrString.range(of: word) {
attrString[range].link = url
}
}
}
return attrString
}
var body: some View {
Text(formattedString)
}
}
#endif
@ViewBuilder var keywords: some View {
if showKeywords {
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
@@ -101,7 +134,7 @@ struct VideoDescription: View {
}
var showScrollIndicators: Bool {
#if os(macOS)
#if canImport(AppKit)
false
#else
true
@@ -119,6 +152,8 @@ struct VideoDescription: View {
@Environment(\.openURL) private var openURL
@Default(.collapsedLinesDescription) private var collapsedLinesDescription
var player = PlayerModel.shared
func makeUIView(context _: Context) -> some UIView {
@@ -152,7 +187,12 @@ struct VideoDescription: View {
}
func updateNumberOfLines() {
label.numberOfLines = expand ? 0 : VideoDescription.collapsedLines
if expand || collapsedLinesDescription > 0 {
label.numberOfLines = expand ? 0 : collapsedLinesDescription
label.isHidden = false
} else {
label.isHidden = true
}
}
func urlTapHandler(_ url: URL) {

View File

@@ -169,6 +169,7 @@ struct VideoDetails: View {
@State private var subscriptionToggleButtonDisabled = false
@State private var page = DetailsPage.info
@State private var descriptionExpanded = false
@State private var chaptersExpanded = false
@Environment(\.navigationStyle) private var navigationStyle
#if os(iOS)
@@ -184,10 +185,13 @@ struct VideoDetails: View {
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
@Default(.playerSidebar) private var playerSidebar
@Default(.showInspector) private var showInspector
@Default(.showChapters) private var showChapters
@Default(.showRelated) private var showRelated
#if !os(tvOS)
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
#endif
@Default(.expandVideoDescription) private var expandVideoDescription
@Default(.expandChapters) private var expandChapters
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -241,6 +245,10 @@ struct VideoDetails: View {
}
})
.background(colorScheme == .dark ? Color.black : .white)
.onAppear {
descriptionExpanded = expandVideoDescription
chaptersExpanded = expandChapters
}
}
#if os(iOS)
@@ -309,29 +317,29 @@ struct VideoDetails: View {
.padding(.horizontal)
}
if player.videoBeingOpened.isNil,
!video.isLocal,
!video.chapters.isEmpty
{
Section(header: chaptersHeader) {
ChaptersView()
if player.videoBeingOpened.isNil {
if showChapters,
!video.isLocal,
!video.chapters.isEmpty
{
Section(header: chaptersHeader) {
ChaptersView(expand: $chaptersExpanded)
}
}
}
if player.videoBeingOpened.isNil,
video.isLocal || showInspector == .always
{
InspectorView(video: player.videoForDisplay)
.padding(.horizontal)
}
if showInspector == .always || video.isLocal {
InspectorView(video: player.videoForDisplay)
.padding(.horizontal)
}
if player.videoBeingOpened.isNil,
!sidebarQueue,
!(player.videoForDisplay?.related.isEmpty ?? true)
{
RelatedView()
.padding(.horizontal)
.padding(.top, 20)
if showRelated,
!sidebarQueue,
!(player.videoForDisplay?.related.isEmpty ?? true)
{
RelatedView()
.padding(.horizontal)
.padding(.top, 20)
}
}
}
}
@@ -405,25 +413,78 @@ struct VideoDetails: View {
}
var descriptionHeader: some View {
HStack {
Text("Description".localized())
if !expandVideoDescription, !descriptionExpanded {
Spacer()
Image(systemName: "arrow.up.and.down")
.imageScale(.small)
#if canImport(UIKit)
Button(action: {
descriptionExpanded.toggle()
}) {
HStack {
Text("Description".localized())
Spacer()
Image(systemName: descriptionExpanded ? "chevron.up" : "chevron.down")
.imageScale(.small)
}
.padding(.horizontal)
.font(.caption)
.foregroundColor(.secondary)
}
#elseif canImport(AppKit)
HStack {
Text("Description".localized())
Spacer()
Button { descriptionExpanded.toggle()
} label: {
Image(systemName: descriptionExpanded ? "chevron.up" : "chevron.down")
.imageScale(.small)
}
}
}
.padding(.horizontal)
.font(.caption)
.foregroundColor(.secondary)
}
var chaptersHeader: some View {
Text("Chapters".localized())
.padding(.horizontal)
.font(.caption)
.foregroundColor(.secondary)
#endif
}
var chaptersHaveImages: Bool {
player.videoForDisplay?.chapters.allSatisfy { $0.image != nil } ?? false
}
var chaptersHeader: some View {
Group {
if !chaptersHaveImages {
#if canImport(UIKit)
Button(action: {
chaptersExpanded.toggle()
}) {
HStack {
Text("Chapters".localized())
Spacer()
Image(systemName: chaptersExpanded ? "chevron.up" : "chevron.down")
.imageScale(.small)
}
.padding(.horizontal)
.font(.caption)
.foregroundColor(.secondary)
}
#elseif canImport(AppKit)
HStack {
Text("Chapters".localized())
Spacer()
Button(action: { chaptersExpanded.toggle() }) {
Image(systemName: chaptersExpanded ? "chevron.up" : "chevron.down")
.imageScale(.small)
}
}
.padding(.horizontal)
.font(.caption)
.foregroundColor(.secondary)
#endif
} else {
// No button, just the title when there are images
Text("Chapters".localized())
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
}
}
}

View File

@@ -27,10 +27,7 @@ struct VideoPlayerSizeModifier: ViewModifier {
content
.frame(width: geometry.size.width)
.frame(maxHeight: maxHeight)
#if !os(macOS)
.aspectRatio(ratio, contentMode: usedAspectRatioContentMode)
#endif
}
var ratio: CGFloat? { // swiftlint:disable:this no_cgfloat
@@ -46,10 +43,10 @@ struct VideoPlayerSizeModifier: ViewModifier {
}
var usedAspectRatioContentMode: ContentMode {
#if os(iOS)
fullScreen ? .fill : .fit
#else
#if os(tvOS)
.fit
#else
fullScreen ? .fill : .fit
#endif
}

View File

@@ -22,7 +22,7 @@ struct VideoPlayerView: View {
static let defaultAspectRatio = 16 / 9.0
static var defaultMinimumHeightLeft: Double {
#if os(macOS)
300
335
#else
200
#endif
@@ -156,7 +156,7 @@ struct VideoPlayerView: View {
.persistentSystemOverlays(!fullScreenPlayer)
#endif
#if os(macOS)
.frame(minWidth: 1100, minHeight: 700)
.frame(minWidth: playerSidebar != .never ? 1100 : 650, minHeight: 700)
#endif
}
@@ -294,6 +294,9 @@ struct VideoPlayerView: View {
}
})
#endif
.background(Color.black)
if !detailsHiddenInFullScreen {
VideoDetails(
video: player.videoForDisplay,

View File

@@ -91,6 +91,13 @@ struct PlaylistsView: View {
loadResource()
}
#if os(iOS)
.refreshControl { refreshControl in
model.load(force: true) {
model.reloadPlaylists.toggle()
refreshControl.endRefreshing()
}
}
.backport
.refreshable {
DispatchQueue.main.async {
model.load(force: true) { model.reloadPlaylists.toggle() }

View File

@@ -1,24 +1,28 @@
import Introspect
import Repeat
import SwiftUI
import SwiftUIIntrospect
@available(iOS 15.0, macOS 12, *)
struct FocusableSearchTextField: View {
@ObservedObject private var state = SearchModel.shared
#if os(iOS)
@State private var textField: UITextField?
#elseif os(macOS)
@State private var textField: NSTextField?
#endif
var body: some View {
SearchTextField()
#if os(iOS)
.introspectTextField { field in
textField = field
#if os(macOS)
.introspect(.textField, on: .macOS(.v12, .v13, .v14)) { textField in
state.textField = textField
}
.onAppear {
DispatchQueue.main.async {
state.textField?.becomeFirstResponder()
}
}
#elseif os(iOS)
.introspect(.textField, on: .iOS(.v15, .v16, .v17)) { textField in
state.textField = textField
}
.onChange(of: state.focused) { newValue in
if newValue, let textField, !textField.isFirstResponder {
if newValue, let textField = state.textField, !textField.isFirstResponder {
textField.becomeFirstResponder()
textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
}

View File

@@ -95,7 +95,11 @@ struct SearchView: View {
filtersMenu
}
FocusableSearchTextField()
if #available(macOS 12, *) {
FocusableSearchTextField()
} else {
SearchTextField()
}
}
#endif
}
@@ -175,7 +179,11 @@ struct SearchView: View {
searchMenu
}
ToolbarItem(placement: .principal) {
FocusableSearchTextField()
if #available(iOS 15, *) {
FocusableSearchTextField()
} else {
SearchTextField()
}
}
}
.navigationBarTitleDisplayMode(.inline)

View File

@@ -153,7 +153,7 @@ struct AccountForm: View {
return
}
let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
let account = AccountsModel.add(instance: instance, id: nil, name: name, username: username, password: password)
selectedAccount?.wrappedValue = account
presentationMode.wrappedValue.dismiss()

View File

@@ -11,7 +11,7 @@ struct AddPublicInstanceButton: View {
_ = InstancesModel.shared.add(app: app, name: "", url: account.urlString)
regenerateID()
} label: {
Label("Add \(account.urlString)", systemImage: "plus")
Label(String(format: "Add %@", account.urlString), systemImage: "plus")
}
.id(id)
}

View File

@@ -5,6 +5,7 @@ struct AdvancedSettings: View {
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
@Default(.mpvCacheSecs) private var mpvCacheSecs
@Default(.mpvCachePauseWait) private var mpvCachePauseWait
@Default(.mpvDeinterlace) private var mpvDeinterlace
@Default(.mpvEnableLogging) private var mpvEnableLogging
@Default(.showCacheStatus) private var showCacheStatus
@Default(.feedCacheSize) private var feedCacheSize
@@ -87,6 +88,8 @@ struct AdvancedSettings: View {
}
.multilineTextAlignment(.trailing)
Toggle("deinterlace", isOn: $mpvDeinterlace)
if mpvEnableLogging {
logButton
}

View File

@@ -0,0 +1,167 @@
import SwiftUI
struct ExportSettings: View {
@ObservedObject private var model = ImportExportSettingsModel.shared
@State private var presentingShareSheet = false
@StateObject private var settings = SettingsModel.shared
private var filesToShare = [ImportExportSettingsModel.exportFile]
@ObservedObject private var navigation = NavigationModel.shared
var body: some View {
Group {
#if os(macOS)
VStack {
list
importExportButtons
}
#else
list
#if os(iOS)
.listStyle(.insetGrouped)
.sheet(
isPresented: $presentingShareSheet,
onDismiss: { self.model.isExportInProgress = false }
) {
ShareSheet(activityItems: filesToShare)
.id("settings-share-\(filesToShare.count)")
}
#endif
#endif
}
.navigationTitle("Export Settings")
}
var list: some View {
List {
exportView
}
.onAppear {
model.reset()
}
}
var importExportButtons: some View {
HStack {
importButton
Spacer()
exportButton
}
}
@ViewBuilder var importButton: some View {
#if os(macOS)
Button {
navigation.presentingSettingsFileImporter = true
} label: {
Label("Import", systemImage: "square.and.arrow.down")
}
#endif
}
struct ExportGroupRow: View {
let group: ImportExportSettingsModel.ExportGroup
@ObservedObject private var model = ImportExportSettingsModel.shared
var body: some View {
Button(action: { model.toggleExportGroupSelection(group) }) {
HStack {
Text(group.label)
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.opacity(isGroupInSelectedGroups ? 1 : 0)
}
.animation(nil, value: isGroupInSelectedGroups)
.contentShape(Rectangle())
}
}
var isGroupInSelectedGroups: Bool {
model.selectedExportGroups.contains(group)
}
}
var exportView: some View {
Group {
Section(header: Text("Settings")) {
ForEach(ImportExportSettingsModel.ExportGroup.settingsGroups) { group in
ExportGroupRow(group: group)
}
}
Section(header: Text("Locations")) {
ForEach(ImportExportSettingsModel.ExportGroup.locationsGroups) { group in
ExportGroupRow(group: group)
.disabled(!model.isGroupEnabled(group))
}
}
Section(header: Text("Other"), footer: otherGroupsFooter) {
ForEach(ImportExportSettingsModel.ExportGroup.otherGroups) { group in
ExportGroupRow(group: group)
}
}
#if !os(macOS)
Section {
exportButton
}
#endif
}
.buttonStyle(.plain)
.disabled(model.isExportInProgress)
}
var exportButton: some View {
Button(action: exportSettings) {
Label(model.isExportInProgress ? "Export in progress..." : "Export...", systemImage: model.isExportInProgress ? "fireworks" : "square.and.arrow.up")
.animation(nil, value: model.isExportInProgress)
#if !os(macOS)
.foregroundColor(.accentColor)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
#endif
}
.disabled(!model.isExportAvailable)
}
@ViewBuilder var otherGroupsFooter: some View {
Text("Other data include last used playback preferences and listing options")
}
func exportSettings() {
let export = {
model.isExportInProgress = true
Delay.by(0.3) {
model.exportAction()
#if !os(macOS)
self.presentingShareSheet = true
#endif
}
}
if model.isGroupSelected(.accountsUnencryptedPasswords) {
settings.presentAlert(Alert(
title: Text("Are you sure you want to export unencrypted passwords?"),
message: Text("Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import"),
primaryButton: .destructive(Text("Export"), action: export),
secondaryButton: .cancel()
))
} else {
export()
}
}
}
struct ExportSettings_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ExportSettings()
}
}
}

View File

@@ -0,0 +1,189 @@
import SwiftUI
struct ImportSettingsAccountRow: View {
var account: Account
var fileModel: ImportSettingsFileModel
@State private var password = ""
@State private var isValid = false
@State private var isValidated = false
@State private var isValidating = false
@State private var validationError: String?
@State private var validationDebounce = Debounce()
@ObservedObject private var model = ImportSettingsSheetViewModel.shared
func afterValidation() {
if isValid {
model.importableAccounts.insert(account.id)
model.selectedAccounts.insert(account.id)
model.importableAccountsPasswords[account.id] = password
} else {
model.selectedAccounts.remove(account.id)
model.importableAccounts.remove(account.id)
model.importableAccountsPasswords.removeValue(forKey: account.id)
}
}
var body: some View {
Button(action: { model.toggleAccount(account, accounts: accounts) }) {
let accountExists = AccountsModel.shared.find(account.id) != nil
VStack(alignment: .leading) {
HStack {
Text(account.username)
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.opacity(isChecked ? 1 : 0)
}
Text(account.instance?.description ?? "")
.font(.caption)
.foregroundColor(.secondary)
Group {
if let instanceID = account.instanceID {
if accountExists {
HStack {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Color("AppRedColor"))
Text("Account already exists")
}
} else {
Group {
if InstancesModel.shared.find(instanceID) != nil {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Custom Location already exists")
}
} else if model.selectedInstances.contains(instanceID) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Custom Location selected for import")
}
} else {
HStack {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
Text("Custom Location not selected for import")
}
.foregroundColor(Color("AppRedColor"))
}
}
.frame(minHeight: 20)
if account.password.isNil || account.password!.isEmpty {
Group {
if password.isEmpty {
HStack {
Image(systemName: "key")
Text("Password required to import")
}
.foregroundColor(Color("AppRedColor"))
} else {
AccountValidationStatus(
app: .constant(instance.app),
isValid: $isValid,
isValidated: $isValidated,
isValidating: $isValidating,
error: $validationError
)
}
}
.frame(minHeight: 20)
} else {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Password saved in import file")
}
}
}
}
}
.foregroundColor(.primary)
.font(.caption)
.padding(.vertical, 2)
if !accountExists && (account.password.isNil || account.password!.isEmpty) {
SecureField("Password", text: $password)
.onChange(of: password) { _ in validate() }
#if !os(tvOS)
.textFieldStyle(RoundedBorderTextFieldStyle())
#endif
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onChange(of: isValid) { _ in afterValidation() }
.animation(nil, value: isChecked)
}
.buttonStyle(.plain)
}
var isChecked: Bool {
model.isSelectedForImport(account)
}
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
fileModel.locationsSettingsGroupImporter
}
var accounts: [Account] {
fileModel.locationsSettingsGroupImporter?.accounts ?? []
}
private var instance: Instance! {
(fileModel.locationsSettingsGroupImporter?.instances ?? []).first { $0.id == account.instanceID }
}
private var validator: AccountValidator {
AccountValidator(
app: .constant(instance.app),
url: instance.apiURLString,
account: Account(instanceID: instance.id, urlString: instance.apiURLString, username: account.username, password: password),
id: .constant(account.username),
isValid: $isValid,
isValidated: $isValidated,
isValidating: $isValidating,
error: $validationError
)
}
private func validate() {
isValid = false
validationDebounce.invalidate()
guard !account.username.isEmpty, !password.isEmpty else {
validator.reset()
return
}
isValidating = true
validationDebounce.debouncing(1) {
validator.validateAccount()
}
}
}
struct ImportSettingsAccountRow_Previews: PreviewProvider {
static var previews: some View {
let fileModel = ImportSettingsFileModel(url: URL(string: "https://gist.githubusercontent.com/arekf/578668969c9fdef1b3828bea864c3956/raw/f794a95a20261bcb1145e656c8dda00bea339e2a/yattee-recents.yatteesettings")!)
return List {
ImportSettingsAccountRow(
account: .init(name: "arekf", urlString: "https://instance.com", username: "arekf"),
fileModel: fileModel
)
ImportSettingsAccountRow(
account: .init(name: "arekf", urlString: "https://instance.com", username: "arekf", password: "a"),
fileModel: fileModel
)
}
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
import SwiftUI
struct ImportSettingsFileImporterViewModifier: ViewModifier {
@Binding var isPresented: Bool
func body(content: Content) -> some View {
content
.fileImporter(isPresented: $isPresented, allowedContentTypes: [.json]) { result in
do {
let selectedFile = try result.get()
var urlToOpen: URL?
if let bookmarkURL = URLBookmarkModel.shared.loadBookmark(selectedFile) {
urlToOpen = bookmarkURL
}
if selectedFile.startAccessingSecurityScopedResource() {
URLBookmarkModel.shared.saveBookmark(selectedFile)
urlToOpen = selectedFile
}
guard let urlToOpen else { return }
NavigationModel.shared.presentSettingsImportSheet(urlToOpen, forceSettings: true)
} catch {
NavigationModel.shared.presentAlert(title: "Could not open Files")
}
}
}
}

View File

@@ -0,0 +1,262 @@
import SwiftUI
struct ImportSettingsSheetView: View {
@Binding var settingsFile: URL?
@StateObject private var model = ImportSettingsSheetViewModel.shared
@StateObject private var importExportModel = ImportExportSettingsModel.shared
@Environment(\.presentationMode) private var presentationMode
@State private var presentingCompletedAlert = false
private let accountsModel = AccountsModel.shared
var body: some View {
Group {
#if os(macOS)
list
.frame(width: 700, height: 800)
#else
NavigationView {
list
}
#endif
}
.onAppear {
guard let fileModel else { return }
model.reset(fileModel.locationsSettingsGroupImporter)
importExportModel.reset(fileModel)
}
.onChange(of: settingsFile) { _ in
importExportModel.reset(fileModel)
}
}
var list: some View {
List {
importGroupView
importOptions
metadata
}
.alert(isPresented: $presentingCompletedAlert) {
completedAlert
}
#if os(iOS)
.backport
.scrollDismissesKeyboardInteractively()
#endif
.navigationTitle("Import Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { presentationMode.wrappedValue.dismiss() }) {
Text("Cancel")
}
}
ToolbarItem(placement: .confirmationAction) {
Button(action: {
fileModel?.performImport()
presentingCompletedAlert = true
ImportExportSettingsModel.shared.reset()
}) {
Text("Import")
}
.disabled(!canImport)
}
}
}
var completedAlert: Alert {
Alert(
title: Text("Import Completed"),
dismissButton: .default(Text("Close")) {
if accountsModel.isEmpty,
let account = InstancesModel.shared.all.first?.anonymousAccount
{
accountsModel.setCurrent(account)
}
presentationMode.wrappedValue.dismiss()
}
)
}
var canImport: Bool {
return !model.selectedAccounts.isEmpty || !model.selectedInstances.isEmpty || !importExportModel.selectedExportGroups.isEmpty
}
var fileModel: ImportSettingsFileModel? {
guard let settingsFile else { return nil }
return ImportSettingsFileModel(url: settingsFile)
}
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
guard let fileModel else { return nil }
return fileModel.locationsSettingsGroupImporter
}
struct ExportGroupRow: View {
let group: ImportExportSettingsModel.ExportGroup
@ObservedObject private var model = ImportExportSettingsModel.shared
var body: some View {
Button(action: { model.toggleExportGroupSelection(group) }) {
HStack {
Text(group.label)
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.opacity(isChecked ? 1 : 0)
}
.contentShape(Rectangle())
.foregroundColor(.primary)
.animation(nil, value: isChecked)
}
.buttonStyle(.plain)
}
var isChecked: Bool {
model.selectedExportGroups.contains(group)
}
}
var importGroupView: some View {
Group {
Section(header: Text("Settings")) {
ForEach(ImportExportSettingsModel.ExportGroup.settingsGroups) { group in
ExportGroupRow(group: group)
.disabled(!fileModel!.isGroupIncludedInFile(group))
}
}
Section(header: Text("Other")) {
ForEach(ImportExportSettingsModel.ExportGroup.otherGroups) { group in
ExportGroupRow(group: group)
.disabled(!fileModel!.isGroupIncludedInFile(group))
}
}
}
}
@ViewBuilder var metadata: some View {
if let fileModel {
Section(header: Text("File information")) {
MetadataRow(name: Text("Name"), value: Text(fileModel.filename))
if let date = fileModel.metadataDate {
MetadataRow(name: Text("Date"), value: Text(date))
}
if let build = fileModel.metadataBuild {
MetadataRow(name: Text("Build"), value: Text(build))
}
if let platform = fileModel.metadataPlatform {
MetadataRow(name: Text("Platform"), value: Text(platform))
}
}
}
}
struct MetadataRow: View {
let name: Text
let value: Text
var body: some View {
HStack {
name
.layoutPriority(2)
Spacer()
value
.layoutPriority(1)
.lineLimit(2)
.foregroundColor(.secondary)
}
}
}
var instances: [Instance] {
locationsSettingsGroupImporter?.instances ?? []
}
var accounts: [Account] {
locationsSettingsGroupImporter?.accounts ?? []
}
struct ImportInstanceRow: View {
var instance: Instance
var accounts: [Account]
@ObservedObject private var model = ImportSettingsSheetViewModel.shared
var body: some View {
Button(action: { model.toggleInstance(instance, accounts: accounts) }) {
VStack {
Group {
HStack {
Text(instance.description)
Spacer()
Image(systemName: "checkmark")
.opacity(isChecked ? 1 : 0)
.foregroundColor(.accentColor)
}
if model.isInstanceAlreadyAdded(instance) {
HStack {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
Text("Custom Location already exists")
}
.font(.caption)
.padding(.vertical, 2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.contentShape(Rectangle())
.foregroundColor(.primary)
.transaction { t in t.animation = nil }
}
.buttonStyle(.plain)
}
var isChecked: Bool {
model.isImportable(instance) && model.selectedInstances.contains(instance.id)
}
}
@ViewBuilder var importOptions: some View {
if let fileModel {
if fileModel.isPublicInstancesSettingsGroupInFile || !instances.isEmpty {
Section(header: Text("Locations")) {
if fileModel.isPublicInstancesSettingsGroupInFile {
ExportGroupRow(group: .locationsSettings)
}
ForEach(instances) { instance in
ImportInstanceRow(instance: instance, accounts: accounts)
}
}
}
if !accounts.isEmpty {
Section(header: Text("Accounts")) {
ForEach(accounts) { account in
ImportSettingsAccountRow(account: account, fileModel: fileModel)
}
}
}
}
}
}
struct ImportSettingsSheetView_Previews: PreviewProvider {
static var previews: some View {
ImportSettingsSheetView(settingsFile: .constant(URL(string: "https://gist.githubusercontent.com/arekf/578668969c9fdef1b3828bea864c3956/raw/f794a95a20261bcb1145e656c8dda00bea339e2a/yattee-recents.yatteesettings")!))
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import SwiftUI
final class ImportSettingsSheetViewModel: ObservableObject {
static let shared = ImportSettingsSheetViewModel()
@Published var selectedInstances = Set<Instance.ID>()
@Published var selectedAccounts = Set<Account.ID>()
@Published var importableAccounts = Set<Account.ID>()
@Published var importableAccountsPasswords = [Account.ID: String]()
func toggleInstance(_ instance: Instance, accounts: [Account]) {
if selectedInstances.contains(instance.id) {
selectedInstances.remove(instance.id)
} else {
guard isImportable(instance) else { return }
selectedInstances.insert(instance.id)
}
removeNonImportableFromSelectedAccounts(accounts: accounts)
}
func toggleAccount(_ account: Account, accounts: [Account]) {
if selectedAccounts.contains(account.id) {
selectedAccounts.remove(account.id)
} else {
guard isImportable(account.id, accounts: accounts) else { return }
selectedAccounts.insert(account.id)
}
}
func isSelectedForImport(_ account: Account) -> Bool {
importableAccounts.contains(account.id) && selectedAccounts.contains(account.id)
}
func isImportable(_ accountID: Account.ID, accounts: [Account]) -> Bool {
guard let account = accounts.first(where: { $0.id == accountID }),
let instanceID = account.instanceID,
AccountsModel.shared.find(accountID) == nil
else { return false }
return ((account.password != nil && !account.password!.isEmpty) ||
importableAccounts.contains(account.id)) && (
(InstancesModel.shared.find(instanceID) != nil) ||
selectedInstances.contains(instanceID)
)
}
func isImportable(_ instance: Instance) -> Bool {
!isInstanceAlreadyAdded(instance)
}
func isInstanceAlreadyAdded(_ instance: Instance) -> Bool {
InstancesModel.shared.find(instance.id) != nil || InstancesModel.shared.findByURLString(instance.apiURLString) != nil
}
func removeNonImportableFromSelectedAccounts(accounts: [Account]) {
selectedAccounts = Set(selectedAccounts.filter { isImportable($0, accounts: accounts) })
}
func reset() {
selectedAccounts = []
selectedInstances = []
importableAccounts = []
}
func reset(_ importer: LocationsSettingsGroupImporter? = nil) {
reset()
guard let importer else { return }
selectedInstances = Set(importer.instances.filter { isImportable($0) }.map(\.id))
importableAccounts = Set(importer.accounts.filter { isImportable($0.id, accounts: importer.accounts) }.map(\.id))
selectedAccounts = importableAccounts
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
import SwiftUI
import SwiftyJSON
struct ImportSettingsSheetViewModifier: ViewModifier {
@Binding var isPresented: Bool
@Binding var settingsFile: URL?
func body(content: Content) -> some View {
content
.sheet(isPresented: $isPresented) {
ImportSettingsSheetView(settingsFile: $settingsFile)
}
}
}
struct ImportSettingsSheetViewModifier_Previews: PreviewProvider {
static var previews: some View {
Text("")
.modifier(
ImportSettingsSheetViewModifier(
isPresented: .constant(true),
settingsFile: .constant(URL(string: "https://gist.githubusercontent.com/arekf/87b4d6702755b01139431dcb809f9fdc/raw/7bb5cdba3ffc0c479f5260430ddc43c4a79a7a72/yattee-177-iPhone.yatteesettings")!)
)
)
}
}

View File

@@ -15,6 +15,7 @@ struct PlayerControlsSettings: View {
@Default(.gestureBackwardSeekDuration) private var gestureBackwardSeekDuration
@Default(.gestureForwardSeekDuration) private var gestureForwardSeekDuration
@Default(.systemControlsSeekDuration) private var systemControlsSeekDuration
@Default(.playerActionsButtonLabelStyle) private var playerActionsButtonLabelStyle
@Default(.actionButtonShareEnabled) private var actionButtonShareEnabled
@Default(.actionButtonSubscribeEnabled) private var actionButtonSubscribeEnabled
@Default(.actionButtonCloseEnabled) private var actionButtonCloseEnabled
@@ -117,6 +118,15 @@ struct PlayerControlsSettings: View {
Section(header: SettingsHeader(text: "Actions Buttons".localized())) {
actionButtonToggles
}
Section {
Picker("Action button labels", selection: $playerActionsButtonLabelStyle) {
ForEach(ButtonLabelStyle.allCases, id: \.rawValue) { style in
Text(style.description).tag(style)
}
}
.modifier(SettingsPickerModifier())
}
}
private var systemControlsCommandsPicker: some View {

View File

@@ -10,6 +10,7 @@ struct PlayerSettings: View {
@Default(.showKeywords) private var showKeywords
#if !os(tvOS)
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
@Default(.collapsedLinesDescription) private var collapsedLinesDescription
#endif
@Default(.expandVideoDescription) private var expandVideoDescription
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@@ -30,6 +31,9 @@ struct PlayerSettings: View {
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
@Default(.showInspector) private var showInspector
@Default(.showChapters) private var showChapters
@Default(.expandChapters) private var expandChapters
@Default(.showRelated) private var showRelated
@ObservedObject private var accounts = AccountsModel.shared
@@ -73,8 +77,21 @@ struct PlayerSettings: View {
}
#if !os(tvOS)
Section(header: SettingsHeader(text: "Inspector".localized())) {
inspectorVisibilityPicker
Section(header: SettingsHeader(text: "Info".localized())) {
expandVideoDescriptionToggle
collapsedLineDescriptionStepper
showChaptersToggle
expandChaptersToggle
showRelatedToggle
#if os(macOS)
HStack {
Text("Inspector")
inspectorVisibilityPicker
}
.padding(.leading, 20)
#else
inspectorVisibilityPicker
#endif
}
#endif
@@ -96,9 +113,6 @@ struct PlayerSettings: View {
showScrollToTopInCommentsToggle
#endif
#if !os(tvOS)
expandVideoDescriptionToggle
#endif
returnYouTubeDislikeToggle
}
}
@@ -184,6 +198,24 @@ struct PlayerSettings: View {
Toggle("Open video description expanded", isOn: $expandVideoDescription)
}
#if !os(tvOS)
private var collapsedLineDescriptionStepper: some View {
LazyVStack {
Stepper(value: $collapsedLinesDescription, in: 0 ... 10) {
Text("Description preview")
#if os(macOS)
Spacer()
#endif
if collapsedLinesDescription == 0 {
Text("No preview")
} else {
Text("\(collapsedLinesDescription) lines")
}
}
}
}
#endif
private var returnYouTubeDislikeToggle: some View {
Toggle("Enable Return YouTube Dislike", isOn: $enableReturnYouTubeDislike)
}
@@ -240,15 +272,31 @@ struct PlayerSettings: View {
}
#endif
private var inspectorVisibilityPicker: some View {
Picker("Visibility", selection: $showInspector) {
Text("Always").tag(ShowInspectorSetting.always)
Text("Only for local files and URLs").tag(ShowInspectorSetting.onlyLocal)
#if !os(tvOS)
private var inspectorVisibilityPicker: some View {
Picker("Inspector", selection: $showInspector) {
Text("Always").tag(ShowInspectorSetting.always)
Text("Only for local files and URLs").tag(ShowInspectorSetting.onlyLocal)
}
#if os(macOS)
.labelsHidden()
#endif
}
#if os(macOS)
.labelsHidden()
#endif
}
private var showChaptersToggle: some View {
Toggle("Chapters (if available)", isOn: $showChapters)
}
private var expandChaptersToggle: some View {
Toggle("Open vertical chapters expanded", isOn: $expandChapters)
.disabled(!showChapters)
.foregroundColor(showChapters ? .primary : .secondary)
}
private var showRelatedToggle: some View {
Toggle("Related", isOn: $showRelated)
}
#endif
}
struct PlayerSettings_Previews: PreviewProvider {

View File

@@ -201,7 +201,7 @@ struct QualityProfileForm: View {
@ViewBuilder var formatsPicker: some View {
#if os(macOS)
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
@@ -210,8 +210,16 @@ struct QualityProfileForm: View {
toggleFormat(format, value: value)
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
Group {
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
.listStyle(.inset)
}
}
Spacer()
#else
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
@@ -227,7 +235,7 @@ struct QualityProfileForm: View {
}
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
(initialized ? formats : qualityProfile.formats).contains(format)
(initialized || qualityProfile.isNil ? formats : qualityProfile.formats).contains(format)
}
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {

View File

@@ -175,14 +175,24 @@ struct QualitySettings: View {
}
}
#if os(macOS)
List {
if #available(macOS 12.0, *) {
#if os(macOS)
List {
list
}
.listStyle(.inset(alternatesRowBackgrounds: true))
#else
list
}
.listStyle(.inset(alternatesRowBackgrounds: true))
#else
list
#endif
#endif
} else {
#if os(macOS)
List {
list
}
#else
list
#endif
}
}
}

View File

@@ -7,7 +7,7 @@ struct SettingsView: View {
#if os(macOS)
private enum Tabs: Hashable {
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, help
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, importExport, help
}
@State private var selection: Tabs = .browsing
@@ -24,13 +24,22 @@ struct SettingsView: View {
@Default(.instances) private var instances
@State private var filesToShare = []
@ObservedObject private var navigation = NavigationModel.shared
@ObservedObject private var settingsModel = SettingsModel.shared
var body: some View {
settings
.alert(isPresented: $model.presentingAlert) { model.alert }
#if os(iOS)
.backport
.scrollDismissesKeyboardInteractively()
#if !os(tvOS)
.modifier(ImportSettingsFileImporterViewModifier(isPresented: $navigation.presentingSettingsFileImporter))
.modifier(ImportSettingsSheetViewModifier(isPresented: $settingsModel.presentingSettingsImportSheet, settingsFile: $settingsModel.settingsImportURL))
#endif
#if os(iOS)
.backport
.scrollDismissesKeyboardInteractively()
#endif
.alert(isPresented: $model.presentingAlert) { model.alert }
}
var settings: some View {
@@ -101,6 +110,14 @@ struct SettingsView: View {
}
.tag(Tabs.advanced)
Group {
ExportSettings()
}
.tabItem {
Label("Export", systemImage: "square.and.arrow.up")
}
.tag(Tabs.importExport)
Form {
Help()
}
@@ -110,7 +127,7 @@ struct SettingsView: View {
.tag(Tabs.help)
}
.padding(20)
.frame(width: 650, height: windowHeight)
.frame(width: 700, height: windowHeight)
#else
NavigationView {
settingsList
@@ -206,6 +223,8 @@ struct SettingsView: View {
.padding(.horizontal, 20)
#endif
importView
Section(footer: helpFooter) {
NavigationLink {
Help()
@@ -260,13 +279,35 @@ struct SettingsView: View {
}
#endif
var importView: some View {
Section {
Button(action: importSettings) {
Label("Import Settings...", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.foregroundColor(.accentColor)
.buttonStyle(.plain)
NavigationLink(destination: LazyView(ExportSettings())) {
Label("Export Settings", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
}
func importSettings() {
navigation.presentingSettingsFileImporter = true
}
#if os(macOS)
private var windowHeight: Double {
switch selection {
case .browsing:
return 800
case .player:
return 480
return 550
case .controls:
return 920
case .quality:
@@ -278,7 +319,9 @@ struct SettingsView: View {
case .locations:
return 600
case .advanced:
return 380
return 500
case .importExport:
return 580
case .help:
return 650
}

View File

@@ -50,8 +50,15 @@ struct SponsorBlockSettings: View {
}
}
list
.listStyle(.inset(alternatesRowBackgrounds: true))
Group {
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
.listStyle(.inset)
}
}
Spacer()
#else
ForEach(SponsorBlockAPI.categories, id: \.self) { category in

View File

@@ -34,9 +34,8 @@ struct ChannelsView: View {
Text(channel.name)
.lineLimit(1)
}
#if !os(tvOS)
.backport
.badge(showUnwatchedFeedBadges ? feedCount.unwatchedByChannelText(channel) : nil)
#endif
Group {
#if os(tvOS)
@@ -74,9 +73,8 @@ struct ChannelsView: View {
Color.clear.padding(.bottom, 50)
.listRowBackground(Color.clear)
#if os(iOS)
.listRowSeparator(.hidden)
#endif
.backport
.listRowSeparator(false)
}
}
#if !os(tvOS)
@@ -91,8 +89,14 @@ struct ChannelsView: View {
subscriptions.load(force: true)
}
#if os(iOS)
.refreshControl { refreshControl in
subscriptions.load(force: true) {
refreshControl.endRefreshing()
}
}
.backport
.refreshable {
subscriptions.load(force: true)
await subscriptions.load(force: true)
}
#endif
#if !os(tvOS)

View File

@@ -22,8 +22,14 @@ struct FeedView: View {
feed.loadResources()
}
#if os(iOS)
.refreshControl { refreshControl in
feed.loadResources(force: true) {
refreshControl.endRefreshing()
}
}
.backport
.refreshable {
feed.loadResources(force: true)
await feed.loadResources(force: true)
}
#endif
#if !os(tvOS)

View File

@@ -16,7 +16,11 @@ struct TrendingCountry: View {
VStack {
#if !os(tvOS)
HStack {
TextField("Country", text: $query, prompt: Text(Self.prompt))
if #available(iOS 15.0, macOS 12.0, *) {
TextField("Country", text: $query, prompt: Text(Self.prompt))
} else {
TextField(Self.prompt, text: $query)
}
Button("Done") { selectCountryAndDismiss() }
.keyboardShortcut(.defaultAction)
@@ -53,8 +57,12 @@ struct TrendingCountry: View {
return Group {
#if os(macOS)
list
.listStyle(.inset(alternatesRowBackgrounds: true))
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
}
#else
list
#endif

View File

@@ -95,7 +95,12 @@ struct TrendingView: View {
.navigationTitle("Trending")
#endif
#if os(iOS)
.refreshControl { refreshControl in
resource.load().onCompletion { _ in
refreshControl.endRefreshing()
}
}
.backport
.refreshable {
DispatchQueue.main.async {
resource.load()

View File

@@ -48,19 +48,23 @@ struct ThumbnailView: View {
}
@ViewBuilder var asyncImageIfAvailable: some View {
CachedAsyncImage(url: url, urlCache: BaseCacheModel.imageCache, transaction: Transaction(animation: .default)) { phase in
switch phase {
case let .success(image):
image
.resizable()
case .failure:
placeholder.onAppear {
guard let url else { return }
thumbnails.insertUnloadable(url)
if #available(iOS 15, macOS 12, *) {
CachedAsyncImage(url: url, urlCache: BaseCacheModel.imageCache) { phase in
switch phase {
case let .success(image):
image
.resizable()
case .failure:
placeholder.onAppear {
guard let url else { return }
thumbnails.insertUnloadable(url)
}
default:
placeholder
}
default:
placeholder
}
} else {
webImage
}
}

View File

@@ -13,7 +13,7 @@ struct ControlsBar: View {
@State private var shareURL: URL?
@Binding var expansionState: ExpansionState
@State var gestureThrottle = Throttle(interval: 0.25) // swiftlint:disable:this swiftui_state_private
@State var gestureThrottle = Throttle(interval: 0.25) // swiftlint:disable:this private_swiftui_state
var presentingControls = true
var backgroundEnabled = true

View File

@@ -8,7 +8,7 @@ struct OpenSettingsButton: View {
#endif
var body: some View {
Button {
let button = Button {
presentationMode.wrappedValue.dismiss()
#if os(macOS)
@@ -20,7 +20,13 @@ struct OpenSettingsButton: View {
Label("Open Settings", systemImage: "gearshape.2")
}
.buttonStyle(.plain)
.buttonStyle(.borderedProminent)
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
button
.buttonStyle(.borderedProminent)
} else {
button
}
}
}

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