1
0
mirror of https://github.com/yattee/yattee.git synced 2025-04-27 23:40:35 +05:30

Compare commits

...

491 Commits

Author SHA1 Message Date
Arkadiusz Fal
2a597ab3cb Add missing bundle platform 2025-03-23 15:08:04 +01:00
Arkadiusz Fal
4d662115e4 Update GitHub release workflow 2025-03-23 15:07:15 +01:00
Arkadiusz Fal
e068257f14 Bump build number to 200 2025-03-23 15:04:56 +01:00
Arkadiusz Fal
8b809fb0f1 Update GitHub release workflow 2025-03-23 15:04:16 +01:00
Arkadiusz Fal
d3e80f500e Update GitHub release workflow 2025-03-23 13:38:51 +01:00
Arkadiusz Fal
9343e9d023 Bump build number to 199 2025-03-23 13:35:52 +01:00
Arkadiusz Fal
e4b25b0f80 Update CHANGELOG 2025-03-23 13:35:52 +01:00
Arkadiusz Fal
09c2fb19a9 Fix swiftformat offenses 2025-03-23 13:32:46 +01:00
Arkadiusz Fal
043b07274e Update packages 2025-03-23 13:32:19 +01:00
Arkadiusz Fal
7f7e12d719
Merge pull request #851 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2025-03-23 13:27:47 +01:00
Arkadiusz Fal
d990c6630e
Merge pull request #863 from lifo9/add-support-for-invidious-companion
Add support for invidious companion
2025-03-23 13:27:33 +01:00
Jakub Filo
5239b36cfe Add support for invidious companion 2025-03-18 22:56:45 +01:00
Ghost of Sparta
addc13ebfb
Translated using Weblate (Hungarian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2025-03-01 17:01:56 +00:00
azazaazur
2a6f26ec68
Translated using Weblate (Turkish)
Currently translated at 97.3% (547 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2025-02-15 17:01:58 +01:00
Mohammed Al Otaibi
2e2f502d97
Translated using Weblate (Arabic)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2025-01-30 15:02:02 +01:00
Sophia Park
59afc2f4c7
Translated using Weblate (Korean)
Currently translated at 14.2% (80 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ko/
2025-01-06 08:07:37 +01:00
Finn
2f902e74bb
Translated using Weblate (German)
Currently translated at 94.8% (533 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/de/
2025-01-03 12:01:20 +00:00
Ghost of Sparta
500b75da4f
Translated using Weblate (Hungarian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-12-28 13:00:32 +01:00
Arkadiusz Fal
3a17cc4dee Add missing bundle platform 2024-12-26 19:43:42 +01:00
Arkadiusz Fal
16897338e6 Bump build number to 198 2024-12-26 19:41:35 +01:00
Arkadiusz Fal
7c870b8e61 Update changelog 2024-12-26 19:41:26 +01:00
Arkadiusz Fal
75d9c5c747 Add Hungarian locale 2024-12-26 19:10:51 +01:00
Arkadiusz Fal
9e0f1a72ab Update packages 2024-12-26 19:09:28 +01:00
Arkadiusz Fal
7f3b3ac0ab
Merge pull request #849 from derspyy/0length
Stop making videos with unknown length shorts.
2024-12-26 18:57:13 +01:00
Arkadiusz Fal
84b70b794b
Merge pull request #845 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-12-26 18:56:34 +01:00
Ghost of Sparta
e6bae84162
Translated using Weblate (Hungarian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-12-25 21:00:36 +01:00
piuvas
9efbac3d15
make 0-length videos not shorts. 2024-12-21 12:55:44 -03:00
Blueberry
1289f57f60
Translated using Weblate (Russian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-12-13 23:00:53 +01:00
Ghost of Sparta
cc03ab059b
Translated using Weblate (Hungarian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-12-10 18:00:28 +00:00
Ghost of Sparta
17484f65fd
Translated using Weblate (Hungarian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-12-09 16:00:36 +01:00
Ghost of Sparta
65247227e7
Translated using Weblate (Hungarian)
Currently translated at 93.2% (524 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-12-08 11:00:44 +00:00
Ghost of Sparta
625c01aaac
Translated using Weblate (Hungarian)
Currently translated at 56.5% (318 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-12-06 20:00:30 +01:00
Twig
7465ff9c5c
Translated using Weblate (Turkish)
Currently translated at 96.0% (540 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/tr/
2024-11-24 18:00:26 +01:00
Arkadiusz Fal
41de28a698 Bump build number to 197 2024-11-08 16:29:59 +01:00
Arkadiusz Fal
7baab7a88a Update github actions checkout version 2024-11-08 15:26:55 +01:00
Arkadiusz Fal
43599632b2 Update changelog 2024-11-08 15:20:06 +01:00
Arkadiusz Fal
e4f413ed2d Update packages 2024-11-08 15:20:06 +01:00
Arkadiusz Fal
661b7547c5 Fix application groups 2024-11-08 15:09:59 +01:00
Arkadiusz Fal
d69f410d92 Add Tamil language 2024-11-08 15:07:29 +01:00
Arkadiusz Fal
db7abe31ea
Merge pull request #836 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-11-08 15:05:22 +01:00
ButterflyOfFire
fff36ece26
Translated using Weblate (Kabyle)
Currently translated at 17.0% (96 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/kab/
2024-11-08 14:04:18 +00:00
ButterflyOfFire
8a0e9ae75a
Added translation using Weblate (Kabyle) 2024-11-08 14:04:17 +00:00
trilame
d6e5b5ed76
Translated using Weblate (French)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2024-11-08 14:04:16 +00:00
தமிழ்நேரம்
cef1a1caea
Translated using Weblate (Tamil)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ta/
2024-11-08 14:04:16 +00:00
தமிழ்நேரம்
6c6abe8c84
Added translation using Weblate (Tamil) 2024-11-08 14:04:15 +00:00
Istvan Tanczos
9732537602
Translated using Weblate (Hungarian)
Currently translated at 1.0% (6 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2024-11-08 14:04:15 +00:00
Istvan Tanczos
c0deeabaed
Added translation using Weblate (Hungarian) 2024-11-08 14:04:14 +00:00
Arkadiusz Fal
f29dbcbe36
Merge pull request #834 from mmaalo/norwegian100
Norwegian Language
2024-11-08 15:04:05 +01:00
Arkadiusz Fal
c7e1a50e56
Merge pull request #833 from blennster/main
Fix uneven playback when using MPV and not syncing refreshrate
2024-11-08 15:03:51 +01:00
Arkadiusz Fal
dd205db15f
Merge pull request #824 from yattee/mpvkit-0-39-0
update MPVKit to v0.39.0
2024-11-08 15:03:18 +01:00
Arkadiusz Fal
9ca5d292ec
Merge pull request #820 from yattee/video-details-gestures
add drag gestures to video details
2024-11-08 15:03:07 +01:00
Arkadiusz Fal
748bc16342
Merge pull request #818 from yattee/changes-to-opengl-view
improvements to MPVGLView
2024-11-08 15:02:15 +01:00
Arkadiusz Fal
798d2fc67f
Merge pull request #817 from yattee/fix-subtitles
improved subtitle handling
2024-11-08 15:01:56 +01:00
Arkadiusz Fal
a5a88f8890
Merge pull request #815 from yattee/refined-audio-ducking
proper audio interrupt and route change handling
2024-11-08 15:01:27 +01:00
Arkadiusz Fal
f69ccb6bd6
Merge pull request #814 from yattee/fullscreen-gesture-toggle
allow users to disable fullscreen swipe gesture
2024-11-08 15:01:06 +01:00
Arkadiusz Fal
892b3dea17
Merge pull request #813 from yattee/update-introspect
Update SwiftUI-Introspect
2024-11-08 15:00:35 +01:00
mmaalo
9a11e9f9f5 completed Localizable.strings for norwegian language 2024-10-07 21:23:12 +02:00
Emil Blennow
055d5575ba
fix uneven playback when using MPV and not syncing refreshrate 2024-10-06 17:32:02 +02:00
Toni Förster
28b6a517b6
update MPVKit to v0.39.0
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-24 10:41:02 +02:00
Toni Förster
b4bcd0c0a0
add drag gestures to video details 2024-09-15 20:56:04 +02:00
Toni Förster
e62010d5d5
improvements to MPVGLView
- add Pixel Buffer Object to (PBO)
- add some debug logging
- add scissor testing
- add dirty region checking

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-14 15:04:17 +02:00
Toni Förster
3339e8cb1f
improved subtitle handling
- fix subtitle disabling not working
- make subtitle adding/removing async
- make subtitle menu non blocking

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-14 11:33:12 +02:00
Toni Förster
4855f9bead
fix tvOS build
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-13 11:48:40 +02:00
Toni Förster
a65ed67751
proper audio interrupt and route change handling
- set AVAudioSession inactive on pause and stop
- handle audio route changes
2024-09-13 11:21:07 +02:00
Toni Förster
72dcbe4515
allow user to disable fullscreen swipe gesture
furthermore, some rewording

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-12 16:57:48 +02:00
Toni Förster
7e02b08933
update SwiftUI-Introspect 2024-09-11 20:55:00 +02:00
Arkadiusz Fal
8596ee8811 Bump build number to 196 2024-09-11 09:37:13 +02:00
Arkadiusz Fal
894439ad5e Update CHANGELOG 2024-09-11 09:35:32 +02:00
Arkadiusz Fal
5dad7a1b47 Update dependencies 2024-09-11 09:31:24 +02:00
Arkadiusz Fal
6d48a825cd
Merge pull request #809 from yattee/refactor-search
refactor Search
2024-09-11 09:29:43 +02:00
Arkadiusz Fal
ed11e593ff
Merge pull request #810 from yattee/auto-retry-video-loading
Retry loading video before presenting error
2024-09-11 09:29:28 +02:00
Arkadiusz Fal
102dfba751
Merge pull request #805 from yattee/mpv-better-performance
MPV: improved A/V sync
2024-09-11 09:29:14 +02:00
Arkadiusz Fal
4202b27c03
Merge pull request #807 from yattee/more-robust-resolution-handling
more robust resolution handling
2024-09-11 09:29:00 +02:00
Arkadiusz Fal
2f937f74fa
Merge pull request #806 from yattee/orientation-location-cleanup
Orientation/Fullscreen fixes and cleanup
2024-09-11 09:28:39 +02:00
Toni Förster
34a957b28e
use system background color for background
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-11 08:55:18 +02:00
Toni Förster
0bef798341
add border around search field
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-10 19:01:41 +02:00
Toni Förster
28a7b6e981
auto retry loading the video before showing error
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-10 11:07:20 +02:00
Toni Förster
4663aab3da
refactor Search
- have a separate body view for each os
- macOS: set navigation title for search
- macOS: set min width to 835 for main content
- macOS: set main window min height to 600
- macOS: don’t have text behind the cancel button
- split SearchTextField into macOS and iOS/tvOS
- iOS: move search menu to the right
- iOS: unified search field
- make min width a constant
- add option to disable search suggestions

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-10 09:38:14 +02:00
Toni Förster
0de0445805
check if subtitles are added before removing them
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-09 14:18:49 +02:00
Toni Förster
9cb0325503
more robust resolution handling
Currently, we have a hard-coded list of resolutions. Since Invidious reports the actual resolution of a stream and does not hard-code them to a fixed value anymore, resolutions that are not in the list won’t be handled, and the stream cannot be played back.

Instead of hard-coding even more resolutions (and inadvertently might not cover all), we revert the list back to a finite set of resolutions, the users can select from. All other resolutions are handled dynamically and compared to the existing set of defined resolutions when selecting the best stream for playback.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-09 12:59:39 +02:00
Toni Förster
5e85fd294c
MPV: improved A/V sync
- use displays refresh rate
- execute needs drawing with higher priority
- run create() with higher priority
- determine the number of threads used for rendering
- enable VSYNC and change video-sync  to display-resample
- iOS/tvOS: set new display refresh rate on change
- run setSize with higher priority
- add more options to MPVClient
- get refresh rate updates
- sync refresh rate to fps
- update CADisplayLink to current refresh rate
- update refresh rate on macOS
- Add experimental feature to sync display  with content fps

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-08 15:59:42 +02:00
Toni Förster
b2421da95d
apply new fullscreen logic to AppleAVPlayerView
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-06 17:13:08 +02:00
Toni Förster
4e4add3c42
fix double rotation
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-06 16:47:15 +02:00
Toni Förster
2185718d50
orientation fullscreen code cleanup
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-06 15:46:58 +02:00
Arkadiusz Fal
b0264aaabe Bump build number to 195 2024-09-05 23:01:29 +02:00
Arkadiusz Fal
035f3503c4 Update CHANGELOG 2024-09-05 23:01:19 +02:00
Arkadiusz Fal
e3ac11c172
Merge pull request #786 from stonerl/simplified-fullscreen-and-orientation
iOS: Simplified fullscreen and orientation
2024-09-05 22:59:54 +02:00
Arkadiusz Fal
7aed6ac0d9
Merge pull request #799 from stonerl/controls-background
player controls: add background opacity selection
2024-09-05 22:54:30 +02:00
Arkadiusz Fal
457c0ce7b3
Merge pull request #797 from stonerl/shorts-resolutions
add missing Shorts resolutions
2024-09-05 22:53:42 +02:00
Arkadiusz Fal
747baf3edd
Merge pull request #801 from stonerl/O2-for-macOS
use -O1 on macOS
2024-09-05 22:53:26 +02:00
Arkadiusz Fal
cd24a0322f
Merge pull request #802 from stonerl/buttons-interfere-with-search
macOS: only apply player shortcuts when window is active
2024-09-05 22:53:16 +02:00
Toni Förster
d525a22215
macOS only apply player shortcuts when window is active 2024-09-05 21:53:25 +02:00
Toni Förster
322a550666
simplified fullscreen and orientation handling
- iPad: rotate to device orientation on startup
- fixed controls in portrait fullscreen
- iOS: don’t call setNeedsDrawing multiple times
- On iOS we call set needs drawing only once.
- Added cooldown time to MPV.Client setNeedsDrawing to avoid multiple successive calls
- make fullscreen animation smoother
- dragGesture now calls toggleFullScreenAction
- fix tvOS and macOS build

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-05 18:17:14 +02:00
Toni Förster
98fa0b98e5
use -O1 on macOS
On macOS optimisation level -O3 seems to be a bit aggressive and can cause crashes when opening MPV.

- fixes #783

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-05 17:35:52 +02:00
Toni Förster
5313e4ead0
player controls: add background opacity selection
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-05 15:14:39 +02:00
Toni Förster
fa7b897e76
add missing Shorts resolutions
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-04 12:44:43 +02:00
Arkadiusz Fal
9bf3df1a29 Bump build number to 194 2024-09-04 09:38:15 +02:00
Arkadiusz Fal
34a805b986 Fix build issue 2024-09-04 09:37:38 +02:00
Arkadiusz Fal
36f680be62 Update CHANGELOG 2024-09-04 09:36:05 +02:00
Arkadiusz Fal
a27ab02433 Update dependencies 2024-09-04 09:33:23 +02:00
Arkadiusz Fal
59dd0785b3
Merge pull request #778 from stonerl/swipe-up-for-fullscreen
Gestures: swipe up toggles fullscreen
2024-09-04 09:16:23 +02:00
Arkadiusz Fal
d7be915e7e
Merge pull request #779 from stonerl/better-audio-ducking
Better audio ducking
2024-09-04 09:15:35 +02:00
Arkadiusz Fal
3752f67630
Merge pull request #780 from stonerl/add-overlay-to-video-context-menu
don’t open video when dismissing context menu
2024-09-04 09:15:03 +02:00
Arkadiusz Fal
dfe7565138
Merge pull request #789 from stonerl/fix-picture-in-picture
fix picture in picture
2024-09-04 09:14:34 +02:00
Arkadiusz Fal
4d02538cb9
Merge pull request #793 from stonerl/mpv-remove-video-layer
mpv: remove video layer when entering background
2024-09-04 09:14:05 +02:00
Arkadiusz Fal
3229528a09
Merge pull request #794 from stonerl/enable-o3-optimization
enable -O3
2024-09-04 09:13:23 +02:00
Arkadiusz Fal
fffc4f4a5f
Merge pull request #791 from stonerl/hi-res-invidious-logo
hi-res invidious logos
2024-09-04 09:13:01 +02:00
Toni Förster
e85bfe5007
enable -O3
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-03 21:40:48 +02:00
Toni Förster
b00b733fd5
don’t open video when dismissing context menu
fixes #510

fix tvOS build

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-03 21:21:34 +02:00
Toni Förster
119c663436
Gestures: swipe up toggles fullscreen 2024-09-03 21:20:56 +02:00
Toni Förster
e8fcee23ef
make audio ducking and interruption more robust
Signed-off-by: Toni Förster <toni.foerster@gmail.com>

fix audio ducking and bluetooth play/pause

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-03 21:19:30 +02:00
Toni Förster
d56ef74a99
fix picture in picture
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-03 21:17:20 +02:00
Toni Förster
98f5b1a22b
mpv: remove video layer when entering background
- fixes #792

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-03 14:58:18 +02:00
Toni Förster
f0b7bd3ab8
hi-res invidious logos
second try

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-03 01:01:52 +02:00
Arkadiusz Fal
2d7a101ce0 Bump build number to 193 2024-08-31 17:08:23 +02:00
Arkadiusz Fal
b2114174b4 Update CHANGELOG 2024-08-31 17:08:09 +02:00
Arkadiusz Fal
e9f502a486
Merge pull request #775 from stonerl/fix-crash-on-hls-live-playback 2024-08-31 14:54:08 +02:00
Toni Förster
6978e9437c
fix crash on HLS live playback
fixes #774

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-31 14:22:28 +02:00
Arkadiusz Fal
2026201a5f
Merge pull request #765 from stonerl/partial-fix-for-503
Update now playing info when using system controls – Partial fix for 503
2024-08-31 13:21:41 +02:00
Toni Förster
633af02577
don’t activate AVAudioSession on app start
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-31 13:19:54 +02:00
Arkadiusz Fal
1fd62f04aa Update dependencies 2024-08-31 13:08:46 +02:00
Arkadiusz Fal
e749307a0e
Merge pull request #772 from stonerl/re-enter-fullscreen-on-background-return
fixed fullscreen handling for backgrounding
2024-08-31 13:08:40 +02:00
Toni Förster
d76ec881be
fixed fullscreen handling for backgrounding
Currently when returning from background, the app is fullscreen but the video is in portrait mode.

Now when entering background we leave fullscreen and enter it again the the app is in foreground.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-31 13:00:50 +02:00
Arkadiusz Fal
72a39a2c75
Merge pull request #767 from stonerl/new-defaults
Changes to defaults
2024-08-31 12:48:13 +02:00
Arkadiusz Fal
8a84db5a2d
Merge pull request #766 from stonerl/music-mode-thumbnail
make thumbnail fill the view in music mode
2024-08-31 12:47:52 +02:00
Arkadiusz Fal
663c37e3d2
Merge pull request #768 from stonerl/improved-image-loading
Video Thumbnails: retry 3 times fetching from URL
2024-08-31 12:47:31 +02:00
Arkadiusz Fal
ea2b329df2
Merge pull request #769 from stonerl/new-invidious-logo
circular invidious logo
2024-08-31 12:46:55 +02:00
Arkadiusz Fal
bd79f56800
Merge pull request #770 from stonerl/correct-landscape-setting
apply correct orientation
2024-08-31 12:46:41 +02:00
Arkadiusz Fal
9a650b4ac0
Merge pull request #762 from stonerl/allow-username-and-password-in-url
Invidious: propper HTTP basic auth support
2024-08-31 12:46:33 +02:00
Toni Förster
13382270d5
Revert "fixed some potential crashes"
This reverts commit bde9aade1173a8a466e3d54e2adc39bea49efe97.
2024-08-31 02:50:56 +02:00
Toni Förster
24626c2299
apply correct orientation
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 23:20:47 +02:00
Toni Förster
18ac577c7f
increase retry delay to 1 second
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 23:14:41 +02:00
Toni Förster
617af2cd20
format ordering adapted
- MP4 contains av1 encoded video which is not hardware accelerated.
- MP4 format removed from AVPlayer

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 22:04:31 +02:00
Toni Förster
1b778318dc
circular invidious logo
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 20:58:42 +02:00
Toni Förster
c9ce574c7a
VideoThumbnails: retry 3 times fetching from URL
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 20:01:25 +02:00
Toni Förster
9a1f0d7aaa
Changes to defaults
- Don’t use System Controls with AVPlayer by default
- Changed default order of the formats
- no default AVPlayer profiles except for tvOS
- new constants isIOS, isTvOS, isMacOS
- ArtWork for nowPlaying is .medium on iPhone .maxres on all other platforms
- changes to the player bar defaults

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 16:03:35 +02:00
Toni Förster
1cb695848c
make thumbnail fill the view in music mode
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-30 03:43:00 +02:00
Toni Förster
740a2f85ac
updateNowPlayingInfo also with System controls
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-29 15:10:04 +02:00
Toni Förster
1a22ac71be
move AVAudioSession configuration to AppDelegate
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-29 15:09:16 +02:00
Toni Förster
f0d581d512
remove autocorrect from location url TextField
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 18:05:40 +02:00
Toni Förster
049a42f2e8
allow basic auth with auth endpoint
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 17:40:16 +02:00
Toni Förster
cea2684a29
sanitise user and password in url
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 16:21:49 +02:00
Toni Förster
772e5016c4
make sure no log entries are lost
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 15:43:15 +02:00
Toni Förster
ed3d9a7d7c
invidious support for basic auth urls
This adds user, password and port to the proxy and thumbnail url, if they exist.

fixes #614 & #731

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 15:42:35 +02:00
Toni Förster
bde9aade11
fixed some potential crashes
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 15:39:03 +02:00
Arkadiusz Fal
a194738bb6 Bump build number to 192 2024-08-28 13:40:12 +02:00
Arkadiusz Fal
45567254f2 Update CHANGELOG 2024-08-28 13:40:00 +02:00
Arkadiusz Fal
a3139ad059 Fix swiftformat offense 2024-08-28 13:34:36 +02:00
Arkadiusz Fal
598f17479f
Merge pull request #760 from stonerl/hide-video-actions
Hide VideoActions Bar when no buttons is visible
2024-08-28 13:33:43 +02:00
Arkadiusz Fal
e888abfba9
Merge pull request #761 from stonerl/revert-new-appicons
Revert new AppIcons
2024-08-28 13:33:32 +02:00
Toni Förster
e1e53b2d36
Revert new AppIcons
This reverts commit 59da0e71b634080e98421e05d3e20242dd7d936d.

Revert "Merge pull request #758 from stonerl/new-app-icons-second-try"

This reverts commit 7b26fdf4008b08237c7554533523c005324897dc, reversing
changes made to 67b41e36d5f4ec210759bebb83b4e2c050e04151.

Revert "Merge pull request #756 from stonerl/new-app-icons-second-try"

This reverts commit b51eadc7a9d9c8746aeca3994a3f5bc70d88d203, reversing
changes made to 0c1fb02d508985b0f394a1a8b25d2ed9776fee0e.
2024-08-28 12:42:57 +02:00
Toni Förster
9510d91d61
Hide VideoActions Bar when no buttons is visible
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-28 12:32:59 +02:00
Arkadiusz Fal
59da0e71b6 Fix bundle identifier 2024-08-27 22:46:07 +02:00
Arkadiusz Fal
6bdfb7368c
Merge pull request #759 from stonerl/videoactions-color
Color changes to VideoActions
2024-08-27 22:26:56 +02:00
Arkadiusz Fal
7b26fdf400
Merge pull request #758 from stonerl/new-app-icons-second-try
change AppStore icon
2024-08-27 22:26:31 +02:00
Arkadiusz Fal
67b41e36d5
Merge pull request #757 from stonerl/zh-hans
Add Chinese (Simplified) - zh-Hans to LanguageCodes
2024-08-27 22:26:17 +02:00
Toni Förster
c9c60349df
Color changes to VideoActions
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 22:02:00 +02:00
Toni Förster
6a70663f06
add more iOS icons
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 17:59:59 +02:00
Toni Förster
3d556d836f
change the App Store Icon
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 16:26:56 +02:00
Toni Förster
8feeb33a55
distinguish between iOS and macOS AppIcons
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 15:45:54 +02:00
Toni Förster
497c3bfc12
Add Chinese (Simplified) - zh-Hans to LanguageCodes
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 15:33:06 +02:00
Arkadiusz Fal
b51eadc7a9
Merge pull request #756 from stonerl/new-app-icons-second-try
refreshed icons for iOS and macOS
2024-08-27 14:11:20 +02:00
Toni Förster
7d0c1180c4
refreshed icons for iOS and macOS
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 13:31:13 +02:00
Arkadiusz Fal
0c1fb02d50
Merge pull request #755 from yattee/revert-752-new-app-icons
Revert "refreshed icons for iOS and macOS"
2024-08-27 08:21:46 +02:00
Arkadiusz Fal
6f358fab56
Revert "refreshed icons for iOS and macOS" 2024-08-27 08:19:45 +02:00
Arkadiusz Fal
7631e2a8ed Bump build number to 191 2024-08-27 08:00:24 +02:00
Arkadiusz Fal
3a5f3fdfde Update CHANGELOG 2024-08-27 08:00:17 +02:00
Arkadiusz Fal
e3633bdaf7
Merge pull request #752 from stonerl/new-app-icons
refreshed icons for iOS and macOS
2024-08-27 07:56:31 +02:00
Arkadiusz Fal
e912d910bc
Merge pull request #754 from stonerl/fix-mpv-crash-on-macos
fix mpv crashing on macOS
2024-08-27 07:56:03 +02:00
Arkadiusz Fal
5ccb0f90d5
Merge pull request #753 from stonerl/use-new-mpvkit-repo
add new MPVKit repo
2024-08-27 07:55:55 +02:00
Toni Förster
278bc343c2
fix mpv crashing on macOS
fixes #712

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-27 04:04:23 +02:00
Toni Förster
5dc197664d
add new MPVKit repo
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-26 22:59:24 +02:00
Toni Förster
192550ba7a
refreshed icons for iOS and macOS
- added support for adaptive/tinted Icons on iOS.
- closes #700
- new macOS icon set, courtesy of @Kongolabongo (Discourse)

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-26 15:55:36 +02:00
Arkadiusz Fal
3369e23e74 Bump build number to 190 2024-08-26 08:20:57 +02:00
Arkadiusz Fal
4381511c91 Update CHANGELOG 2024-08-26 08:20:51 +02:00
Arkadiusz Fal
af99df9b8a
Merge pull request #750 from stonerl/refined-font-sclaing-for-captions
refined chapter font scaling
2024-08-26 08:16:55 +02:00
Arkadiusz Fal
21f21cc944
Merge pull request #749 from stonerl/fix-chapter-regression
fix regression and improve curentChapter handling
2024-08-26 08:16:45 +02:00
Arkadiusz Fal
e1d8bb8125
Merge pull request #748 from stonerl/fix-potential-crashes
fix some potential crashes
2024-08-26 08:16:28 +02:00
Arkadiusz Fal
d948ea6887
Merge pull request #747 from stonerl/fix-endless-loading-of-streams
Improved stream resolution handling
2024-08-26 08:16:19 +02:00
Toni Förster
66eb8051bf
refined chapter font scaling
adapted the scaling of chapter fonts after some user feedback

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-26 01:27:09 +02:00
Toni Förster
95d3170d31
fix regression and improve curentChapter handling
with #745 I left som testing changes in the PR that resulted in currentChapter index not being updated. This is fixed now.

Also, the ScrollViewReader waiter 0.5s before jumping to the current Chapter. So it is always drawn correctly.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-26 00:46:35 +02:00
Toni Förster
74b6adb247
fix some potential crashes
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-25 23:41:06 +02:00
Toni Förster
a45522f710
Improved stream resolution handling
Invidious now reports the actual resolution and doesn’t hardcode them anymore.

See: https://github.com/iv-org/invidious/pull/4586

- Extended the list of possible resolutions in the StreamModel
- trigger videoLoadFailureHandler if no streams are available
- more logging for backend.bestPlayable

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-25 17:23:04 +02:00
Arkadiusz Fal
0b01adf6eb Bump build number to 189 2024-08-24 17:55:30 +02:00
Arkadiusz Fal
444f6bcc03 Update CHANGELOG 2024-08-24 17:55:22 +02:00
Arkadiusz Fal
3f871bce2c Fix possible crashes 2024-08-24 17:52:35 +02:00
Arkadiusz Fal
dc3492fd96 Fix tvOS comments 2024-08-24 14:30:51 +02:00
Arkadiusz Fal
c7365a7dc1 Fix build issues 2024-08-24 14:04:02 +02:00
Arkadiusz Fal
fc9fc194f2 Bump build number to 188 2024-08-24 13:58:59 +02:00
Arkadiusz Fal
317ac63a3f Update CHANGELOG 2024-08-24 13:58:46 +02:00
Arkadiusz Fal
dc7cee7388 Revert "Stay fullscreen when opening notification center"
This reverts commit 08c922f57c81ca453a3e89d8e1d3a0e13ba141e3.
2024-08-24 13:56:25 +02:00
Arkadiusz Fal
1c5d909201 Revert mpvkit package location upgrade 2024-08-24 13:27:56 +02:00
Arkadiusz Fal
146da6b9cc
Merge pull request #746 from stonerl/SwiftyJSON-fixes
add missing SwiftyJSON arguments
2024-08-24 13:20:57 +02:00
Toni Förster
6be13451e0
add missing SwiftyJSON arguments
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-24 13:17:26 +02:00
Arkadiusz Fal
0c41ab6aa2 Update packages 2024-08-24 12:59:38 +02:00
Arkadiusz Fal
1b1e95711a Fix typo 2024-08-24 12:59:38 +02:00
Arkadiusz Fal
13d4a592bc
Merge pull request #570 from stonerl/close-fullscreen-on-end
add option to exit fullscreen on end
2024-08-24 12:16:26 +02:00
Arkadiusz Fal
ab493614ba
Merge pull request #744 from stonerl/hide-comments
Allow hiding comments
2024-08-24 12:13:44 +02:00
Arkadiusz Fal
0b57187435
Merge pull request #738 from stonerl/chore-linter-warnings
chore: address linter warnings
2024-08-24 12:12:25 +02:00
Arkadiusz Fal
e7928d1016
Merge branch 'main' into chore-linter-warnings 2024-08-24 12:12:12 +02:00
Arkadiusz Fal
b3d73aae92
Merge pull request #735 from stonerl/update-dependencies
update dependencies
2024-08-24 12:11:16 +02:00
Arkadiusz Fal
bafcacb9a1
Merge pull request #724 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-08-24 12:10:54 +02:00
maboroshin
931d373d41
Translated using Weblate (Japanese)
Currently translated at 98.5% (554 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2024-08-24 10:09:45 +00:00
Limonov
5d1620b0a0
Translated using Weblate (Russian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:44 +00:00
Limonov
44cfe8e6bc
Translated using Weblate (Ukrainian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/uk/
2024-08-24 10:09:44 +00:00
Limonov
8220bbc3fe
Translated using Weblate (Russian)
Currently translated at 98.9% (556 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:43 +00:00
Limonov
f01836010c
Translated using Weblate (Russian)
Currently translated at 98.5% (554 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:43 +00:00
Limonov
37ccb27c8e
Translated using Weblate (Russian)
Currently translated at 99.2% (558 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:42 +00:00
Kaambiz
93eb2eb258
Translated using Weblate (Persian)
Currently translated at 75.4% (424 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fa/
2024-08-24 10:09:41 +00:00
Saukki
ce9db8cd12
Translated using Weblate (Finnish)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fi/
2024-08-24 10:09:41 +00:00
Saukki
d361690d13
Added translation using Weblate (Finnish) 2024-08-24 10:09:40 +00:00
Limonov
ded9699a3a
Translated using Weblate (Russian)
Currently translated at 98.9% (556 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:40 +00:00
Limonov
101ddf79d5
Translated using Weblate (Russian)
Currently translated at 99.6% (560 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:39 +00:00
Limonov
d11c5a8c42
Translated using Weblate (Ukrainian)
Currently translated at 88.7% (499 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/uk/
2024-08-24 10:09:39 +00:00
Limonov
b2a84ef01b
Translated using Weblate (Russian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:38 +00:00
Limonov
6c4eb0c840
Translated using Weblate (Russian)
Currently translated at 97.6% (549 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ru/
2024-08-24 10:09:38 +00:00
Arkadiusz Fal
2b41c73d56
Merge pull request #743 from stonerl/controllcenter-swipe
Stay fullscreen when opening notification center
2024-08-24 12:09:28 +02:00
Arkadiusz Fal
516e305cd3
Merge pull request #742 from stonerl/subscription-button
Improvements to opening channels from Videos
2024-08-24 12:09:07 +02:00
Arkadiusz Fal
8921a38504
Merge pull request #741 from stonerl/timecodes-in-comments
iOS: make timestamps in comments touchable
2024-08-24 12:08:27 +02:00
Arkadiusz Fal
61d589a9b5
Merge pull request #740 from stonerl/thumbnails
Improved thumbnail handling
2024-08-24 12:07:56 +02:00
Arkadiusz Fal
55cbd1fe80
Merge pull request #737 from stonerl/xcode16-recommended-settings
Xcode 16 - update recommended settings
2024-08-24 12:06:35 +02:00
Arkadiusz Fal
d501cb938c
Merge pull request #745 from stonerl/updateWatch-status
only updateWatch status while video is playing
2024-08-24 12:02:42 +02:00
Toni Förster
8e97d3f42f
set playingFullscreen to proper value
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 22:56:55 +02:00
Toni Förster
64a18678ce
add option to exit fullscreen on end 2024-08-20 22:19:19 +02:00
Toni Förster
522aecfbc1
only updateWatch status while video is playing
This should circumvent edge cases where videos are marked as watch when they failed to play back.

Fixes #660

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 22:10:35 +02:00
Toni Förster
f5e509c091
Allow hiding comments 2024-08-20 20:39:21 +02:00
Toni Förster
08c922f57c
Stay fullscreen when opening notification center
The upper 5% of the screen ignore swipe down gestures when in fullscreen, to avoid leaving fullscreen when opening the notification center.

fixes #702

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 16:14:57 +02:00
Toni Förster
0e8436ab40
VideoDetails: click on channel name opens channel
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 14:13:46 +02:00
Toni Förster
f3c876acf6
VideoDetails: open channel when touching the logo
The touch was consumed by the double touch action and the channel did not open.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 14:00:43 +02:00
Toni Förster
70d821fe5d
use correct systemImage for the Subscribe button
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 13:18:32 +02:00
Toni Förster
9a450c9503
minor tweak to comment replies
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 01:23:54 +02:00
Toni Förster
7e346bf49c
iOS: make timestamps in comments touchable
Timestamps in comments can now be touched and jump to the corresponding part in the video.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-20 00:52:04 +02:00
Toni Förster
35534bcbb1
Improved thumbnail handling
- ThumbnailsModel optionally returns the quality
- Have constants for 4:3 and 16:9 aspect ratio
- Add high quality options for thumbnails
- Rename Highest quality to Best quality
- make 4:3 thumbnails fill the VideoCell
- use .maxes instead of .maxresdefault (the latter sometimes returns white images)

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-19 16:35:29 +02:00
Toni Förster
94577332a1
chore: address linter warnings
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-18 14:46:51 +02:00
Toni Förster
56b17b0aa1
Xcode 16 - update recommended settings
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-18 10:49:50 +02:00
Toni Förster
5512337984
update dependencies
MPVKit has a new home.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-08-15 02:59:39 +02:00
Arkadiusz Fal
af75afa912 Bump build number to 187 2024-07-06 13:51:40 +02:00
Arkadiusz Fal
f32bcae5eb Update CHANGELOG 2024-07-06 13:51:04 +02:00
Arkadiusz Fal
c55161b6b6 Fix typo 2024-07-06 13:44:40 +02:00
Arkadiusz Fal
3fd99f464a Revert change to OSD positions 2024-07-06 13:44:40 +02:00
Arkadiusz Fal
43c8484514 Allow import of accounts to manually added (not imported) instances 2024-07-06 13:44:40 +02:00
Arkadiusz Fal
7755b392b7 Fix seek OSD layout on tvOS
Fix #707
2024-07-06 12:51:07 +02:00
Arkadiusz Fal
e0998638b1 Fix macOS settings windows
Fix #699
Fix #710
2024-07-06 12:45:44 +02:00
Arkadiusz Fal
511a528eb6 Add import export of missing settings 2024-07-06 12:32:31 +02:00
Arkadiusz Fal
89dfbdb5c7 Fix swiftformat offenses 2024-07-06 11:48:49 +02:00
Arkadiusz Fal
f88a1d17d6 Update dependecies 2024-07-06 11:45:15 +02:00
Toni Förster
9e05909659 fix duplicated share button
fixes #691

add `--initial-audio-sync=<yes|no>` to MPV settings. Might fix #690 but needs to be toggled by the user. We leave the standard settings.

Also added links to the icons on macOS.
2024-07-06 11:35:21 +02:00
Arkadiusz Fal
b966f4509a
Merge pull request #704 from patelhiren/subscriptions-account-picker
tvOS: Allow account picker by long pressing channels button in subscriptions view
2024-07-06 11:35:15 +02:00
Arkadiusz Fal
dc7073dcb5
Merge pull request #701 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-07-06 11:28:49 +02:00
Hyunjae Chung
a258ee3be4
Translated using Weblate (Korean)
Currently translated at 11.2% (63 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ko/
2024-06-19 15:09:16 +02:00
Hiren Patel
b626f50adc - tvOS: Allow account picker by long pressing channels button in subscription view. 2024-06-16 23:21:49 -04:00
Pieter Janssens
78dc47dc24
Translated using Weblate (Dutch)
Currently translated at 80.7% (454 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/nl/
2024-06-14 19:09:21 +00:00
Arkadiusz Fal
46725bf4d9 Bump build number to 186 2024-06-13 19:12:19 +02:00
Arkadiusz Fal
8697ec8faf Update packages 2024-06-13 19:12:19 +02:00
Arkadiusz Fal
8a015d29c3 Update CHANGELOG 2024-06-13 19:12:19 +02:00
Arkadiusz Fal
4097d11b5e Fix build issues 2024-06-13 19:12:19 +02:00
Arkadiusz Fal
5323d53f9e Fix swiftformat offenses 2024-06-13 19:05:09 +02:00
Arkadiusz Fal
e3e0c4a92f Remove duplicated button 2024-06-13 19:04:46 +02:00
Arkadiusz Fal
9e5efc1aa6
Merge pull request #697 from patelhiren/main
tvOS: Refined Subscriptions View
2024-06-13 19:00:15 +02:00
Arkadiusz Fal
1ed4c20c3a
Merge pull request #696 from stonerl/improved-conditional-proxying
improved conditional proxying
2024-06-13 18:59:42 +02:00
Arkadiusz Fal
ced9eb28d7
Merge pull request #695 from stonerl/more-async-work
more responsive UI when Favorites are used.
2024-06-13 18:59:30 +02:00
Arkadiusz Fal
ea49758ed2
Merge pull request #694 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-06-13 18:58:58 +02:00
Hiren Patel
65ec675859 - Improved Subscription View for tvOS
- Refined Subscriptions view on tvOS

- Refined Subscriptions view on tvOS

- Refined Subscriptions view on tvOS

- Refined Subscriptions view on tvOS
2024-05-31 19:06:15 -04:00
Hyunjae Chung
9a650799d3
Translated using Weblate (Korean)
Currently translated at 6.4% (36 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ko/
2024-05-25 08:09:13 +02:00
Ophiushi
ddd1f243f7
Translated using Weblate (French)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2024-05-25 08:09:12 +02:00
Toni Förster
94f19d55c8
more responsive UI when Favorites are used.
- The reloading of items in the favorite widgets is now done async, so it does not block the UI when opening Home.
- Tasks that check for changes of the widget and favorites are now terminated when another view e.g. Subscriptions is opened.
- We only update the Favourites when the player is not in the foreground, to avoid unresponsiveness.
2024-05-24 19:46:42 +02:00
Toni Förster
30cdaf88e1
improved conditional proxying
We don't test every single stream anymore. If we get an 200, 206 or 403 we immediately stop testing streams. Unknown streams are also skipped. This speeds up starting playback, since we don'T have to wait for the network anymore.
2024-05-24 18:44:41 +02:00
Hyunjae Chung
8139bba31e
Added translation using Weblate (Korean) 2024-05-24 07:37:13 +02:00
Arkadiusz Fal
d6cfadab9a Bump build number to 185 2024-05-23 12:11:58 +02:00
Arkadiusz Fal
5b917ef91d Update CHANGELOG 2024-05-23 11:55:31 +02:00
Arkadiusz Fal
34cb7860b3 Fix build issues 2024-05-23 11:55:31 +02:00
Arkadiusz Fal
934bd65752 Fix swiftformat offenses 2024-05-23 11:44:58 +02:00
Arkadiusz Fal
e53985534e Update packages 2024-05-23 11:44:26 +02:00
Arkadiusz Fal
03e4c6d4e6
Merge pull request #689 from stonerl/more-mpv-settings
MPV: speed up playback start
2024-05-23 11:41:56 +02:00
Arkadiusz Fal
335e99cb7b
Merge pull request #680 from stonerl/add-user-agent-to-header
Add User-Agent to request
2024-05-23 11:37:53 +02:00
Arkadiusz Fal
ae9aa6fac7
Merge branch 'main' into add-user-agent-to-header 2024-05-23 11:37:46 +02:00
Arkadiusz Fal
2f4fb9fc67
Merge pull request #684 from stonerl/better-caption-handling
Improved Captions handling
2024-05-23 11:37:03 +02:00
Arkadiusz Fal
f6bea6e045
Merge pull request #685 from stonerl/chapter-images-for-invidious
Invidious: add images to chapters
2024-05-23 11:35:51 +02:00
Arkadiusz Fal
fa712d8177
Merge pull request #688 from patelhiren/main
Fix thumbnails failing to load on tvOS
2024-05-23 11:34:19 +02:00
Arkadiusz Fal
03d24fbc42
Merge pull request #682 from stonerl/faster-chapter-extraction
faster chapter extraction
2024-05-23 11:34:07 +02:00
Arkadiusz Fal
4fd3a37705
Merge pull request #681 from stonerl/speed-up-sorting
speed up sorting for Stream
2024-05-23 11:33:51 +02:00
Arkadiusz Fal
a66857b1fb
Merge pull request #683 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-05-23 11:33:14 +02:00
Toni Förster
e44c7f84c8
MPV: speed up playback start
We initialize MPV with more options so it does not have to guess. This dramatically speeds up playback start.
2024-05-23 10:53:45 +02:00
Hiren Patel
6b5ecbdd8b - Fix thumbnails failing to load on tvOS
Thumbnails fail to load on tvOS when using SDImageAWebPCoder. Use SDImageWebPCoder on tvOS.
2024-05-21 22:58:14 -04:00
Toni Förster
15ce82a686
added en-GB to LanguageCodes 2024-05-20 21:53:48 +02:00
Toni Förster
7e3e393c65
Invidious: add images to chapters
Invidious, by design, has no images attached to chapters, in contrast to Piped.

Since the majority of videos with chapters don't have chapter-specific images and only use the videos' thumbnail, there is no difference here when compared to Piped's native thumbnail support.
2024-05-20 20:11:41 +02:00
Toni Förster
108b4de483
allow user to choose captions color 2024-05-20 17:50:47 +02:00
Toni Förster
7c9810ddf0
tvOS does not support WebKit 2024-05-20 16:03:13 +02:00
Toni Förster
96df7fdec5
let the user select caption size 2024-05-20 15:47:35 +02:00
Toni Förster
4fa5a15ad4
fallback language for captions 2024-05-20 14:42:24 +02:00
Toni Förster
c9125644ed
improvements to captions on tvOS 2024-05-20 14:20:08 +02:00
Toni Förster
4db02b2638
Improved Captions handling
New options for captions in `Settings-Player`:

- Always show captions
- Default language

User can now select whether they want to show captions automatically when the video starts, and select the language.

Captions selector now shows proper name -> `English (en)` instead of only `en`
2024-05-20 03:50:51 +02:00
Mohammed Al Otaibi
9c5f066e55
Translated using Weblate (Arabic)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-05-19 23:01:50 +02:00
joaooliva
c7908d08ae
Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2024-05-19 23:01:49 +02:00
Toni Förster
c9fb41c8e8
faster chapter extraction
The extraction of chapters is now faster since it is run in parallel for each pattern. Also a new pattern hast been added: "(start) title"
2024-05-19 17:43:35 +02:00
Toni Förster
2e9cceafa5
Add User-Agent to request
We generate a User-Agent for the platform we are running on and add it to the URLTester requests and also to MPV and AVPlayer
2024-05-19 13:46:14 +02:00
Toni Förster
fa09b2021c
speed up sorting for Stream
This should help to start playback a bit faster.
2024-05-19 12:39:47 +02:00
Arkadiusz Fal
90777d91f6 Bump build number to 184 2024-05-19 11:52:38 +02:00
Arkadiusz Fal
6959778775 Update CHANGELOG 2024-05-19 11:52:38 +02:00
Arkadiusz Fal
0f43efef6f Fix build issue 2024-05-19 11:52:37 +02:00
Arkadiusz Fal
959fb0d1fc
Merge pull request #676 from stonerl/fix-pip-broken-with-mpv
fix PiP Mode Not Working Using MPV
2024-05-19 09:51:38 +02:00
Arkadiusz Fal
81be57904b
Merge pull request #679 from stonerl/mpv-settings
Advanced Settings: cache-pause-initial
2024-05-19 09:51:26 +02:00
Arkadiusz Fal
a42345896d
Merge pull request #677 from stonerl/quality-reorderdering-iOS16
changed description for Format reordering
2024-05-19 09:51:10 +02:00
Toni Förster
43fc9e20c0
Advanced Settings: cache-pause-initial
`cache-pause-initial` status can now be selected by the user.

Also, on macOS and iOS, a link next to the option leads the user to the info on the mpv website.
2024-05-19 03:50:33 +02:00
Toni Förster
1a1bd1ba5b
fix PiP Mode Not Working Using MPV
fixes #674

I accidentally broke PiP when using MPV. While testing this, I noticed that PiP sometimes does not start, so I tried to make MPV to PiP a bit more robust.
2024-05-19 00:58:52 +02:00
Toni Förster
99aca8e23c
changed description for Format reordering
reordering Formats only works on iOS 16 and newer
2024-05-19 00:46:01 +02:00
Arkadiusz Fal
ddee3b74f0 Bump build number to 183 2024-05-18 11:55:59 +02:00
Arkadiusz Fal
b271aed52b Update CHANGELOG 2024-05-18 11:55:32 +02:00
Arkadiusz Fal
1c608c78a1 Update packages 2024-05-18 11:49:32 +02:00
Arkadiusz Fal
0ec227ba80 Fix application groups 2024-05-18 11:48:32 +02:00
Arkadiusz Fal
2a93ff52a3
Merge pull request #673 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-05-18 11:42:26 +02:00
Arkadiusz Fal
896d46d0cf
Merge pull request #662 from stonerl/piped-proxy
Conditional proxying
2024-05-18 11:42:11 +02:00
mere
ad79180530
Translated using Weblate (Romanian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2024-05-18 11:42:04 +02:00
gallegonovato
101f20c538
Translated using Weblate (Spanish)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2024-05-18 11:42:03 +02:00
Arkadiusz Fal
f28cec79ba
Merge pull request #672 from stonerl/favorites-view-tweaks
HomeView: Changes to Favourites and History Widget
2024-05-18 11:41:57 +02:00
Arkadiusz Fal
a12755ec4b
Merge pull request #671 from stonerl/snappy-ui
Snappy UI - Offloading non UI task to background threads
2024-05-18 11:41:09 +02:00
Toni Förster
38c4ddbe43
HomeView: Changes to Favourites and History Widget
The History Widget in the Home View was hard-coded to 10 items. Now it uses the limit set in the settings.

The items weren't immediate updated when the limit was changed.

List Views had a hard-coded limit of 10 items. Now they use the limit supplied in the parameter.
2024-05-18 00:36:40 +02:00
Toni Förster
e35f8b7892
Snappy UI - Offloading non UI task to background threads
This gives a huge increase in perceived performance. The UI is now much more responsive since some tasks are run in the background and don't block the UI anymore.
2024-05-17 21:36:02 +02:00
Toni Förster
c3e4c074d6
Add HTTP Response StatusCode List and fix potential race condition 2024-05-17 19:30:24 +02:00
Toni Förster
6eba2a45c8
Conditional proxying
I added a new feature. When instances are not proxied, Yattee first checks the URL to make sure it is not a restricted video. Usually, music videos and sports content can only be played back by the same IP address that requested the URL in the first place. That is why some videos do not play when the proxy is disabled.

This approach has multiple advantages. First and foremost, It reduced the load on Invidious/Piped instances, since users can now directly access the videos without going through the instance, which might be severely bandwidth limited. Secondly, users don't need to manually turn on the proxy when they want to watch IP address bound content, since Yattee automatically proxies such content.

Furthermore, adding the proxy option allows mitigating some severe playback issues with invidious instances. Invidious by default returns proxied URLs for videos, and due to some bug in the Invidious proxy, scrubbing or continuing playback at a random timestamp can lead to severe wait times for the users.

This should fix numerous playback issues: #666, #626, #590, #585, #498, #457, #400
2024-05-16 19:35:31 +02:00
Arkadiusz Fal
1fe8a32fb8 Bump build number to 182 2024-05-16 19:09:12 +02:00
Arkadiusz Fal
5a0c1bbae3 Update CHANGELOG 2024-05-16 19:07:50 +02:00
Arkadiusz Fal
4ce9dc6729 Fix build issues 2024-05-16 19:05:00 +02:00
Arkadiusz Fal
b783db30b6 Update dependencies 2024-05-16 19:01:02 +02:00
Arkadiusz Fal
7741e531f4 Swiftformat fixes 2024-05-16 19:01:02 +02:00
Arkadiusz Fal
4b21cd48e3
Merge pull request #669 from yattee/revert-652-seek-gesture
Revert "small delay before vertical scrubbing is possible"
2024-05-16 19:00:30 +02:00
Arkadiusz Fal
d67e375f16
Revert "small delay before vertical scrubbing is possible" 2024-05-16 18:59:54 +02:00
Arkadiusz Fal
db2417e455
Merge pull request #627 from 0x000C/bugfix/619
Fix #619: Remove ports from shared YouTube links
2024-05-16 18:25:35 +02:00
Arkadiusz Fal
7f8aa51c78
Update Model/Applications/VideosAPI.swift 2024-05-16 18:25:15 +02:00
Arkadiusz Fal
4038f7fdb9
Update Model/Applications/VideosAPI.swift 2024-05-16 18:25:08 +02:00
Arkadiusz Fal
8a7e4c84b5
Merge pull request #653 from stonerl/mpvkit-0-38-0
upgrade to mpvkit-0.38.0
2024-05-16 18:24:41 +02:00
Arkadiusz Fal
eb491a890d
Merge pull request #654 from stonerl/upgrade-dependencies
upgraded some dependencies
2024-05-16 18:23:31 +02:00
Arkadiusz Fal
c6724472a6
Merge pull request #667 from stonerl/hls-set-target-quality
HLS: set target bitrate / AVPlayer: higher resolution
2024-05-16 18:23:05 +02:00
Arkadiusz Fal
201de81351
Merge pull request #650 from stonerl/rework-qualitiy-settings
Rework qualitiy settings
2024-05-16 18:22:58 +02:00
Arkadiusz Fal
ae12eefafc
Merge pull request #665 from stonerl/chapter-pictures
allow user to disable thumbnails and jump to current chapter in horizontal view
2024-05-16 18:22:19 +02:00
Arkadiusz Fal
46f89db11a
Merge pull request #644 from stonerl/music-mode
MusicMode: don't bindPlayerToLayer when entering foreground
2024-05-16 18:21:35 +02:00
Arkadiusz Fal
c9d20d28de
Merge pull request #645 from timonus/make-url-scheme-work
Handle deep links.
2024-05-16 18:21:15 +02:00
Arkadiusz Fal
094461d359
Merge pull request #641 from stonerl/switch-to-previous-backend
switch to previous backend when leaving PiP
2024-05-16 18:20:49 +02:00
Arkadiusz Fal
b9649b6356
Merge pull request #651 from stonerl/time-reset-on-stream-change
preserve time on stream change
2024-05-16 18:20:19 +02:00
Arkadiusz Fal
3d8feda808
Merge pull request #661 from stonerl/number-fields
Advanced settings: make number fields .numPad
2024-05-16 18:19:59 +02:00
Arkadiusz Fal
d9aa5105fa
Merge pull request #636 from stonerl/captions
fix handling and displaying captions
2024-05-16 18:18:49 +02:00
Arkadiusz Fal
42110f32da
Merge pull request #664 from stonerl/call-correct-class
call correct class of  SDImageAWebPCoder
2024-05-16 18:17:47 +02:00
Arkadiusz Fal
a6c5c3905a
Merge pull request #648 from stonerl/sponsorblock-jump-to-end
SponsorBlock jump to end instead of pausing
2024-05-16 18:17:32 +02:00
Arkadiusz Fal
7b484e80b8
Merge pull request #646 from stonerl/EOF-start-playback-again
Restart finished video
2024-05-16 18:17:13 +02:00
Arkadiusz Fal
68f3d5c631
Merge pull request #655 from stonerl/chapter-title-on-jump
Chapter title on jump
2024-05-16 18:16:24 +02:00
Arkadiusz Fal
9d291cca28
Merge pull request #639 from stonerl/sponsor-block
SponsorBlock Improvements
2024-05-16 18:15:38 +02:00
Arkadiusz Fal
7c108f18ff
Merge pull request #652 from stonerl/seek-gesture
small delay before vertical scrubbing is possible
2024-05-16 18:14:13 +02:00
Arkadiusz Fal
1a3012853d
Merge pull request #642 from stonerl/queue-header
only show Queue header in sidebar view
2024-05-16 18:13:53 +02:00
Arkadiusz Fal
5b64290bc5
Merge pull request #640 from stonerl/audio-session-interrupts
handle audio session interrupts by other media
2024-05-16 18:13:36 +02:00
Arkadiusz Fal
34989a4f0f
Merge pull request #638 from stonerl/log-streaming
XCode enable IDEPreferLogStreaming
2024-05-16 18:13:22 +02:00
Arkadiusz Fal
4ab60080f6
Merge pull request #635 from stonerl/related-videos
don't show related in sidebar when disabled in settings
2024-05-16 18:13:05 +02:00
Arkadiusz Fal
a3a198a32d
Merge pull request #637 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-05-16 18:12:36 +02:00
Toni Förster
b54044cbc5
HLS: set target bitrate / AVPlayer: higher resolution
HLS: try matching the set resolution. This works okay with AVPlayer. With MPV it is hit and miss, most of the time MPV targets the highest available bitrate, instead of the set bitrate.

AVPlayer now supports higher resolution up to 1080p60.
2024-05-13 07:54:24 +02:00
Toni Förster
ebc48fde90
jump to current chapter in horizontal view 2024-05-11 23:49:46 +02:00
Toni Förster
7c50db426f
Chapters: allow user to disable thumbnails
Chapters have their own section is the settings. The users can decide if chapter thumbnails should be shown or not. Also they can decide to only show thumbnails if they are not the same.

The VideoDetailsView had to be modified, to avoid compiler type-checking errors.
2024-05-11 22:12:10 +02:00
Toni Förster
a1bde07ee1
don't draw chapter mark if start is 0 2024-05-11 17:51:35 +02:00
Toni Förster
fba01e35a3
apply best resolution from non HLS to HLS stream
This makes sure that HLS Streams can be compared with non HLS streams, otherwise HLS would have been the first selected since the maxResolution.value might have been higher then what could actually be the highest resolution served.
2024-05-11 14:08:40 +02:00
Toni Förster
16bf83274f
call correct class of SDImageAWebPCoder
We added a non-existing class and this caused a lot of severe hangs.
2024-05-10 15:38:19 +02:00
Toni Förster
d8c8f8084b
fix a crash when format is hls 2024-05-09 21:15:14 +02:00
Toni Förster
2590f041c2
Advanced settings: make number fields .numPad 2024-05-09 15:02:31 +02:00
Toni Förster
790cb5ce1d
upgraded some dependencies 2024-05-03 17:15:40 +02:00
Toni Förster
7b7f877fa5
upgrade to mpvkit-0.38.0
subtitles are working gain (was broken with 0.37.0)
2024-05-03 17:08:30 +02:00
Toni Förster
1d86154012
make heading equal size to related heading 2024-05-03 16:51:12 +02:00
Toni Förster
03fbb4933a
new SeekType chapterSkip
When a user taps on a chapter, the pop now also shows the name of the chapter. The chapters are now marked in AppRedColor instead of orange.

This is based on PR #639

That one needs to be merged first before this one can go in.
2024-05-03 15:20:51 +02:00
Kaambiz
bb1d3cd273
Translated using Weblate (Persian)
Currently translated at 74.7% (420 of 562 strings)

Co-authored-by: Kaambiz <kambizx@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fa/
Translation: Yattee/Localizable.strings
2024-05-02 15:07:40 +02:00
José Alves Amaro
fa978dc6d0
Translated using Weblate (Portuguese)
Currently translated at 96.2% (541 of 562 strings)

Co-authored-by: José Alves Amaro <Nykold@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt/
Translation: Yattee/Localizable.strings
2024-05-02 15:07:39 +02:00
Toni Förster
3da081b40c
small delay before vertical scrubbing is possible
This avoids accidentally scrubbing. The screen now needs to be touched at least 250 ms before time scrubbing is possible.

should fix #393
2024-05-01 19:11:42 +02:00
Toni Förster
0d5a907517
preserve time on stream change
fixes #371
2024-05-01 17:01:54 +02:00
Toni Förster
ef7a486fd4
add migration for old profiles to new format 2024-05-01 14:30:19 +02:00
Toni Förster
54915dcea1
rework quality settings
- The order of the formats can now be changed in the Quality Settings.
- sortingOrder is now part of QualitiyProfile.
- bestPlayable is now part of PlayerBackend.
- hls and stream aren't treated as unknown anymore.
2024-04-29 20:03:51 +02:00
Toni Förster
86b91916a7
Restart finished video
After the video has ended, hitting play restarts the video from the beginning

fixes #449

If I understand the issue correctly, however, videos can now be played again without hitting the "Start from the beginning" button.
2024-04-27 00:37:04 +02:00
Toni Förster
4144a29608
only bind Player if the active backend is AVPlayer 2024-04-26 19:42:13 +02:00
Toni Förster
c41b635276
MusicMode: don't bindPlayerToLayer when entering foreground
fixes #617
2024-04-26 14:48:07 +02:00
Toni Förster
c118c77c14
SponsorBlock jump to end instead of pausing
When the time stamp for the last segment is greater the duration currently the video pauses. This interferes with PR #646.

Now instead of pausing we jump to the duration (end time) of the video instead.
2024-04-26 14:03:13 +02:00
Tim Johnsen
626652c421
Update iOS/AppDelegate.swift 2024-04-25 10:18:10 -07:00
Tim Johnsen
fb42b80276
Handle deep links. 2024-04-25 10:01:44 -07:00
Toni Förster
00baf60970
only show Queue header in sidebar view 2024-04-25 13:15:55 +02:00
Toni Förster
0230106a1e
switch to previous backend when leaving PiP
Currently, when leaving PiP the backend doesn't switch to the one that was used before starting PiP.

Now, when the backend was MPV, it switches back to it after leaving PiP.
2024-04-24 21:32:32 +02:00
Toni Förster
169b9451ed
handle audio session interrupts by other media
fixes #495
2024-04-24 14:43:51 +02:00
Toni Förster
ae65acdd16
Interface tweaks for SponsorBlock during playback
Show/Hide categories in timeline.
Show/Hide notice after skip.
Show adjusted or actual total time.
2024-04-23 22:08:08 +02:00
Toni Förster
b5ac760af2
SponsorBlock set colors for each category
Default colors are defined in alignment to upstream. These can be changed by the user.
The colors are also used in the Timeline and the seek view.
2024-04-23 17:16:31 +02:00
Toni Förster
321eaecd21
SponsorBlock align categories with upstream 2024-04-23 11:43:05 +02:00
Toni Förster
0e7d66849d
whitespace fixes 2024-04-23 11:39:32 +02:00
Toni Förster
25208c2b5c
XCode enable IDEPreferLogStreaming
helps when debugging over WiFi
2024-04-23 10:11:26 +02:00
Toni Förster
f3637e2426
fix handling and displaying captions
fixes #490

It also fixes the caption picker being empty when a caption was selected in the previous watched video.
2024-04-21 01:04:31 +02:00
Toni Förster
dd6106447f
don't show related in sidebar when disabled in settings
fixes #634
2024-04-20 13:35:36 +02:00
Arkadiusz Fal
d1cf45c6a1 Bump build number to 181 2024-04-02 23:14:51 +02:00
Arkadiusz Fal
07f3d841b3 Update CHANGELOG 2024-04-02 23:14:18 +02:00
Arkadiusz Fal
b488f86160 Downgrade MPVKit to 0.36.0-1
commit dca1e345a26d09a3d621d7656a94e6427f3f7b83
2024-04-02 23:14:18 +02:00
Arkadiusz Fal
e64c3a3c77 Update packages 2024-04-02 23:14:17 +02:00
Arkadiusz Fal
576a993faf
Merge pull request #631 from stonerl/comments-piped
hopefully fixes #629
2024-04-02 22:51:07 +02:00
Toni Förster
c77c5a6d21
don't add empty comments 2024-04-02 15:08:36 +02:00
Toni Förster
ae16680fc2
removed some unnecessary print() 2024-04-02 14:28:06 +02:00
Toni Förster
807c0a1e2e
hopefully fixes #629
should also get rid of empty comments if they couldn't be loaded
2024-04-02 14:28:06 +02:00
Arkadiusz Fal
96a2119a05
Merge pull request #632 from stonerl/invidious-html-comments
iv: use html comments instead of plain text
2024-04-01 23:12:12 +02:00
Arkadiusz Fal
7e940d6304
Merge pull request #624 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-04-01 22:59:05 +02:00
mere
11402cc2a6
Translated using Weblate (Romanian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2024-04-01 22:58:48 +02:00
floe nele
975d8b0ba0
Translated using Weblate (Dutch)
Currently translated at 44.3% (249 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/nl/
2024-04-01 22:58:48 +02:00
jonnysemon
e349898d9e
Translated using Weblate (Arabic)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-04-01 22:58:48 +02:00
rexcsk
a8802da5a7
Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/zh_Hant/
2024-04-01 22:58:48 +02:00
maboroshin
19993dfc04
Translated using Weblate (Japanese)
Currently translated at 98.3% (553 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2024-04-01 22:58:48 +02:00
joaooliva
e99dd442e1
Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2024-04-01 22:58:48 +02:00
gallegonovato
d886113f27
Translated using Weblate (Spanish)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2024-04-01 22:58:48 +02:00
Ophiushi
ea9b759887
Translated using Weblate (French)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2024-04-01 22:58:48 +02:00
rexcsk
0e784be231
Translated using Weblate (Chinese (Traditional))
Currently translated at 99.6% (560 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/zh_Hant/
2024-04-01 22:58:48 +02:00
rexcsk
d0ab73eeb2
Translated using Weblate (English)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2024-04-01 22:58:48 +02:00
Arkadiusz Fal
2193129818
Merge pull request #622 from rickykresslein/limit-recents-shown
Show/hide recents and limit number of recents shown
2024-04-01 22:58:42 +02:00
Toni Förster
f84c6d319a
iv: use html comments instead of plain text
It now correctly displays emojis hyphens
2024-04-01 15:08:08 +02:00
0x000C
1f667818db Change shareURL() in VideosAPI and callers to use URLs instead of hostnames 2024-03-20 20:21:26 -07:00
0x000C
784893048d Merge branch 'bugfix/619' of https://github.com/0x000C/yattee into bugfix/619 2024-03-18 22:06:23 -07:00
0x000C
6ec516dc3d fix: Remove ports from shared YouTube links
Fix #619: Remove ports from shared YouTube links
2024-03-18 22:01:34 -07:00
0x000C
1c7da30caf fix: Remove ports from shared YouTube links 2024-03-18 21:58:16 -07:00
Ricky Kresslein
87337f31a5 Updated importer and exporter to include new defaults 2024-02-28 18:35:03 +01:00
Arkadiusz Fal
cf5262a86e Bump build number to 180 2024-02-28 14:35:54 +01:00
Arkadiusz Fal
d6be0ffa5b Update CHANGELOG 2024-02-28 14:35:54 +01:00
Arkadiusz Fal
1df8241a01 Update packages 2024-02-28 14:35:54 +01:00
Arkadiusz Fal
43e5eae658 Use latest stable Xcode for build 2024-02-28 13:50:03 +01:00
Arkadiusz Fal
71b4560ff8
Merge pull request #623 from rickykresslein/help-text
Add help text to all header buttons
2024-02-28 13:38:33 +01:00
Arkadiusz Fal
f6bb2fe5d1
Merge pull request #621 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-02-28 13:38:00 +01:00
rexcsk
272aafe504
Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/zh_Hant/
2024-02-28 13:36:29 +01:00
rexcsk
580d782c56
Translated using Weblate (Chinese (Simplified))
Currently translated at 93.9% (528 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/zh_Hans/
2024-02-28 13:36:25 +01:00
Ricky Kresslein
238ddc7ad9 Add help text to all header buttons 2024-02-28 01:03:33 +01:00
Ricky Kresslein
5559e78bc0 Add setting to show/hide recents and limit number of recents shown 2024-02-28 00:56:12 +01:00
rexcsk
6cc38df4e9
Added translation using Weblate (Chinese (Traditional)) 2024-02-27 15:30:55 +01:00
Arkadiusz Fal
7b34c7e72b Bump build number to 179 2024-02-21 10:17:38 +01:00
Arkadiusz Fal
0dd7943849 Update CHANGELOG 2024-02-21 10:17:22 +01:00
Arkadiusz Fal
6745934a78 Update packages 2024-02-21 10:16:05 +01:00
Arkadiusz Fal
76801a34ee
Merge pull request #616 from rickykresslein/main
Add skip, play/pause, and fullscreen shortcuts to macOS player
2024-02-21 10:11:57 +01:00
Arkadiusz Fal
4d0318d4b0
Merge pull request #612 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-02-21 10:11:19 +01:00
Ricky Kresslein
9d4446a6ef Add skip, play/pause, and fullscreen shortcuts to macOS player 2024-02-17 10:40:27 +01:00
mere
b74017894c
Translated using Weblate (Romanian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ro/
2024-02-09 22:02:08 +01:00
Mohammed Al Otaibi
9fef6c0276
Translated using Weblate (Arabic)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-02-08 10:01:56 +01:00
gallegonovato
fcbeb45d1e
Translated using Weblate (Spanish)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/es/
2024-02-08 10:01:55 +01:00
maboroshin
66f7286cdc
Translated using Weblate (Japanese)
Currently translated at 98.5% (554 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ja/
2024-02-06 11:01:58 +01:00
jonnysemon
e1e068ba11
Translated using Weblate (Arabic)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/ar/
2024-02-06 11:01:58 +01:00
Ophiushi
524c99dd54
Translated using Weblate (French)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/fr/
2024-02-06 11:01:58 +01:00
joaooliva
b57ed7055c
Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pt_BR/
2024-02-04 22:01:56 +01:00
Arkadiusz Fal
d84d701b07
Translated using Weblate (Polish)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2024-02-04 22:01:56 +01:00
Arkadiusz Fal
bcfd4126b6
Translated using Weblate (English)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2024-02-04 22:01:56 +01:00
Arkadiusz Fal
97b16cfd04 Fix Package.resolved 2024-02-03 22:02:14 +01:00
Arkadiusz Fal
d5b81ceba1 Use macOS 13 in release workflow 2024-02-03 22:00:00 +01:00
Arkadiusz Fal
f3ba61a168 Bump build number to 178 2024-02-03 21:55:29 +01:00
Arkadiusz Fal
c68aa1d30c Update CHANGELOG 2024-02-03 21:55:17 +01:00
Arkadiusz Fal
d187fc322c Update packages 2024-02-03 21:55:09 +01:00
Arkadiusz Fal
e616022278 Use Xcode 14.3.1 for fastlane builds 2024-02-03 21:49:45 +01:00
Arkadiusz Fal
1b0486df05 Localizations improvements 2024-02-03 21:49:45 +01:00
Arkadiusz Fal
e6deb9ef26 Add import on tvOS, other export/import improvements 2024-02-03 21:49:45 +01:00
Arkadiusz Fal
0216c17b95
Merge pull request #610 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-02-03 21:47:22 +01:00
Arkadiusz Fal
1eff757caf
Translated using Weblate (Polish)
Currently translated at 100.0% (561 of 561 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2024-02-03 21:47:09 +01:00
Arkadiusz Fal
4cfd00b307
Translated using Weblate (English)
Currently translated at 100.0% (561 of 561 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2024-02-03 21:47:09 +01:00
Arkadiusz Fal
8075db3ac8
Merge pull request #609 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-02-03 21:30:20 +01:00
Arkadiusz Fal
2cd867e344
Translated using Weblate (Polish)
Currently translated at 100.0% (560 of 560 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2024-02-03 21:30:07 +01:00
Arkadiusz Fal
b5b2e7f13d
Translated using Weblate (English)
Currently translated at 100.0% (560 of 560 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2024-02-03 21:30:07 +01:00
Arkadiusz Fal
cbd7c417d2
Merge pull request #608 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-02-03 21:21:11 +01:00
Arkadiusz Fal
ed7a233c9b
Translated using Weblate (Polish)
Currently translated at 100.0% (554 of 554 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/pl/
2024-02-03 21:20:49 +01:00
Arkadiusz Fal
d75e3e9a61
Translated using Weblate (English)
Currently translated at 100.0% (554 of 554 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/en/
2024-02-03 21:20:49 +01:00
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
214 changed files with 12552 additions and 2705 deletions

View File

@ -29,7 +29,7 @@ jobs:
name: Releasing ${{ matrix.lane }} version to TestFlight
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
@ -38,10 +38,13 @@ jobs:
run: |
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: maierj/fastlane-action@v3.0.0
with:
lane: ${{ matrix.lane }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.lane }} build
path: fastlane/builds/**/*.ipa
@ -50,7 +53,7 @@ jobs:
name: Build and notarize macOS app
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
@ -59,6 +62,9 @@ jobs:
run: |
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
@ -70,7 +76,7 @@ jobs:
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: mac notarized build
path: ${{ env.ZIP_PATH }}
@ -80,10 +86,10 @@ jobs:
name: Create GitHub release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
path: artifacts
- uses: ncipollo/release-action@v1

View File

@ -1,22 +1,123 @@
## Build 173
* Updated dependencies
* Updated localizations
* Fixed reported crashes
* Other minor changes and improvements
## Build 199
## What's Changed
* Add support for invidious companion by @lifo9 in https://github.com/yattee/yattee/pull/863
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/851
## Previous builds
* Description is collapsible with a button
* Links in description are clickable on macOS
* Aspect ratio is honored on resize on macOS
* Added support for private Invidious instances
* Collapsible chapters view, player setting "Open vertical chapters expanded"
* Current chapter is highlighted
* Disabled portrait upside down orientation on iPhone
* Fixed issue with handling private Invidious instances requests
* Fixed issue where Piped login token would not refresh
* Fixed issue with MPV subtitles not working
* Added Persian, Spanish, Turkish and Russian localizations
* Fixed issue with displaying account username
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
* Added Settings Import/Export
* Export all settings, instances and accounts
* Import selected elements from the file
* Include unencrypted passwords in the export or provide them during the import
* Import via URL for tvOS
* Added Controls setting "Action button labels" icon or icon and text
* Added Advanced setting for MPV: "deinterlace"
* Add help text to all header buttons (by @rickykresslein)
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
* Fix issues with empty comments (by @stonerl)
* Improved Invidious comments (by @stonerl)
* Allow import of accounts to manually added (not imported) instances
* Add import export of missing settings
* macOS: Fix settings windows layout
* Fix seek OSD layout on tvOS, revert OSD position
* Allow users to disable fullscreen swipe gesture by @stonerl in https://github.com/yattee/yattee/pull/814
* Proper audio interrupt and route change handling by @stonerl in https://github.com/yattee/yattee/pull/815
* Improved subtitle handling by @stonerl in https://github.com/yattee/yattee/pull/817
* Improvements to MPVGLView by @stonerl in https://github.com/yattee/yattee/pull/818
* Add drag gestures to video details by @stonerl in https://github.com/yattee/yattee/pull/820
* Fix uneven playback when using MPV and not syncing refreshrate by @blennster in https://github.com/yattee/yattee/pull/833
* Norwegian Language by @mmaalo in https://github.com/yattee/yattee/pull/834
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/836
* Update MPVKit to v0.39.0 by @stonerl in https://github.com/yattee/yattee/pull/824
* Update SwiftUI-Introspect by @stonerl in https://github.com/yattee/yattee/pull/813
* Orientation/Fullscreen fixes and cleanup by @stonerl in https://github.com/yattee/yattee/pull/806
* More robust resolution handling by @stonerl in https://github.com/yattee/yattee/pull/807
* MPV: improved A/V sync by @stonerl in https://github.com/yattee/yattee/pull/805
* Retry loading video before presenting error by @stonerl in https://github.com/yattee/yattee/pull/810
* Refactor Search by @stonerl in https://github.com/yattee/yattee/pull/809
* iOS: Simplified fullscreen and orientation by @stonerl in https://github.com/yattee/yattee/pull/786
* macOS: only apply player shortcuts when window is active by @stonerl in https://github.com/yattee/yattee/pull/802
* player controls: add background opacity selection by @stonerl in https://github.com/yattee/yattee/pull/799
* add missing Shorts resolutions by @stonerl in https://github.com/yattee/yattee/pull/797
* use -O1 on macOS by @stonerl in https://github.com/yattee/yattee/pull/801
* Gestures: swipe up toggles fullscreen by @stonerl in https://github.com/yattee/yattee/pull/778
* dont open video when dismissing context menu by @stonerl in https://github.com/yattee/yattee/pull/780
* mpv: remove video layer when entering background by @stonerl in https://github.com/yattee/yattee/pull/793
* hi-res invidious logos by @stonerl in https://github.com/yattee/yattee/pull/791
* enable -O3 by @stonerl in https://github.com/yattee/yattee/pull/794
* Better audio ducking by @stonerl in https://github.com/yattee/yattee/pull/779
* fix picture in picture by @stonerl in https://github.com/yattee/yattee/pull/789
* Invidious: propper HTTP basic auth support by @stonerl in https://github.com/yattee/yattee/pull/762
* Apply correct orientation by @stonerl in https://github.com/yattee/yattee/pull/770
* Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769
* Video Thumbnails: retry 3 times fetching from URL by @stonerl in https://github.com/yattee/yattee/pull/768
* Make thumbnail fill the view in music mode by @stonerl in https://github.com/yattee/yattee/pull/766
* Changes to defaults by @stonerl in https://github.com/yattee/yattee/pull/767
* Fixed fullscreen handling for backgrounding by @stonerl in https://github.com/yattee/yattee/pull/772
* Update now playing info when using system controls Partial fix for 503 by @stonerl in https://github.com/yattee/yattee/pull/765
* Stop making videos with unknown length shorts. by @derspyy in https://github.com/yattee/yattee/pull/849
* Add Hungarian to locales list
* Fix crash on HLS live playback by @stonerl in https://github.com/yattee/yattee/pull/775
* Fix mpv crashing on macOS by @stonerl in https://github.com/yattee/yattee/pull/754
* Refreshed icons for iOS and macOS by @stonerl in https://github.com/yattee/yattee/pull/752
* Add new MPVKit repo by @stonerl in https://github.com/yattee/yattee/pull/753
* Add Chinese (Simplified) - zh-Hans to LanguageCodes by @stonerl in https://github.com/yattee/yattee/pull/757
* Color changes to VideoActions by @stonerl in https://github.com/yattee/yattee/pull/759
* Hide VideoActions Bar when no buttons is visible by @stonerl in https://github.com/yattee/yattee/pull/760
* Improved stream resolution handling by @stonerl in https://github.com/yattee/yattee/pull/747
* Fix some potential crashes by @stonerl in https://github.com/yattee/yattee/pull/748
* Fix regression and improve curentChapter handling by @stonerl in https://github.com/yattee/yattee/pull/749
* Refined chapter font scaling by @stonerl in https://github.com/yattee/yattee/pull/750
* Improved thumbnail handling by @stonerl in https://github.com/yattee/yattee/pull/740
* iOS: make timestamps in comments touchable by @stonerl in https://github.com/yattee/yattee/pull/741
* Improvements to opening channels from Videos by @stonerl in https://github.com/yattee/yattee/pull/742
* Allow hiding comments by @stonerl in https://github.com/yattee/yattee/pull/744
* Add option to exit fullscreen on end by @stonerl in https://github.com/yattee/yattee/pull/570
* Only updateWatch status while video is playing by @stonerl in https://github.com/yattee/yattee/pull/745
* Xcode 16 - update recommended settings by @stonerl in https://github.com/yattee/yattee/pull/737
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/724
* tvOS: Allow account picker by long pressing channels button in subscriptions view by @patelhiren in https://github.com/yattee/yattee/pull/704
* tvOS: Refined Subscriptions View by @patelhiren in https://github.com/yattee/yattee/pull/697
* More responsive UI when Favorites are used. by @stonerl in https://github.com/yattee/yattee/pull/695
* Improved conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/696
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
* SponsorBlock Improvements by @stonerl in https://github.com/yattee/yattee/pull/639
* Chapter title on jump by @stonerl in https://github.com/yattee/yattee/pull/655
* Restart finished video by @stonerl in https://github.com/yattee/yattee/pull/646
* SponsorBlock jump to end instead of pausing by @stonerl in https://github.com/yattee/yattee/pull/648
* Call correct class of SDImageAWebPCoder by @stonerl in https://github.com/yattee/yattee/pull/664
* Fix handling and displaying captions by @stonerl in https://github.com/yattee/yattee/pull/636
* Advanced settings: make number fields .numPad by @stonerl in https://github.com/yattee/yattee/pull/661
* Preserve time on stream change by @stonerl in https://github.com/yattee/yattee/pull/651
* Switch to previous backend when leaving PiP by @stonerl in https://github.com/yattee/yattee/pull/641
* Handle deep links by @timonus in https://github.com/yattee/yattee/pull/645
* Music Mode: don't bindPlayerToLayer when entering foreground by @stonerl in https://github.com/yattee/yattee/pull/644
* Allow user to disable thumbnails and jump to current chapter in horizontal view by @stonerl in https://github.com/yattee/yattee/pull/665
* Rework qualitiy settings by @stonerl in https://github.com/yattee/yattee/pull/650
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
* Conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/662
* HomeView: Changes to Favourites and History Widget by @stonerl in https://github.com/yattee/yattee/pull/672
* Snappy UI - Offloading non UI task to background threads by @stonerl in https://github.com/yattee/yattee/pull/671
* Fix PiP Mode Not Working Using MPV by @stonerl in https://github.com/yattee/yattee/pull/676
* Fix thumbnails failing to load on tvOS by @patelhiren in https://github.com/yattee/yattee/pull/688
* speed up sorting for Stream by @stonerl in https://github.com/yattee/yattee/pull/681
* faster chapter extraction by @stonerl in https://github.com/yattee/yattee/pull/682
* Invidious: add images to chapters by @stonerl in https://github.com/yattee/yattee/pull/685
* Improved Captions handling by @stonerl in https://github.com/yattee/yattee/pull/684
* Add User-Agent to request by @stonerl in https://github.com/yattee/yattee/pull/680
* MPV: speed up playback start by @stonerl in https://github.com/yattee/yattee/pull/689
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
* Add Chinese (Traditional) localization (by @rexcsk)
* Localization fixes
* Updated localizations
* Upgraded dependencies
* Fixed reported crash
* Other minor changes and improvements
**Big thanks to the past, current and future project contributors!**
**Big thanks to the current, past and future project contributors!**

View File

@ -17,13 +17,13 @@ extension String {
var outputText = self
results.reversed().forEach { match in
(1 ..< match.numberOfRanges).reversed().forEach { rangeIndex in
for match in results.reversed() {
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
let rangeBounds = match.range(at: rangeIndex)
guard let range = Range(rangeBounds, in: self) else {
return
continue
}
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup

View File

@ -6,8 +6,10 @@ extension UIViewController {
}
public class func swizzleHomeIndicatorProperty() {
swizzle(origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
forClass: UIViewController.self)
swizzle(
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
forClass: UIViewController.self
)
}
}

View File

@ -4,11 +4,11 @@ extension URL {
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
var urlAbsoluteString = absoluteString
guard urlAbsoluteString.hasPrefix(Constants.yatteeProtocol) else {
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
return self
}
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Constants.yatteeProtocol.count))
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
if absoluteString.contains("://") {
return URL(string: urlAbsoluteString)
}

View File

@ -1,6 +1,6 @@
source "https://rubygems.org"
gem "fastlane"
gem 'fastlane'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

View File

@ -1,42 +1,46 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.877.0)
aws-sdk-core (3.190.1)
aws-eventstream (1.3.2)
aws-partitions (1.1072.0)
aws-sdk-core (3.220.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.76.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.142.0)
aws-sdk-core (~> 3, >= 3.189.0)
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.182.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.3)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -55,24 +59,24 @@ GEM
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.0)
fastlane (2.219.0)
fastimage (2.4.0)
fastlane (2.227.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
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
@ -81,6 +85,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@ -93,10 +98,10 @@ GEM
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1)
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)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
@ -104,12 +109,14 @@ GEM
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)
xcpretty (~> 0.4.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.2)
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,19 +124,18 @@ 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.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-core (1.8.0)
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-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@ -145,34 +151,38 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
json (2.10.2)
jwt (2.10.1)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.3.0)
nanaimo (0.3.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.2.1)
optparse (0.4.0)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.1)
public_suffix (5.0.4)
rake (13.1.0)
plist (3.7.2)
public_suffix (6.0.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rouge (2.0.7)
rexml (3.4.1)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.18.0)
rubyzip (2.4.1)
security (0.1.5)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
@ -180,6 +190,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -189,25 +200,27 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.5.0)
webrick (1.8.1)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.23.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.0)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-21
arm64-darwin-23
arm64-darwin-24
x86_64-darwin-19
x86_64-darwin-20
x86_64-darwin-21
x86_64-linux
DEPENDENCIES

View File

@ -10,11 +10,28 @@ struct AccountsBridge: Defaults.Bridge {
return nil
}
// Parse the urlString to check for embedded username and password
var sanitizedUrlString = value.urlString
if var urlComponents = URLComponents(string: value.urlString) {
if let user = urlComponents.user, let password = urlComponents.password {
// Sanitize the embedded username and password
let sanitizedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
let sanitizedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password
// Update the URL components with sanitized credentials
urlComponents.user = sanitizedUser
urlComponents.password = sanitizedPassword
// Reconstruct the sanitized URL
sanitizedUrlString = urlComponents.string ?? value.urlString
}
}
return [
"id": value.id,
"instanceID": value.instanceID ?? "",
"name": value.name,
"apiURL": value.urlString,
"apiURL": sanitizedUrlString,
"username": value.username,
"password": value.password ?? ""
]

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

@ -10,14 +10,16 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
let apiURLString: String
var frontendURL: String?
var proxiesVideos: Bool
var invidiousCompanion: Bool
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false) {
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name ?? app.rawValue
self.apiURLString = apiURLString
self.frontendURL = frontendURL
self.proxiesVideos = proxiesVideos
self.invidiousCompanion = invidiousCompanion
}
var apiURL: URL! {
@ -68,4 +70,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

@ -16,7 +16,8 @@ struct InstancesBridge: Defaults.Bridge {
"name": value.name,
"apiURL": value.apiURLString,
"frontendURL": value.frontendURL ?? "",
"proxiesVideos": value.proxiesVideos ? "true" : "false"
"proxiesVideos": value.proxiesVideos ? "true" : "false",
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
]
}
@ -33,7 +34,8 @@ struct InstancesBridge: Defaults.Bridge {
let name = object["name"] ?? ""
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
let proxiesVideos = object["proxiesVideos"] == "true"
let invidiousCompanion = object["invidiousCompanion"] == "true"
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos)
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
}
}

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]
@ -71,6 +79,17 @@ final class InstancesModel: ObservableObject {
Defaults[.instances][index] = instance
}
func setInvidiousCompanion(_ instance: Instance, _ invidiousCompanion: Bool) {
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
return
}
var instance = Defaults[.instances][index]
instance.invidiousCompanion = invidiousCompanion
Defaults[.instances][index] = instance
}
func remove(_ instance: Instance) {
let accounts = accounts(instance.id)
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {

View File

@ -81,7 +81,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
if let suggestions = content.json.dictionaryValue["suggestions"] {
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities)
}
return []
@ -123,7 +123,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
}
["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"].forEach { type in
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json)
}
@ -247,27 +247,27 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
func feed(_ page: Int?) -> Resource? {
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
resourceWithAuthCheck(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
.withParam("page", String(page ?? 1))
}
var feed: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/feed"))
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/feed"))
}
var subscriptions: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.post)
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.delete)
.onCompletion { _ in onCompletion() }
@ -308,11 +308,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return nil
}
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
return resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource? {
@ -445,6 +445,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
urlComponents.scheme = instanceURLComponents.scheme
urlComponents.host = instanceURLComponents.host
urlComponents.user = instanceURLComponents.user
urlComponents.password = instanceURLComponents.password
urlComponents.port = instanceURLComponents.port
guard let url = urlComponents.url else {
return nil
@ -495,14 +498,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
indexID: indexID,
live: json["liveNow"].boolValue,
upcoming: json["isUpcoming"].boolValue,
short: length <= Video.shortLength,
short: length <= Video.shortLength && length != 0.0,
publishedAt: publishedAt,
likes: json["likeCount"].int,
dislikes: json["dislikeCount"].int,
keywords: json["keywords"].arrayValue.compactMap { $0.string },
streams: extractStreams(from: json),
related: extractRelated(from: json),
chapters: extractChapters(from: description),
chapters: createChapters(from: description, thumbnails: json),
captions: extractCaptions(from: json)
)
}
@ -553,6 +556,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
)
}
// Determines if the request requires Basic Auth credentials to be removed
private func needsBasicAuthRemoval(for path: String) -> Bool {
return path.hasPrefix("\(Self.basePath)/auth/")
}
// Creates a resource URL with consideration for removing Basic Auth credentials
private func createResourceURL(baseURL: URL, path: String) -> URL {
var resourceURL = baseURL
// Remove Basic Auth credentials if required
if needsBasicAuthRemoval(for: path), var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) {
urlComponents.user = nil
urlComponents.password = nil
resourceURL = urlComponents.url ?? baseURL
}
return resourceURL.appendingPathComponent(path)
}
func resourceWithAuthCheck(baseURL: URL, path: String) -> Resource {
let sanitizedURL = createResourceURL(baseURL: baseURL, path: path)
return super.resource(absoluteURL: sanitizedURL)
}
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
details["videoThumbnails"].arrayValue.compactMap { json in
guard let url = json["url"].url,
@ -563,18 +590,41 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return nil
}
// some of instances are not configured properly and return thumbnails links
// with incorrect scheme
// Some instances are not configured properly and return thumbnail links
// with an incorrect scheme or a missing port.
components.scheme = accountUrlComponents.scheme
components.port = accountUrlComponents.port
// If basic HTTP authentication is used,
// the username and password need to be prepended to the URL.
components.user = accountUrlComponents.user
components.password = accountUrlComponents.password
guard let thumbnailUrl = components.url else {
return nil
}
print("Final thumbnail URL: \(thumbnailUrl)")
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
}
}
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
var chapters = extractChapters(from: description)
if !chapters.isEmpty {
let thumbnailsData = extractThumbnails(from: thumbnails)
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
for chapter in chapters.indices {
if let url = thumbnailURL {
chapters[chapter].image = url
}
}
}
return chapters
}
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
@ -605,21 +655,29 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
if json["liveNow"].boolValue {
return hls
}
let videoId = json["videoId"].stringValue
return extractFormatStreams(from: json["formatStreams"].arrayValue) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) +
return extractFormatStreams(from: json["formatStreams"].arrayValue, videoId: videoId) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue, videoId: videoId) +
hls
}
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [Stream] {
streams.compactMap { stream in
guard let streamURL = stream["url"].url else {
return nil
}
let finalURL: URL
if let videoId, let itag = stream["itag"].string, account.instance.invidiousCompanion {
let companionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(itag)"
finalURL = URL(string: companionURLString) ?? streamURL
} else {
finalURL = streamURL
}
return SingleAssetStream(
instance: account.instance,
avAsset: AVURLAsset(url: streamURL),
avAsset: AVURLAsset(url: finalURL),
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
kind: .stream,
encoding: stream["encoding"].string ?? ""
@ -627,7 +685,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
let audioStreams = streams
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
.sorted {
@ -642,19 +700,35 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return videoStreams.compactMap { videoStream in
guard let audioAssetURL = audioStream["url"].url,
let videoAssetURL = videoStream["url"].url
let videoAssetURL = videoStream["url"].url,
let audioItag = audioStream["itag"].string,
let videoItag = videoStream["itag"].string
else {
return nil
}
let finalAudioURL: URL
let finalVideoURL: URL
if let videoId, account.instance.invidiousCompanion {
let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)"
let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)"
finalAudioURL = URL(string: audioCompanionURLString) ?? audioAssetURL
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
} else {
finalAudioURL = audioAssetURL
finalVideoURL = videoAssetURL
}
return Stream(
instance: account.instance,
audioAsset: AVURLAsset(url: audioAssetURL),
videoAsset: AVURLAsset(url: videoAssetURL),
audioAsset: AVURLAsset(url: finalAudioURL),
videoAsset: AVURLAsset(url: finalVideoURL),
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
kind: .adaptive,
encoding: videoStream["encoding"].string,
videoFormat: videoStream["type"].string
videoFormat: videoStream["type"].string,
bitrate: videoStream["bitrate"].int,
requestRange: videoStream["init"].string ?? videoStream["index"].string
)
}
}
@ -691,6 +765,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
let author = details["author"]?.string ?? ""
let channelId = details["authorId"]?.string ?? UUID().uuidString
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
let htmlContent = details["contentHtml"]?.string ?? ""
let decodedContent = decodeHtml(htmlContent)
return Comment(
id: UUID().uuidString,
author: author,
@ -699,12 +775,25 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
pinned: false,
hearted: false,
likeCount: details["likeCount"]?.int ?? 0,
text: details["content"]?.string ?? "",
text: decodedContent,
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
channel: Channel(app: .invidious, id: channelId, name: author)
)
}
private func decodeHtml(_ htmlEncodedString: String) -> String {
if let data = htmlEncodedString.data(using: .utf8) {
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
return attributedString.string
}
}
return htmlEncodedString
}
private func extractCaptions(from content: JSON) -> [Captions] {
content["captions"].arrayValue.compactMap { details in
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }

View File

@ -515,7 +515,8 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
.dictionaryValue["files"]?.arrayValue.first?
.dictionaryValue["fileUrl"]?.url
{
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream))
let resolution = Stream.Resolution.predefined(.hd720p30)
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
}
return streams

View File

@ -113,8 +113,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
}
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
let details = content.json.dictionaryValue
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
guard let details = content?.json.dictionaryValue else {
return CommentsPage(comments: [], nextPage: nil, disabled: true)
}
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
let nextPage = details["nextpage"]?.string
let disabled = details["disabled"]?.bool ?? false
@ -154,7 +157,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
method: .post,
parameters: ["username": username, "password": password],
encoding: JSONEncoding.default
).responseDecodable(of: JSON.self) { [weak self] response in
)
.responseDecodable(of: JSON.self) { [weak self] response in
guard let self else {
return
}
@ -411,6 +415,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
default:
return nil
}
@ -487,6 +492,35 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
completion(asset)
return
}
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
let hostValue = hostItem.value
else {
completion(asset)
return
}
urlComponents.host = hostValue
guard let newUrl = urlComponents.url else {
completion(asset)
return
}
completion(AVURLAsset(url: newUrl))
}
// Overload used for hlsURLS
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
let asset = AVURLAsset(url: url)
nonProxiedAsset(asset: asset, completion: completion)
}
private func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
@ -575,10 +609,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return nil
}
return URL(string: thumbnailURL
.absoluteString
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
return URL(
string: thumbnailURL
.absoluteString
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
)
}
@ -662,16 +697,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
videoStreams.forEach { videoStream in
for videoStream in videoStreams {
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
return
continue
}
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
else {
return
continue
}
let audioAsset = AVURLAsset(url: audioAssetUrl)
@ -683,6 +718,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
let videoFormat = videoStream.dictionaryValue["format"]?.string
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
var requestRange: String?
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
{
requestRange = "\(initStart)-\(initEnd)"
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
{
requestRange = "\(indexStart)-\(indexEnd)"
} else {
requestRange = nil
}
if videoOnly {
streams.append(
@ -692,7 +741,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
videoAsset: videoAsset,
resolution: resolution,
kind: .adaptive,
videoFormat: videoFormat
videoFormat: videoFormat,
bitrate: bitrate,
requestRange: requestRange
)
)
} else {
@ -723,15 +774,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let commentorUrl = details["commentorUrl"]?.string
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
let commentId = details["commentId"]?.string ?? UUID().uuidString
// Sanity checks: return nil if required data is missing
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
return nil
}
return Comment(
id: details["commentId"]?.string ?? UUID().uuidString,
id: commentId,
author: author,
authorAvatarURL: details["thumbnail"]?.string ?? "",
time: details["commentedTime"]?.string ?? "",
pinned: details["pinned"]?.bool ?? false,
hearted: details["hearted"]?.bool ?? false,
likeCount: details["likeCount"]?.int ?? 0,
text: extractCommentText(from: details["commentText"]?.stringValue),
text: commentText,
repliesPage: details["repliesPage"]?.string,
channel: Channel(app: .piped, id: channelId, name: author)
)

View File

@ -66,7 +66,7 @@ protocol VideosAPI {
failureHandler: ((RequestError) -> Void)?,
completionHandler: @escaping (PlayerQueueItem) -> Void
)
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
func comments(_ id: Video.ID, page: String?) -> Resource?
}
@ -108,14 +108,19 @@ extension VideosAPI {
.onFailure { failureHandler?($0) }
}
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
var urlComponents = account?.instance?.urlComponents
else {
return nil
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
var urlComponents: URLComponents?
if let frontendURLString,
let frontendURL = URL(string: frontendURLString)
{
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
} else if let instanceComponents = account?.instance?.urlComponents {
urlComponents = instanceComponents
}
urlComponents.host = frontendHost
guard var urlComponents else {
return nil
}
var queryItems = [URLQueryItem]()
@ -147,58 +152,94 @@ extension VideosAPI {
/*
The following chapter patterns are covered:
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)
1) "start - end - title" / "start - end: Title" / "start - end title"
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
3) "index. title - start" / "index. title start"
4) "title: (start)"
5) "(start) title"
The order is important!
These represent:
- "start" and "end" are timestamps, defining the start and end of the individual chapter
- "title" is the name of the chapter
- "index" is the chapter's position in a list
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
*/
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|$)"
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
]
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 extractChaptersGroup = DispatchGroup()
var capturedChapters: [Int: [Chapter]] = [:]
let lock = NSLock()
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 }
for (index, pattern) in patterns.enumerated() {
extractChaptersGroup.enter()
DispatchQueue.global().async {
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
let titleRange = line.range(withName: "title")
let startRange = line.range(withName: "start")
var hours: Double?
var minutes: Double?
var seconds: Double?
guard let titleSubstringRange = Range(titleRange, in: description),
let startSubstringRange = Range(startRange, in: description)
else {
return nil
}
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])
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?
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 Chapter(title: titleCapture, start: startSeconds)
}
guard var startSeconds = seconds else { return nil }
startSeconds += (minutes ?? 0) * 60
startSeconds += (hours ?? 0) * 60 * 60
return .init(title: titleCapture, start: startSeconds)
if !extractedChapters.isEmpty {
lock.lock()
capturedChapters[index] = extractedChapters
lock.unlock()
}
}
extractChaptersGroup.leave()
}
}
extractChaptersGroup.wait()
// Now we sort the keys of the capturedChapters dictionary.
// These keys correspond to the priority of each pattern.
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
// Return first non-empty result in the order of patterns
for key in sortedKeys {
if let chapters = capturedChapters[key], !chapters.isEmpty {
return chapters
}
}
return []

View File

@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
}
var allowsDisablingVidoesProxying: Bool {
self == .invidious
self == .invidious || self == .piped
}
var supportsOpeningVideosByID: Bool {

View File

@ -13,6 +13,7 @@ struct ChannelPlaylistsCacheModel: CacheModel {
var storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)

View File

@ -13,6 +13,7 @@ struct ChannelsCacheModel: CacheModel {
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)

View File

@ -14,6 +14,7 @@ struct FeedCacheModel: CacheModel {
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
@ -22,7 +23,7 @@ struct FeedCacheModel: CacheModel {
let date = iso8601DateFormatter.string(from: Date())
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
let feedTimeObject: JSON = ["date": date]
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map { $0.json.object }]
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map(\.json.object)]
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
}

View File

@ -14,6 +14,7 @@ struct PlaylistsCacheModel: CacheModel {
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
@ -21,7 +22,7 @@ struct PlaylistsCacheModel: CacheModel {
let date = iso8601DateFormatter.string(from: Date())
logger.info("caching \(playlistCacheKey(account)) -- \(date)")
let feedTimeObject: JSON = ["date": date]
let playlistsObject: JSON = ["playlists": playlists.map { $0.json.object }]
let playlistsObject: JSON = ["playlists": playlists.map(\.json.object)]
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
}

View File

@ -15,6 +15,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
let storage = try? Storage<String, JSON>(
diskConfig: SubscribedChannelsModel.diskConfig,
memoryConfig: SubscribedChannelsModel.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)

View File

@ -13,6 +13,7 @@ struct VideosCacheModel: CacheModel {
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)

View File

@ -152,7 +152,7 @@ struct Channel: Identifiable, Hashable {
"subscriptionsText": subscriptionsText as Any,
"totalViews": totalViews as Any,
"verified": verified as Any,
"videos": videos.map { $0.json.object }
"videos": videos.map(\.json.object)
]
}

View File

@ -19,7 +19,7 @@ struct ChannelPlaylist: Identifiable {
"title": title,
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
"channel": channel?.json.object ?? "",
"videos": videos.map { $0.json.object },
"videos": videos.map(\.json.object),
"videosCount": String(videosCount ?? 0)
]
}

View File

@ -35,26 +35,22 @@ final class CommentsModel: ObservableObject {
func load(page: String? = nil) {
guard let video = player.currentVideo else { return }
if !firstPage && !nextPageAvailable {
return
}
firstPage = page.isNil || page!.isEmpty
guard firstPage || nextPageAvailable else { return }
player
.playerAPI(video)?
.comments(video.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {
self?.all += page.comments
self?.nextPage = page.nextPage
self?.disabled = page.disabled
guard let self else { return }
if let commentsPage: CommentsPage = response.typedContent() {
self.all += commentsPage.comments
self.nextPage = commentsPage.nextPage
self.disabled = commentsPage.disabled
}
}
.onFailure { [weak self] requestError in
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
.onFailure { [weak self] _ in
self?.disabled = true
}
.onCompletion { [weak self] _ in
self?.loaded = true

View File

@ -274,7 +274,7 @@ extension Country {
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
Country.allCases
.map { $0.name }
.map(\.name)
.filter(predicate)
.compactMap { string in Country.allCases.first { $0.name == string } }
}

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

@ -121,7 +121,7 @@ final class FeedModel: ObservableObject, CacheModel {
backgroundContext.perform { [weak self] in
guard let self else { return }
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished }
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter(\.finished)
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
let unwatchedCount = max(0, feed.count - watched.count)

View File

@ -47,7 +47,7 @@ extension PlayerModel {
}
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
guard let currentVideo, saveHistory else { return }
guard let currentVideo, saveHistory, isPlaying else { return }
let id = currentVideo.videoID
let time = time ?? backend.currentTime

View File

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

View File

@ -0,0 +1,55 @@
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,
"showSearchSuggestions": Defaults[.showSearchSuggestions],
"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,42 @@
import Defaults
import SwiftyJSON
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
"seekGestureSpeed": Defaults[.seekGestureSpeed],
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
"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,24 @@
import Defaults
import SwiftyJSON
final class HistorySettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"saveRecents": Defaults[.saveRecents],
"saveHistory": Defaults[.saveHistory],
"showRecents": Defaults[.showRecents],
"limitRecents": Defaults[.limitRecents],
"limitRecentsAmount": Defaults[.limitRecentsAmount],
"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,54 @@
import Defaults
import SwiftyJSON
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
"expandVideoDescription": Defaults[.expandVideoDescription],
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
"showChapters": Defaults[.showChapters],
"showChapterThumbnails": Defaults[.showChapterThumbnails],
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
"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],
"captionsAutoShow": Defaults[.captionsAutoShow],
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
"captionsFontColor": Defaults[.captionsFontColor]
]
}
override var platformJSON: JSON {
var export = JSON()
#if !os(macOS)
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
#endif
export["showComments"].bool = Defaults[.showComments]
#if !os(tvOS)
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
#endif
#if os(iOS)
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
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,15 @@
import Defaults
import SwiftyJSON
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
"sponsorBlockColors": Defaults[.sponsorBlockColors],
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
]
}
}

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,150 @@
import Defaults
import Foundation
import SwiftyJSON
final class ImportSettingsFileModel: ObservableObject {
static let shared = ImportSettingsFileModel()
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
var loadTask: URLSessionTask?
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()
}
}
@Published var json = JSON()
func loadData(_ url: URL) {
json = JSON()
loadTask?.cancel()
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let data else { return }
if let json = try? JSON(data: data) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.json = json
self.sheetViewModel.reset(locationsSettingsGroupImporter)
self.importExportModel.reset(self)
}
}
}
loadTask?.resume()
}
func filename(_ url: URL) -> String {
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
}
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,64 @@
import Defaults
import SwiftyJSON
struct AdvancedSettingsGroupImporter {
var json: JSON
func performImport() {
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
}
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
}
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 mpvCachePauseInital = json["mpvCachePauseInital"].bool {
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
}
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
Defaults[.mpvDeinterlace] = mpvDeinterlace
}
if let mpvHWdec = json["mpvHWdec"].string {
Defaults[.mpvHWdec] = mpvHWdec
}
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
}
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
}
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
}
if let showCacheStatus = json["showCacheStatus"].bool {
Defaults[.showCacheStatus] = showCacheStatus
}
if let feedCacheSize = json["feedCacheSize"].string {
Defaults[.feedCacheSize] = feedCacheSize
}
}
}

