Compare commits

..

98 Commits

Author SHA1 Message Date
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
63 changed files with 1711 additions and 457 deletions

View File

@@ -40,7 +40,7 @@ jobs:
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '14.3.1'
xcode-version: latest-stable
- uses: maierj/fastlane-action@v3.0.0
with:
lane: ${{ matrix.lane }}
@@ -64,7 +64,7 @@ jobs:
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '14.3.1'
xcode-version: latest-stable
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize

View File

@@ -1,9 +1,13 @@
## Build 179
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
## Build 181
* 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)
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
* Updated localizations
* Updated dependencies
## Previous builds
* 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
@@ -11,10 +15,11 @@
* Import via URL for tvOS
* Added Controls setting "Action button labels" icon or icon and text
* Added Advanced setting for MPV: "deinterlace"
* Updated dependencies (mpvkit 0.37.0)
* Add help text to all header buttons (by @rickykresslein)
* Add Chinese (Traditional) localization (by @rexcsk)
* Localization fixes
* Updated localizations
* 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

@@ -54,19 +54,19 @@ GEM
rexml
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.894.0)
aws-sdk-core (3.191.3)
aws-partitions (1.906.0)
aws-sdk-core (3.191.6)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-kms (1.78.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0)
aws-sdk-s3 (1.146.1)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
@@ -85,7 +85,7 @@ GEM
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
excon (0.110.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -114,7 +114,7 @@ GEM
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.3.0)
fastimage (2.3.1)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
@@ -132,12 +132,12 @@ GEM
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.7.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.4.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -158,7 +158,7 @@ GEM
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.1)
jwt (2.8.0)
jwt (2.8.1)
base64
mini_magick (4.12.0)
mini_mime (1.1.5)
@@ -170,8 +170,8 @@ GEM
optparse (0.4.0)
os (1.1.4)
plist (3.7.1)
public_suffix (5.0.4)
rake (13.1.0)
public_suffix (5.0.5)
rake (13.2.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)

View File

@@ -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)
}
@@ -654,7 +654,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
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
)
}
}
@@ -691,6 +692,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 +702,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

@@ -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
@@ -663,16 +666,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)
@@ -684,6 +687,7 @@ 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
if videoOnly {
streams.append(
@@ -693,7 +697,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
videoAsset: videoAsset,
resolution: resolution,
kind: .adaptive,
videoFormat: videoFormat
videoFormat: videoFormat,
bitrate: bitrate
)
)
} else {
@@ -724,15 +729,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, frontendURL: String?, time: CMTime?) -> URL?
func comments(_ id: Video.ID, page: String?) -> Resource?
}
@@ -108,15 +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 {
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
}
guard var urlComponents else {
return nil
}
urlComponents.host = frontendHost
var queryItems = [URLQueryItem]()
switch item.contentType {

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 = 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

@@ -15,7 +15,11 @@ final class HistorySettingsGroupExporter: SettingsGroupExporter {
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton],
"showRecents": Defaults[.showRecents],
"limitRecents": Defaults[.limitRecents],
"limitRecentsAmount": Defaults[.limitRecentsAmount]
]
}
}

View File

@@ -50,5 +50,17 @@ struct HistorySettingsGroupImporter {
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
}
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
}
}
}

View File

@@ -116,16 +116,6 @@ final class AVPlayerBackend: PlayerBackend {
#endif
}
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 }
return streams.first { $0.kind == .hls } ??
sortedByResolution.first { $0.kind == .stream } ??
sortedByResolution.first
}
func canPlay(_ stream: Stream) -> Bool {
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
}
@@ -134,7 +124,7 @@ final class AVPlayerBackend: PlayerBackend {
_ stream: Stream,
of video: Video,
preservingTime: Bool,
upgrading _: Bool
upgrading: Bool
) {
isLoadingVideo = true
@@ -145,7 +135,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,6 +150,13 @@ 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)
}
avPlayer.play()
model.objectWillChange.send()
}
@@ -219,7 +216,8 @@ 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)
@@ -228,7 +226,7 @@ final class AVPlayerBackend: PlayerBackend {
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 +301,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
}
@@ -387,7 +391,7 @@ final class AVPlayerBackend: PlayerBackend {
}
if preservingTime {
if model.preservedTime.isNil {
if model.preservedTime.isNil || upgrading {
model.saveTime {
replaceItemAndSeek()
startPlaying()

View File

@@ -201,29 +201,6 @@ final class MPVBackend: PlayerBackend {
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
}
@@ -254,7 +231,18 @@ final class MPVBackend: PlayerBackend {
let startPlaying = {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(true)
do {
try AVAudioSession.sharedInstance().setActive(true)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleAudioSessionInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: nil
)
} catch {
self.logger.error("Error setting up audio session: \(error)")
}
#endif
DispatchQueue.main.async { [weak self] in
@@ -264,6 +252,10 @@ final class MPVBackend: PlayerBackend {
self.startClientUpdates()
// Captions should only be displayed when selected by the user,
// not when the video starts. So, we remove them.
self.client?.removeSubs()
if !preservingTime,
!upgrading,
let segment = self.model.sponsorBlock.segments.first,
@@ -309,7 +301,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 +313,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 +322,7 @@ final class MPVBackend: PlayerBackend {
}
if preservingTime {
if model.preservedTime.isNil {
if model.preservedTime.isNil || upgrading {
model.saveTime {
replaceItem(self.model.preservedTime)
}
@@ -354,6 +346,13 @@ final class MPVBackend: PlayerBackend {
setRate(model.currentRate)
// 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)
}
client?.play()
}
@@ -519,8 +518,6 @@ final class MPVBackend: PlayerBackend {
guard client.eofReached else {
return
}
getTimeUpdates()
eofPlaybackModeAction()
}
@@ -627,4 +624,31 @@ final class MPVBackend: PlayerBackend {
logger.info("MPV backend received unhandled property: \(name)")
}
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
logger.info("Audio session interruption received.")
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt
else {
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
return
}
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
logger.info("Interruption type received: \(String(describing: type))")
switch type {
case .began:
pause()
logger.info("Audio session interrupted.")
default:
break
}
}
deinit {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
}
}

View File

@@ -128,6 +128,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,
@@ -138,6 +140,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))")
}
@@ -160,6 +166,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)
}

View File

@@ -29,7 +29,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
@@ -131,6 +130,52 @@ extension PlayerBackend {
}
}
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
// filter out non HLS streams
let nonHLSStreams = streams.filter { $0.kind != .hls }
// find max resolution from non HLS streams
let bestResolution = nonHLSStreams
.filter { $0.resolution <= maxResolution.value }
.max { $0.resolution < $1.resolution }
// finde max bitrate from non HLS streams
let bestBitrate = nonHLSStreams
.filter { $0.resolution <= maxResolution.value }
.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
return streams.map { stream in
if stream.kind == .hls {
stream.resolution = bestResolution?.resolution ?? maxResolution.value
stream.bitrate = bestBitrate?.bitrate ?? (bestResolution?.resolution.bitrate ?? maxResolution.value.bitrate)
stream.format = .hls
} else if stream.kind == .stream {
stream.format = .stream
}
return stream
}
.filter { stream in
stream.resolution <= maxResolution.value
}
.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 {
print("Failed to extract lhsFormat or rhsFormat")
return false
}
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
return lhsFormatIndex > rhsFormatIndex
}
return lhs.resolution < rhs.resolution
}
}
func updateControls(completionHandler: (() -> Void)? = nil) {
print("updating controls")

View File

@@ -76,6 +76,8 @@ final class PlayerModel: ObservableObject {
}
}
var previousActiveBackend: PlayerBackendType?
lazy var playerBackendView = PlayerBackendView()
@Published var playerSize: CGSize = .zero { didSet {
@@ -176,7 +178,7 @@ final class PlayerModel: ObservableObject {
@Default(.resetWatchedStatusOnPlaying) var resetWatchedStatusOnPlaying
@Default(.playerRate) var playerRate
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
#if os(macOS)
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
@@ -192,7 +194,7 @@ final class PlayerModel: ObservableObject {
var onPlayStream = [(Stream) -> Void]()
var rateToRestore: Float?
private var remoteCommandCenterConfigured = false
#if os(macOS)
var keyPressMonitor: Any?
#endif
@@ -532,7 +534,7 @@ final class PlayerModel: ObservableObject {
}
}
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
}
@@ -541,7 +543,7 @@ final class PlayerModel: ObservableObject {
let wasPlaying = isPlaying
if to == .mpv {
if to == .mpv && !isInClosePip {
closePiP()
}
@@ -664,6 +666,7 @@ final class PlayerModel: ObservableObject {
}
func startPiP() {
previousActiveBackend = activeBackend
avPlayerBackend.startPictureInPictureOnPlay = false
avPlayerBackend.startPictureInPictureOnSwitch = false
@@ -673,7 +676,7 @@ final class PlayerModel: ObservableObject {
}
guard let video = currentVideo else { return }
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return }
guard let stream = backend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: qualityProfile!.formats) else { return }
exitFullScreen()
@@ -716,6 +719,12 @@ final class PlayerModel: ObservableObject {
#endif
backend.closePiP()
if previousActiveBackend == .mpv {
saveTime {
self.changeActiveBackend(from: self.activeBackend, to: .mpv, isInClosePip: true)
self.controls.resetTimer()
}
}
}
var pipImage: String {
@@ -771,10 +780,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
@@ -935,7 +946,10 @@ final class PlayerModel: ObservableObject {
#else
func handleEnterForeground() {
setNeedsDrawing(presentingPlayer)
avPlayerBackend.bindPlayerToLayer()
if !musicMode, activeBackend == .appleAVPlayer {
avPlayerBackend.bindPlayerToLayer()
}
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return
@@ -1158,7 +1172,7 @@ final class PlayerModel: ObservableObject {
return nil
}
#if os(macOS)
private func assignKeyPressMonitor() {
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { keyEvent -> NSEvent? in
@@ -1188,12 +1202,13 @@ final class PlayerModel: ObservableObject {
if !self.controls.isLoadingVideo {
self.backend.togglePlay()
}
default: return keyEvent
default:
return keyEvent
}
return nil
}
}
private func destroyKeyPressMonitor() {
if let keyPressMonitor = keyPressMonitor {
NSEvent.removeMonitor(keyPressMonitor)

View File

@@ -127,12 +127,12 @@ extension PlayerModel {
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)
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution, formatOrder: profile.formats)
}
func advanceToNextItem() {

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

@@ -3,13 +3,13 @@ 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 mp4
case av1
case webm
@@ -23,7 +23,6 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
return "Stream"
case .webm:
return "WebM"
default:
return rawValue.uppercased()
}
@@ -35,14 +34,14 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
return nil
case .stream:
return nil
case .mp4:
return .mp4
case .webm:
return .webm
case .avc1:
return .avc1
case .mp4:
return .mp4
case .av1:
return .av1
case .webm:
return .webm
}
}
}
@@ -53,7 +52,7 @@ 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)"
@@ -101,7 +100,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 { $0.rawValue }.joined(separator: Self.formatsSeparator),
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
]
}
@@ -116,7 +116,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

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

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