View File

@ -0,0 +1,148 @@
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 {
for favoriteJSON in favorites {
if let jsonString = favoriteJSON.rawString(options: []),
let item = FavoriteItem.bridge.deserialize(jsonString)
{
FavoritesModel.shared.add(item)
}
}
}
if let widgetsFavorites = json["widgetsSettings"].array {
for widgetJSON in widgetsFavorites {
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 showSearchSuggestions = json["showSearchSuggestions"].bool {
Defaults[.showSearchSuggestions] = showSearchSuggestions
}
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,148 @@
import Defaults
import SwiftyJSON
struct ConstrolsSettingsGroupImporter {
var json: JSON
func performImport() {
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
}
if let fullscreenPlayerGestureEnabled = json["fullscreenPlayerGestureEnabled"].bool {
Defaults[.fullscreenPlayerGestureEnabled] = fullscreenPlayerGestureEnabled
}
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 playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
}
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,66 @@
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 showRecents = json["showRecents"].bool {
Defaults[.showRecents] = showRecents
}
if let limitRecents = json["limitRecents"].bool {
Defaults[.limitRecents] = limitRecents
}
if let limitRecentsAmount = json["limitRecentsAmount"].int {
Defaults[.limitRecentsAmount] = limitRecentsAmount
}
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 {
for accountJSON in accounts {
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) ?? InstancesModel.shared.findByURLString(account.urlString)
{
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,135 @@
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 exitFullscreenOnEOF = json["exitFullscreenOnEOF"].bool {
Defaults[.exitFullscreenOnEOF] = exitFullscreenOnEOF
}
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 showChapterThumbnails = json["showChapterThumbnails"].bool {
Defaults[.showChapterThumbnails] = showChapterThumbnails
}
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
}
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 let showComments = json["showComments"].bool {
Defaults[.showComments] = showComments
}
#if !os(tvOS)
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
}
#endif
#if os(iOS)
if let isOrientationLocked = json["isOrientationLocked"].bool {
Defaults[.isOrientationLocked] = isOrientationLocked
}
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
}
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
{
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
}
#endif
if let captionsAutoShow = json["captionsAutoShow"].bool {
Defaults[.captionsAutoShow] = captionsAutoShow
}
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
}
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
}
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
}
if let captionsFontColor = json["captionsFontColor"].string {
Defaults[.captionsFontColor] = captionsFontColor
}
}
}

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 {
for qualityProfileJSON in qualityProfiles {
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 {
for recentlyOpenedJSON in recentlyOpened {
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = RecentItemBridge().deserialize(dict) {
RecentsModel.shared.add(item)
}
}
}
}
}