@@ -54,6 +54,32 @@ class Stream: Equatable, Hashable, Identifiable {
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
}
// These values are an approximation.
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
var bitrate: Int {
switch self {
case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30:
return 56000000 // 56 Mbit/s
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30:
return 24000000 // 24 Mbit/s
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30:
return 12000000 // 12 Mbit/s
case .hd720p60, .hd720p50, .hd720p48, .hd720p30:
return 9500000 // 9.5 Mbit/s
case .sd480p30:
return 4000000 // 4 Mbit/s
case .sd360p30:
return 1500000 // 1.5 Mbit/s
case .sd240p30:
return 1000000 // 1 Mbit/s
case .sd144p30:
return 600000 // 0.6 Mbit/s
case .unknown:
return 0
}
}
static func from(resolution: String, fps: Int? = nil) -> Self {
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
}
@@ -64,7 +90,7 @@ class Stream: Equatable, Hashable, Identifiable {
}
enum Kind: String, Comparable {
case stream, adaptive, hls
case hls, adaptive, stream
private var sortOrder: Int {
switch self {
@@ -82,37 +108,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 +133,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 +169,7 @@ class Stream: Equatable, Hashable, Identifiable {
var encoding: String?
var videoFormat: String?
var bitrate: Int?
init(
instance: Instance? = nil,
@@ -161,7 +180,8 @@ class Stream: Equatable, Hashable, Identifiable {
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil,
videoFormat: String? = nil
videoFormat: String? = nil,
bitrate: Int? = nil
) {
self.instance = instance
self.audioAsset = audioAsset
@@ -172,6 +192,7 @@ class Stream: Equatable, Hashable, Identifiable {
self.kind = kind
self.encoding = encoding
format = .from(videoFormat ?? "")
self.bitrate = bitrate
}
var isLocal: Bool {
@@ -184,22 +205,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 {

View File

@@ -5,6 +5,23 @@ import SwiftUI
enum Constants {
static let yatteeProtocol = "yattee://"
static let overlayAnimation = Animation.linear(duration: 0.2)
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

View File

@@ -77,6 +77,8 @@ extension Defaults.Keys {
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
static let showChapters = Key<Bool>("showChapters", default: true)
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: true)
static let expandChapters = Key<Bool>("expandChapters", default: true)
static let showRelated = Key<Bool>("showRelated", default: true)
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
@@ -165,11 +167,11 @@ extension Defaults.Keys {
// MARK: GROUP - Quality
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
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])
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
#if os(iOS)
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
@@ -225,6 +227,9 @@ extension Defaults.Keys {
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)
@@ -240,6 +245,10 @@ extension Defaults.Keys {
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
@@ -577,3 +586,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] = [.sponsor, .selfpromo, .interaction, .intro, .outro, .preview, .filler, .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

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

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

@@ -312,7 +312,6 @@ struct ControlsOverlay: View {
.foregroundColor(.primary)
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 240, height: 40)
@@ -374,12 +373,12 @@ struct ControlsOverlay: View {
let captions = player.currentVideo?.captions ?? []
Picker("Captions", selection: captionsBinding) {
if captions.isEmpty {
Text("Not available")
Text("Not available").tag(Captions?.none)
} else {
Text("Disabled").tag(Captions?.none)
}
ForEach(captions) { caption in
Text(caption.description).tag(Optional(caption))
ForEach(captions) { caption in
Text(caption.description).tag(Optional(caption))
}
}
}
.disabled(captions.isEmpty)

View File

@@ -13,6 +13,18 @@ struct Seek: View {
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
@Default(.sponsorBlockColors) private var sponsorBlockColors
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
private func getColor(for category: String) -> Color {
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
let b = Double(rgbValue & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
return Color("AppRedColor") // Fallback color if no match found
}
var body: some View {
Group {
@@ -25,6 +37,7 @@ struct Seek: View {
#endif
}
.opacity(visible || YatteeApp.isForPreviews ? 1 : 0)
.animation(.easeIn)
}
var content: some View {
@@ -51,7 +64,8 @@ struct Seek: View {
if let segment = projectedSegment {
Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor")
.font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(Color("AppRedColor"))
.foregroundColor(getColor(for: segment.category))
.padding(.bottom, 3)
}
} else {
#if !os(tvOS)
@@ -69,7 +83,16 @@ struct Seek: View {
Divider()
Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor")
.font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(getColor(for: category))
.padding(.bottom, 3)
case let .chapterSkip(chapter):
Divider()
Text(chapter)
.font(.system(size: playerControlsLayout.segmentFontSize))
.truncationMode(.tail)
.multilineTextAlignment(.center)
.foregroundColor(Color("AppRedColor"))
.padding(.bottom, 3)
default:
EmptyView()
}
@@ -117,6 +140,7 @@ struct Seek: View {
var visible: Bool {
guard !(model.lastSeekTime.isNil && !model.isSeeking) else { return false }
if let type = model.lastSeekType, !type.presentable { return false }
if !showNoticeAfterSkip { if case .segmentSkip? = model.lastSeekType { return false }}
return !controls.presentingControls && !controls.presentingOverlays && model.presentingOSD
}

View File

@@ -51,11 +51,24 @@ struct TimelineView: View {
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
@Default(.sponsorBlockColors) private var sponsorBlockColors
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
var playerControlsLayout: PlayerControlsLayout {
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
private func getColor(for category: String) -> Color {
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
let b = Double(rgbValue & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
return Color("AppRedColor") // Fallback color if no match found
}
var chapters: [Chapter] {
player.currentVideo?.chapters ?? []
}
@@ -73,13 +86,15 @@ struct TimelineView: View {
Group {
VStack(spacing: 3) {
if dragging {
if let segment = projectedSegment,
let description = SponsorBlockAPI.categoryDescription(segment.category)
{
Text(description)
.font(.system(size: playerControlsLayout.segmentFontSize))
.fixedSize()
.foregroundColor(Color("AppRedColor"))
if showCategoriesInTimeline {
if let segment = projectedSegment,
let description = SponsorBlockAPI.categoryDescription(segment.category)
{
Text(description)
.font(.system(size: playerControlsLayout.segmentFontSize))
.fixedSize()
.foregroundColor(getColor(for: segment.category))
}
}
if let chapter = projectedChapter {
Text(chapter.title)
@@ -145,8 +160,10 @@ struct TimelineView: View {
.frame(width: (dragging ? projectedValue : current) * oneUnitWidth)
.zIndex(1)
segmentsLayers
.zIndex(2)
if showCategoriesInTimeline {
segmentsLayers
.zIndex(2)
}
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
@@ -236,7 +253,7 @@ struct TimelineView: View {
}
}
} else {
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
Text(dragging || !showTimeWithSkipsRemoved ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
.clipShape(RoundedRectangle(cornerRadius: 3))
.frame(minWidth: 35)
}
@@ -299,7 +316,7 @@ struct TimelineView: View {
ForEach(segments, id: \.uuid) { segment in
Rectangle()
.offset(x: segmentLayerHorizontalOffset(segment))
.foregroundColor(Color("AppRedColor"))
.foregroundColor(getColor(for: segment.category))
.frame(maxHeight: height)
.frame(width: segmentLayerWidth(segment))
}
@@ -314,9 +331,9 @@ struct TimelineView: View {
}
var chaptersLayers: some View {
ForEach(chapters) { chapter in
ForEach(chapters.filter { $0.start != 0 }) { chapter in
RoundedRectangle(cornerRadius: 4)
.fill(Color.orange)
.fill(Color("AppRedColor"))
.frame(maxWidth: 2, maxHeight: height)
.offset(x: (chapter.start * oneUnitWidth) - 1)
}

View File

@@ -433,12 +433,12 @@ struct PlaybackSettings: View {
let captions = player.currentVideo?.captions ?? []
Picker("Captions".localized(), selection: $player.captions) {
if captions.isEmpty {
Text("Not available")
Text("Not available").tag(Captions?.none)
} else {
Text("Disabled").tag(Captions?.none)
}
ForEach(captions) { caption in
Text(caption.description).tag(Optional(caption))
ForEach(captions) { caption in
Text(caption.description).tag(Optional(caption))
}
}
}
.disabled(captions.isEmpty)

View File

@@ -9,13 +9,18 @@ import SwiftUI
var chapterIndex: Int
@ObservedObject private var player = PlayerModel.shared
var showThumbnail: Bool
var isCurrentChapter: Bool {
player.currentChapterIndex == chapterIndex
if let currentChapterIndex = player.currentChapterIndex {
return currentChapterIndex == chapterIndex
}
return false
}
var body: some View {
Button(action: {
player.backend.seek(to: chapter.start, seekType: .userInteracted)
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
}) {
Group {
verticalChapter
@@ -27,7 +32,7 @@ import SwiftUI
var verticalChapter: some View {
VStack(spacing: 12) {
if !chapter.image.isNil {
if !chapter.image.isNil, showThumbnail {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
@@ -40,7 +45,7 @@ import SwiftUI
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
.frame(maxWidth: !chapter.image.isNil ? Self.thumbnailWidth : nil, alignment: .leading)
.frame(maxWidth: !chapter.image.isNil && showThumbnail ? Self.thumbnailWidth : nil, alignment: .leading)
}
}
@@ -72,7 +77,7 @@ import SwiftUI
var body: some View {
Button {
player.backend.seek(to: chapter.start, seekType: .userInteracted)
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
} label: {
Group {
horizontalChapter
@@ -126,7 +131,7 @@ struct ChapterView_Preview: PreviewProvider {
ChapterViewTVOS(chapter: .init(title: "Chapter", start: 30))
.injectFixtureEnvironmentObjects()
#else
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0)
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0, showThumbnail: true)
.injectFixtureEnvironmentObjects()
#endif
}

View File

@@ -5,18 +5,16 @@ import SwiftUI
struct ChaptersView: View {
@ObservedObject private var player = PlayerModel.shared
@Binding var expand: Bool
let chaptersHaveImages: Bool
let showThumbnails: Bool
var chapters: [Chapter] {
player.videoForDisplay?.chapters ?? []
}
var chaptersHaveImages: Bool {
chapters.allSatisfy { $0.image != nil }
}
var body: some View {
if !chapters.isEmpty {
if chaptersHaveImages {
if chaptersHaveImages, showThumbnails {
#if os(tvOS)
List {
Section {
@@ -29,7 +27,22 @@ struct ChaptersView: View {
.listStyle(.plain)
#else
ScrollView(.horizontal) {
LazyHStack(spacing: 20) { chapterViews(for: chapters[...]) }.padding(.horizontal, 15)
ScrollViewReader { scrollViewProxy in
LazyHStack(spacing: 20) {
chapterViews(for: chapters[...], scrollViewProxy: scrollViewProxy)
}
.padding(.horizontal, 15)
.onAppear {
if let currentChapterIndex = player.currentChapterIndex {
scrollViewProxy.scrollTo(currentChapterIndex, anchor: .center)
}
}
.onChange(of: player.currentChapterIndex) { currentChapterIndex in
if let index = currentChapterIndex {
scrollViewProxy.scrollTo(index, anchor: .center)
}
}
}
}
#endif
} else if expand {
@@ -67,10 +80,11 @@ struct ChaptersView: View {
}
#if !os(tvOS)
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true) -> some View {
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true, scrollViewProxy: ScrollViewProxy? = nil) -> some View {
ForEach(Array(chaptersToShow.indices), id: \.self) { index in
let chapter = chaptersToShow[index]
ChapterView(chapter: chapter, chapterIndex: index)
ChapterView(chapter: chapter, chapterIndex: index, showThumbnail: showThumbnails)
.id(index)
.opacity(index == 0 ? 1.0 : opacity)
.allowsHitTesting(clickable)
}
@@ -80,7 +94,7 @@ struct ChaptersView: View {
struct ChaptersView_Previews: PreviewProvider {
static var previews: some View {
ChaptersView(expand: .constant(false))
ChaptersView(expand: .constant(false), chaptersHaveImages: false, showThumbnails: true)
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -11,6 +11,7 @@ struct PlayerQueueView: View {
@ObservedObject private var player = PlayerModel.shared
@Default(.saveHistory) private var saveHistory
@Default(.showRelated) private var showRelated
var body: some View {
Group {
@@ -19,7 +20,7 @@ struct PlayerQueueView: View {
autoplaying
}
playingNext
if sidebarQueue {
if sidebarQueue, showRelated {
related
}
}
@@ -90,10 +91,9 @@ struct PlayerQueueView: View {
}
var queueHeader: some View {
Text("Queue".localized())
Text(sidebarQueue ? "Queue".localized() : "")
#if !os(macOS)
.foregroundColor(.secondary)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}

View File

@@ -186,6 +186,8 @@ struct VideoDetails: View {
@Default(.playerSidebar) private var playerSidebar
@Default(.showInspector) private var showInspector
@Default(.showChapters) private var showChapters
@Default(.showChapterThumbnails) private var showChapterThumbnails
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showChapterThumbnailsOnlyWhenDifferent
@Default(.showRelated) private var showRelated
#if !os(tvOS)
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
@@ -287,6 +289,63 @@ struct VideoDetails: View {
}
}
func infoView(video: Video) -> some View {
VStack(alignment: .leading, spacing: 10) {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
VStack {
ProgressView()
.progressViewStyle(.circular)
}
.frame(maxWidth: .infinity)
} else if let description = video.description, !description.isEmpty {
Section(header: descriptionHeader) {
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
.padding(.horizontal)
}
} else if !video.isLocal {
Text("No description")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
if player.videoBeingOpened.isNil {
if showChapters,
!video.isLocal,
!video.chapters.isEmpty
{
Section(header: chaptersHeader) {
ChaptersView(expand: $chaptersExpanded, chaptersHaveImages: chaptersHaveImages, showThumbnails: showThumbnails)
}
}
if showInspector == .always || video.isLocal {
InspectorView(video: player.videoForDisplay)
.padding(.horizontal)
}
if showRelated,
!sidebarQueue,
!(player.videoForDisplay?.related.isEmpty ?? true)
{
RelatedView()
.padding(.horizontal)
.padding(.top, 20)
}
}
}
.onAppear {
if !pageAvailable(page) {
page = .info
}
}
.transition(.opacity)
.animation(nil, value: player.currentItem)
#if os(iOS)
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
#endif
}
var pageView: some View {
ScrollView(.vertical) {
LazyVStack {
@@ -296,69 +355,12 @@ struct VideoDetails: View {
switch page {
case .info:
Group {
if let video {
VStack(alignment: .leading, spacing: 10) {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
VStack {
ProgressView()
.progressViewStyle(.circular)
}
.frame(maxWidth: .infinity)
} else if let description = video.description, !description.isEmpty {
Section(header: descriptionHeader) {
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
.padding(.horizontal)
}
} else if !video.isLocal {
Text("No description")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
if player.videoBeingOpened.isNil {
if showChapters,
!video.isLocal,
!video.chapters.isEmpty
{
Section(header: chaptersHeader) {
ChaptersView(expand: $chaptersExpanded)
}
}
if showInspector == .always || video.isLocal {
InspectorView(video: player.videoForDisplay)
.padding(.horizontal)
}
if showRelated,
!sidebarQueue,
!(player.videoForDisplay?.related.isEmpty ?? true)
{
RelatedView()
.padding(.horizontal)
.padding(.top, 20)
}
}
}
}
if let video = self.video {
infoView(video: video)
}
.onAppear {
if video != nil, !pageAvailable(page) {
page = .info
}
}
.transition(.opacity)
.animation(nil, value: player.currentItem)
#if os(iOS)
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
#endif
case .queue:
PlayerQueueView(sidebarQueue: false)
.padding(.horizontal)
case .comments:
CommentsView()
.onAppear {
@@ -447,9 +449,27 @@ struct VideoDetails: View {
player.videoForDisplay?.chapters.allSatisfy { $0.image != nil } ?? false
}
var chapterImagesTheSame: Bool {
guard let firstChapterURL = player.videoForDisplay?.chapters.first?.image else {
return false
}
return player.videoForDisplay?.chapters.allSatisfy { $0.image == firstChapterURL } ?? false
}
var showThumbnails: Bool {
if !chaptersHaveImages || !showChapterThumbnails {
return false
}
if showChapterThumbnailsOnlyWhenDifferent {
return !chapterImagesTheSame
}
return true
}
var chaptersHeader: some View {
Group {
if !chaptersHaveImages {
if !chaptersHaveImages || !showThumbnails {
#if canImport(UIKit)
Button(action: {
chaptersExpanded.toggle()

View File

@@ -73,7 +73,7 @@ struct AdvancedSettings: View {
.frame(minWidth: 140, alignment: .leading)
TextField("cache-secs", text: $mpvCacheSecs)
#if !os(macOS)
.keyboardType(.URL)
.keyboardType(.numberPad)
#endif
}
.multilineTextAlignment(.trailing)
@@ -83,7 +83,7 @@ struct AdvancedSettings: View {
.frame(minWidth: 140, alignment: .leading)
TextField("cache-pause-wait", text: $mpvCachePauseWait)
#if !os(macOS)
.keyboardType(.URL)
.keyboardType(.numberPad)
#endif
}
.multilineTextAlignment(.trailing)

View File

@@ -10,6 +10,9 @@ struct HistorySettings: View {
@Default(.saveRecents) private var saveRecents
@Default(.saveLastPlayed) private var saveLastPlayed
@Default(.saveHistory) private var saveHistory
@Default(.showRecents) private var showRecents
@Default(.limitRecents) private var limitRecents
@Default(.limitRecentsAmount) private var limitRecentsAmount
@Default(.showWatchingProgress) private var showWatchingProgress
@Default(.watchedThreshold) private var watchedThreshold
@Default(.watchedVideoStyle) private var watchedVideoStyle
@@ -56,6 +59,26 @@ struct HistorySettings: View {
Section(header: SettingsHeader(text: "History".localized())) {
Toggle("Save history of searches, channels and playlists", isOn: $saveRecents)
Toggle("Save history of played videos", isOn: $saveHistory)
Toggle("Show recents in sidebar", isOn: $showRecents)
#if os(macOS)
HStack {
Toggle("Limit recents shown", isOn: $limitRecents)
.frame(minWidth: 140, alignment: .leading)
.disabled(!showRecents)
Spacer()
counterButtons(for: $limitRecentsAmount)
.disabled(!limitRecents)
}
#else
Toggle("Limit recents shown", isOn: $limitRecents)
.disabled(!showRecents)
HStack {
Text("Recents shown")
Spacer()
counterButtons(for: $limitRecentsAmount)
.disabled(!limitRecents)
}
#endif
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
.disabled(!saveHistory)
Toggle("Keep last played video in the queue after restart", isOn: $saveLastPlayed)
@@ -169,6 +192,71 @@ struct HistorySettings: View {
.foregroundColor(.red)
}
}
private func counterButtons(for _value: Binding<Int>) -> some View {
var value: Binding<Int> {
Binding(
get: { return _value.wrappedValue },
set: {
if $0 < 1 {
_value.wrappedValue = 1
} else {
_value.wrappedValue = $0
}
}
)
}
return HStack {
#if !os(tvOS)
Label("Minus", systemImage: "minus")
.imageScale(.large)
.labelStyle(.iconOnly)
.padding(7)
.foregroundColor(limitRecents ? .accentColor : .gray)
.accessibilityAddTraits(.isButton)
#if os(iOS)
.frame(minHeight: 35)
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
#endif
.contentShape(Rectangle())
.onTapGesture {
value.wrappedValue -= 1
}
#endif
#if os(tvOS)
let textFieldWidth = 100.00
#else
let textFieldWidth = 30.00
#endif
TextField("Duration", value: value, formatter: NumberFormatter())
.frame(width: textFieldWidth, alignment: .trailing)
.multilineTextAlignment(.center)
.labelsHidden()
.foregroundColor(limitRecents ? .accentColor : .gray)
#if !os(macOS)
.keyboardType(.numberPad)
#endif
#if !os(tvOS)
Label("Plus", systemImage: "plus")
.imageScale(.large)
.labelStyle(.iconOnly)
.padding(7)
.foregroundColor(limitRecents ? .accentColor : .gray)
.accessibilityAddTraits(.isButton)
#if os(iOS)
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
#endif
.contentShape(Rectangle())
.onTapGesture {
value.wrappedValue += 1
}
#endif
}
}
}
struct HistorySettings_Previews: PreviewProvider {

View File

@@ -66,6 +66,7 @@ struct HomeSettings: View {
.font(.system(size: 30))
#endif
}
.help("Add to Favorites")
#if !os(tvOS)
.buttonStyle(.borderless)
#endif

View File

@@ -1,4 +1,3 @@
import SwiftUI
struct ImportSettings: View {

View File

@@ -9,9 +9,23 @@ struct MultiselectRow: View {
@State private var toggleChecked = false
var body: some View {
#if os(macOS)
#if os(tvOS)
Button(action: { action(!selected) }) {
HStack {
Text(self.title)
Spacer()
if selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
}
.disabled(disabled)
#else
Toggle(title, isOn: $toggleChecked)
#if os(macOS)
.toggleStyle(.checkbox)
#endif
.onAppear {
guard !disabled else { return }
toggleChecked = selected
@@ -19,24 +33,6 @@ struct MultiselectRow: View {
.onChange(of: toggleChecked) { new in
action(new)
}
#else
Button(action: { action(!selected) }) {
HStack {
Text(self.title)
Spacer()
if selected {
Image(systemName: "checkmark")
#if os(iOS)
.foregroundColor(.accentColor)
#endif
}
}
.contentShape(Rectangle())
}
.disabled(disabled)
#if !os(tvOS)
.buttonStyle(.plain)
#endif
#endif
}
}

View File

@@ -32,6 +32,8 @@ struct PlayerSettings: View {
@Default(.showInspector) private var showInspector
@Default(.showChapters) private var showChapters
@Default(.showChapterThumbnails) private var showThumbnails
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showThumbnailsOnlyWhenDifferent
@Default(.expandChapters) private var expandChapters
@Default(.showRelated) private var showRelated
@@ -80,8 +82,6 @@ struct PlayerSettings: View {
Section(header: SettingsHeader(text: "Info".localized())) {
expandVideoDescriptionToggle
collapsedLineDescriptionStepper
showChaptersToggle
expandChaptersToggle
showRelatedToggle
#if os(macOS)
HStack {
@@ -93,6 +93,13 @@ struct PlayerSettings: View {
inspectorVisibilityPicker
#endif
}
Section(header: SettingsHeader(text: "Chapters".localized())) {
showChaptersToggle
showThumbnailsToggle
showThumbnailsWhenDifferentToggle
expandChaptersToggle
}
#endif
let interface = Section(header: SettingsHeader(text: "Interface".localized())) {
@@ -284,7 +291,19 @@ struct PlayerSettings: View {
}
private var showChaptersToggle: some View {
Toggle("Chapters (if available)", isOn: $showChapters)
Toggle("Show chapters", isOn: $showChapters)
}
private var showThumbnailsToggle: some View {
Toggle("Show thumbnails", isOn: $showThumbnails)
.disabled(!showChapters)
.foregroundColor(showChapters ? .primary : .secondary)
}
private var showThumbnailsWhenDifferentToggle: some View {
Toggle("Show thumbnails only when unique", isOn: $showThumbnailsOnlyWhenDifferent)
.disabled(!showChapters || !showThumbnails)
.foregroundColor(showChapters && showThumbnails ? .primary : .secondary)
}
private var expandChaptersToggle: some View {

View File

@@ -1,6 +1,11 @@
import Defaults
import SwiftUI
struct FormatState: Equatable {
let format: QualityProfile.Format
var isActive: Bool
}
struct QualityProfileForm: View {
@Binding var qualityProfileID: QualityProfile.ID?
@@ -15,6 +20,7 @@ struct QualityProfileForm: View {
@State private var backend = PlayerBackendType.mpv
@State private var resolution = ResolutionSetting.hd1080p60
@State private var formats = [QualityProfile.Format]()
@State private var orderedFormats: [FormatState] = []
@Default(.qualityProfiles) private var qualityProfiles
@@ -26,6 +32,7 @@ struct QualityProfileForm: View {
return nil
}
// swiftlint:disable trailing_closure
var body: some View {
VStack {
Group {
@@ -40,8 +47,11 @@ struct QualityProfileForm: View {
#endif
.onAppear(perform: initializeForm)
.onChange(of: backend, perform: backendChanged)
.onChange(of: formats) { _ in validate() }
.onChange(of: backend, perform: { _ in backendChanged(self.backend); updateActiveFormats(); validate() })
.onChange(of: name, perform: { _ in validate() })
.onChange(of: resolution, perform: { _ in validate() })
.onChange(of: orderedFormats, perform: { _ in validate() })
#if os(iOS)
.padding(.vertical)
#elseif os(tvOS)
@@ -53,6 +63,8 @@ struct QualityProfileForm: View {
#endif
}
// swiftlint:enable trailing_closure
var header: some View {
HStack {
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
@@ -124,9 +136,20 @@ struct QualityProfileForm: View {
}
var formatsFooter: some View {
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading) {
Text("Formats can be reordered and will be selected in this order.")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("**Note:** HLS is an adaptive format where specific resolution settings don't apply.")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top)
Text("Yattee attempts to match the quality that is closest to the set resolution, but exact results cannot be guaranteed.")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 0.1)
}
.padding(.top, 2)
}
@ViewBuilder var qualityPicker: some View {
@@ -199,17 +222,25 @@ struct QualityProfileForm: View {
#endif
}
var filteredFormatList: some View {
ForEach(Array(orderedFormats.enumerated()), id: \.element.format) { idx, element in
let format = element.format
MultiselectRow(
title: format.description,
selected: element.isActive
) { value in
orderedFormats[idx].isActive = value
}
}
.onMove { source, destination in
orderedFormats.move(fromOffsets: source, toOffset: destination)
validate()
}
}
@ViewBuilder var formatsPicker: some View {
#if os(macOS)
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
disabled: isFormatDisabled(format)
) { value in
toggleFormat(format, value: value)
}
}
let list = filteredFormatList
Group {
if #available(macOS 12.0, *) {
@@ -222,28 +253,19 @@ struct QualityProfileForm: View {
}
Spacer()
#else
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
disabled: isFormatDisabled(format)
) { value in
toggleFormat(format, value: value)
}
}
filteredFormatList
#endif
}
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
(initialized || qualityProfile.isNil ? formats : qualityProfile.formats).contains(format)
return orderedFormats.first { $0.format == format }?.isActive ?? false
}
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
if let index = formats.firstIndex(where: { $0 == format }), !value {
formats.remove(at: index)
} else if value {
formats.append(format)
if let index = orderedFormats.firstIndex(where: { $0.format == format }) {
orderedFormats[index].isActive = value
}
validate() // Check validity after a toggle operation
}
var footer: some View {
@@ -274,34 +296,52 @@ struct QualityProfileForm: View {
return !avPlayerFormats.contains(format)
}
func updateActiveFormats() {
for (index, format) in orderedFormats.enumerated() where isFormatDisabled(format.format) {
orderedFormats[index].isActive = false
}
}
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
guard backend == .appleAVPlayer else { return false }
return resolution.value > .hd720p30
return resolution.value > .hd1080p60
}
func initializeForm() {
guard editing else {
validate()
return
if editing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.name = qualityProfile.name ?? ""
self.backend = qualityProfile.backend
self.resolution = qualityProfile.resolution
self.orderedFormats = qualityProfile.order.map { order in
let format = QualityProfile.Format.allCases[order]
let isActive = qualityProfile.formats.contains(format)
return FormatState(format: format, isActive: isActive)
}
self.initialized = true
}
} else {
name = ""
backend = .mpv
resolution = .hd720p60
orderedFormats = QualityProfile.Format.allCases.map {
FormatState(format: $0, isActive: true)
}
initialized = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.name = qualityProfile.name ?? ""
self.backend = qualityProfile.backend
self.resolution = qualityProfile.resolution
self.formats = .init(qualityProfile.formats)
self.initialized = true
}
validate()
}
func backendChanged(_: PlayerBackendType) {
formats.filter { isFormatDisabled($0) }.forEach { format in
if let index = formats.firstIndex(where: { $0 == format }) {
formats.remove(at: index)
}
let defaultFormats = QualityProfile.Format.allCases.map {
FormatState(format: $0, isActive: true)
}
if backend == .appleAVPlayer {
orderedFormats = orderedFormats.filter { !isFormatDisabled($0.format) }
} else {
orderedFormats = defaultFormats
}
if isResolutionDisabled(resolution),
@@ -312,20 +352,33 @@ struct QualityProfileForm: View {
}
func validate() {
valid = !formats.isEmpty
if !initialized {
valid = false
} else if editing {
let savedOrderFormats = qualityProfile.order.map { order in
let format = QualityProfile.Format.allCases[order]
let isActive = qualityProfile.formats.contains(format)
return FormatState(format: format, isActive: isActive)
}
valid = name != qualityProfile.name
|| backend != qualityProfile.backend
|| resolution != qualityProfile.resolution
|| orderedFormats != savedOrderFormats
} else { valid = true }
}
func submitForm() {
guard valid else { return }
formats = formats.unique()
let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format }
let formProfile = QualityProfile(
id: qualityProfile?.id ?? UUID().uuidString,
name: name,
backend: backend,
resolution: resolution,
formats: Array(formats)
formats: activeFormats,
order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! }
)
if editing {

View File

@@ -1,15 +1,21 @@
import Defaults
import SwiftUI
import UIKit
struct SponsorBlockSettings: View {
@ObservedObject private var settings = SettingsModel.shared
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
@Default(.sponsorBlockColors) private var sponsorBlockColors
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
var body: some View {
Group {
#if os(macOS)
sections
Spacer()
#else
List {
@@ -35,41 +41,70 @@ struct SponsorBlockSettings: View {
.labelsHidden()
#if !os(macOS)
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.URL)
#endif
}
Section(header: SettingsHeader(text: "Categories to Skip".localized()), footer: categoriesDetails) {
#if os(macOS)
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
MultiselectRow(
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selected: sponsorBlockCategories.contains(category)
) { value in
toggleCategory(category, value: value)
}
}
Section(header: Text("Playback")) {
Toggle("Categories in timeline", isOn: $showCategoriesInTimeline)
Toggle("Post-skip notice", isOn: $showNoticeAfterSkip)
Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved)
}
Group {
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
.listStyle(.inset)
}
}
Spacer()
#else
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
MultiselectRow(
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selected: sponsorBlockCategories.contains(category)
) { value in
toggleCategory(category, value: value)
}
}
#endif
Section(header: SettingsHeader(text: "Categories to Skip".localized())) {
categoryRows
}
colorSection
Button {
settings.presentAlert(
Alert(
title: Text("Restore Default Colors?"),
message: Text("This action will reset all custom colors back to their original defaults. " +
"Any custom color changes you've made will be lost."),
primaryButton: .destructive(Text("Restore")) {
resetColors()
},
secondaryButton: .cancel()
)
)
} label: {
Text("Restore Default Colors …")
.foregroundColor(.red)
}
Section(footer: categoriesDetails) {
EmptyView()
}
}
}
private var colorSection: some View {
Section(header: SettingsHeader(text: "Colors for Categories")) {
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
LazyVStack(alignment: .leading) {
ColorPicker(
SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selection: Binding(
get: { getColor(for: category) },
set: { setColor($0, for: category) }
)
)
}
}
}
}
private var categoryRows: some View {
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
LazyVStack(alignment: .leading) {
MultiselectRow(
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selected: sponsorBlockCategories.contains(category)
) { value in
toggleCategory(category, value: value)
}
}
}
}
@@ -79,17 +114,17 @@ struct SponsorBlockSettings: View {
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
Text(SponsorBlockAPI.categoryDescription(category) ?? "Category")
.fontWeight(.bold)
.padding(.bottom, 0.5)
#if os(tvOS)
.focusable()
#endif
Text(SponsorBlockAPI.categoryDetails(category) ?? "Details")
.padding(.bottom, 3)
.padding(.bottom, 10)
.fixedSize(horizontal: false, vertical: true)
}
}
.foregroundColor(.secondary)
.padding(.top, 3)
}
func toggleCategory(_ category: String, value: Bool) {
@@ -99,6 +134,40 @@ struct SponsorBlockSettings: View {
sponsorBlockCategories.insert(category)
}
}
private func getColor(for category: String) -> Color {
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
let b = Double(rgbValue & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
return Color("AppRedColor") // Fallback color if no match found
}
private func setColor(_ color: Color, for category: String) {
let uiColor = UIColor(color)
// swiftlint:disable no_cgfloat
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
// swiftlint:enable no_cgfloat
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let r = Int(red * 255.0)
let g = Int(green * 255.0)
let b = Int(blue * 255.0)
let rgbValue = (r << 16) | (g << 8) | b
sponsorBlockColors[category] = String(format: "#%06x", rgbValue)
}
private func resetColors() {
sponsorBlockColors = SponsorBlockColors.dictionary
}
}
struct SponsorBlockSettings_Previews: PreviewProvider {

View File

@@ -113,6 +113,7 @@ struct SubscriptionsView: View {
} label: {
Label("Play all unwatched", systemImage: "play")
}
.help("Play all unwatched")
.disabled(!feed.canPlayUnwatchedFeed)
}
@@ -130,6 +131,7 @@ struct SubscriptionsView: View {
} label: {
Label("Mark all as watched", systemImage: "checkmark.circle.fill")
}
.help("Mark all as watched")
.disabled(!feed.canMarkAllFeedAsWatched)
}
@@ -139,6 +141,7 @@ struct SubscriptionsView: View {
} label: {
Label("Mark all as unwatched", systemImage: "checkmark.circle")
}
.help("Mark all as unwatched")
}
}

View File

@@ -37,6 +37,7 @@ struct FavoriteButton: View {
.contentShape(Rectangle())
#endif
}
.help(isFavorite ? "Remove from Favorites" : "Add to Favorites")
.disabled(item.isNil)
.onAppear {
isFavorite = item.isNil ? false : favorites.contains(item)

View File

@@ -11,6 +11,7 @@ struct HomeSettingsButton: View {
}
.font(.caption)
.imageScale(.small)
.help("Home Settings")
}
}

View File

@@ -16,6 +16,7 @@ struct ListingStyleButtons: View {
.imageScale(.small)
#endif
}
.help(listingStyle == .cells ? "List" : "Cells")
#endif
}

View File

@@ -38,6 +38,7 @@ struct ShareButton<LabelView: View>: View {
label
}
.menuStyle(.borderlessButton)
.help("Share")
#if os(macOS)
.frame(maxWidth: 60)
#endif
@@ -76,7 +77,7 @@ struct ShareButton<LabelView: View>: View {
private var youtubeActions: some View {
Group {
if let url = accounts.api.shareURL(contentItem, frontendHost: "www.youtube.com") {
if let url = accounts.api.shareURL(contentItem, frontendURL: "https://www.youtube.com") {
Button(labelForShareURL("YouTube")) {
shareAction(url)
}
@@ -86,7 +87,7 @@ struct ShareButton<LabelView: View>: View {
shareAction(
accounts.api.shareURL(
contentItem,
frontendHost: "www.youtube.com",
frontendURL: "https://www.youtube.com",
time: player.backend.currentTime
)!
)

View File

@@ -153,7 +153,7 @@ struct YatteeApp: App {
#if DEBUG
SiestaLog.Category.enabled = .common
#endif
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
if !Defaults[.lastAccountIsPublic] {
@@ -204,6 +204,7 @@ struct YatteeApp: App {
URLBookmarkModel.shared.refreshAll()
migrateHomeHistoryItems()
migrateQualityProfiles()
}
func migrateHomeHistoryItems() {
@@ -221,6 +222,16 @@ struct YatteeApp: App {
Defaults[.homeHistoryItems] = -1
}
@Default(.qualityProfiles) private var qualityProfilesData
func migrateQualityProfiles() {
for profile in qualityProfilesData where profile.order.isEmpty {
var updatedProfile = profile
updatedProfile.order = Array(QualityProfile.Format.allCases.indices)
QualityProfilesModel.shared.update(profile, updatedProfile)
}
}
var navigationStyle: NavigationStyle {
#if os(iOS)
return horizontalSizeClass == .compact ? .tab : .sidebar

View File

@@ -3,7 +3,7 @@
"Add Account" = "إضافة حساب";
"Add Account..." = "إضافة حساب…";
"Add Location" = "إضافة موقع";
"Add Location..." = "إضافة موقع...";
"Add Location..." = "أضِف موقع..";
"%@ Playlist" = "قائمة تشغيل %@";
"%@ Channel" = "قناة %@";
"%@ subscribers" = "مشتركين %@";
@@ -387,7 +387,7 @@
"Backend" = "الواجهة الخلفية";
"Badge" = "الشارة";
"Close PiP when starting playing other video" = "غلق الفيديو المصغر عند بدء تشغيل فيديو آخر";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).\n";
"Filter" = " عامل التصفية";
"Frontend URL" = "عنوان URL للواجهة الأمامية";
"Fullscreen size" = "حجم ملء الشاشة";

View File

@@ -9,7 +9,7 @@
"Add Account" = "Add Account";
"Add Account..." = "Add Account...";
"Add Location" = "Add Location";
"Add Location..." = "Add Location...";
"Add Location..." = "Add Location..";
"Add profile..." = "Add profile...";
"Add Quality Profile" = "Add Quality Profile";
"Add to %@" = "Add to %@";

View File

@@ -112,12 +112,12 @@
"Help" = "Ayuda";
"Hide sidebar" = "Ocultar barra lateral";
"Add Location" = "Añadir ubicación";
"Add Location..." = "Añadir ubicación...";
"Add Location..." = "Añadir ubicación..";
"Decrease rate" = "Tasa de disminución";
"Decreased opacity" = "Opacidad disminuida";
"High" = "Alto";
"%lld videos" = "%lld videos";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Recordatorios explícitos para marquen \"me gusta\", se suscriban o interactúen con ellos en cualquier plataforma de pago o gratuita (por ejemplo, haciendo clic en un vídeo).";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Recordatorios explícitos para que indiquen les guste, se suscriban o interactúen con ellos en cualquier plataforma de pago o gratuita (por ejemplo, haciendo clic en un vídeo).\n";
"Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply)." = "Los formatos se seleccionarán en orden como se indica.\nHLS es un formato adaptable (no aplica la configuración de resolución).";
"Fullscreen size" = "Tamaño de pantalla completa";
"Badge & Decreased opacity" = "Insignia y opacidad dosminuída";

View File

@@ -483,3 +483,7 @@
"Not Playing" = "پخش نمی‌شود";
"Video Details" = "جزییات ویدیو";
"Live Streams" = "پخش زنده";
"Backend" = "Backend";
"Bugs and great feature ideas can be sent to the GitHub issues tracker. " = "ایرادها و پیشنهادی خوب برای امکانات را می‌توانید به GitHub issues tracker بفرستید. ";
"Copy %@ link" = "پیوند %@ را کپی کنید";
"Copy %@ link with time" = "پیوند %@ با مهرزمان کپی کنید";

View File

@@ -1,7 +1,7 @@
" subscribers" = " abonnés";
"Add Location..." = "Ajouter une instance";
"Add Location..." = "Ajouter une instance..";
"Add profile..." = "Ajouter un profil…";
"Add Quality Profile" = "Ajouter un profil de qualité";
"Delete" = "Supprimer";
@@ -264,7 +264,7 @@
"Don't use public locations" = "Ne pas utiliser d'instances publiques";
"Enable Return YouTube Dislike" = "Activer Return YouTube Dislike";
"Enter fullscreen in landscape" = "Entrer en plein écran en mode paysage";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.\n";
"Frontend URL" = "URL frontale";
"Public Locations" = "Instances publiques";
"Public Manifest" = "Manifeste publique";

View File

@@ -529,7 +529,7 @@
"For custom locations you can configure Frontend URL in Locations settings" = "場所を指定するには、場所の設定からフロントエンドのURLを設定します";
"Public Locations" = "公開された場所";
"Switch to public locations" = "公開された場所に切り替え";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "有料/無料のプラットフォームかを問わず、いいね、登録などを明示的に操作を促す(例: 動画をクリック)。";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "有料/無料のプラットフォームかを問わず、いいね、登録などを明示的に操作を促す(例: 動画をクリック)。\n";
"Proxy videos" = "動画閲覧にプロキシ使用";
"Sections" = "表示するボタン";
"System controls show buttons for %@" = "システム制御「%@」用のボタンを表示";

View File

@@ -145,7 +145,7 @@
"I want to ask a question" = "Ik wil een vraag stellen";
"If you are interested what's coming in future updates, you can track project Milestones." = "Als je geïnteresseerd bent in toekomstige updates, kan je Milestones van het project volgen.";
"Increase rate" = "Verhoog tempo";
"Info" = "";
"Info" = "Info";
"Instance of current account" = "Instantie van huidig account";
/* SponsorBlock category name */
@@ -259,3 +259,38 @@
"Save history of played videos" = "Sla geschiedenis van afgespeelde videos op";
"Save history of searches, channels and playlists" = "Sla geschiedenis van zoekopdrachten, kanalen en afspeellijsten op";
"Search" = "Zoeken";
"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." = "Stukken normaal in het begin van een video met een animatie, stil plaatje, of stukje van een andere video van dezelfde maker.";
"Discord Server" = "Discord Server";
"Enable logging" = "Loggen inschakelen";
"Interface" = "Interface";
/* SponsorBlock category name */
"Intro" = "Intro";
"LIVE" = "LIVE";
"Matrix Chat" = "Matrix Chat";
/* SponsorBlock category name */
"Outro" = "Outro";
"Proxy videos" = "Video's door proxyserver leiden";
"Reset" = "Herstellen";
"Search history is empty" = "Zoekgeschiedenis is leeg";
"Search..." = "Zoeken...";
"Sections" = "Secties";
"Seek gesture sensitivity" = "Zoek gebaar gevoeligheid";
"Seek gesture speed" = "Zoek gebaar snelheid";
"Seek with horizontal swipe on video" = "Scrollen met horizontale sleep op video";
"Select location closest to you:" = "Selecteer de dichtstbijzijnde locatie:";
/* SponsorBlock category name */
"Self-promotion" = "Zelfpromotie";
"Settings" = "Instellingen";
"Share %@ link" = "%@ link delen";
"Share %@ link with time" = "%@ link met tijd delen";
"Share..." = "Delen...";
/* Video duration filter in search */
"Short" = "Kort";
"Show account username" = "Gebruikersnaam van account laten zien";
"Show anonymous accounts" = "Anonieme accounts laten zien";
"Show channel name" = "Naam van kanaal laten zien";
"Show history" = "Geschiedenis laten zien";

View File

@@ -355,7 +355,7 @@
"Could not extract channel information" = "Não pôde extrair informação do canal";
"For custom locations you can configure Frontend URL in Locations settings" = "Para localizações personalizadas você pode configurar URL do frontend nas configurações de localização";
"Add Location" = "Adicionar Localização";
"Add Location..." = "Adicionar Localização";
"Add Location..." = "Adicionar Localização..";
"For videos which feature music as the primary content." = "Para vídeos que têm música como conteúdo principal.";
"Close video after playing last in the queue" = "Fechar vídeo depois de tocar o último na fila";
"Clear Search History" = "Limpar Histórico de Busca";
@@ -406,7 +406,7 @@
"Country" = "País";
"Clear All" = "Limpar Tudo";
"Clear All Recents" = "Limpar Todos os Recentes";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clique em um vídeo).";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar em um vídeo).\n";
"Duration" = "Duração";
"Edit Quality Profile" = "Editar Perfil de Qualidade";
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "Discussões acontecem no Discord e no Matrix. É um bom lugar para perguntas gerais.";

View File

@@ -59,7 +59,7 @@
"Add Account" = "Adicionar Conta";
"Add Account..." = "Adicionar Conta…";
"Add Location" = "Adicionar Localização";
"Add Location..." = "Adicionar Localização";
"Add Location..." = "Adicionar Localização...";
"Add profile..." = "Adicionar perfil…";
"Add Quality Profile" = "Adicionar Perfil de Qualidade";
"Add to %@" = "Adicionar a %@";
@@ -148,7 +148,7 @@
"Enter fullscreen in landscape" = "Entrar no ecrã inteiro em modo paisagem";
"Error" = "Erro";
"Error when accessing playlist" = "Erro ao acessar playlist";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clique num vídeo).";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos para dar gosto, subscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar num vídeo).\n";
"Favorites" = "Favoritos";
"Filter" = "Filtro";
"Filter: active" = "Filtro: ativo";
@@ -604,3 +604,9 @@
"Add %@" = "Adicionar %@";
"No preview" = "Sem prévia";
"Chapters (if available)" = "Capítulos (se disponível)";
"Import Settings..." = "Definições de Importação...";
"Export Settings" = "Definições de Exportação";
"Accounts passwords (unencrypted)" = "Palavras-passe das contas (não encriptadas)";
"Other" = "Outro";
"Other data" = "Outros dados";
"Export..." = "Exportar…";

View File

@@ -10,7 +10,7 @@
"Accounts are not supported for the application of this instance" = "Conturile nu sunt acceptate pentru aplicaţia acestei instanțe";
"%lld videos" = "%lld videoclipuri";
"Add Location" = "Adaugă locație";
"Add Location..." = "Adaugă locație...";
"Add Location..." = "Adaugă locație..";
"Add profile..." = "Adaugă profil...";
"Add to %@" = "Adaugă la %@";
"Add to Playlist" = "Adaugă la playlist";
@@ -62,7 +62,7 @@
"Edit" = "Editați";
"Edit Playlist" = "Editați Playlist";
"Enter fullscreen in landscape" = "Introduceți ecranul complet în peisaj";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).\n";
"Find Other" = "Găsiți alte";
"Finding something to play..." = "Să găsești ceva de jucat...";
"For videos which feature music as the primary content." = "Pentru videoclipurile care includ muzica ca conținut principal.";

View File

@@ -90,7 +90,7 @@
"Delete" = "删除";
"Disabled" = "禁用";
"Discord Server" = "Discord 服务器";
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "讨论在 Discord 及 Matrix 中行,您可以在里面询问一些普通的问题。";
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "討論在 Discord 及 Matrix 中行,您可以在裡面詢問一些簡單的問題。";
"Don't use public locations" = "不要使用公开地址";
"Donations" = "捐赠";
"Done" = "完成";

View File

@@ -0,0 +1,631 @@
"Format" = "格式";
"Driver" = "驅動";
"Audio" = "音效";
"Show only icons" = "只顯示圖標";
"Center" = "正中";
"File" = "文件";
"Documents" = "文件";
"Video" = "視頻";
"Codec" = "編碼";
"Size" = "大小";
"Sample Rate" = "取樣率";
"Mark channel feed as watched" = "標記頻道為已觀看";
"Clear all" = "清除所有";
"\"%@\" will be irreversibly removed from this device." = "\"%@\" 將會於此裝置上被永久移除。";
"Music Mode" = "音樂模式";
"Close video" = "關閉視頻";
"Play next item" = "播放下一項目";
"Maximum width expanded" = "最大寬度已展開";
"Show unwatched feed badges" = "顯示未觀看的\"最新影片\"標誌";
"Gesture: fowards" = "手勢: 向前";
"Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart." = "手勢設置控制遠程箭頭按鈕的跳過間隔(用於第二代 Siri Remote 或更新版本)。更改系統控制設置需要重新啓動。";
"Opened File" = "已打開文件";
"Landscape left" = "橫屏左邊";
"Landscape right" = "橫屏右邊";
"Gesture settings control skipping interval for double tap gesture on left/right side of the player. Changing system controls settings requires restart." = "手勢設置控制玩家左右兩側雙擊手勢的跳過間隔。更改系統控制設置需要重新啓動。";
"Show scroll to top button in comments" = "在評論中顯示滾動到頂部按鈕";
"(watched and shorts hidden)" = "(隱藏已觀看及短片)";
"(watched hidden)" = "(隱藏已觀看)";
"Show video context menu options to force selected backend" = "顯示視頻內容目錄選項來強制已選取的後端";
"Other data" = "其他資料";
"Other data include last used playback preferences and listing options" = "其他資料包括上次的播放喜好和清單選項";
"File information" = "檔案資訊";
"Build" = "版本";
"Action button labels" = "動作按鈕標籤";
"Icon and text" = "圖示及文字";
"Password required to import" = "需要匯入的密碼";
"Edit" = "編輯";
"Enable Return YouTube Dislike" = "啟用YouTube 不喜歡回報";
"Enter fullscreen in landscape" = "橫屏下進入全屏";
"Error when accessing playlist" = "播放清單出錯";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "明確提醒在任何付費或免費平台上按讚、訂閱或與他們互動(例如點擊影片)。\n";
"For videos which feature music as the primary content." = "以音樂為主要內容的視頻。";
"I like this app!" = "我喜歡這app!";
"If you are interested what's coming in future updates, you can track project Milestones." = "如果您對將來的功能更新感興趣,您可以追蹤我們的專案里程碑。";
"Increase rate" = "增长率";
/* SponsorBlock category name */
"Intro" = "簡介";
"Issues Tracker" = "問題追蹤介面";
/* Selected video has just finished playing */
"Just watched" = "已觀看";
/* Player controls layout size */
"Large" = "大";
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "大佈局並不適合所有設備,使用它可能導致控制按鈕在屏幕上並不貼合。";
"LIVE" = "直播";
"Low quality" = "低畫質";
"Low" = "低";
"Mark video as watched after playing" = "播放後標記為已觀看";
"MPV Documentation" = "MPV 文檔";
"Orientation" = "方向";
"Music" = "音樂";
"Part of a video promoting a product or service not directly related to the creator. The creator will receive payment or compensation in the form of money or free products." = "視頻宣傳產品或服務的一部分,與創作者沒有直接關係。創作者將以金錢或免費產品的形式獲得報酬或補償。";
"Remove from Playlist" = "從播放清單中移除";
"Replies" = "回覆";
"Reset" = "重設";
"Restart the app to apply the settings above." = "重啟app 以應用以上設置。";
"Sections" = "章節";
"Share..." = "分享...";
"Show account username" = "顯示帳戶名稱";
"Show channel name" = "顯示頻道名稱";
"This cannot be reverted. You might need to switch between views or restart the app to see changes." = "這不能復原。你可能需要轉換顯示或重啟app 才能顯示變更。";
/* Player controls layout size for TV */
"TV" = "電視";
"unknown" = "不明";
"Unsubscribe" = "取消訂閱";
"Typically near or at the end of the video when the credits pop up and/or endcards are shown." = "通常在視頻結束時或接近視頻結尾時,出現 Credits Pop Up 和結束卡片。";
"Upload date" = "上載日期";
"URL" = "網址";
"Used to create links from videos, channels and playlists" = "用於從視頻、頻道和播放清單創建連結";
/* Player controls layout size */
"Very Large" = "非常大";
"Videos" = "視頻";
/* Video sort order in search */
"Views" = "觀看次數";
"Watched" = "已觀看";
"No chapters information available" = "沒有章節資訊";
"Share Logs..." = "分享日誌…";
"Any format" = "任何格式";
"%@ formats" = "%@ 格式";
"Keep last played video in the queue after restart" = "重啟後保留最後播放視頻至隊列";
"Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"" = "播放清單空白\n\n點擊並按住視頻然後\n\"加至播放清單\"";
"Could not refresh Subscriptions" = "無法更新訂閱列表";
"Could not load streams" = "無法加載視頻";
"Could not open video" = "無法開啟視頻";
"Channel could not be found" = "無法找到頻道";
"For custom locations you can configure Frontend URL in Locations settings" = "對於自定義站台,您可以在設置中配置前端 URL";
"This URL could not be opened" = "無法打開此URL";
"Could not open channel" = "無法打開頻道";
/* Selected video is being played */
"Watching now" = "觀看中";
/* Video date filter in search */
"Week" = "星期";
"Yattee" = "Yattee";
"Could not update your token." = "無法更新你的權仗(Token)。";
"Could not refresh Trending" = "無法更新趨勢";
"Could not extract channel information" = "無法獲取頻道資訊";
"Yattee %@ (build %@)" = "Yattee %@ (版本 %@)";
/* Video date filter in search */
"Year" = "年";
"You can find information about using Yattee in the Wiki pages." = "您可以在 GitHub 相關頁面中找到有關使用 Yattee 的信息。";
"You can use automatic profile selection based on current device status or switch it in video playback settings controls." = "您可以使用基於當前設備狀態的自動配置文件選擇,或在視頻播放設置控件中進行切換。";
"Could not extract playlist ID" = "無法提取播放清單ID";
"Could not load video" = "無法載入視頻";
"You need to create an instance and accounts\nto access %@ section" = "你需要建立站台及帳戶\n來存取 %@ 部分";
"You need to select an account\nto access %@ section" = "你需要選擇帳戶\n來存取 %@ 部分";
"If you want this app to be available in your language, join translation project." = "如果你想此app 以你的語言顯示,請加入翻譯專案。";
"Private" = "私人";
"Playback queue is empty" = "播放隊列空白";
"Playing Next" = "播放下一個";
"You can switch between profiles in playback settings controls." = "您可以在播放設置控件中切換配置文件。";
"Current Playlist" = "當前播放清單";
"Stream & Player" = "串流及播放器";
"Statistics" = "數據";
"Hardware decoder" = "硬體解碼";
"Stream FPS" = "串流 FPS";
"Cached time" = "緩存時間";
"Rate & Captions" = "評分及字幕";
"Dropped frames" = "損失幀數";
"Could not create share link" = "無法建立分享連結";
"%@ Channel" = "%@ 頻道";
"%@ Playlist" = "%@ 播放清單";
"%@ subscribers" = "%@ 訂閱者";
"Accounts" = "帳戶";
"Accounts are not supported for the application of this instance" = "本站並不支持帳戶";
"Add Account" = "新增帳戶";
"%lld videos" = "%lld 視頻";
"Add Account..." = "新增帳戶...";
"Add Location" = "新增站點";
"Add Location..." = "新增站點..";
"Add profile..." = "新增配置...";
"Add Quality Profile" = "新增畫質配置";
"Add to %@" = "添加到 %@";
"Add to Favorites" = "加至我的最愛";
"Add to Playlist" = "加至播放清單";
"Add to Playlist..." = "加至播放清單...";
"Advanced" = "高級";
/* Trending category, section containing all kinds of videos */
"All" = "所有";
"Always use AVPlayer for live videos" = "總是使用AVPlayer(直播)";
"Anonymous" = "匿名";
/* Video date filter in search
Video duration filter in search */
"Any" = "任何";
"Apply to all" = "套用至全部";
"Are you sure you want to unsubscribe from %@?" = "確定要取消訂閱 %@";
"Automatic" = "自動";
"Autoplaying Next" = "自動播放下一個";
"Backend" = "後台";
"Categories to Skip" = "要跳過的類別";
"Cellular" = "流動網絡";
"Chapters" = "章節";
"Charging" = "充電中";
"Clear" = "清除";
"Clear All" = "清除所有";
"Clear All Recents" = "清除所有最近";
"Clear History" = "清除記錄";
"Clear Search History" = "清除搜尋記錄";
"Clear Search History..." = "清除搜尋記錄...";
"Clear the queue" = "清除隊列";
"Close" = "關閉";
"Close PiP when player is opened" = "當播放器打開時,關閉 PiP";
"Close player when closing video" = "當關閉視頻時,關閉播放器";
"Close player when starting PiP" = "當啟動PiP時關閉播放器";
"Close Video" = "關閉視頻";
"Close video after playing last in the queue" = "播放完最後隊列後關閉視頻";
"Connected successfully (%@)" = "連接成功 (%@)";
"Connection failed" = "連接失敗";
"Contact" = "聯繫";
"Continue" = "繼續";
"Continue from %@" = "從 %@繼續";
"Contributing" = "貢獻";
"Controls" = "控制";
"Copy %@ link" = "複製 %@ 連結";
"Copy %@ link with time" = "複製 %@ 連結(含時間)";
"Could not load locations manifest" = "無法加載站台列表";
"Country Name or Code" = "國家名稱或代碼";
"Create Playlist" = "新建播放清單";
"Current: %@\n%@" = "現在: %@\n%@";
/* Locations settings, custom instance is selected as current */
"Custom" = "自定義";
"Custom Locations" = "自定義站台";
/* Video sort order in search */
"Date" = "日期";
"Decrease rate" = "下降率";
"Decreased opacity" = "減少透明度";
"Delete" = "刪除";
"Disabled" = "禁用";
"Discord Server" = "Discord 伺服器";
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "討論在 Discord 及 Matrix 中進行,您可以在裡面詢問一些簡單的問題。";
"Don't use public locations" = "不使用公開站台";
"Donations" = "捐贈";
"Done" = "完成";
"Duration" = "時長";
"Edit Playlist" = "編輯播放清單";
"Edit Quality Profile" = "編輯質量配置";
"Edit..." = "編輯...";
"Enable logging" = "啟用日誌";
"Error" = "錯誤";
"Favorites" = "我的最愛";
"Filter" = "篩選";
"Filter: active" = "篩選: 啟用";
"Find Other" = "搜尋其他";
"Finding something to play..." = "正在尋找視頻...";
"Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply)." = "格式將按列出的順序選擇。\nHLS是一種自適應格式解析度設定不適用。";
"Frontend URL" = "前端網址";
"Fullscreen size" = "全屏大小";
"Gaming" = "遊戲";
"Help" = "幫助";
"Hide sidebar" = "隱藏側邊欄";
"High" = "高";
"Highest" = "最高";
"Highest quality" = "最高畫質";
"History" = "歷史";
"Honor orientation lock" = "方向鎖";
/* Video date filter in search */
"Hour" = "小時";
"I am lost" = "我迷失了";
"I found a bug /" = "我發現bug";
"I have a feature request" = "我有一個功能需要";
"I want to ask a question" = "我想問問題";
"If you are reporting a bug, include all relevant details (especially: app version, used device and system version, steps to reproduce)." = "如果你要反饋一個程式錯誤請包括所有相關資料特別是App 版本,使用設備以及系統版本,重現步驟)。";
"Info" = "資訊";
"Instance of current account" = "此帳戶站台";
/* SponsorBlock category name */
"Interaction" = "交互";
"Interface" = "介面";
/* Loading stream OSD */
"Loading streams..." = "加載中...";
"Loading..." = "加載中...";
"Locations" = "地址";
"Lock portrait mode" = "鎖定直屏";
/* Video duration filter in search */
"Long" = "長";
"Lowest" = "最低";
"Mark as watched" = "標記為已觀看";
"Mark watched videos with" = "標記已觀看視頻";
"Matrix Channel" = "Matrix 頻道";
"Matrix Chat" = "Matrix 聊天";
/* Player controls layout size */
"Medium" = "中";
"Medium quality" = "中畫質";
"Milestones" = "里程碑";
/* Video date filter in search */
"Month" = "月";
"More info can be found in:" = "更多資訊可在:";
"Movies" = "電影";
"Name" = "名稱";
"New Playlist" = "新播放清單";
"Next" = "下一個";
"No description" = "無簡介";
"No Playlists" = "沒有播放清單";
"No results" = "沒有結果";
"Normal" = "正常";
"Not available" = "不可用";
"Not Playing" = "沒有播放";
"Nothing" = "沒有東西";
/* SponsorBlock category name */
"Offtopic in Music Videos" = "在音樂視頻中的無關內容";
"Only when signed in" = "僅當登錄後";
"Open \"Playlists\" tab to create new one" = "打開「播放清單」 頁面來創建新的";
"Open Settings" = "打開設置";
/* Loading stream OSD */
"Opening %@ stream..." = "正在打開 %@ ...";
"Opening audio stream..." = "正在打開音訊...";
/* SponsorBlock category name */
"Outro" = "結尾";
"Password" = "密碼";
"Pause" = "暫停";
"Pause when entering background" = "進入後台時暫停";
"Pause when player is closed" = "播放器關閉時暫停";
"Picture in Picture" = "畫中畫";
"Play" = "播放";
"Play All" = "全部播放";
"Play in PiP" = "在畫中畫播放";
"Play Last" = "播放最後";
"Play Music" = "播放音樂";
"Play Next" = "播放下一部";
"Play Now" = "即時播放";
"Playback" = "播放";
"Player" = "播放器";
"Playlist" = "播放清單";
"Playlist \"%@\" will be deleted.\nIt cannot be reverted." = "播放清單 “%@” 將被删除。\n此操作不可恢復。";
"Playlists" = "播放清單";
"Popular" = "熱播";
"Preferred Formats" = "喜好格式";
"Profiles" = "配置";
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "推廣與創作者本身直接相關的產品或服務。這通常包括商品或盈利平台的推廣。";
"Proxy videos" = "代理視頻";
"Public Locations" = "公共站台";
"Public Manifest" = "公共清單";
"Quality" = "畫質";
"Quality Profile" = "畫質配置";
"Queue" = "隊列";
"Queue is empty" = "隊列為空";
"Rate" = "速度";
/* Video sort order in search */
"Rating" = "評級";
"Recents" = "最近";
"Red" = "紅";
"Refresh" = "更新";
"Regular size" = "正常大小";
"Regular Size" = "正常大小";
"Related" = "相關";
/* Video sort order in search */
"Relevance" = "相關度";
"Remove" = "移除";
"Remove from Favorites" = "從我的最愛中移除";
"Remove from history" = "從歷史中移除";
"Remove from the queue" = "從隊列中移除";
"Reset search filters" = "重設搜尋篩選";
"Reset watched status when playing again" = "重新播放後重設播放狀態";
"Resolution" = "分辨率";
"Restart" = "重新啟動";
"Restart/Play next" = "重新播放/播放下一個";
"Restore default profiles..." = "重置默認配置文件...";
"Rotate to portrait when exiting fullscreen" = "退出全屏後旋轉為直屏";
"Round corners" = "圓角";
"Save" = "儲存";
"Save history of played videos" = "儲存已播放視頻記錄";
"Save history of searches, channels and playlists" = "儲存搜尋,頻道及播放清單記錄";
"Search" = "搜尋";
"Search history is empty" = "搜尋歷史為空";
"Search..." = "搜尋...";
"Seek gesture sensitivity" = "手勢靈敏度";
"Seek gesture speed" = "手勢速度";
"Seek with horizontal swipe on video" = "視頻水平滑動搜索";
"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." = "通常在視頻開頭找到的片段,包括動畫、靜止幀或剪輯,這些片段也可以由同一創作者在其他視頻中看到。";
"Select location closest to you:" = "選取離你最近的站台:";
/* SponsorBlock category name */
"Self-promotion" = "自我推銷";
"Settings" = "設置";
"Share %@ link" = "分享 %@ 連結";
"Share %@ link with time" = "分享 %@ 連結(含時間)";
/* Video duration filter in search */
"Short" = "短";
"Show anonymous accounts" = "顯示匿名帳戶";
"Show history" = "顯示歷史";
"Show keywords" = "顯示關鍵字";
"Show playback statistics" = "顯示播放統計";
"Show progress of watching on thumbnails" = "縮圖顯示播放進度";
"Show sidebar when space permits" = "空間充裕時顯示側邊欄";
"Show video length" = "顯示視頻長度";
"Shuffle" = "隨機播放";
"Shuffle All" = "隨機播放全部";
"Sidebar" = "側邊欄";
"Sign In Required" = "需要登錄";
/* Player controls layout size */
"Small" = "小";
/* Player controls layout size */
"Smaller" = "更小";
"Sort" = "排序";
"Sort: %@" = "排序: %@";
"Source" = "源";
/* SponsorBlock category name */
"Sponsor" = "宣傳";
"SponsorBlock" = "SponsorBlock (跳過贊助廣告)";
"SponsorBlock API Instance" = "SponsorBlock API 站台";
"Subscribe" = "訂閱";
/* Subscriptions title */
"Subscriptions" = "訂閱";
"Switch to other public location" = "轉換至其他公共站台";
"Switch to public locations" = "轉換至公共站台";
"System controls buttons" = "系統控制鍵";
"System controls show buttons for %@" = "系統控制%@鍵";
"That's nice to hear. It is fun to deliver apps other people want to use. You can consider donating to the project or help by contributing to new features development." = "很高興聽到您這麼說。提供人們想要的應用程序是一件很有趣的事情。您可以考慮為項目捐款,或為新功能開發做出貢獻。";
"This cannot be reverted" = "這不能復原";
"This information will be processed only on your device and used to connect you to the server in the specified country." = "此信息將僅在您的設備上處理,並用於將您連接到指定國家/地區的服務器。";
"This will remove all your custom profiles and return their default values. This cannot be reverted." = "這將刪除所有自定義配置文件並還原為其默認值。此操作無法復原。";
"Thumbnails" = "縮圖";
/* Video date filter in search */
"Today" = "今日";
"Trending" = "趨勢";
"Username" = "用戶名稱";
/* Selected video was played on given date */
"Watched %@" = "已觀看 %@";
"Welcome" = "歡迎";
"Wi-Fi" = "無線網絡";
"Wiki" = "GitHub";
"You have no Playlists" = "你沒有播放清單";
"You have no playlists\n\nTap on \"New Playlist\" to create one" = "你沒有播放清單\n\n點擊\"新建播放清單\"建立";
"Public" = "公開";
"Unlisted" = "未列出";
"Now Playing" = "現正播放";
"Current Location" = "現在位置";
"Add Channels, Playlists and Searches to Favorites using" = "添加頻道、播放清單和搜尋到我的最愛";
"Make default" = "設為預設";
"Visibility" = "可見度";
"It can be changed later in settings. You can use your own locations too." = "稍後可以在設置中更改。你也可以使用自己的地址。";
"Press and hold remote button to open captions and quality menus" = "按住遙控按鈕打開字幕和畫質功能表";
"Comments are disabled" = "留言被關閉";
"No comments" = "沒有留言";
"Open logs in Finder" = "在Finder 中開啟日誌";
"Could not extract SID from received cookies: %@" = "無法從Cookies 提取SID: %@";
"Could not refresh Popular" = "無法更新熱播";
"Could not open playlist" = "無法打開播放清單";
"Could not extract video ID" = "無法提取視頻ID";
"This video could not be opened" = "這視頻無法打開";
"No locations available at the moment" = "現時沒有可用地址";
"Could not refresh Playlists" = "無法更新播放清單";
"Translations" = "翻譯";
"No documents" = "沒有文件";
"Recent Documents" = "最近文件";
"Home" = "主頁";
"Share files from Finder on a Mac\nor iTunes on Windows" = "在 Windows iTunes 或 Mac 上\n共享 Finder 中的文件";
"Show Home" = "顯示主頁";
"Show Open Videos quick actions" = "顯示打開視頻快速操作";
"Recent History" = "最近歷史";
"Show Favorites" = "顯示我的最愛";
"Inspector visibility" = "檢查器可見度";
"Edit Favorites…" = "編輯我的最愛…";
"Show Open Videos toolbar button" = "顯示打開視頻工具按鈕";
"Buttons labels" = "按鈕標簽";
"Files" = "文件";
"Show Documents" = "顯示文件";
"Pages toolbar position" = "頁面工具列位置";
"Video Details" = "視頻詳情";
"Show Inspector" = "顯示檢查器";
"Reload manifest" = "重新載入清單";
"Clear Queue before opening" = "開啟前清除隊列";
"Open" = "打開";
"Video actions buttons" = "視頻操作按鈕";
"Pages buttons" = "頁面按鈕";
"URL to Open" = "要打開的URL";
"Enter link to open" = "輸入要打開的連結";
"Could not open Files" = "無法打開文件";
"Paste" = "貼上";
"Open Videos" = "打開視頻";
"Enter links to open, one per line" = "輸入需要打開的連結,每行一個";
"Playback Mode" = "播放模式";
"Add" = "添加";
"Hide" = "隱藏";
"Always" = "總是";
"Only for local files and URLs" = "僅針對本地文件以及連結";
"Right" = "右";
"Channels" = "頻道";
"Open Files" = "打開文件";
"Share" = "分享";
"Show icons and text when space permits" = "在空間允許時顯示圖標和文字";
"Left" = "左";
"FPS" = "FPS";
"Address" = "地址";
"Remove…" = "移除…";
"Show sidebar" = "顯示側邊欄";
"Locations Manifest" = "地址清單";
"Remove Location" = "移除地址";
"Open Video" = "打開視頻";
"Default Profile" = "預設配置";
"Playback history is empty" = "播放記錄空白";
"Copy%@link" = "複製%@連結";
"Share%@link" = "分享%@連結";
"Are you sure you want to remove this document?" = "你確定要移除文件?";
"Could not delete document" = "無法刪除文件";
"Live Streams" = "直播";
"Shorts" = "短片";
"Channel" = "頻道";
"Mark channel feed as unwatched" = "標記頻道為未觀看";
"Could not find any links to open in your clipboard" = "無法在你的剪輯版中找到任何可以打開的連結";
"Actions buttons" = "動作按鈕";
"Are you sure you want to remove %@ location?" = "你確定想要刪除 %@ 地址?";
"Verified" = "已認證";
"Open expanded" = "展開";
"Short videos: visible" = "短片: 可見";
"Player Bar" = "播放器控制條";
"Short videos: hidden" = "短片: 隱藏";
"Play all unwatched" = "播放所有未觀看";
"Double tap gesture" = "雙擊手勢";
"Tap and hold channel thumbnail to open context menu with more actions" = "點擊並按住頻道縮圖以打開包含更多操作的選單";
"Always show controls buttons" = "總是顯示控制按鈕";
"Single tap gesture" = "單擊手勢";
"Seeking" = "搜索中";
"Right click channel thumbnail to open context menu with more actions" = "右鍵單擊頻道縮圖以打開具有更多操作的選單";
"Controls Buttons" = "控制按鈕";
"System controls" = "系統控制";
"Controls button: backwards" = "控制按鈕: 向後";
"Controls button: forwards" = "控制按鈕: 向前";
"Gesture: backwards" = "手勢: 向後";
"Hide player" = "隱藏播放器";
"Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart." = "手勢設置控制雙擊播放器左/右的跳躍間隔。更改系統控制設置需要重新啓動。";
"Lock orientation" = "鎖定方向";
"Cache" = "緩存";
"Actions Buttons" = "動作按鈕";
"Total size: %@" = "總大小: %@";
"Open channels with description expanded" = "打開頻道(含描述展開)";
"Subscribe/Unsubscribe" = "訂閱/取消訂閱";
"Show cache status" = "顯示緩存狀態";
"Maximum feed items" = "最大\"最新影片\"數目";
"Open channel" = "打開頻道";
"Inspector" = "檢查器";
"Open video description expanded" = "打開視頻描述";
"Mark all as unwatched" = "標記所有為未觀看";
"Mark all as watched" = "標記所有為已觀看";
"Playback Settings" = "播放設定";
"Replay" = "重播";
"Fullscreen" = "全屏幕";
"Lock" = "鎖定";
"Description" = "描述";
"Autoplay next" = "自動播放下一個";
"Stream" = "串流";
"Are you sure you want to clear cache?" = "你確定要清除緩存嗎?";
"Show Next in Queue" = "在隊列中顯示下一個";
"Show toggle watch status button" = "顯示切換觀看狀態按鈕";
"Next in Queue" = "隊列中下一個";
"List" = "列表";
"Cells" = "網格";
"Toggle size" = "替換大小";
"Toggle player" = "替換播放器";
"Do nothing" = "不做";
"Feed" = "最新影片";
"Queue - shuffled" = "隊列 - 隨機";
"Loop one" = "單個循環";
"File Extension" = "副檔名";
"Opening file..." = "正在打開文件...";
"Public account" = "公共帳戶";
"Enter account credentials to connect..." = "輸入帳號密碼來連接...";
"Enter location address to connect..." = "輸入站台地址來連接...";
"Seek" = "搜索";
"Your Accounts" = "你的帳戶";
"Browse without account" = "匿名瀏覽";
"Close video and player on end" = "播放結束時關閉視頻及播放器";
"Use system controls with AVPlayer" = "在AVPlayer 時使用系統控制按鈕";
"Rotate when entering fullscreen on landscape video" = "觀看橫向全屏視頻時旋轉";
"Available" = "可用";
"Home Settings" = "主頁設置";
"Watched: hidden" = "已觀看: 隱藏";
"No rotation" = "不要旋轉";
"Startup section" = "啟動部分";
"Watched: visible" = "已觀看: 可見";
"No videos to show" = "沒有視頻顯示";
"(shorts hidden)" = "(隱藏短片)";
"Disable filters" = "禁用過濾";
"Limit" = "上限";
"Are you sure you want to remove %@ from Favorites?" = "你確定要從我的最愛中刪除 %@ 嗎?";
"Keep channels with unwatched videos on top of subscriptions list" = "保留頻道內未觀看視頻在訂閱列表頂端";
"Play Now in MPV" = "在MPV 中播放";
"Play Now in AVPlayer" = "在AVPlayer 中播放";
"Show channel avatars in videos lists" = "在視頻列表中顯示頻道頭像";
"Show channel avatars in channels lists" = "在頻道列表中顯示頻道頭像";
"Podcasts" = "播客";
"Releases" = "發布";
"Add %@" = "添加 %@";
"Description preview" = "描述預覽";
"No preview" = "沒有預覽";
"Open vertical chapters expanded" = "展開垂直章節";
"Chapters (if available)" = "章節(如有)";
"Import Settings..." = "匯入設定...";
"Export Settings" = "匯出設定";
"Accounts passwords (unencrypted)" = "帳戶密碼 (非加密)";
"Other" = "其他";
"Export..." = "匯出…";
"Are you sure you want to export unencrypted passwords?" = "你確定要匯出未加密的密碼?";
"Icon only" = "僅圖示";
"Export" = "匯出";
"Import" = "匯入";
"Platform" = "平台";
"Custom Location already exists" = "自定義站台已存在";
"Custom Location selected for import" = "選擇導入的自定義站台";
"Custom Location not selected for import" = "未選擇導入的自定義站台";
"Account already exists" = "帳戶已存在";
"Password saved in import file" = "密碼已儲存在匯入文件";
"Export in progress..." = "匯出中...";
"In progress..." = "進行中…";
" subscribers" = " 訂閱者";
"10 seconds forwards/backwards" = "前放/回放10秒";
"Are you sure you want to clear history of watched videos?" = "確定要清除播放歷史?";
"Are you sure you want to clear search history?" = "確定要清除搜尋歷史?";
"Are you sure you want to delete playlist?" = "確定要刪除播放清單?";
"Are you sure you want to restore default quality profiles?" = "確定要回復預設質量配置?";
"Badge" = "標記";
"Badge & Decreased opacity" = "標記及降低透明度";
"Badge color" = "標記顏色";
"Based on system color scheme" = "根據系統配置";
"Battery" = "電池";
"Blue" = "藍";
"Browsing" = "瀏覽";
"Buffering stream..." = "緩衝中...";
"Bugs and great feature ideas can be sent to the GitHub issues tracker. " = "程式錯誤及出色的功能構思也可以在 GitHub 問題追蹤介面提出。 ";
"Cancel" = "取消";
"Button" = "按鈕";
"Captions" = "字幕";
"Category" = "類別";
"Close PiP and open player when application enters foreground" = "當應用程式進入前台時,關閉 PiP 並打開播放器";
"Close PiP when starting playing other video" = "當播放其他視頻時,關閉 PiP";
"Comments" = "留言";
"Country" = "國家";
"When partially watched video is played" = "播放未完全觀看視頻時";
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "請勿與任何人共用此文件,否則您可能會失去對帳戶的存取權限。如果您不選擇匯出密碼,系統將要求您在匯入過程中提供密碼";

View File

@@ -1196,6 +1196,7 @@
3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
373197D82732015300EF734F /* RelatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedView.swift; sourceTree = "<group>"; };
37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; };
37367E582B8F63C200436163 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCacheModel.swift; sourceTree = "<group>"; };
373C8FE3275B955100CB5936 /* CommentsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsPage.swift; sourceTree = "<group>"; };
373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
@@ -2773,6 +2774,7 @@
es,
tr,
ru,
"zh-Hant",
);
mainGroup = 37D4B0BC2671614700C925CA;
packageReferences = (
@@ -4045,6 +4047,7 @@
3767F3322B25053B00F257BC /* es */,
3767F3332B25058300F257BC /* tr */,
3767F3342B2505EF00F257BC /* ru */,
37367E582B8F63C200436163 /* zh-Hant */,
);
name = Localizable.strings;
sourceTree = "<group>";
@@ -4059,7 +4062,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
@@ -4090,7 +4093,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -4121,7 +4124,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
@@ -4141,7 +4144,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
@@ -4305,7 +4308,7 @@
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
@@ -4358,7 +4361,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
@@ -4410,7 +4413,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -4449,7 +4452,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
DEAD_CODE_STRIPPING = YES;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -4484,7 +4487,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4508,7 +4511,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4534,7 +4537,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4559,7 +4562,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4585,7 +4588,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -4625,7 +4628,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
DEVELOPMENT_ASSET_PATHS = "";
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
@@ -4666,7 +4669,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -4690,7 +4693,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 179;
CURRENT_PROJECT_VERSION = 181;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -4864,8 +4867,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
requirement = {
branch = main;
kind = branch;
kind = upToNextMajorVersion;
minimumVersion = 2.1.1;
};
};
372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */ = {
@@ -4960,8 +4963,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SDWebImage/SDWebImage";
requirement = {
branch = master;
kind = branch;
kind = upToNextMajorVersion;
minimumVersion = 5.19.1;
};
};
37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
@@ -5000,8 +5003,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/cxfksword/MPVKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.36.0;
kind = upToNextMinorVersion;
minimumVersion = 0.38.0;
};
};
/* End XCRemoteSwiftPackageReference section */

View File

@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "3dc6a42c7727c49bf26508e29b0a0b35f9c7e1ad",
"version" : "5.8.1"
"revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a",
"version" : "5.9.1"
}
},
{
@@ -25,7 +25,7 @@
"location" : "https://github.com/hyperoslo/Cache.git",
"state" : {
"branch" : "master",
"revision" : "a73f7d09534c35a509d2914849a75c15c12fbbbd"
"revision" : "f44a8f6b5ec27730198725ccc542fef0d1cc6b3d"
}
},
{
@@ -60,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/cxfksword/MPVKit.git",
"state" : {
"revision" : "645f430ff0b99ccc2c61062727ad7e8bf32ca72a",
"version" : "0.37.0"
"revision" : "f646e4b625e9c8a2ff22a7e0bb5557306300be5d",
"version" : "0.38.0"
}
},
{
@@ -70,7 +70,7 @@
"location" : "https://github.com/pinterest/PINCache",
"state" : {
"branch" : "master",
"revision" : "97a5dbd3f1e69605bcd4103fdb32ca855887c47a"
"revision" : "f856226e8bee58d75cb6be1707ae0cb2f5801150"
}
},
{
@@ -87,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ashleymills/Reachability.swift",
"state" : {
"revision" : "c01127cb51f591045696128effe43c16840d08bf",
"version" : "5.2.0"
"revision" : "57da4b1270cab7c2228919eabc0e4e1bf93e48ea",
"version" : "5.2.2"
}
},
{
@@ -105,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"branch" : "master",
"revision" : "80c8b2023a5efb4415a2c615acfec075e5c243d2"
"revision" : "f6afa0132961d593f07970d84e2d8b588c29ea04",
"version" : "5.19.1"
}
},
{
@@ -123,8 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
"state" : {
"revision" : "261b6cec35686d2dc192b809ab50742b4502a73b",
"version" : "2.2.6"
"revision" : "53573d6dd017e354c0e7d8f1c86b77ef1383c996",
"version" : "2.2.7"
}
},
{
@@ -132,8 +132,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git",
"state" : {
"revision" : "8a33fb3ca75a01267f775f891f7d61f675e95072",
"version" : "0.14.5"
"revision" : "f534cfe830a7807ecc3d0332127a502426cfa067",
"version" : "0.14.6"
}
},
{
@@ -159,8 +159,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
"state" : {
"branch" : "main",
"revision" : "2abb11839f80ebb07a58ac5e146a1da664260c16"
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
"version" : "2.1.1"
}
},
{
@@ -177,10 +177,10 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/SwiftyJSON/SwiftyJSON.git",
"state" : {
"revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version" : "5.0.1"
"revision" : "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828",
"version" : "5.0.2"
}
}
],
"version" : 2
"version" : 3
}

View File

@@ -60,6 +60,13 @@
ReferencedContainer = "container:Yattee.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "IDEPreferLogStreaming"
value = "Yes"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@@ -19,4 +19,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
#endif
return true
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
if url.scheme == "yattee" {
OpenURLHandler.handle(url)
return true
}
return false
}
}