View File

@ -0,0 +1,33 @@
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 })
}
if let sponsorBlockColors = json["sponsorBlockColors"].dictionary {
let colors = sponsorBlockColors.mapValues { json in json.stringValue }
Defaults[.sponsorBlockColors] = colors
}
if let sponsorBlockShowTimeWithSkipsRemoved = json["sponsorBlockShowTimeWithSkipsRemoved"].bool {
Defaults[.sponsorBlockShowTimeWithSkipsRemoved] = sponsorBlockShowTimeWithSkipsRemoved
}
if let sponsorBlockShowCategoriesInTimeline = json["sponsorBlockShowCategoriesInTimeline"].bool {
Defaults[.sponsorBlockShowCategoriesInTimeline] = sponsorBlockShowCategoriesInTimeline
}
if let sponsorBlockShowNoticeAfterSkip = json["sponsorBlockShowNoticeAfterSkip"].bool {
Defaults[.sponsorBlockShowNoticeAfterSkip] = sponsorBlockShowNoticeAfterSkip
}
}
}

View File

@ -3,7 +3,7 @@ import Foundation
final class MenuModel: ObservableObject {
static let shared = MenuModel()
private var cancellables = [AnyCancellable]()
private var cancellables = Set<AnyCancellable>()
init() {
registerChildModel(AccountsModel.shared)
@ -12,10 +12,16 @@ final class MenuModel: ObservableObject {
}
func registerChildModel<T: ObservableObject>(_ model: T?) {
guard !model.isNil else {
guard let model else {
return
}
cancellables.append(model!.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() })
model.objectWillChange
.receive(on: DispatchQueue.main) // Ensure the update occurs on the main thread
.debounce(for: .milliseconds(10), scheduler: DispatchQueue.main) // Debounce to avoid immediate feedback loops
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}

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

@ -26,7 +26,7 @@ final class NetworkStateModel: ObservableObject {
}
var bufferingStateText: String? {
guard detailsAvailable else { return nil }
guard detailsAvailable && player.hasStarted else { return nil }
return String(format: "%.0f%%", bufferingState)
}

View File

@ -147,7 +147,7 @@ struct OpenVideosModel {
if prepending {
videos.reverse()
}
videos.forEach { video in
for video in videos {
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
}
}

View File

@ -40,6 +40,11 @@ final class AVPlayerBackend: PlayerBackend {
var isLoadingVideo = false
var hasStarted = false
var isPaused: Bool {
avPlayer.timeControlStatus == .paused
}
var isPlaying: Bool {
avPlayer.timeControlStatus == .playing
}
@ -97,7 +102,7 @@ final class AVPlayerBackend: PlayerBackend {
private var frequentTimeObserver: Any?
private var infrequentTimeObserver: Any?
private var playerTimeControlStatusObserver: Any?
private var playerTimeControlStatusObserver: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation?
@ -114,27 +119,37 @@ final class AVPlayerBackend: PlayerBackend {
#if os(iOS)
controller.player = avPlayer
#endif
logger.info("AVPlayerBackend initialized.")
}
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
let sortedByResolution = streams
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
.sorted { $0.resolution > $1.resolution }
deinit {
// Invalidate any observers to avoid memory leaks
statusObservation?.invalidate()
playerTimeControlStatusObserver?.invalidate()
return streams.first { $0.kind == .hls } ??
sortedByResolution.first { $0.kind == .stream } ??
sortedByResolution.first
// Remove any time observers added to AVPlayer
if let frequentObserver = frequentTimeObserver {
avPlayer.removeTimeObserver(frequentObserver)
}
if let infrequentObserver = infrequentTimeObserver {
avPlayer.removeTimeObserver(infrequentObserver)
}
// Remove notification observers
removeItemDidPlayToEndTimeObserver()
logger.info("AVPlayerBackend deinitialized.")
}
func canPlay(_ stream: Stream) -> Bool {
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
stream.kind == .hls || stream.kind == .stream
}
func playStream(
_ stream: Stream,
of video: Video,
preservingTime: Bool,
upgrading _: Bool
upgrading: Bool
) {
isLoadingVideo = true
@ -145,7 +160,7 @@ final class AVPlayerBackend: PlayerBackend {
_ = url.startAccessingSecurityScopedResource()
}
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime, upgrading: upgrading)
} else {
model.logger.info("playing stream with many assets:")
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
@ -160,7 +175,22 @@ final class AVPlayerBackend: PlayerBackend {
return
}
// After the video has ended, hitting play restarts the video from the beginning.
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
{
seek(to: 0, seekType: .loopRestart)
}
#if !os(macOS)
model.setAudioSessionActive(true)
#endif
avPlayer.play()
// Setting hasStarted to true the first time player started
if !hasStarted {
hasStarted = true
}
model.objectWillChange.send()
}
@ -168,7 +198,9 @@ final class AVPlayerBackend: PlayerBackend {
guard avPlayer.timeControlStatus != .paused else {
return
}
#if !os(macOS)
model.setAudioSessionActive(false)
#endif
avPlayer.pause()
model.objectWillChange.send()
}
@ -182,7 +214,11 @@ final class AVPlayerBackend: PlayerBackend {
}
func stop() {
#if !os(macOS)
model.setAudioSessionActive(false)
#endif
avPlayer.replaceCurrentItem(with: nil)
hasStarted = false
}
func cancelLoads() {
@ -219,16 +255,20 @@ final class AVPlayerBackend: PlayerBackend {
_ url: URL,
stream: Stream,
of video: Video,
preservingTime: Bool = false
preservingTime: Bool = false,
upgrading: Bool = false
) {
asset?.cancelLoading()
asset = AVURLAsset(url: url)
asset = AVURLAsset(
url: url,
options: ["AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "\(UserAgentManager.shared.userAgent)"]]
)
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
var error: NSError?
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
case .loaded:
DispatchQueue.main.async { [weak self] in
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime, upgrading: upgrading)
}
case .failed:
DispatchQueue.main.async { [weak self] in
@ -303,11 +343,17 @@ final class AVPlayerBackend: PlayerBackend {
private func insertPlayerItem(
_ stream: Stream,
for video: Video,
preservingTime: Bool = false
preservingTime: Bool = false,
upgrading: Bool = false
) {
removeItemDidPlayToEndTimeObserver()
model.playerItem = playerItem(stream)
if stream.isHLS {
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
}
guard model.playerItem != nil else {
return
}
@ -325,7 +371,7 @@ final class AVPlayerBackend: PlayerBackend {
let startPlaying = {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(true)
self.model.setAudioSessionActive(true)
#endif
self.setRate(self.model.currentRate)
@ -387,7 +433,7 @@ final class AVPlayerBackend: PlayerBackend {
}
if preservingTime {
if model.preservedTime.isNil {
if model.preservedTime.isNil || upgrading {
model.saveTime {
replaceItemAndSeek()
startPlaying()
@ -523,6 +569,7 @@ final class AVPlayerBackend: PlayerBackend {
}
}
}
case .failed:
DispatchQueue.main.async {
self.model.playerError = item.error
@ -759,7 +806,7 @@ final class AVPlayerBackend: PlayerBackend {
opened = true
controller.startPictureInPicture()
} else {
print("PiP not possible, waited \(delay) seconds")
self.logger.info("PiP not possible, waited \(delay) seconds")
}
}
}

View File

@ -2,15 +2,16 @@ import AVFAudio
import CoreMedia
import Defaults
import Foundation
import Libmpv
import Logging
import MediaPlayer
import MPVKit
import Repeat
import SwiftUI
final class MPVBackend: PlayerBackend {
static var timeUpdateInterval = 0.5
static var networkStateUpdateInterval = 1.0
static var networkStateUpdateInterval = 0.1
static var refreshRateUpdateInterval = 0.5
private var logger = Logger(label: "mpv-backend")
@ -22,13 +23,14 @@ final class MPVBackend: PlayerBackend {
var stream: Stream?
var video: Video?
var captions: Captions? { didSet {
guard let captions else {
client?.removeSubs()
return
var captions: Captions? {
didSet {
Task {
await handleCaptionsChange()
}
}
addSubTrack(captions.url)
}}
}
var currentTime: CMTime?
var loadedVideo = false
@ -44,6 +46,8 @@ final class MPVBackend: PlayerBackend {
}
}}
var hasStarted = false
var isPaused = false
var isPlaying = true { didSet {
networkStateTimer.start()
@ -87,6 +91,7 @@ final class MPVBackend: PlayerBackend {
private var clientTimer: Repeater!
private var networkStateTimer: Repeater!
private var refreshRateTimer: Repeater!
private var onFileLoaded: (() -> Void)?
@ -182,50 +187,30 @@ final class MPVBackend: PlayerBackend {
}
init() {
// swiftlint:disable shorthand_optional_binding
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self = self, self.model.activeBackend == .mpv else {
guard let self, self.model.activeBackend == .mpv else {
return
}
self.getTimeUpdates()
}
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self = self, self.model.activeBackend == .mpv else {
guard let self, self.model.activeBackend == .mpv else {
return
}
self.updateNetworkState()
}
// swiftlint:enable shorthand_optional_binding
refreshRateTimer = .init(interval: .seconds(Self.refreshRateUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self, self.model.activeBackend == .mpv else { return }
self.checkAndUpdateRefreshRate()
}
}
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
streams
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
.max { lhs, rhs in
let predicates: [AreInIncreasingOrder] = [
{ $0.resolution < $1.resolution },
{ $0.format > $1.format }
]
for predicate in predicates {
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
} ??
streams.first { $0.kind == .hls } ??
streams.first
}
func canPlay(_ stream: Stream) -> Bool {
stream.resolution != .unknown && stream.format != .av1
stream.format != .av1
}
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
@ -238,9 +223,22 @@ final class MPVBackend: PlayerBackend {
#endif
var captions: Captions?
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
captions = video.captions.first { $0.code == captionsLanguageCode } ??
video.captions.first { $0.code.contains(captionsLanguageCode) }
if Defaults[.captionsAutoShow] == true {
let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode],
captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode]
// Try to get captions with the default language code first
captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ??
video.captions.first { $0.code.contains(captionsDefaultLanguageCode) }
// If there are still no captions, try to get captions with the fallback language code
if captions.isNil && !captionsFallbackLanguageCode.isEmpty {
captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ??
video.captions.first { $0.code.contains(captionsFallbackLanguageCode) }
}
} else {
captions = nil
}
let updateCurrentStream = {
@ -254,7 +252,7 @@ final class MPVBackend: PlayerBackend {
let startPlaying = {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(true)
self.model.setAudioSessionActive(true)
#endif
DispatchQueue.main.async { [weak self] in
@ -264,6 +262,9 @@ final class MPVBackend: PlayerBackend {
self.startClientUpdates()
if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() }
PlayerModel.shared.captions = self.captions
if !preservingTime,
!upgrading,
let segment = self.model.sponsorBlock.segments.first,
@ -309,7 +310,7 @@ final class MPVBackend: PlayerBackend {
}
}
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
}
} else {
@ -321,7 +322,7 @@ final class MPVBackend: PlayerBackend {
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
self?.pause()
}
@ -330,7 +331,7 @@ final class MPVBackend: PlayerBackend {
}
if preservingTime {
if model.preservedTime.isNil {
if model.preservedTime.isNil || upgrading {
model.saveTime {
replaceItem(self.model.preservedTime)
}
@ -344,9 +345,20 @@ final class MPVBackend: PlayerBackend {
startClientUpdates()
}
func startRefreshRateUpdates() {
refreshRateTimer.start()
}
func stopRefreshRateUpdates() {
refreshRateTimer.pause()
}
func play() {
isPlaying = true
#if !os(macOS)
model.setAudioSessionActive(true)
#endif
startClientUpdates()
startRefreshRateUpdates()
if controls.presentingControls {
startControlsUpdates()
@ -354,14 +366,34 @@ final class MPVBackend: PlayerBackend {
setRate(model.currentRate)
// After the video has ended, hitting play restarts the video from the beginning.
if let currentTime, currentTime.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
currentTime.seconds > 0 && model.playerTime.duration.seconds > 0
{
seek(to: 0, seekType: .loopRestart)
}
client?.play()
isPlaying = true
isPaused = false
// Setting hasStarted to true the first time player started
if !hasStarted {
hasStarted = true
}
}
func pause() {
isPlaying = false
#if !os(macOS)
model.setAudioSessionActive(false)
#endif
stopClientUpdates()
stopRefreshRateUpdates()
client?.pause()
isPaused = true
isPlaying = false
}
func togglePlay() {
@ -377,7 +409,15 @@ final class MPVBackend: PlayerBackend {
}
func stop() {
#if !os(macOS)
model.setAudioSessionActive(false)
#endif
stopClientUpdates()
stopRefreshRateUpdates()
client?.stop()
isPlaying = false
isPaused = false
hasStarted = false
}
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
@ -393,8 +433,8 @@ final class MPVBackend: PlayerBackend {
}
func closeItem() {
client?.pause()
client?.stop()
pause()
stop()
self.video = nil
self.stream = nil
}
@ -455,6 +495,52 @@ final class MPVBackend: PlayerBackend {
}
}
private func checkAndUpdateRefreshRate() {
guard let screenRefreshRate = client?.getScreenRefreshRate() else {
logger.warning("Failed to get screen refresh rate.")
return
}
let contentFps = client?.currentContainerFps ?? screenRefreshRate
guard Defaults[.mpvSetRefreshToContentFPS] else {
// If the current refresh rate doesn't match the screen refresh rate, reset it
if client?.currentRefreshRate != screenRefreshRate {
client?.updateRefreshRate(to: screenRefreshRate)
client?.currentRefreshRate = screenRefreshRate
#if !os(macOS)
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
#endif
logger.info("Reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
}
return
}
// Adjust the refresh rate to match the content if it differs
if screenRefreshRate != contentFps {
client?.updateRefreshRate(to: contentFps)
client?.currentRefreshRate = contentFps
#if !os(macOS)
notifyViewToUpdateDisplayLink(with: contentFps)
#endif
logger.info("Adjusted screen refresh rate to match content: \(contentFps) Hz")
} else if client?.currentRefreshRate != screenRefreshRate {
// Ensure the refresh rate is set back to the screen's rate if no adjustment is needed
client?.updateRefreshRate(to: screenRefreshRate)
client?.currentRefreshRate = screenRefreshRate
#if !os(macOS)
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
#endif
logger.info("Checked and reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
}
}
#if !os(macOS)
private func notifyViewToUpdateDisplayLink(with refreshRate: Int) {
NotificationCenter.default.post(name: .updateDisplayLinkFrameRate, object: nil, userInfo: ["refreshRate": refreshRate])
}
#endif
func handle(_ event: UnsafePointer<mpv_event>!) {
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
@ -519,8 +605,6 @@ final class MPVBackend: PlayerBackend {
guard client.eofReached else {
return
}
getTimeUpdates()
eofPlaybackModeAction()
}
@ -537,8 +621,14 @@ final class MPVBackend: PlayerBackend {
}
func addSubTrack(_ url: URL) {
client?.removeSubs()
client?.addSubTrack(url)
Task {
if let areSubtitlesAdded = client?.areSubtitlesAdded {
if await areSubtitlesAdded() {
await client?.removeSubs()
}
}
await client?.addSubTrack(url)
}
}
func setVideoToAuto() {
@ -602,6 +692,17 @@ final class MPVBackend: PlayerBackend {
}
}
private func handleCaptionsChange() async {
guard let captions else {
if let isSubtitlesAdded = client?.areSubtitlesAdded, await isSubtitlesAdded() {
await client?.removeSubs()
}
return
}
addSubTrack(captions.url)
}
private func handlePropertyChange(_ name: String, _ property: mpv_event_property) {
switch name {
case "pause":

View File

@ -1,11 +1,13 @@
import CoreMedia
import Defaults
import Foundation
import Libmpv
import Logging
import MPVKit
#if !os(macOS)
import Siesta
import UIKit
#else
import AppKit
#endif
final class MPVClient: ObservableObject {
@ -14,6 +16,8 @@ final class MPVClient: ObservableObject {
}
private var logger = Logger(label: "mpv-client")
private var needsDrawingCooldown = false
private var needsDrawingWorkItem: DispatchWorkItem?
var mpv: OpaquePointer!
var mpvGL: OpaquePointer!
@ -27,6 +31,7 @@ final class MPVClient: ObservableObject {
var backend: MPVBackend!
var seeking = false
var currentRefreshRate = 60
func create(frame: CGRect? = nil) {
#if !os(macOS)
@ -37,7 +42,7 @@ final class MPVClient: ObservableObject {
mpv = mpv_create()
if mpv == nil {
print("failed creating context\n")
logger.critical("failed creating context\n")
exit(1)
}
@ -60,13 +65,71 @@ final class MPVClient: ObservableObject {
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
#endif
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
// CACHING //
checkError(mpv_set_option_string(mpv, "cache-pause-initial", Defaults[.mpvCachePauseInital] ? "yes" : "no"))
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
// PLAYBACK //
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize]))
checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor]))
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
// Enable VSYNC needed for `video-sync`
if Defaults[.mpvSetRefreshToContentFPS] {
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "1"))
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
checkError(mpv_set_option_string(mpv, "interpolation", "yes"))
checkError(mpv_set_option_string(mpv, "tscale", "mitchell"))
checkError(mpv_set_option_string(mpv, "tscale-window", "blackman"))
checkError(mpv_set_option_string(mpv, "vd-lavc-framedrop", "nonref"))
checkError(mpv_set_option_string(mpv, "display-fps-override", "\(String(getScreenRefreshRate()))"))
}
// CPU //
// Determine number of threads based on system core count
let numberOfCores = ProcessInfo.processInfo.processorCount
let threads = numberOfCores * 2
// Log the number of cores and threads
logger.info("Number of CPU cores: \(numberOfCores)")
// Set the number of threads dynamically
checkError(mpv_set_option_string(mpv, "vd-lavc-threads", "\(threads)"))
// GPU //
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
#if !os(macOS)
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
#endif
// We set this to ordered since we use OpenGL and Apple's implementation is ancient.
checkError(mpv_set_option_string(mpv, "dither", "ordered"))
// DEMUXER //
// We request to test for lavf first and skip probing other demuxer.
checkError(mpv_set_option_string(mpv, "demuxer", "lavf"))
checkError(mpv_set_option_string(mpv, "audio-demuxer", "lavf"))
checkError(mpv_set_option_string(mpv, "sub-demuxer", "lavf"))
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
checkError(mpv_set_option_string(mpv, "demuxer-lavf-probe-info", Defaults[.mpvDemuxerLavfProbeInfo]))
// Disable ytdl, since it causes crashes on macOS.
#if os(macOS)
checkError(mpv_set_option_string(mpv, "ytdl", "no"))
#endif
checkError(mpv_initialize(mpv))
@ -76,7 +139,7 @@ final class MPVClient: ObservableObject {
get_proc_address_ctx: nil
)
queue = DispatchQueue(label: "mpv")
queue = DispatchQueue(label: "mpv", qos: .userInteractive, attributes: [.concurrent])
withUnsafeMutablePointer(to: &initParams) { initParams in
var params = [
@ -86,7 +149,7 @@ final class MPVClient: ObservableObject {
]
if mpv_render_context_create(&mpvGL, mpv, &params) < 0 {
print("failed to initialize mpv GL context")
logger.critical("failed to initialize mpv GL context")
exit(1)
}
@ -127,6 +190,8 @@ final class MPVClient: ObservableObject {
func loadFile(
_ url: URL,
audio: URL? = nil,
bitrate: Int? = nil,
kind: Stream.Kind,
sub: URL? = nil,
time: CMTime? = nil,
forceSeekable: Bool = false,
@ -137,6 +202,10 @@ final class MPVClient: ObservableObject {
args.append("replace")
// needed since mpvkit 0.38.0
// https://github.com/mpv-player/mpv/issues/13806#issuecomment-2029818905
args.append("-1")
if let time, time.seconds > 0 {
options.append("start=\(Int(time.seconds))")
}
@ -159,6 +228,10 @@ final class MPVClient: ObservableObject {
args.append(options.joined(separator: ","))
}
if kind == .hls, bitrate != 0 {
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
}
command("loadfile", args: args, returnValueCallback: completionHandler)
}
@ -272,6 +345,31 @@ final class MPVClient: ObservableObject {
mpv.isNil ? false : getFlag("eof-reached")
}
var currentContainerFps: Int {
guard !mpv.isNil else { return 30 }
let fps = getDouble("container-fps")
return Int(fps.rounded())
}
func areSubtitlesAdded() async -> Bool {
guard !mpv.isNil else { return false }
let trackCount = await Task(operation: { getInt("track-list/count") }).value
guard trackCount > 0 else { return false }
for index in 0 ..< trackCount {
if let trackType = await Task(operation: { getString("track-list/\(index)/type") }).value, trackType == "sub" {
return true
}
}
return false
}
func logCurrentFps() {
let fps = currentContainerFps
logger.info("Current container FPS: \(fps)")
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
guard !seeking else {
logger.warning("ignoring seek, another in progress")
@ -315,7 +413,7 @@ final class MPVClient: ObservableObject {
return
}
DispatchQueue.main.async { [weak self] in
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
guard let self else { return }
let model = self.backend.model
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
@ -343,10 +441,30 @@ final class MPVClient: ObservableObject {
}
func setNeedsDrawing(_ needsDrawing: Bool) {
// Check if we are currently in a cooldown period
guard !needsDrawingCooldown else {
logger.info("Not drawing, cooldown in progress")
return
}
logger.info("needs drawing: \(needsDrawing)")
// Set the cooldown flag to true and cancel any existing work item
needsDrawingCooldown = true
needsDrawingWorkItem?.cancel()
#if !os(macOS)
glView?.needsDrawing = needsDrawing
#endif
// Create a new DispatchWorkItem to reset the cooldown flag after 0.1 seconds
let workItem = DispatchWorkItem { [weak self] in
self?.needsDrawingCooldown = false
}
needsDrawingWorkItem = workItem
// Schedule the cooldown reset after 0.1 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
}
func command(
@ -374,16 +492,59 @@ final class MPVClient: ObservableObject {
}
}
func updateRefreshRate(to refreshRate: Int) {
setString("display-fps-override", "\(String(refreshRate))")
logger.info("Updated refresh rate during playback to: \(refreshRate) Hz")
}
// Retrieve the screen's current refresh rate dynamically.
func getScreenRefreshRate() -> Int {
var refreshRate = 60 // Default to 60 Hz in case of failure
#if os(macOS)
// macOS implementation using NSScreen
if let screen = NSScreen.main,
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID,
let mode = CGDisplayCopyDisplayMode(displayID),
mode.refreshRate > 0
{
refreshRate = Int(mode.refreshRate)
logger.info("Screen refresh rate: \(refreshRate) Hz")
} else {
logger.warning("Failed to get refresh rate from NSScreen.")
}
#else
// iOS implementation using UIScreen with a failover
let mainScreen = UIScreen.main
refreshRate = mainScreen.maximumFramesPerSecond
// Failover: if maximumFramesPerSecond is 0 or an unexpected value
if refreshRate <= 0 {
refreshRate = 60 // Fallback to 60 Hz
logger.warning("Failed to get refresh rate from UIScreen, falling back to 60 Hz.")
} else {
logger.info("Screen refresh rate: \(refreshRate) Hz")
}
#endif
currentRefreshRate = refreshRate
return refreshRate
}
func addVideoTrack(_ url: URL) {
command("video-add", args: [url.absoluteString])
}
func addSubTrack(_ url: URL) {
command("sub-add", args: [url.absoluteString])
func addSubTrack(_ url: URL) async {
await Task {
command("sub-add", args: [url.absoluteString])
}.value
}
func removeSubs() {
command("sub-remove")
func removeSubs() async {
await Task {
command("sub-remove")
}.value
}
func setVideoToAuto() {
@ -394,6 +555,22 @@ final class MPVClient: ObservableObject {
setString("video", "no")
}
func setSubToAuto() {
setString("sub", "auto")
}
func setSubToNo() {
setString("sub", "no")
}
func setSubFontSize(scaleSize: String) {
setString("sub-scale", scaleSize)
}
func setSubFontColor(color: String) {
setString("sub-color", color)
}
var tracksCount: Int {
Int(getString("track-list/count") ?? "-1") ?? -1
}

View File

@ -1,6 +1,7 @@
import CoreMedia
import Defaults
import Foundation
import Logging
#if !os(macOS)
import UIKit
#endif
@ -19,6 +20,8 @@ protocol PlayerBackend {
var loadedVideo: Bool { get }
var isLoadingVideo: Bool { get }
var hasStarted: Bool { get }
var isPaused: Bool { get }
var isPlaying: Bool { get }
var isSeeking: Bool { get }
var playerItemDuration: CMTime? { get }
@ -29,7 +32,6 @@ protocol PlayerBackend {
var videoWidth: Double? { get }
var videoHeight: Double? { get }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
func canPlay(_ stream: Stream) -> Bool
func canPlayAtRate(_ rate: Double) -> Bool
@ -74,6 +76,10 @@ protocol PlayerBackend {
}
extension PlayerBackend {
var logger: Logger {
return Logger(label: "stream.yattee.player.backend")
}
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
model.seek.registerSeek(at: time, type: seekType, restore: currentTime)
seek(to: time, seekType: seekType, completionHandler: completionHandler)
@ -110,15 +116,22 @@ extension PlayerBackend {
model.prepareCurrentItemForHistory(finished: true)
if model.queue.isEmpty {
if Defaults[.closeVideoOnEOF] {
#if os(tvOS)
#if os(tvOS)
if Defaults[.closeVideoOnEOF] {
if model.activeBackend == .appleAVPlayer {
model.avPlayerBackend.controller?.dismiss(animated: false)
}
#endif
model.resetQueue()
model.hide()
}
model.resetQueue()
model.hide()
}
#else
if Defaults[.closeVideoOnEOF] {
model.resetQueue()
model.hide()
} else if Defaults[.exitFullscreenOnEOF], model.playingFullScreen {
model.exitFullScreen()
}
#endif
} else {
model.advanceToNextItem()
}
@ -131,11 +144,91 @@ extension PlayerBackend {
}
}
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
logger.info("Starting bestPlayable function")
logger.info("Total streams received: \(streams.count)")
logger.info("Max resolution allowed: \(String(describing: maxResolution.value))")
logger.info("Format order: \(formatOrder)")
// Filter out non-HLS streams and streams with resolution more than maxResolution
let nonHLSStreams = streams.filter {
let isHLS = $0.kind == .hls
// Check if the stream's resolution is within the maximum allowed resolution
let isWithinResolution = $0.resolution.map { $0 <= maxResolution.value } ?? false
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: $0.resolution)) - Bitrate: \($0.bitrate ?? 0)")
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
return !isHLS && isWithinResolution
}
logger.info("Non-HLS streams after filtering: \(nonHLSStreams.count)")
// Find max resolution and bitrate from non-HLS streams
let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution }
let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
logger.info("Best resolution stream: \(String(describing: bestResolutionStream?.id)) with resolution: \(String(describing: bestResolutionStream?.resolution))")
logger.info("Best bitrate stream: \(String(describing: bestBitrateStream?.id)) with bitrate: \(String(describing: bestBitrateStream?.bitrate))")
let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value
let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate
logger.info("Final best resolution selected: \(String(describing: bestResolution))")
logger.info("Final best bitrate selected: \(bestBitrate)")
let adjustedStreams = streams.map { stream in
if stream.kind == .hls {
logger.info("Adjusting HLS stream ID: \(stream.id)")
stream.resolution = bestResolution
stream.bitrate = bestBitrate
stream.format = .hls
} else if stream.kind == .stream {
logger.info("Adjusting non-HLS stream ID: \(stream.id)")
stream.format = .stream
}
return stream
}
let filteredStreams = adjustedStreams.filter { stream in
// Check if the stream's resolution is within the maximum allowed resolution
let isWithinResolution = stream.resolution <= maxResolution.value
logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)")
return isWithinResolution
}
logger.info("Filtered streams count after adjustments: \(filteredStreams.count)")
let bestStream = filteredStreams.max { lhs, rhs in
if lhs.resolution == rhs.resolution {
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
else {
logger.info("Failed to extract lhsFormat or rhsFormat for streams \(lhs.id) and \(rhs.id)")
return false
}
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
logger.info("Comparing formats for streams \(lhs.id) and \(rhs.id) - LHS Format Index: \(lhsFormatIndex), RHS Format Index: \(rhsFormatIndex)")
return lhsFormatIndex > rhsFormatIndex
}
logger.info("Comparing resolutions for streams \(lhs.id) and \(rhs.id) - LHS Resolution: \(String(describing: lhs.resolution)), RHS Resolution: \(String(describing: rhs.resolution))")
return lhs.resolution < rhs.resolution
}
logger.info("Best stream selected: \(String(describing: bestStream?.id)) with resolution: \(String(describing: bestStream?.resolution)) and format: \(String(describing: bestStream?.format))")
return bestStream
}
func updateControls(completionHandler: (() -> Void)? = nil) {
print("updating controls")
logger.info("updating controls")
guard model.presentingPlayer, !model.controls.presentingOverlays else {
print("ignored controls update")
logger.info("ignored controls update")
completionHandler?()
return
}
@ -143,7 +236,7 @@ extension PlayerBackend {
DispatchQueue.main.async(qos: .userInteractive) {
#if !os(macOS)
guard UIApplication.shared.applicationState != .background else {
print("not performing controls updates in background")
logger.info("not performing controls updates in background")
completionHandler?()
return
}

View File

@ -47,7 +47,7 @@ final class PlayerModel: ObservableObject {
static var shared = PlayerModel()
let logger = Logger(label: "stream.yattee.app")
let logger = Logger(label: "stream.yattee.player.model")
var playerItem: AVPlayerItem?
@ -76,6 +76,8 @@ final class PlayerModel: ObservableObject {
}
}
var previousActiveBackend: PlayerBackendType?
lazy var playerBackendView = PlayerBackendView()
@Published var playerSize: CGSize = .zero { didSet {
@ -88,7 +90,7 @@ final class PlayerModel: ObservableObject {
}}
@Published var aspectRatio = VideoPlayerView.defaultAspectRatio
@Published var stream: Stream?
@Published var currentRate: Double = 1.0 { didSet { handleCurrentRateChange() } }
@Published var currentRate = 1.0 { didSet { handleCurrentRateChange() } }
@Published var qualityProfileSelection: QualityProfile? { didSet { handleQualityProfileChange() } }
@ -128,7 +130,15 @@ final class PlayerModel: ObservableObject {
#if os(iOS)
@Published var lockedOrientation: UIInterfaceOrientationMask?
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
@Published var isOrientationLocked: Bool {
didSet {
Defaults[.isOrientationLocked] = isOrientationLocked
}
}
@Default(.rotateToLandscapeOnEnterFullScreen) var rotateToLandscapeOnEnterFullScreen
@Default(.lockPortraitWhenBrowsing) var lockPortraitWhenBrowsing
var fullscreenInitiatedByButton = false
#endif
@Published var currentChapterIndex: Int?
@ -177,6 +187,11 @@ final class PlayerModel: ObservableObject {
@Default(.playerRate) var playerRate
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
#if os(macOS)
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
#endif
#if !os(macOS)
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
#endif
@ -188,10 +203,43 @@ final class PlayerModel: ObservableObject {
var rateToRestore: Float?
private var remoteCommandCenterConfigured = false
// Used in the PlayerModel extension in PlayerQueue
var retryAttempts = [String: Int]()
#if os(macOS)
var keyPressMonitor: Any?
#endif
init() {
#if os(iOS)
isOrientationLocked = Defaults[.isOrientationLocked]
if isOrientationLocked, lockPortraitWhenBrowsing {
lockedOrientation = UIInterfaceOrientationMask.portrait
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else if isOrientationLocked {
lockOrientationAction()
}
#endif
#if !os(macOS)
mpvBackend.controller = mpvController
mpvBackend.client = mpvController.client
// Register for audio session interruption notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioSessionInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: nil
)
// Register for audio session route change notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange(_:)),
name: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance()
)
#endif
playbackMode = Defaults[.playbackMode]
@ -208,10 +256,25 @@ final class PlayerModel: ObservableObject {
currentRate = playerRate
}
#if !os(macOS)
deinit {
NotificationCenter.default.removeObserver(
self, name: AVAudioSession.interruptionNotification, object: nil
)
NotificationCenter.default.removeObserver(
self,
name: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance()
)
}
#endif
func show() {
#if os(macOS)
if presentingPlayer {
Windows.player.focus()
assignKeyPressMonitor()
return
}
#endif
@ -227,6 +290,7 @@ final class PlayerModel: ObservableObject {
#if os(macOS)
Windows.player.open()
Windows.player.focus()
assignKeyPressMonitor()
#endif
}
@ -246,6 +310,7 @@ final class PlayerModel: ObservableObject {
}
#if os(macOS)
destroyKeyPressMonitor()
Windows.player.hide()
#endif
}
@ -284,6 +349,14 @@ final class PlayerModel: ObservableObject {
backend.isPlaying
}
var isPaused: Bool {
backend.isPaused
}
var hasStarted: Bool {
backend.hasStarted
}
var playerItemDuration: CMTime? {
guard !currentItem.isNil else {
return nil
@ -480,7 +553,10 @@ final class PlayerModel: ObservableObject {
}
private func handlePresentationChange() {
backend.setNeedsDrawing(presentingPlayer)
#if os(macOS)
// TODO: Check whether this is needed on macOS
backend.setNeedsDrawing(presentingPlayer)
#endif
#if os(iOS)
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
@ -509,18 +585,16 @@ final class PlayerModel: ObservableObject {
if !presentingPlayer {
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
if lockPortraitWhenBrowsing {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
} else {
Orientation.lockOrientation(.allButUpsideDown)
Orientation.lockOrientation(.all)
}
OrientationModel.shared.stopOrientationUpdates()
#endif
}
}
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true, isInClosePip: Bool = false) {
guard activeBackend != to else {
return
}
@ -529,7 +603,7 @@ final class PlayerModel: ObservableObject {
let wasPlaying = isPlaying
if to == .mpv {
if to == .mpv && !isInClosePip {
closePiP()
}
@ -622,60 +696,65 @@ final class PlayerModel: ObservableObject {
}
func closeCurrentItem(finished: Bool = false) {
pause()
videoBeingOpened = nil
advancing = false
forceBackendOnPlay = nil
guard !closing else { return }
closing = true
controls.presentingControls = false
self.prepareCurrentItemForHistory(finished: finished)
if playingFullScreen { exitFullScreen() }
self.hide()
Delay.by(0.8) { [weak self] in
Delay.by(0.3) { [weak self] in
guard let self else { return }
self.closePiP()
pause()
videoBeingOpened = nil
advancing = false
forceBackendOnPlay = nil
withAnimation {
self.currentItem = nil
controls.presentingControls = false
self.prepareCurrentItemForHistory(finished: finished)
self.hide()
Delay.by(0.7) { [weak self] in
guard let self else { return }
if playingInPictureInPicture { self.closePiP() }
withAnimation {
self.currentItem = nil
}
self.updateNowPlayingInfo()
self.backend.closeItem()
self.aspectRatio = VideoPlayerView.defaultAspectRatio
self.resetAutoplay()
self.closing = false
}
self.updateNowPlayingInfo()
self.backend.closeItem()
self.aspectRatio = VideoPlayerView.defaultAspectRatio
self.resetAutoplay()
self.closing = false
self.playingFullScreen = false
}
}
func startPiP() {
previousActiveBackend = activeBackend
avPlayerBackend.startPictureInPictureOnPlay = false
avPlayerBackend.startPictureInPictureOnSwitch = false
if activeBackend == .appleAVPlayer {
guard activeBackend != .appleAVPlayer else {
avPlayerBackend.tryStartingPictureInPicture()
return
}
guard let video = currentVideo else { return }
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return }
avPlayerBackend.startPictureInPictureOnSwitch = true
exitFullScreen()
if avPlayerBackend.video == video {
if activeBackend != .appleAVPlayer {
avPlayerBackend.startPictureInPictureOnSwitch = true
saveTime {
self.changeActiveBackend(from: .mpv, to: .appleAVPlayer)
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
if let pipController = self?.pipController, pipController.isPictureInPictureActive, self?.avPlayerBackend.isPlaying == true {
self?.exitFullScreen()
self?.controls.objectWillChange.send()
timer.invalidate()
} else if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.startPictureInPictureOnSwitch == false {
self?.avPlayerBackend.startPictureInPictureOnSwitch = true
self?.avPlayerBackend.tryStartingPictureInPicture()
}
}
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
} else {
avPlayerBackend.startPictureInPictureOnPlay = true
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
}
controls.objectWillChange.send()
}
var transitioningToPiP: Bool {
@ -703,7 +782,28 @@ final class PlayerModel: ObservableObject {
show()
#endif
backend.closePiP()
avPlayerBackend.closePiP()
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.isPlaying == true, self?.playingInPictureInPicture == false {
timer.invalidate()
}
}
guard previousActiveBackend == .mpv else { return }
saveTime {
self.changeActiveBackend(from: .appleAVPlayer, to: .mpv, isInClosePip: true)
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
if self?.activeBackend == .mpv, self?.mpvBackend.isPlaying == true {
timer.invalidate()
}
}
}
// We need to remove the itme from the player, if not it will be displayed when next video goe to PiP.
Delay.by(1.0) {
self.avPlayerBackend.closeItem()
}
}
var pipImage: String {
@ -715,7 +815,7 @@ final class PlayerModel: ObservableObject {
}
func toggleFullScreenAction() {
toggleFullscreen(playingFullScreen, showControls: false)
toggleFullscreen(playingFullScreen, showControls: false, initiatedByButton: true)
}
func togglePiPAction() {
@ -728,20 +828,21 @@ final class PlayerModel: ObservableObject {
#if os(iOS)
var lockOrientationImage: String {
lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation"
isOrientationLocked ? "lock.rotation" : "lock.rotation.open"
}
func lockOrientationAction() {
if lockedOrientation.isNil {
// This makes toggling orientation lock more robust
if lockedOrientation.isNil || !isOrientationLocked {
isOrientationLocked = true
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
lockedOrientation = orientationMask
let orientation = OrientationTracker.shared.currentInterfaceOrientation
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
// iOS 16 workaround
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
Orientation.lockOrientation(orientationMask, andRotateTo: playingFullScreen ? nil : orientation)
} else {
isOrientationLocked = false
lockedOrientation = nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
Orientation.lockOrientation(.all)
}
}
#endif
@ -759,10 +860,12 @@ final class PlayerModel: ObservableObject {
func handleCurrentItemChange() {
if currentItem == nil {
captions = nil
FeedModel.shared.calculateUnwatchedFeed()
}
// Captions need to be set to nil on item change, to clear the previus values.
captions = nil
#if os(macOS)
Windows.player.window?.title = windowTitle
#endif
@ -829,26 +932,29 @@ final class PlayerModel: ObservableObject {
}
func updateRemoteCommandCenter() {
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
let previousTrackCommand = MPRemoteCommandCenter.shared().previousTrackCommand
let nextTrackCommand = MPRemoteCommandCenter.shared().nextTrackCommand
let commandCenter = MPRemoteCommandCenter.shared()
let skipForwardCommand = commandCenter.skipForwardCommand
let skipBackwardCommand = commandCenter.skipBackwardCommand
let previousTrackCommand = commandCenter.previousTrackCommand
let nextTrackCommand = commandCenter.nextTrackCommand
if !remoteCommandCenterConfigured {
remoteCommandCenterConfigured = true
#if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .moviePlayback
)
UIApplication.shared.beginReceivingRemoteControlEvents()
#endif
let interval = TimeInterval(systemControlsSeekDuration) ?? 10
let preferredIntervals = [NSNumber(value: interval)]
// Remove existing targets to avoid duplicates
skipForwardCommand.removeTarget(nil)
skipBackwardCommand.removeTarget(nil)
previousTrackCommand.removeTarget(nil)
nextTrackCommand.removeTarget(nil)
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
// Re-add targets for handling commands
skipForwardCommand.preferredIntervals = preferredIntervals
skipBackwardCommand.preferredIntervals = preferredIntervals
@ -872,22 +978,22 @@ final class PlayerModel: ObservableObject {
return .success
}
MPRemoteCommandCenter.shared().playCommand.addTarget { [weak self] _ in
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
MPRemoteCommandCenter.shared().pauseCommand.addTarget { [weak self] _ in
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
MPRemoteCommandCenter.shared().togglePlayPauseCommand.addTarget { [weak self] _ in
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
self?.togglePlay()
return .success
}
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.backend.seek(to: event.positionTime, seekType: .userInteracted)
@ -922,22 +1028,43 @@ final class PlayerModel: ObservableObject {
}
#else
func handleEnterForeground() {
setNeedsDrawing(presentingPlayer)
avPlayerBackend.bindPlayerToLayer()
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
guard let self = self else { return }
if !self.musicMode, self.activeBackend == .mpv {
self.mpvBackend.addVideoTrackFromStream()
self.mpvBackend.setVideoToAuto()
self.mpvBackend.controls.resetTimer()
} else if !self.musicMode, self.activeBackend == .appleAVPlayer {
self.avPlayerBackend.bindPlayerToLayer()
}
}
#if os(iOS)
OrientationTracker.shared.startDeviceOrientationTracking()
#endif
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
}
show()
closePiP()
// Needs to be delayed a bit, otherwise the PiP windows stays open
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.closePiP()
}
}
func handleEnterBackground() {
#if os(iOS)
OrientationTracker.shared.stopDeviceOrientationTracking()
#endif
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
pause()
} else if !playingInPictureInPicture {
} else if !playingInPictureInPicture, activeBackend == .appleAVPlayer {
avPlayerBackend.removePlayerFromLayer()
} else if activeBackend == .mpv, !musicMode {
mpvBackend.setVideoToNo()
}
}
#endif
@ -947,6 +1074,7 @@ final class PlayerModel: ObservableObject {
logger.info("entering fullscreen")
toggleFullscreen(false, showControls: showControls)
self.playingFullScreen = true
}
func exitFullScreen(showControls: Bool = true) {
@ -954,6 +1082,7 @@ final class PlayerModel: ObservableObject {
logger.info("exiting fullscreen")
toggleFullscreen(true, showControls: showControls)
self.playingFullScreen = false
}
func updateNowPlayingInfo() {
@ -961,18 +1090,22 @@ final class PlayerModel: ObservableObject {
guard activeBackend == .mpv else { return }
#endif
#if os(iOS)
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
return
}
#endif
guard let video = currentItem?.video else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = .none
return
}
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
// Determine the media type based on musicMode
let mediaType: NSNumber
if musicMode {
mediaType = MPMediaType.anyAudio.rawValue as NSNumber
} else {
mediaType = MPMediaType.anyVideo.rawValue as NSNumber
}
// Prepare the Now Playing info dictionary
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: video.displayTitle as AnyObject,
MPMediaItemPropertyArtist: video.displayAuthor as AnyObject,
@ -980,7 +1113,7 @@ final class PlayerModel: ObservableObject {
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
MPMediaItemPropertyMediaType: mediaType
]
if !currentArtwork.isNil {
@ -1001,7 +1134,7 @@ final class PlayerModel: ObservableObject {
func updateCurrentArtwork() {
guard let video = currentVideo,
let thumbnailURL = video.thumbnailURL(quality: .medium)
let thumbnailURL = video.thumbnailURL(quality: Constants.isIPhone ? .medium : .maxres)
else {
return
}
@ -1023,7 +1156,7 @@ final class PlayerModel: ObservableObject {
task.resume()
}
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true) {
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true, initiatedByButton: Bool = false) {
controls.presentingControls = showControls && isFullScreen
#if os(macOS)
@ -1035,18 +1168,27 @@ final class PlayerModel: ObservableObject {
#if os(iOS)
if playingFullScreen {
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
fullscreenInitiatedByButton = initiatedByButton
avPlayerBackend.controller.enterFullScreen(animated: true)
return
}
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
let lockOrientation = rotateToLandscapeOnEnterFullScreen.interfaceOrientation
if currentVideoIsLandscape {
let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
// not sure why but first rotation call is ignore so doing rotate to same orientation first
Delay.by(delay) {
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
if initiatedByButton {
Orientation.lockOrientation(isOrientationLocked
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
: .landscape)
}
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape
? OrientationTracker.shared.currentInterfaceOrientation
: rotateToLandscapeOnEnterFullScreen.interfaceOrientation
Orientation.lockOrientation(
isOrientationLocked
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
: .all,
andRotateTo: orientation
)
}
} else {
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
@ -1054,10 +1196,12 @@ final class PlayerModel: ObservableObject {
avPlayerBackend.controller.dismiss(animated: true)
return
}
let rotationOrientation = Constants.isIPhone ? UIInterfaceOrientation.portrait : nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
if lockPortraitWhenBrowsing {
lockedOrientation = UIInterfaceOrientationMask.portrait
}
let rotationOrientation = lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
Orientation.lockOrientation(lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
}
#endif
}
@ -1146,4 +1290,181 @@ final class PlayerModel: ObservableObject {
return nil
}
#if !os(macOS)
func setAudioSessionActive(_ setActive: Bool) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
do {
try AVAudioSession.sharedInstance().setActive(setActive)
} catch {
self.logger.error("Error setting up audio session: \(error)")
}
}
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
logger.info("Audio session interruption received.")
logger.info("Notification object: \(String(describing: notification.object))")
guard let info = notification.userInfo else {
logger.info("userInfo is missing in the notification.")
return
}
// Extract the interruption type
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
return
}
logger.info("Interruption type received: \(type)")
// Check availability for iOS 14.5 or newer to handle interruption reason
// Currently only for debugging purpose
#if os(iOS)
if #available(iOS 14.5, *) {
// Extract the interruption reason, if available
if let reasonValue = info[AVAudioSessionInterruptionReasonKey] as? UInt,
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
{
logger.info("Interruption reason received: \(reason)")
switch reason {
case .default:
logger.info("Interruption reason: Default or unspecified interruption occurred.")
case .appWasSuspended:
logger.info("Interruption reason: The app was suspended during the interruption.")
@unknown default:
logger.info("Unknown interruption reason received.")
}
} else {
logger.info("AVAudioSessionInterruptionReasonKey is missing or not a UInt in userInfo.")
}
} else {
logger.info("Interruption reason handling is not available on this iOS version.")
}
#endif
// Handle the specific interruption type
switch type {
case .began:
pause()
logger.info("Audio session interrupted (began).")
case .ended:
// Extract any interruption options, if available
if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
logger.info("Interruption options received: \(optionsValue)")
if optionsValue & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 {
play()
logger.info("Interruption option indicates playback should resume automatically.")
} else {
logger.info("Interruption option indicates playback should not resume automatically.")
}
} else {
logger.info("AVAudioSessionInterruptionOptionKey is missing or not a UInt in userInfo.")
}
logger.info("Audio session interruption ended.")
// Check if audio was resumed or if there's any indication of ducking
let currentVolume = AVAudioSession.sharedInstance().outputVolume
logger.info("Current output volume: \(currentVolume)")
default:
logger.info("Unknown interruption type received.")
}
}
@objc func handleRouteChange(_ notification: Notification) {
logger.info("Audio route change received.")
guard let info = notification.userInfo else {
logger.info("userInfo is missing in the notification.")
return
}
guard let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
logger.info("AVAudioSessionRouteChangeReasonKey is missing or not a UInt in userInfo.")
return
}
logger.info("Route change reason received: \(reason)")
let currentCategory = AVAudioSession.sharedInstance().category
logger.info("Current audio session category before change: \(currentCategory)")
switch reason {
case .categoryChange:
logger.info("Audio session category changed.")
let newCategory = AVAudioSession.sharedInstance().category
logger.info("New audio session category: \(newCategory)")
case .oldDeviceUnavailable, .newDeviceAvailable:
logger.info("Audio route change may indicate ducking or device change.")
let currentRoute = AVAudioSession.sharedInstance().currentRoute
logger.info("Current audio route: \(currentRoute)")
for output in currentRoute.outputs {
logger.info("Output port type: \(output.portType), UID: \(output.uid)")
switch output.portType {
case .headphones, .bluetoothA2DP:
logger.info("Detected port type \(output.portType). Executing play().")
play()
default:
logger.info("Detected port type \(output.portType). Executing pause().")
pause()
}
}
case .noSuitableRouteForCategory:
logger.info("No suitable route for the current category.")
default:
logger.info("Unhandled route change reason: \(reason)")
}
}
#endif
#if os(macOS)
private func assignKeyPressMonitor() {
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] keyEvent -> NSEvent? in
// Check if the player window is the key window
guard let self, let window = Windows.playerWindow, window.isKeyWindow else { return keyEvent }
switch keyEvent.keyCode {
case 124:
if !self.liveStreamInAVPlayer {
let interval = TimeInterval(self.buttonForwardSeekDuration) ?? 10
self.backend.seek(
relative: .secondsInDefaultTimescale(interval),
seekType: .userInteracted
)
}
case 123:
if !self.liveStreamInAVPlayer {
let interval = TimeInterval(self.buttonBackwardSeekDuration) ?? 10
self.backend.seek(
relative: .secondsInDefaultTimescale(-interval),
seekType: .userInteracted
)
}
case 3:
self.toggleFullscreen(
self.playingFullScreen,
showControls: false
)
case 49:
if !self.controls.isLoadingVideo {
self.backend.togglePlay()
}
default:
return keyEvent
}
return nil
}
}
private func destroyKeyPressMonitor() {
if let keyPressMonitor {
NSEvent.removeMonitor(keyPressMonitor)
}
}
#endif
}

View File

@ -94,7 +94,9 @@ extension PlayerModel {
}
} else {
self.videoBeingOpened = nil
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
self.availableStreams = processedStreams
}
}
}
}
@ -125,14 +127,32 @@ extension PlayerModel {
var streamByQualityProfile: Stream? {
let profile = qualityProfile ?? .defaultProfile
// First attempt: Filter by both `canPlay` and `isPreferred`
if let streamPreferredForProfile = backend.bestPlayable(
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
maxResolution: profile.resolution
maxResolution: profile.resolution, formatOrder: profile.formats
) {
return streamPreferredForProfile
}
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
// Fallback: Filter by `canPlay` only
let fallbackStream = backend.bestPlayable(
availableStreams.filter { backend.canPlay($0) },
maxResolution: profile.resolution, formatOrder: profile.formats
)
// If no stream is found, trigger the error handler
guard let finalStream = fallbackStream else {
let error = RequestError(
userMessage: "No supported streams available.",
cause: NSError(domain: "stream.yatte.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "No supported streams available"])
)
videoLoadFailureHandler(error, video: currentVideo)
return nil
}
// Return the found stream
return finalStream
}
func advanceToNextItem() {
@ -339,6 +359,31 @@ extension PlayerModel {
}
private func videoLoadFailureHandler(_ error: RequestError, video: Video? = nil) {
guard let video else {
presentErrorAlert(error)
return
}
let videoID = video.videoID
let currentRetry = retryAttempts[videoID] ?? 0
if currentRetry < Defaults[.videoLoadingRetryCount] {
retryAttempts[videoID] = currentRetry + 1
logger.info("Retry attempt \(currentRetry + 1) for video \(videoID) due to error: \(error)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
self.enqueueVideo(video, play: true, prepending: true, loadDetails: true)
}
return
}
retryAttempts[videoID] = 0
presentErrorAlert(error, video: video)
}
private func presentErrorAlert(_ error: RequestError, video: Video? = nil) {
var message = error.userMessage
if let errorDictionary = error.json.dictionaryObject,
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],

View File

@ -44,22 +44,6 @@ extension PlayerModel {
}
private func skip(_ segment: Segment, at time: CMTime) {
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.pause()
self.backend.eofPlaybackModeAction()
}
return
}
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
DispatchQueue.main.async { [weak self] in
@ -69,6 +53,14 @@ extension PlayerModel {
self?.segmentRestorationTime = time
}
logger.info("SponsorBlock skipping to: \(segment.end)")
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
logger.error("Segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
DispatchQueue.main.async { [weak self] in
self?.backend.eofPlaybackModeAction()
}
}
}
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {

View File

@ -1,3 +1,4 @@
import AVFoundation
import Foundation
import Siesta
import SwiftUI
@ -41,7 +42,9 @@ extension PlayerModel {
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
return
}
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
self.availableStreams = processedStreams
}
} else {
self.logger.critical("no streams available from \(instance.description)")
}
@ -53,28 +56,172 @@ extension PlayerModel {
}
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in
stream.instance = instance
func streamsWithInstance(instance: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
// Queue for stream processing
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue")
// Queue for accessing the processedStreams array
let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
// DispatchGroup for managing multiple tasks
let streamProcessingGroup = DispatchGroup()
if instance.app == .invidious, instance.proxiesVideos {
if let audio = stream.audioAsset {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
var processedStreams = [Stream]()
let instance = instance
var hasForbiddenAsset = false
var hasAllowedAsset = false
for stream in streams {
streamProcessingQueue.async(group: streamProcessingGroup) {
let forbiddenAssetTestGroup = DispatchGroup()
if !hasAllowedAsset, !hasForbiddenAsset, !instance.proxiesVideos, stream.format != Stream.Format.unknown {
let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
if let firstStream = nonHLSAssets.first {
let asset = firstStream.0
let url = firstStream.1
let requestRange = firstStream.2
if instance.app == .invidious {
self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
switch status {
case HTTPStatus.Forbidden:
hasForbiddenAsset = true
case HTTPStatus.PartialContent:
hasAllowedAsset = true
case HTTPStatus.OK:
hasAllowedAsset = true
default:
break
}
}
} else if instance.app == .piped {
self.testPipedAssets(asset: asset!, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
switch status {
case HTTPStatus.Forbidden:
hasForbiddenAsset = true
case HTTPStatus.PartialContent:
hasAllowedAsset = true
case HTTPStatus.OK:
hasAllowedAsset = true
default:
break
}
}
}
} else if let firstHLS = hlsURLs.first {
let asset = AVURLAsset(url: firstHLS)
if instance.app == .piped {
self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
switch status {
case HTTPStatus.Forbidden:
hasForbiddenAsset = true
case HTTPStatus.PartialContent:
hasAllowedAsset = true
case HTTPStatus.OK:
hasAllowedAsset = true
default:
break
}
}
}
}
}
if let video = stream.videoAsset {
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
forbiddenAssetTestGroup.wait()
// Post-processing code
if instance.app == .invidious, hasForbiddenAsset || instance.proxiesVideos {
if let audio = stream.audioAsset {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
}
if let video = stream.videoAsset {
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
}
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
if let hlsURL = stream.hlsURL {
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
if let nonProxiedURL = possibleNonProxiedURL {
stream.hlsURL = nonProxiedURL.url
}
}
} else {
if let audio = stream.audioAsset {
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
stream.audioAsset = nonProxiedAudioAsset
}
}
if let video = stream.videoAsset {
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
stream.videoAsset = nonProxiedVideoAsset
}
}
}
}
// Append to processedStreams within the processedStreamsQueue
processedStreamsQueue.sync {
processedStreams.append(stream)
}
}
}
return stream
streamProcessingGroup.notify(queue: .main) {
// Access and pass processedStreams within the processedStreamsQueue block
processedStreamsQueue.sync {
completion(processedStreams)
}
}
}
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
if lhs.resolution.isNil || rhs.resolution.isNil {
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(AVURLAsset?, URL, String?)], hlsURLs: [URL]) {
var nonHLSAssets = [(AVURLAsset?, URL, String?)]()
var hlsURLs = [URL]()
for stream in streams {
if stream.isHLS {
if let url = stream.hlsURL?.url {
hlsURLs.append(url)
}
} else {
if let asset = stream.audioAsset {
nonHLSAssets.append((asset, asset.url, stream.requestRange))
}
if let asset = stream.videoAsset {
nonHLSAssets.append((asset, asset.url, stream.requestRange))
}
}
}
return (nonHLSAssets, hlsURLs)
}
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
// In case the range is nil, generate a random one.
let randomEnd = Int.random(in: 200 ... 800)
let requestRange = range ?? "0-\(randomEnd)"
forbiddenAssetTestGroup.enter()
URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
completion(statusCode)
forbiddenAssetTestGroup.leave()
}
}
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
PipedAPI.nonProxiedAsset(asset: asset) { possibleNonProxiedAsset in
if let nonProxiedAsset = possibleNonProxiedAsset {
self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
} else {
completion(0)
}
}
}
func streamsSorter(lhs: Stream, rhs: Stream) -> Bool {
// Use optional chaining to simplify nil handling
guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else {
return lhs.kind < rhs.kind
}
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
// Compare either kind or resolution based on conditions
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
}
}

View File

@ -16,10 +16,12 @@ struct ScreenSaverManager {
return false
}
noSleepReturn = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason as CFString,
&noSleepAssertion)
noSleepReturn = IOPMAssertionCreateWithName(
kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason as CFString,
&noSleepAssertion
)
return noSleepReturn == kIOReturnSuccess
}

View File

@ -69,7 +69,10 @@ final class PlaylistsModel: ObservableObject {
.onSuccess { resource in
self.error = nil
if let playlists: [Playlist] = resource.typedContent() {
self.playlists = playlists
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.playlists = playlists
}
PlaylistsCacheModel.shared.storePlaylist(account: account, playlists: playlists)
onSuccess()
}

View File

@ -3,15 +3,15 @@ import Foundation
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
static var bridge = QualityProfileBridge()
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream])
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream], order: Array(Format.allCases.indices))
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
case hls
case stream
case mp4
case avc1
case av1
case stream
case webm
case mp4
case av1
case hls
var id: String {
rawValue
@ -23,7 +23,6 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
return "Stream"
case .webm:
return "WebM"
default:
return rawValue.uppercased()
}
@ -31,18 +30,18 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
var streamFormat: Stream.Format? {
switch self {
case .hls:
return nil
case .stream:
return nil
case .mp4:
return .mp4
case .webm:
return .webm
case .avc1:
return .avc1
case .stream:
return nil
case .webm:
return .webm
case .mp4:
return .mp4
case .av1:
return .av1
case .hls:
return nil
}
}
}
@ -53,21 +52,23 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
var backend: PlayerBackendType
var resolution: ResolutionSetting
var formats: [Format]
var order: [Int]
var description: String {
if let name, !name.isEmpty { return name }
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
}
var formatsDescription: String {
if formats.count == Format.allCases.count {
switch formats.count {
case Format.allCases.count:
return "Any format".localized()
}
if formats.count <= 3 {
case 0:
return "No format selected".localized()
case 1 ... 3:
return formats.map(\.description).joined(separator: ", ")
default:
return String(format: "%@ formats".localized(), String(formats.count))
}
return String(format: "%@ formats".localized(), String(formats.count))
}
func isPreferred(_ stream: Stream) -> Bool {
@ -75,7 +76,8 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
return true
}
let resolutionMatch = !stream.resolution.isNil && resolution.value >= stream.resolution
let defaultResolution = Stream.Resolution.custom(height: 720, refreshRate: 30)
let resolutionMatch = resolution.value ?? defaultResolution >= stream.resolution
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
return true
@ -101,7 +103,8 @@ struct QualityProfileBridge: Defaults.Bridge {
"name": value.name ?? "",
"backend": value.backend.rawValue,
"resolution": value.resolution.rawValue,
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
"formats": value.formats.map(\.rawValue).joined(separator: Self.formatsSeparator),
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
]
}
@ -116,7 +119,8 @@ struct QualityProfileBridge: Defaults.Bridge {
let name = object["name"]
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
let order = (object["order"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { Int($0) }
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats)
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats, order: order)
}
}

View File

@ -18,6 +18,8 @@ final class SearchModel: ObservableObject {
@Published var focused = false
@Default(.showSearchSuggestions) private var showSearchSuggestions
#if os(iOS)
var textField: UITextField!
#elseif os(macOS)
@ -102,7 +104,7 @@ final class SearchModel: ObservableObject {
}}
func loadSuggestions(_ query: String) {
guard accounts.app.supportsSearchSuggestions else {
guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else {
querySuggestions.removeAll()
return
}

View File

@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
func showOSD() {
guard !presentingOSD else { return }
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true }
presentingOSD = true
}
func hideOSD() {
guard presentingOSD else { return }
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false }
presentingOSD = false
}
func hideOSDWithDelay() {

View File

@ -1,6 +1,7 @@
import Foundation
enum SeekType: Equatable {
case chapterSkip(String)
case segmentSkip(String)
case segmentRestore
case userInteracted

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

@ -5,7 +5,7 @@ import Logging
import SwiftyJSON
final class SponsorBlockAPI: ObservableObject {
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
static let categories = ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "filler", "music_offtopic"]
let logger = Logger(label: "stream.yattee.app.sb")
@ -21,15 +21,19 @@ final class SponsorBlockAPI: ObservableObject {
case "sponsor":
return "Sponsor".localized()
case "selfpromo":
return "Self-promotion".localized()
case "intro":
return "Intro".localized()
case "outro":
return "Outro".localized()
return "Unpaid/Self Promotion".localized()
case "interaction":
return "Interaction".localized()
return "Interaction Reminder (Subscribe)".localized()
case "intro":
return "Intermission/Intro Animation".localized()
case "outro":
return "Endcards/Credits".localized()
case "preview":
return "Preview/Recap/Hook".localized()
case "filler":
return "Filler Tangent/Jokes".localized()
case "music_offtopic":
return "Offtopic in Music Videos".localized()
return "Music: Non-Music Section".localized()
default:
return name.capitalized
}
@ -46,9 +50,14 @@ final class SponsorBlockAPI: ObservableObject {
"The creator will receive payment or compensation in the form of money or free products.").localized()
case "selfpromo":
return ("Promoting a product or service that is directly related to the creator themselves. " +
return ("The creator will not receive any payment in exchange for this promotion. " +
"This includes charity drives or free shout outs for products or other people they like.\n\n" +
"Promoting a product or service that is directly related to the creator themselves. " +
"This usually includes merchandise or promotion of monetized platforms.").localized()
case "interaction":
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
case "intro":
return ("Segments typically found at the start of a video that include an animation, " +
"still frame or clip which are also seen in other videos by the same creator.").localized()
@ -56,8 +65,11 @@ final class SponsorBlockAPI: ObservableObject {
case "outro":
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
case "interaction":
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
case "preview":
return "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video".localized()
case "filler":
return "Filler Tangent/ Jokes is only for tangential scenes added only for filler or humor that are not required to understand the main content of the video.".localized()
case "music_offtopic":
return "For videos which feature music as the primary content.".localized()
@ -100,8 +112,8 @@ final class SponsorBlockAPI: ObservableObject {
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
self.segments.forEach {
self.logger.info("\($0.start) -> \($0.end)")
for segment in self.segments {
self.logger.info("\(segment.start) -> \(segment.end)")
}
case let .failure(error):
self.segments = []

View File

@ -4,67 +4,130 @@ import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable, Identifiable {
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
case hd2160p60
case hd2160p50
case hd2160p48
case hd2160p30
case hd1440p60
case hd1440p50
case hd1440p48
case hd1440p30
case hd1080p60
case hd1080p50
case hd1080p48
case hd1080p30
case hd720p60
case hd720p50
case hd720p48
case hd720p30
case sd480p30
case sd360p30
case sd240p30
case sd144p30
case unknown
enum Resolution: Comparable, Codable, Defaults.Serializable {
case predefined(PredefinedResolution)
case custom(height: Int, refreshRate: Int)
enum PredefinedResolution: String, CaseIterable, Codable {
// 8K UHD (16:9) Resolutions
case hd4320p60, hd4320p30
// 4K UHD (16:9) Resolutions
case hd2160p60, hd2160p30
// 1440p (16:9) Resolutions
case hd1440p60, hd1440p30
// 1080p (Full HD, 16:9) Resolutions
case hd1080p60, hd1080p30
// 720p (HD, 16:9) Resolutions
case hd720p60, hd720p30
// Standard Definition (SD) Resolutions
case sd480p30
case sd360p30
case sd240p30
case sd144p30
}
var name: String {
"\(height)p\(refreshRate != -1 && refreshRate != 30 ? ", \(refreshRate) fps" : "")"
switch self {
case let .predefined(predefined):
return predefined.rawValue
case let .custom(height, refreshRate):
return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")"
}
}
var height: Int {
if self == .unknown {
return -1
switch self {
case let .predefined(predefined):
return predefined.height
case let .custom(height, _):
return height
}
let resolutionPart = rawValue.components(separatedBy: "p").first!
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
}
var refreshRate: Int {
if self == .unknown {
return -1
switch self {
case let .predefined(predefined):
return predefined.refreshRate
case let .custom(_, refreshRate):
return refreshRate
}
}
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
if refreshRatePart.isEmpty {
return 30
var bitrate: Int {
switch self {
case let .predefined(predefined):
return predefined.bitrate
case let .custom(height, refreshRate):
// Find the closest predefined resolution based on height and refresh rate
let closestPredefined = Stream.Resolution.PredefinedResolution.allCases.min {
abs($0.height - height) + abs($0.refreshRate - refreshRate) <
abs($1.height - height) + abs($1.refreshRate - refreshRate)
}
// Return the bitrate of the closest predefined resolution or a default bitrate if no close match is found
return closestPredefined?.bitrate ?? 5_000_000
}
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
}
static func from(resolution: String, fps: Int? = nil) -> Self {
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
if let predefined = PredefinedResolution(rawValue: resolution) {
return .predefined(predefined)
}
// Attempt to parse height and refresh rate
if let height = Int(resolution.components(separatedBy: "p").first ?? ""), height > 0 {
let refreshRate = fps ?? 30
return .custom(height: height, refreshRate: refreshRate)
}
// Default behavior if parsing fails
return .custom(height: 720, refreshRate: 30)
}
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height)
}
enum CodingKeys: String, CodingKey {
case predefined
case custom
case height
case refreshRate
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let predefinedValue = try? container.decode(PredefinedResolution.self, forKey: .predefined) {
self = .predefined(predefinedValue)
} else if let height = try? container.decode(Int.self, forKey: .height),
let refreshRate = try? container.decode(Int.self, forKey: .refreshRate)
{
self = .custom(height: height, refreshRate: refreshRate)
} else {
// Set default resolution to 720p 30 if decoding fails
self = .custom(height: 720, refreshRate: 30)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .predefined(predefinedValue):
try container.encode(predefinedValue, forKey: .predefined)
case let .custom(height, refreshRate):
try container.encode(height, forKey: .height)
try container.encode(refreshRate, forKey: .refreshRate)
}
}
}
enum Kind: String, Comparable {
case stream, adaptive, hls
case hls, adaptive, stream
private var sortOrder: Int {
switch self {
@ -82,37 +145,23 @@ class Stream: Equatable, Hashable, Identifiable {
}
}
enum Format: String, Comparable {
case webm
enum Format: String {
case avc1
case av1
case mp4
case av1
case webm
case hls
case stream
case unknown
private var sortOrder: Int {
switch self {
case .mp4:
return 0
case .avc1:
return 1
case .av1:
return 2
case .webm:
return 3
case .unknown:
return 4
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
var description: String {
switch self {
case .webm:
return "WebM"
case .hls:
return "adaptive (HLS)"
case .stream:
return "Stream"
default:
return rawValue.uppercased()
}
@ -121,17 +170,23 @@ class Stream: Equatable, Hashable, Identifiable {
static func from(_ string: String) -> Self {
let lowercased = string.lowercased()
if lowercased.contains("webm") {
return .webm
}
if lowercased.contains("avc1") {
return .avc1
}
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
return .mp4
}
if lowercased.contains("av01") {
return .av1
}
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
return .mp4
if lowercased.contains("webm") {
return .webm
}
if lowercased.contains("stream") {
return .stream
}
if lowercased.contains("hls") {
return .hls
}
return .unknown
}
@ -151,6 +206,8 @@ class Stream: Equatable, Hashable, Identifiable {
var encoding: String?
var videoFormat: String?
var bitrate: Int?
var requestRange: String?
init(
instance: Instance? = nil,
@ -161,7 +218,9 @@ class Stream: Equatable, Hashable, Identifiable {
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil,
videoFormat: String? = nil
videoFormat: String? = nil,
bitrate: Int? = nil,
requestRange: String? = nil
) {
self.instance = instance
self.audioAsset = audioAsset
@ -172,6 +231,8 @@ class Stream: Equatable, Hashable, Identifiable {
self.kind = kind
self.encoding = encoding
format = .from(videoFormat ?? "")
self.bitrate = bitrate
self.requestRange = requestRange
}
var isLocal: Bool {
@ -184,22 +245,31 @@ class Stream: Equatable, Hashable, Identifiable {
var quality: String {
guard localURL.isNil else { return "Opened File" }
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
if kind == .hls {
return "adaptive (HLS)"
}
return resolution.name
}
var shortQuality: String {
guard localURL.isNil else { return "File" }
if kind == .hls {
return "HLS"
return "adaptive (HLS)"
}
return resolution?.name ?? "?"
if kind == .stream {
return resolution.name
}
return resolutionAndFormat
}
var description: String {
guard localURL.isNil else { return resolutionAndFormat }
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
return "\(resolutionAndFormat)\(instanceString)"
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
}
var resolutionAndFormat: String {
@ -246,3 +316,97 @@ class Stream: Equatable, Hashable, Identifiable {
}
}
}
extension Stream.Resolution.PredefinedResolution {
var height: Int {
switch self {
// 8K UHD (16:9) Resolutions
case .hd4320p60, .hd4320p30:
return 4320
// 4K UHD (16:9) Resolutions
case .hd2160p60, .hd2160p30:
return 2160
// 1440p (16:9) Resolutions
case .hd1440p60, .hd1440p30:
return 1440
// 1080p (Full HD, 16:9) Resolutions
case .hd1080p60, .hd1080p30:
return 1080
// 720p (HD, 16:9) Resolutions
case .hd720p60, .hd720p30:
return 720
// Standard Definition (SD) Resolutions
case .sd480p30:
return 480
case .sd360p30:
return 360
case .sd240p30:
return 240
case .sd144p30:
return 144
}
}
var refreshRate: Int {
switch self {
// 60 fps Resolutions
case .hd4320p60, .hd2160p60, .hd1440p60, .hd1080p60, .hd720p60:
return 60
// 30 fps Resolutions
case .hd4320p30, .hd2160p30, .hd1440p30, .hd1080p30, .hd720p30,
.sd480p30, .sd360p30, .sd240p30, .sd144p30:
return 30
}
}
// These values are an approximation.
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
var bitrate: Int {
switch self {
// 8K UHD (16:9) Resolutions
case .hd4320p60:
return 180_000_000 // Midpoint between 120 Mbps and 240 Mbps
case .hd4320p30:
return 120_000_000 // Midpoint between 80 Mbps and 160 Mbps
// 4K UHD (16:9) Resolutions
case .hd2160p60:
return 60_500_000 // Midpoint between 53 Mbps and 68 Mbps
case .hd2160p30:
return 40_000_000 // Midpoint between 35 Mbps and 45 Mbps
// 1440p (2K) Resolutions
case .hd1440p60:
return 24_000_000 // 24 Mbps
case .hd1440p30:
return 16_000_000 // 16 Mbps
// 1080p (Full HD, 16:9) Resolutions
case .hd1080p60:
return 12_000_000 // 12 Mbps
case .hd1080p30:
return 8_000_000 // 8 Mbps
// 720p (HD, 16:9) Resolutions
case .hd720p60:
return 7_500_000 // 7.5 Mbps
case .hd720p30:
return 5_000_000 // 5 Mbps
// Standard Definition (SD) Resolutions
case .sd480p30:
return 2_500_000 // 2.5 Mbps
case .sd360p30:
return 1_000_000 // 1 Mbps
case .sd240p30:
return 1_000_000 // 1 Mbps
case .sd144p30:
return 600_000 // 0.6 Mbps
}
}
}

View File

@ -5,10 +5,27 @@ final class ThumbnailsModel: ObservableObject {
static var shared = ThumbnailsModel()
@Published var unloadable = Set<URL>()
private var retryCounts = [URL: Int]()
private let maxRetries = 3
private let retryDelay: TimeInterval = 1.0
func insertUnloadable(_ url: URL) {
DispatchQueue.main.async {
self.unloadable.insert(url)
let retries = (retryCounts[url] ?? 0) + 1
if retries >= maxRetries {
DispatchQueue.main.async {
self.unloadable.insert(url)
self.retryCounts.removeValue(forKey: url)
}
} else {
DispatchQueue.main.async {
self.retryCounts[url] = retries
}
DispatchQueue.global().asyncAfter(deadline: .now() + retryDelay) {
DispatchQueue.main.async {
self.retryCounts[url] = retries
}
}
}
}
@ -20,21 +37,23 @@ final class ThumbnailsModel: ObservableObject {
return unloadable.contains(url)
}
func best(_ video: Video) -> URL? {
func best(_ video: Video) -> (url: URL?, quality: Thumbnail.Quality?) {
for quality in availableQualitites {
let url = video.thumbnailURL(quality: quality)
if !isUnloadable(url) {
return url
return (url, quality)
}
}
return nil
return (nil, nil)
}
private var availableQualitites: [Thumbnail.Quality] {
switch Defaults[.thumbnailsQuality] {
case .highest:
return [.maxresdefault, .medium, .default]
return [.maxres, .high, .medium, .default]
case .high:
return [.high, .medium, .default]
case .medium:
return [.medium, .default]
case .low:

View File

@ -114,7 +114,7 @@ struct URLBookmarkModel {
func refreshAll() {
logger.info("refreshing all bookmarks")
allURLs.forEach { url in
for url in allURLs {
if loadBookmark(url) != nil {
logger.info("bookmark for \(url) exists")
} else {

View File

@ -57,7 +57,7 @@ final class URLParserTests: XCTestCase {
]
func testUrlsParsing() throws {
Self.urls.forEach { urlString in
for urlString in Self.urls {
let url = URL(string: urlString)!
let parser = URLParser(url: url)
XCTAssertEqual(parser.destination, .fileURL)
@ -66,7 +66,7 @@ final class URLParserTests: XCTestCase {
}
func testVideosParsing() throws {
Self.videos.forEach { url, id in
for (url, id) in Self.videos {
let parser = URLParser(url: URL(string: url)!)
XCTAssertEqual(parser.destination, .video)
XCTAssertEqual(parser.videoID, id)
@ -74,7 +74,7 @@ final class URLParserTests: XCTestCase {
}
func testChannelsByNameParsing() throws {
Self.channelsByName.forEach { url, name in
for (url, name) in Self.channelsByName {
let parser = URLParser(url: URL(string: url)!)
XCTAssertEqual(parser.destination, .channel)
XCTAssertEqual(parser.channelName, name)
@ -83,7 +83,7 @@ final class URLParserTests: XCTestCase {
}
func testChannelsByIdParsing() throws {
Self.channelsByID.forEach { url, id in
for (url, id) in Self.channelsByID {
let parser = URLParser(url: URL(string: url)!)
XCTAssertEqual(parser.destination, .channel)
XCTAssertEqual(parser.channelID, id)
@ -92,7 +92,7 @@ final class URLParserTests: XCTestCase {
}
func testUsersParsing() throws {
Self.users.forEach { url, user in
for (url, user) in Self.users {
let parser = URLParser(url: URL(string: url)!)
XCTAssertEqual(parser.destination, .channel)
XCTAssertNil(parser.channelID)
@ -102,7 +102,7 @@ final class URLParserTests: XCTestCase {
}
func testPlaylistsParsing() throws {
Self.playlists.forEach { url, id in
for (url, id) in Self.playlists {
let parser = URLParser(url: URL(string: url)!)
XCTAssertEqual(parser.destination, .playlist)
XCTAssertEqual(parser.playlistID, id)
@ -110,7 +110,7 @@ final class URLParserTests: XCTestCase {
}
func testSearchesParsing() throws {
Self.searches.forEach { url, query in
for (url, query) in Self.searches {
let parser = URLParser(url: URL(string: url)!)
XCTAssertEqual(parser.destination, .search)
XCTAssertEqual(parser.searchQuery, query)
@ -127,7 +127,7 @@ final class URLParserTests: XCTestCase {
"watch?v=IUTGFQpKaPU&t=30s": 30
]
samples.forEach { url, time in
for (url, time) in samples {
XCTAssertEqual(
URLParser(url: URL(string: url)!).time,
time

View File

@ -1,15 +1,17 @@
{
"images" : [
{
"filename" : "Invidious.svg",
"filename" : "Invidious_512x512@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Invidious_512x512@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Invidious_512x512@3x.png",
"idiom" : "universal",
"scale" : "3x"
}

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512pt" height="512pt" version="1.0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><rect x="-.0072516" y=".00056299" width="512.01" height="512.02" fill="#575757" stroke-width=".063019"/><path d="m247.17 455.95c-19.792-0.78921-38.719-4.2564-57.154-10.47-60.968-20.55-108.68-68.579-127-127.86-7.8955-25.538-10.062-53.943-6.2586-82.067 3.7105-27.439 13.603-53.515 29.342-77.344 12.069-18.273 29.138-36.277 47.228-49.816 36.891-27.61 85.944-42.49 132.38-40.157 25.88 1.3001 49.939 6.765 73.106 16.606 8.1948 3.481 20.024 9.6845 27.696 14.525 14.15 8.9272 22.367 15.498 34.482 27.573 13.254 13.211 22.128 24.276 30.398 37.906 7.2081 11.879 14.099 27.15 18.229 40.397 1.5996 5.1305 4.442 16.456 5.6852 22.653 2.3908 11.917 2.6998 15.722 2.7049 33.312 6e-3 18.515-0.46256 24.413-2.9166 36.758-9.3274 46.92-35.58 88.167-74.872 117.64-22.814 17.112-50.027 29.535-78.547 35.858-16.714 3.7059-35.421 5.2453-54.498 4.4846zm-35.1-78.786c-5.3e-4 -0.52647-0.0741-2.0564-0.16311-3.3999l-0.16178-2.4427-4.7018-0.26271c-4.0477-0.22614-4.7968-0.33363-5.3847-0.77253-2.0235-1.5108-1.4679-6.0695 2.2494-18.457 0.8637-2.8781 3.3371-11.321 5.4966-18.762 2.1594-7.4409 5.2002-17.836 6.7573-23.101 1.5571-5.2648 4.1948-14.282 5.8615-20.038 1.6667-5.7562 3.6145-12.4 4.3284-14.764 0.71391-2.3641 3.2583-11.037 5.6542-19.272 4.9475-17.007 8.1626-27.723 8.9438-29.811 0.51852-1.3858 0.54785-1.4139 0.99761-0.95317 0.25486 0.26106 3.8462 7.3667 7.9807 15.79 4.1345 8.4236 13.089 26.573 19.898 40.331 17.188 34.73 37.849 76.578 43.261 87.622l4.5356 9.257 11.359-0.0895c6.2475-0.0492 11.615-0.19623 11.929-0.32672 0.5614-0.23385 0.54167-0.2959-1.3723-4.3176-1.068-2.2442-8.1436-16.601-15.724-31.904-48.687-98.293-61.22-123.86-67.889-138.48-4.7022-10.309-6.9031-14.807-7.7139-15.762-0.82931-0.97742-1.6319-1.0638-2.3704-0.25525-1.1993 1.313-4.1046 10.063-9.3869 28.27-2.0569 7.0899-6.5372 22.425-9.9562 34.077-6.6396 22.629-8.5182 29.037-14.33 48.883-2.0354 6.9495-4.7977 16.369-6.1385 20.931-1.3408 4.5628-4.033 13.81-5.9826 20.549-4.304 14.877-6.136 20.889-7.3886 24.25-2.1371 5.7334-2.5723 6.3292-4.9216 6.7384-0.88855 0.15472-2.4102 0.28196-3.3815 0.28275-2.1993 3e-3 -3.5494 0.36339-4.0558 1.0863-0.42176 0.60215-0.56421 4.8802-0.18251 5.4812 0.20573 0.32388 2.4672 0.37414 23.34 0.51873l8.6151 0.0597-7e-4 -0.95723zm36.751-205.59c4.3282-0.92335 8.4607-4.943 9.4374-9.1796 0.36569-1.5862 0.32543-4.9758-0.077-6.4799-0.85108-3.1813-3.2688-6.291-6.039-7.7675-3.8111-2.0313-9.456-2.0295-13.272 5e-3 -5.9828 3.1888-8.1556 11.089-4.7878 17.408 2.6995 5.0648 8.3611 7.3754 14.738 6.015z" fill="#f0f0f0" stroke-width=".025526"/></g><g transform="matrix(.069892 0 0 -.069892 44.236 474.48)"><path d="m2787 4669c-124-65-123-255 3-319 86-44 196-16 247 62 58 87 26 211-67 258-51 26-132 26-183-1z" fill="#00b6f0" stroke="#00b6f0" stroke-width="4.25"/><path d="m2882 4108c-12-16-63-166-102-303-30-104-101-350-165-565-20-69-58-199-85-290-26-91-64-221-85-290-20-69-58-199-85-290-26-91-64-221-85-290-20-69-57-195-81-280-59-207-93-299-115-310-10-6-35-10-56-10-73 0-84-8-81-54l3-41 228-3 228-2-3 47-3 48-73 3c-66 3-74 5-84 27-13 28 0 104 37 225 13 41 47 156 75 255s66 230 85 290c18 61 56 191 85 290 28 99 66 230 85 290 18 61 56 191 85 290 85 297 123 419 131 429 5 5 17-11 28-35 10-24 192-393 403-819s447-902 523-1058l139-282h168c92 0 168 4 168 8s-75 158-166 342c-588 1183-969 1958-1033 2100-29 63-69 151-89 195-44 95-58 110-80 83z" fill="#575757"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.110",
"green" : "0.110",
"red" : "0.118"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -53,7 +53,6 @@ struct ChannelPlaylistCell: View {
Text("\(playlist.videosCount ?? playlist.videos.count) videos")
.foregroundColor(.secondary)
.frame(height: 20)
}
}

View File

@ -380,7 +380,7 @@ struct ChannelVideosView: View {
navigation.sidebarSectionChanged.toggle()
}
} label: {
Label("Subscribe", systemImage: "circle")
Label("Subscribe", systemImage: "star.circle")
.help("Subscribe")
#if os(iOS)
.labelStyle(.automatic)

View File

@ -2,9 +2,27 @@ import Defaults
import Foundation
import SwiftUI
struct Constants {
static let yatteeProtocol = "yattee://"
enum Constants {
static let overlayAnimation = Animation.linear(duration: 0.2)
static let aspectRatio16x9 = 16.0 / 9.0
static let aspectRatio4x3 = 4.0 / 3.0
static var isAppleTV: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .tv
#else
false
#endif
}
static var isMac: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .mac
#else
false
#endif
}
static var isIPhone: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .phone
@ -21,6 +39,38 @@ struct Constants {
#endif
}
static var isTvOS: Bool {
#if os(tvOS)
true
#else
false
#endif
}
static var isMacOS: Bool {
#if os(macOS)
true
#else
false
#endif
}
static var isIOS: Bool {
#if os(iOS)
true
#else
false
#endif
}
static var detailsVisibility: Bool {
#if os(iOS)
false
#else
true
#endif
}
static var progressViewScale: Double {
#if os(macOS)
0.4
@ -53,11 +103,44 @@ struct Constants {
#endif
}
static var detailsVisibility: Bool {
#if os(iOS)
false
static var contentViewMinWidth: Double {
#if os(macOS)
835
#else
true
0
#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 var defaultNavigationStyle: NavigationStyle {
#if os(macOS)
return .sidebar
#elseif os(iOS)
if isIPad {
return .sidebar
}
return .tab
#else
return .tab
#endif
}

View File

@ -6,122 +6,123 @@ 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 showSearchSuggestions = Key<Bool>("showSearchSuggestions", default: true)
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: Constants.isIPhone)
#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)
static let accountPickerDisplaysUsernameDefault = true
#else
static let accountPickerDisplaysUsernameDefault = UIDevice.current.userInterfaceIdiom == .pad
static let accountPickerDisplaysUsernameDefault = Constants.isIPad
#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: .togglePlayerVisibility)
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: true)
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: true)
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 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)
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases)
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream])
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream])
#if os(iOS)
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
hd2160pMPVProfile,
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile,
sd360pAVPlayerProfile
] : [
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile,
sd360pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd720pAVPlayerProfile.id
static let batteryNonCellularProfileDefault = hd720pAVPlayerProfile.id
static let chargingCellularProfileDefault = hd720pAVPlayerProfile.id
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
#elseif os(tvOS)
static let qualityProfilesDefault = [
hd2160pMPVProfile,
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
static let batteryNonCellularProfileDefault = hd1080pMPVProfile.id
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
#else
static let qualityProfilesDefault = [
hd2160pMPVProfile,
hd1080pMPVProfile,
hd720pMPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
static let batteryNonCellularProfileDefault = hd1080pMPVProfile.id
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(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: false)
#endif
#if os(iOS)
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
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 exitFullscreenOnEOF = Key<Bool>("exitFullscreenOnEOF", default: true)
static let showChapters = Key<Bool>("showChapters", default: true)
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: false)
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)
static let showComments = Key<Bool>("showComments", default: true)
#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 isOrientationLocked = Key<Bool>("isOrientationLocked", default: Constants.isIPhone)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: Constants.isIPhone)
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>("rotateToLandscapeOnEnterFullScreen", default: .landscapeRight)
#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
static let captionsAutoShow = Key<Bool>("captionsAutoShow", default: false)
static let captionsDefaultLanguageCode = Key<String>("captionsDefaultLanguageCode", default: LanguageCodes.English.rawValue)
static let captionsFallbackLanguageCode = Key<String>("captionsDefaultFallbackCode", default: LanguageCodes.English.rawValue)
static let captionsFontScaleSize = Key<String>("captionsFontScale", default: "1.0")
static let captionsFontColor = Key<String>("captionsFontColor", default: "#FFFFFF")
// MARK: GROUP - Controls
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: Constants.isTvOS)
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
static let fullscreenPlayerGestureEnabled = Key<Bool>("fullscreenPlayerGestureEnabled", 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 = Constants.isIPad ? PlayerControlsLayout.medium : .small
static let fullScreenPlayerControlsLayoutDefault = Constants.isIPad ? PlayerControlsLayout.medium : .small
#elseif os(tvOS)
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
@ -132,84 +133,7 @@ extension Defaults.Keys {
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
static let showKeywords = Key<Bool>("showKeywords", default: false)
#if !os(tvOS)
static let 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 collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
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)
static let saveHistory = Key<Bool>("saveHistory", default: true)
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
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 trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
static let startupSection = Key<StartupSection>("startupSection", default: .home)
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
"rotateToLandscapeOnEnterFullScreen",
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
)
#endif
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
#if os(macOS)
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
#else
static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? ButtonLabelStyle.iconOnly : .iconAndText
#endif
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
static let playerControlsBackgroundOpacity = Key<Double>("playerControlsBackgroundOpacity", default: 0.2)
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
@ -218,18 +142,6 @@ extension Defaults.Keys {
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)
@ -246,13 +158,235 @@ extension Defaults.Keys {
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 hd2160p60MPVProfile = QualityProfile(id: "hd2160p60MPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd1080p60MPVProfile = QualityProfile(id: "hd1080p60MPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720p60MPVProfile = QualityProfile(id: "hd720p60MPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let sd360pMPVProfile = QualityProfile(id: "sd360pMPVProfile", backend: .mpv, resolution: .sd360p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p30, formats: [.stream, .hls], order: Array(QualityProfile.Format.allCases.indices))
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.stream, .hls], order: Array(QualityProfile.Format.allCases.indices))
#if os(iOS)
enum QualityProfiles {
// iPad-specific settings
enum iPad {
static let qualityProfilesDefault = [
hd1080p60MPVProfile,
hd1080pMPVProfile,
hd720p60MPVProfile,
hd720pMPVProfile
]
static let batteryCellularProfileDefault = hd720pMPVProfile.id
static let batteryNonCellularProfileDefault = hd720p60MPVProfile.id
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
}
// iPhone-specific settings
enum iPhone {
static let qualityProfilesDefault = [
hd1080p60MPVProfile,
hd1080pMPVProfile,
hd720p60MPVProfile,
hd720pMPVProfile,
sd360pMPVProfile
]
static let batteryCellularProfileDefault = sd360pMPVProfile.id
static let batteryNonCellularProfileDefault = hd720p60MPVProfile.id
static let chargingCellularProfileDefault = hd720pMPVProfile.id
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
}
// Access the correct profile based on device type
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
if Constants.isIPad {
return (
qualityProfilesDefault: iPad.qualityProfilesDefault,
batteryCellularProfileDefault: iPad.batteryCellularProfileDefault,
batteryNonCellularProfileDefault: iPad.batteryNonCellularProfileDefault,
chargingCellularProfileDefault: iPad.chargingCellularProfileDefault,
chargingNonCellularProfileDefault: iPad.chargingNonCellularProfileDefault
)
}
return (
qualityProfilesDefault: iPhone.qualityProfilesDefault,
batteryCellularProfileDefault: iPhone.batteryCellularProfileDefault,
batteryNonCellularProfileDefault: iPhone.batteryNonCellularProfileDefault,
chargingCellularProfileDefault: iPhone.chargingCellularProfileDefault,
chargingNonCellularProfileDefault: iPhone.chargingNonCellularProfileDefault
)
}
}
#elseif os(tvOS)
enum QualityProfiles {
// tvOS-specific settings
enum tvOS {
static let qualityProfilesDefault = [
hd2160p60MPVProfile,
hd1080p60MPVProfile,
hd720p60MPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd1080p60MPVProfile.id
static let batteryNonCellularProfileDefault = hd1080p60MPVProfile.id
static let chargingCellularProfileDefault = hd1080p60MPVProfile.id
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
}
// Access the correct profile based on device type
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
(
qualityProfilesDefault: tvOS.qualityProfilesDefault,
batteryCellularProfileDefault: tvOS.batteryCellularProfileDefault,
batteryNonCellularProfileDefault: tvOS.batteryNonCellularProfileDefault,
chargingCellularProfileDefault: tvOS.chargingCellularProfileDefault,
chargingNonCellularProfileDefault: tvOS.chargingNonCellularProfileDefault
)
}
}
#else
enum QualityProfiles {
// macOS-specific settings
enum macOS {
static let qualityProfilesDefault = [
hd2160p60MPVProfile,
hd1080p60MPVProfile,
hd1080pMPVProfile,
hd720p60MPVProfile
]
static let batteryCellularProfileDefault = hd1080p60MPVProfile.id
static let batteryNonCellularProfileDefault = hd1080p60MPVProfile.id
static let chargingCellularProfileDefault = hd1080p60MPVProfile.id
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
}
// Access the correct profile for other platforms
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
(
qualityProfilesDefault: macOS.qualityProfilesDefault,
batteryCellularProfileDefault: macOS.batteryCellularProfileDefault,
batteryNonCellularProfileDefault: macOS.batteryNonCellularProfileDefault,
chargingCellularProfileDefault: macOS.chargingCellularProfileDefault,
chargingNonCellularProfileDefault: macOS.chargingNonCellularProfileDefault
)
}
}
#endif
static let batteryCellularProfile = Key<QualityProfile.ID>(
"batteryCellularProfile",
default: QualityProfiles.currentProfile.batteryCellularProfileDefault
)
static let batteryNonCellularProfile = Key<QualityProfile.ID>(
"batteryNonCellularProfile",
default: QualityProfiles.currentProfile.batteryNonCellularProfileDefault
)
static let chargingCellularProfile = Key<QualityProfile.ID>(
"chargingCellularProfile",
default: QualityProfiles.currentProfile.chargingCellularProfileDefault
)
static let chargingNonCellularProfile = Key<QualityProfile.ID>(
"chargingNonCellularProfile",
default: QualityProfiles.currentProfile.chargingNonCellularProfileDefault
)
static let forceAVPlayerForLiveStreams = Key<Bool>(
"forceAVPlayerForLiveStreams",
default: true
)
static let qualityProfiles = Key<[QualityProfile]>(
"qualityProfiles",
default: QualityProfiles.currentProfile.qualityProfilesDefault
)
// MARK: GROUP - History
static let saveRecents = Key<Bool>("saveRecents", default: true)
static let saveHistory = Key<Bool>("saveHistory", default: true)
static let showRecents = Key<Bool>("showRecents", default: true)
static let limitRecents = Key<Bool>("limitRecents", default: false)
static let limitRecentsAmount = Key<Int>("limitRecentsAmount", default: 10)
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 showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
// MARK: GROUP - SponsorBlock
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
// 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 showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
static let videoLoadingRetryCount = Key<Int>("videoLoadingRetryCount", default: 10)
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 mpvCachePauseInital = Key<Bool>("mpvCachePauseInitial", default: false)
static let mpvDeinterlace = Key<Bool>("mpvDeinterlace", default: false)
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
static let mpvSetRefreshToContentFPS = Key<Bool>("mpvSetRefreshToContentFPS", 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)
@ -263,11 +397,21 @@ 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 showChapters = Key<Bool>("showChapters", default: true)
static let expandChapters = Key<Bool>("expandChapters", default: true)
static let showRelated = Key<Bool>("showRelated", default: true)
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 {
@ -284,18 +428,34 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
case sd240p30
case sd144p30
var value: Stream.Resolution! {
.init(rawValue: rawValue)
var value: Stream.Resolution {
if let predefined = Stream.Resolution.PredefinedResolution(rawValue: rawValue) {
return .predefined(predefined)
}
// Provide a default value of 720p 30
return .custom(height: 720, refreshRate: 30)
}
var description: String {
switch self {
case .hd2160p60:
return "4K, 60fps"
case .hd2160p30:
return "4K"
let resolution = value
let height = resolution.height
let refreshRate = resolution.refreshRate
// Superscript labels
let superscript4K = "⁴ᴷ"
let superscriptHD = "ᴴᴰ"
// Special handling for specific resolutions
switch height {
case 2160:
// 4K superscript after the refresh rate
return refreshRate == 30 ? "2160p \(superscript4K)" : "2160p\(refreshRate) \(superscript4K)"
case 1440, 1080:
// HD superscript after the refresh rate
return refreshRate == 30 ? "\(height)p \(superscriptHD)" : "\(height)p\(refreshRate) \(superscriptHD)"
default:
return value.name
// Default formatting for other resolutions
return refreshRate == 30 ? "\(height)p" : "\(height)p\(refreshRate)"
}
}
}
@ -401,15 +561,26 @@ enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
var text: Bool {
self == .iconAndText
}
var description: String {
switch self {
case .iconOnly:
return "Icon only".localized()
case .iconAndText:
return "Icon and text".localized()
}
}
}
enum ThumbnailsQuality: String, CaseIterable, Defaults.Serializable {
case highest, medium, low
case highest, high, medium, low
var description: String {
switch self {
case .highest:
return "Highest quality".localized()
return "Best quality".localized()
case .high:
return "High quality".localized()
case .medium:
return "Medium quality".localized()
case .low:
@ -459,26 +630,19 @@ enum PlayerTapGestureAction: String, CaseIterable, Defaults.Serializable {
}
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
case disabled
case landscapeLeft
case landscapeRight
#if os(iOS)
var interaceOrientation: UIInterfaceOrientation {
var interfaceOrientation: UIInterfaceOrientation {
switch self {
case .landscapeLeft:
return .landscapeLeft
case .landscapeRight:
return .landscapeRight
default:
return .portrait
}
}
#endif
var isRotating: Bool {
self != .disabled
}
}
struct WidgetSettings: Defaults.Serializable {
@ -537,3 +701,26 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
case horizontalCells
case list
}
enum SponsorBlockColors: String {
case sponsor = "#00D400" // Green
case selfpromo = "#FFFF00" // Yellow
case interaction = "#CC00FF" // Purple
case intro = "#00FFFF" // Cyan
case outro = "#0202ED" // Dark Blue
case preview = "#008FD6" // Light Blue
case filler = "#7300FF" // Violet
case music_offtopic = "#FF9900" // Orange
// Define all cases, can be used to iterate over the colors
static let allCases: [SponsorBlockColors] = [Self.sponsor, Self.selfpromo, Self.interaction, Self.intro, Self.outro, Self.preview, Self.filler, Self.music_offtopic]
// Create a dictionary with the category names as keys and colors as values
static let dictionary: [String: String] = {
var dict = [String: String]()
for item in allCases {
dict[String(describing: item)] = item.rawValue
}
return dict
}()
}

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() }
}

81
Shared/HTTPStatus.swift Normal file
View File

@ -0,0 +1,81 @@
// HTTP response status codes
enum HTTPStatus {
// Informational responses (100 - 199)
static let Continue = 100
static let SwitchingProtocols = 101
static let Processing = 102
static let EarlyHints = 103
// Successful responses (200 - 299)
static let OK = 200
static let Created = 201
static let Accepted = 202
static let NonAuthoritativeInformation = 203
static let NoContent = 204
static let ResetContent = 205
static let PartialContent = 206
static let MultiStatus = 207
static let AlreadyReported = 208
static let IMUsed = 226
// Redirection messages (300 - 399)
static let MultipleChoices = 300
static let MovedPermanently = 301
static let Found = 302
static let SeeOther = 303
static let NotModified = 304
static let UseProxy = 305
static let SwitchProxy = 306
static let TemporaryRedirect = 307
static let PermanentRedirect = 308
// Client error responses (400 - 499)
static let BadRequest = 400
static let Unauthorized = 401
static let PaymentRequired = 402
static let Forbidden = 403
static let NotFound = 404
static let MethodNotAllowed = 405
static let NotAcceptable = 406
static let ProxyAuthenticationRequired = 407
static let RequestTimeout = 408
static let Conflict = 409
static let Gone = 410
static let LengthRequired = 411
static let PreconditionFailed = 412
static let PayloadTooLarge = 413
static let URITooLong = 414
static let UnsupportedMediaType = 415
static let RangeNotSatisfiable = 416
static let ExpectationFailed = 417
static let IAmATeapot = 418
static let MisdirectedRequest = 421
static let UnprocessableEntity = 422
static let Locked = 423
static let FailedDependency = 424
static let TooEarly = 425
static let UpgradeRequired = 426
static let PreconditionRequired = 428
static let TooManyRequests = 429
static let RequestHeaderFieldsTooLarge = 431
static let UnavailableForLegalReasons = 451
// Server error responses (500 - 599)
static let InternalServerError = 500
static let NotImplemented = 501
static let BadGateway = 502
static let ServiceUnavailable = 503
static let GatewayTimeout = 504
static let HTTPVersionNotSupported = 505
static let VariantAlsoNegotiates = 506
static let InsufficientStorage = 507
static let LoopDetected = 508
static let NotExtended = 510
static let NetworkAuthenticationRequired = 511
}

View File

@ -5,6 +5,7 @@ import UniformTypeIdentifiers
struct FavoriteItemView: View {
var item: FavoriteItem
@Binding var favoritesChanged: Bool
@Environment(\.navigationStyle) private var navigationStyle
@StateObject private var store = FavoriteResourceObserver()
@ -25,8 +26,9 @@ struct FavoriteItemView: View {
@Default(.widgetsSettings) private var widgetsSettings
@Default(.visibleSections) private var visibleSections
init(item: FavoriteItem) {
init(item: FavoriteItem, favoritesChanged: Binding<Bool>) {
self.item = item
_favoritesChanged = favoritesChanged
}
var body: some View {
@ -81,27 +83,41 @@ struct FavoriteItemView: View {
}
}
.contentShape(Rectangle())
.onAppear {
if item.section == .history {
reloadVisibleWatches()
} else {
resource?.addObserver(store)
loadCacheAndResource()
DispatchQueue.main.async {
self.loadCacheAndResource()
}
}
}
.onDisappear {
resource?.removeObservers(ownedBy: store)
}
.onChange(of: player.currentVideo) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
.onChange(of: hideShorts) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
.onChange(of: hideWatched) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
// Delay is necessary to update the list with the new items.
.onChange(of: favoritesChanged) { _ in if !player.presentingPlayer { Delay.by(1.0) { reloadVisibleWatches() } } }
.onChange(of: player.presentingPlayer) { _ in
if player.presentingPlayer {
resource?.removeObservers(ownedBy: store)
} else {
resource?.addObserver(store)
}
}
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
.onChange(of: hideShorts) { _ in reloadVisibleWatches() }
.onChange(of: hideWatched) { _ in reloadVisibleWatches() }
}
}
.id(watchModel.historyToken)
.onChange(of: accounts.current) { _ in
resource?.addObserver(store)
loadCacheAndResource(force: true)
DispatchQueue.main.async {
loadCacheAndResource(force: true)
}
}
.onChange(of: watchModel.historyToken) { _ in
Delay.by(0.5) {
if !player.presentingPlayer {
reloadVisibleWatches()
}
}
@ -155,22 +171,27 @@ struct FavoriteItemView: View {
}
func reloadVisibleWatches() {
guard item.section == .history else { return }
DispatchQueue.main.async {
guard item.section == .history else { return }
visibleWatches = []
visibleWatches = []
let watches = Array(
watches
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
.prefix(favoritesModel.limit(item))
)
let last = watches.last
watches.forEach { watch in
player.loadHistoryVideoDetails(watch) {
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
visibleWatches.append(watch)
guard watch == last else { return }
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
let watches = Array(
watches
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
.prefix(favoritesModel.limit(item))
)
let last = watches.last
for watch in watches {
player.loadHistoryVideoDetails(watch) {
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
visibleWatches.append(watch)
if watch == last {
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
}
}
}
}
}
@ -220,6 +241,9 @@ struct FavoriteItemView: View {
onSuccess = { response in
if let videos: [Video] = response.typedContent() {
FeedCacheModel.shared.storeFeed(account: accounts.current, videos: videos)
DispatchQueue.main.async {
store.contentItems = contentItems
}
}
}
case let .channel(_, id, name):
@ -232,19 +256,21 @@ struct FavoriteItemView: View {
}
onSuccess = { response in
if let channel: Channel = response.typedContent() {
ChannelsCacheModel.shared.store(channel)
store.contentItems = ContentItem.array(of: channel.videos)
} else if let videos: [Video] = response.typedContent() {
channel.videos = videos
ChannelsCacheModel.shared.store(channel)
store.contentItems = ContentItem.array(of: videos)
} else if let channelPage: ChannelPage = response.typedContent() {
if let channel = channelPage.channel {
DispatchQueue.main.async {
if let channel: Channel = response.typedContent() {
ChannelsCacheModel.shared.store(channel)
}
store.contentItems = ContentItem.array(of: channel.videos)
} else if let videos: [Video] = response.typedContent() {
channel.videos = videos
ChannelsCacheModel.shared.store(channel)
store.contentItems = ContentItem.array(of: videos)
} else if let channelPage: ChannelPage = response.typedContent() {
if let channel = channelPage.channel {
ChannelsCacheModel.shared.store(channel)
}
store.contentItems = channelPage.results
store.contentItems = channelPage.results
}
}
}
case let .channelPlaylist(_, id, title):
@ -257,6 +283,9 @@ struct FavoriteItemView: View {
onSuccess = { response in
if let playlist: ChannelPlaylist = response.typedContent() {
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
DispatchQueue.main.async {
store.contentItems = contentItems
}
}
}
case let .playlist(_, id):
@ -265,12 +294,16 @@ struct FavoriteItemView: View {
if let playlist = playlists.first(where: { $0.id == id }) {
contentItems = ContentItem.array(of: playlist.videos)
}
DispatchQueue.main.async {
store.contentItems = contentItems
}
default:
contentItems = []
}
if !contentItems.isEmpty {
store.contentItems = contentItems
DispatchQueue.main.async {
store.contentItems = contentItems
}
}
if force {
@ -436,6 +469,7 @@ struct FavoriteItemView: View {
switch item.section {
case .history:
return nil
case .subscriptions:
if accounts.app.supportsSubscriptions {
return accounts.api.feed(1)
@ -487,14 +521,22 @@ struct FavoriteItemView: View {
}
struct FavoriteItemView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
VStack {
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Search: resistance body upper band workout")))
.environment(\.navigationStyle, .tab)
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Marques")))
.environment(\.navigationStyle, .sidebar)
struct PreviewWrapper: View {
@State private var favoritesChanged = false
var body: some View {
NavigationView {
VStack {
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Search: resistance body upper band workout")), favoritesChanged: $favoritesChanged)
.environment(\.navigationStyle, .tab)
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Marques")), favoritesChanged: $favoritesChanged)
.environment(\.navigationStyle, .sidebar)
}
}
}
}
static var previews: some View {
PreviewWrapper()
}
}

View File

@ -5,18 +5,52 @@ final class FavoriteResourceObserver: ObservableObject, ResourceObserver {
@Published var contentItems = [ContentItem]()
func resourceChanged(_ resource: Resource, event _: ResourceEvent) {
// swiftlint:disable discouraged_optional_collection
var newVideos: [Video]?
var newItems: [ContentItem]?
// swiftlint:enable discouraged_optional_collection
var newChannel: Channel?
var newChannelPlaylist: ChannelPlaylist?
var newPlaylist: Playlist?
var newPage: SearchPage?
if let videos: [Video] = resource.typedContent() {
contentItems = videos.map { ContentItem(video: $0) }
newVideos = videos
} else if let channel: Channel = resource.typedContent() {
contentItems = channel.videos.map { ContentItem(video: $0) }
newChannel = channel
} else if let playlist: ChannelPlaylist = resource.typedContent() {
contentItems = playlist.videos.map { ContentItem(video: $0) }
newChannelPlaylist = playlist
} else if let playlist: Playlist = resource.typedContent() {
contentItems = playlist.videos.map { ContentItem(video: $0) }
newPlaylist = playlist
} else if let page: SearchPage = resource.typedContent() {
contentItems = page.results
newPage = page
} else if let items: [ContentItem] = resource.typedContent() {
contentItems = items
newItems = items
}
DispatchQueue.global(qos: .userInitiated).async {
var newContentItems: [ContentItem] = []
if let videos = newVideos {
newContentItems = videos.map { ContentItem(video: $0) }
} else if let channel = newChannel {
newContentItems = channel.videos.map { ContentItem(video: $0) }
} else if let playlist = newChannelPlaylist {
newContentItems = playlist.videos.map { ContentItem(video: $0) }
} else if let playlist = newPlaylist {
newContentItems = playlist.videos.map { ContentItem(video: $0) }
} else if let page = newPage {
newContentItems = page.results
} else if let items = newItems {
newContentItems = items
}
DispatchQueue.main.async {
if !newContentItems.isEmpty {
self.contentItems = newContentItems
}
}
}
}
}

View File

@ -1,19 +1,14 @@
import SwiftUI
struct HistoryView: View {
var limit = 10
var limit: Int
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
var watches: FetchedResults<Watch>
@ObservedObject private var player = PlayerModel.shared
@State private var visibleWatches = [Watch]()
init(limit: Int = 10) {
self.limit = limit
}
var body: some View {
LazyVStack {
if visibleWatches.isEmpty {
@ -38,10 +33,14 @@ struct HistoryView: View {
func reloadVisibleWatches() {
visibleWatches = Array(watches.filter { $0.videoID != player.currentVideo?.videoID }.prefix(limit))
}
init(limit: Int = 10) {
self.limit = limit
}
}
struct HistoryView_Previews: PreviewProvider {
static var previews: some View {
HistoryView()
HistoryView(limit: 10)
}
}

View File

@ -5,9 +5,11 @@ import UniformTypeIdentifiers
struct HomeView: View {
@ObservedObject private var accounts = AccountsModel.shared
@ObservedObject private var player = PlayerModel.shared
@State private var presentingHomeSettings = false
@State private var favoritesChanged = false
@State private var updateTask: Task<Void, Never>?
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
var watches: FetchedResults<Watch>
@ -16,13 +18,10 @@ struct HomeView: View {
@State private var recentDocumentsID = UUID()
#endif
var favoritesObserver: Any?
#if !os(tvOS)
@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
@ -98,11 +97,11 @@ struct HomeView: View {
VStack(alignment: .leading) {
#if os(tvOS)
ForEach(Defaults[.favorites]) { item in
FavoriteItemView(item: item)
FavoriteItemView(item: item, favoritesChanged: $favoritesChanged)
}
#else
ForEach(favorites) { item in
FavoriteItemView(item: item)
FavoriteItemView(item: item, favoritesChanged: $favoritesChanged)
#if os(macOS)
.workaroundForVerticalScrollingBug()
#endif
@ -125,6 +124,24 @@ struct HomeView: View {
}
}
}
.onDisappear {
updateTask?.cancel()
}
.onChange(of: player.presentingPlayer) { _ in
if player.presentingPlayer {
updateTask?.cancel()
} else {
Task {
for await _ in Defaults.updates(.favorites) {
favoritesChanged.toggle()
}
for await _ in Defaults.updates(.widgetsSettings) {
favoritesChanged.toggle()
}
}
}
}
.redrawOn(change: favoritesChanged)
@ -135,7 +152,7 @@ struct HomeView: View {
#endif
#if os(macOS)
.background(Color.secondaryBackground)
.frame(minWidth: 360)
.frame(minWidth: Constants.contentViewMinWidth)
.toolbar {
ToolbarItemGroup(placement: .automatic) {
HideWatchedButtons()

161
Shared/LanguageCodes.swift Normal file
View File

@ -0,0 +1,161 @@
enum LanguageCodes: String, CaseIterable {
case Afrikaans = "af"
case Arabic = "ar"
case Azerbaijani = "az"
case Bengali = "bn"
case Catalan = "ca"
case Czech = "cs"
case Welsh = "cy"
case Danish = "da"
case German = "de"
case Greek = "el"
case English = "en"
case English_GB = "en-GB"
case Spanish = "es"
case Persian = "fa"
case Finnish = "fi"
case Filipino = "fil"
case French = "fr"
case Irish = "ga"
case Hebrew = "he"
case Hindi = "hi"
case Hungarian = "hu"
case Indonesian = "id"
case Italian = "it"
case Japanese = "ja"
case Javanese = "jv"
case Korean = "ko"
case Lithuanian = "lt"
case Malay = "ms"
case Maltese = "mt"
case Dutch = "nl"
case Norwegian = "no"
case Polish = "pl"
case Portuguese = "pt"
case Romanian = "ro"
case Russian = "ru"
case Slovak = "sk"
case Slovene = "sl"
case Swedish = "sv"
case Swahili = "sw"
case Thai = "th"
case Tagalog = "tl"
case Turkish = "tr"
case Ukrainian = "uk"
case Urdu = "ur"
case Uzbek = "uz"
case Vietnamese = "vi"
case Xhosa = "xh"
case Chinese = "zh"
case Chinese_Hans = "zh-Hans"
case Zulu = "zu"
var description: String {
switch self {
case .Afrikaans:
return "Afrikaans"
case .Arabic:
return "Arabic"
case .Azerbaijani:
return "Azerbaijani"
case .Bengali:
return "Bengali"
case .Catalan:
return "Catalan"
case .Czech:
return "Czech"
case .Welsh:
return "Welsh"
case .Danish:
return "Danish"
case .German:
return "German"
case .Greek:
return "Greek"
case .English:
return "English"
case .English_GB:
return "English (United Kingdom)"
case .Spanish:
return "Spanish"
case .Persian:
return "Persian"
case .Finnish:
return "Finnish"
case .Filipino:
return "Filipino"
case .French:
return "French"
case .Irish:
return "Irish"
case .Hebrew:
return "Hebrew"
case .Hindi:
return "Hindi"
case .Hungarian:
return "Hungarian"
case .Indonesian:
return "Indonesian"
case .Italian:
return "Italian"
case .Japanese:
return "Japanese"
case .Javanese:
return "Javanese"
case .Korean:
return "Korean"
case .Lithuanian:
return "Lithuanian"
case .Malay:
return "Malay"
case .Maltese:
return "Maltese"
case .Dutch:
return "Dutch"
case .Norwegian:
return "Norwegian"
case .Polish:
return "Polish"
case .Portuguese:
return "Portuguese"
case .Romanian:
return "Romanian"
case .Russian:
return "Russian"
case .Slovak:
return "Slovak"
case .Slovene:
return "Slovene"
case .Swedish:
return "Swedish"
case .Swahili:
return "Swahili"
case .Thai:
return "Thai"
case .Tagalog:
return "Tagalog"
case .Turkish:
return "Turkish"
case .Ukrainian:
return "Ukrainian"
case .Urdu:
return "Urdu"
case .Uzbek:
return "Uzbek"
case .Vietnamese:
return "Vietnamese"
case .Xhosa:
return "Xhosa"
case .Chinese:
return "Chinese"
case .Chinese_Hans:
return "Chinese (Simplified)"
case .Zulu:
return "Zulu"
}
}
static func languageName(for code: String) -> String {
return Self(rawValue: code)?.description ?? "Unknown"
}
}

View File

@ -15,7 +15,7 @@ struct AppSidebarNavigation: View {
var body: some View {
#if os(iOS)
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17)) { viewController in
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17, .v18)) { 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

@ -5,12 +5,14 @@ struct AppSidebarRecents: View {
var recents = RecentsModel.shared
@Default(.recentlyOpened) private var recentItems
@Default(.limitRecents) private var limitRecents
@Default(.limitRecentsAmount) private var limitRecentsAmount
var body: some View {
Group {
if !recentItems.isEmpty {
Section(header: Text("Recents")) {
ForEach(recentItems.reversed()) { recent in
ForEach(recentItems.reversed().prefix(limitRecents ? limitRecentsAmount : recentItems.count)) { recent in
Group {
switch recent.type {
case .channel:

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()
@ -168,7 +169,7 @@ struct ContentView: View {
.statusBarHidden(player.playingFullScreen)
#endif
#if os(macOS)
.frame(minWidth: 1200)
.frame(minWidth: 1200, minHeight: 600)
#endif
}

View File

@ -13,6 +13,7 @@ struct Sidebar: View {
@Default(.showDocuments) private var showDocuments
#endif
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
@Default(.showRecents) private var showRecents
var body: some View {
ScrollViewReader { scrollView in
@ -20,8 +21,10 @@ struct Sidebar: View {
mainNavigationLinks
if !accounts.isEmpty {
AppSidebarRecents()
.id("recentlyOpened")
if showRecents {
AppSidebarRecents()
.id("recentlyOpened")
}
if accounts.api.signedIn {
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {

View File

@ -4,8 +4,6 @@ import Siesta
struct OpenURLHandler {
static var firstHandle = true
static let yatteeProtocol = "yattee://"
var accounts: AccountsModel { .shared }
var navigation: NavigationModel { .shared }
var recents: RecentsModel { .shared }
@ -21,6 +19,11 @@ struct OpenURLHandler {
return
}
if url.isFileURL, url.standardizedFileURL.absoluteString.hasSuffix(".\(ImportExportSettingsModel.settingsExtension)") {
navigation.presentSettingsImportSheet(url)
return
}
if accounts.current.isNil {
accounts.setCurrent(accounts.any)
}
@ -206,7 +209,7 @@ struct OpenURLHandler {
return accounts.api.channelByName(name)
}
if let instance = InstancesModel.shared.all.first(where: { $0.app.supportsOpeningChannelsByName }) {
if let instance = InstancesModel.shared.all.first(where: \.app.supportsOpeningChannelsByName) {
return instance.anonymous.channelByName(name)
}
@ -220,7 +223,7 @@ struct OpenURLHandler {
return accounts.api.channelByUsername(username)
}
if let instance = InstancesModel.shared.all.first(where: { $0.app.supportsOpeningChannelsByName }) {
if let instance = InstancesModel.shared.all.first(where: \.app.supportsOpeningChannelsByName) {
return instance.anonymous.channelByUsername(username)
}

View File

@ -4,11 +4,6 @@ import SwiftUI
#if !os(macOS)
final class AppleAVPlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate {
#if os(iOS)
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
#endif
var player: PlayerModel { .shared }
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
@ -17,15 +12,23 @@ import SwiftUI
#if os(iOS)
func playerViewController(_: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator) {
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
if PlayerModel.shared.currentVideoIsLandscape {
let delay = PlayerModel.shared.activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
// not sure why but first rotation call is ignore so doing rotate to same orientation first
Delay.by(delay) {
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
let lockOrientation = player.rotateToLandscapeOnEnterFullScreen.interfaceOrientation
if player.currentVideoIsLandscape {
if player.fullscreenInitiatedByButton {
Orientation.lockOrientation(player.isOrientationLocked
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
: .landscape)
}
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape
? OrientationTracker.shared.currentInterfaceOrientation
: player.rotateToLandscapeOnEnterFullScreen.interfaceOrientation
Orientation.lockOrientation(
player.isOrientationLocked
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
: .all,
andRotateTo: orientation
)
}
}
@ -37,11 +40,11 @@ import SwiftUI
}
if !context.isCancelled {
#if os(iOS)
self.player.lockedOrientation = nil
if Constants.isIPhone {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
if self.player.lockPortraitWhenBrowsing {
self.player.lockedOrientation = UIInterfaceOrientationMask.portrait
}
let rotationOrientation = self.player.lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
Orientation.lockOrientation(self.player.lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
if wasPlaying {
self.player.play()

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