Compare commits

..

70 Commits

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

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

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

This is based on PR #639

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

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

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

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

fixes #449

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

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

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

It also fixes the caption picker being empty when a caption was selected in the previous watched video.
2024-04-21 01:04:31 +02:00
Toni Förster
dd6106447f don't show related in sidebar when disabled in settings
fixes #634
2024-04-20 13:35:36 +02:00
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
66 changed files with 962 additions and 471 deletions

View File

@@ -1,10 +1,25 @@
## 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
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
* SponsorBlock Improvements by @stonerl in https://github.com/yattee/yattee/pull/639
* Chapter title on jump by @stonerl in https://github.com/yattee/yattee/pull/655
* Restart finished video by @stonerl in https://github.com/yattee/yattee/pull/646
* SponsorBlock jump to end instead of pausing by @stonerl in https://github.com/yattee/yattee/pull/648
* Call correct class of SDImageAWebPCoder by @stonerl in https://github.com/yattee/yattee/pull/664
* Fix handling and displaying captions by @stonerl in https://github.com/yattee/yattee/pull/636
* Advanced settings: make number fields .numPad by @stonerl in https://github.com/yattee/yattee/pull/661
* Preserve time on stream change by @stonerl in https://github.com/yattee/yattee/pull/651
* Switch to previous backend when leaving PiP by @stonerl in https://github.com/yattee/yattee/pull/641
* Handle deep links by @timonus in https://github.com/yattee/yattee/pull/645
* Music Mode: don't bindPlayerToLayer when entering foreground by @stonerl in https://github.com/yattee/yattee/pull/644
* Allow user to disable thumbnails and jump to current chapter in horizontal view by @stonerl in https://github.com/yattee/yattee/pull/665
* Rework qualitiy settings by @stonerl in https://github.com/yattee/yattee/pull/650
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
* Updated localizations
* Updated dependencies
* Upgraded dependencies
## Previous builds
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
@@ -16,6 +31,10 @@
* Added Controls setting "Action button labels" icon or icon and text
* Added Advanced setting for MPV: "deinterlace"
* Add help text to all header buttons (by @rickykresslein)
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
* Fix issues with empty comments (by @stonerl)
* Improved Invidious comments (by @stonerl)
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
* Add Chinese (Traditional) localization (by @rexcsk)
* Localization fixes
* Updated localizations

View File

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

View File

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

View File

@@ -57,17 +57,17 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.906.0)
aws-sdk-core (3.191.6)
aws-partitions (1.929.0)
aws-sdk-core (3.196.1)
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.78.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (1.81.0)
aws-sdk-core (~> 3, >= 3.193.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.146.1)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-s3 (1.151.0)
aws-sdk-core (~> 3, >= 3.194.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
@@ -157,27 +157,28 @@ GEM
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.1)
json (2.7.2)
jwt (2.8.1)
base64
mini_magick (4.12.0)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.0)
multipart-post (2.4.1)
nanaimo (0.3.0)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.4.0)
optparse (0.5.0)
os (1.1.4)
plist (3.7.1)
public_suffix (5.0.5)
rake (13.2.0)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rexml (3.2.8)
strscan (>= 3.0.9)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
@@ -190,6 +191,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
strscan (3.1.0)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)

View File

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

View File

@@ -687,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(
@@ -696,7 +697,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
videoAsset: videoAsset,
resolution: resolution,
kind: .adaptive,
videoFormat: videoFormat
videoFormat: videoFormat,
bitrate: bitrate
)
)
} else {

View File

@@ -66,7 +66,7 @@ protocol VideosAPI {
failureHandler: ((RequestError) -> Void)?,
completionHandler: @escaping (PlayerQueueItem) -> Void
)
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
func comments(_ id: Video.ID, page: String?) -> Resource?
}
@@ -108,14 +108,19 @@ extension VideosAPI {
.onFailure { failureHandler?($0) }
}
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
var urlComponents = account?.instance?.urlComponents
else {
return nil
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
var urlComponents: URLComponents?
if let frontendURLString,
let frontendURL = URL(string: frontendURLString)
{
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
} else if let instanceComponents = account?.instance?.urlComponents {
urlComponents = instanceComponents
}
urlComponents.host = frontendHost
guard var urlComponents else {
return nil
}
var queryItems = [URLQueryItem]()

View File

@@ -22,7 +22,7 @@ struct BrowsingSettingsGroupImporter {
}
if let favorites = json["favorites"].array {
favorites.forEach { favoriteJSON in
for favoriteJSON in favorites {
if let jsonString = favoriteJSON.rawString(options: []),
let item = FavoriteItem.bridge.deserialize(jsonString)
{
@@ -32,7 +32,7 @@ struct BrowsingSettingsGroupImporter {
}
if let widgetsFavorites = json["widgetsSettings"].array {
widgetsFavorites.forEach { widgetJSON in
for widgetJSON in widgetsFavorites {
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = WidgetSettingsBridge().deserialize(dict) {
FavoritesModel.shared.updateWidgetSettings(item)

View File

@@ -56,7 +56,7 @@ struct LocationsSettingsGroupImporter {
}
if let accounts = json["accounts"].array {
accounts.forEach { accountJSON in
for accountJSON in accounts {
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
if let account = AccountsBridge().deserialize(dict),
includedAccountsIDs.contains(account.id)

View File

@@ -26,7 +26,7 @@ struct QualitySettingsGroupImporter {
}
if let qualityProfiles = json["qualityProfiles"].array {
qualityProfiles.forEach { qualityProfileJSON in
for qualityProfileJSON in qualityProfiles {
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = QualityProfileBridge().deserialize(dict) {
QualityProfilesModel.shared.update(item, item)

View File

@@ -6,7 +6,7 @@ struct RecentlyOpenedImporter {
func performImport() {
if let recentlyOpened = json["recentlyOpened"].array {
recentlyOpened.forEach { recentlyOpenedJSON in
for recentlyOpenedJSON in recentlyOpened {
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = RecentItemBridge().deserialize(dict) {
RecentsModel.shared.add(item)

View File

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

View File

@@ -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,33 @@ final class MPVBackend: PlayerBackend {
logger.info("MPV backend received unhandled property: \(name)")
}
}
#if !os(macOS)
@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)
}
#endif
}

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 56_000_000 // 56 Mbit/s
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30:
return 24_000_000 // 24 Mbit/s
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30:
return 12_000_000 // 12 Mbit/s
case .hd720p60, .hd720p50, .hd720p48, .hd720p30:
return 9_500_000 // 9.5 Mbit/s
case .sd480p30:
return 4_000_000 // 4 Mbit/s
case .sd360p30:
return 1_500_000 // 1.5 Mbit/s
case .sd240p30:
return 1_000_000 // 1 Mbit/s
case .sd144p30:
return 600_000 // 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

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

View File

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

View File

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

View File

@@ -3,8 +3,24 @@ import Foundation
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
@@ -81,6 +97,20 @@ enum Constants {
#endif
}
static var defaultNavigationStyle: NavigationStyle {
#if os(macOS)
return .sidebar
#elseif os(iOS)
if isIPad {
return .sidebar
} else {
return .tab
}
#else
return .tab
#endif
}
static func seekIcon(_ type: String, _ interval: TimeInterval) -> String {
let interval = Int(interval)
let allVersions = [10, 15, 30, 45, 60, 75, 90]

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 ? [
@@ -243,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
@@ -580,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

@@ -81,7 +81,6 @@ struct FavoriteItemView: View {
}
}
.contentShape(Rectangle())
.onAppear {
if item.section == .history {
reloadVisibleWatches()
@@ -165,7 +164,7 @@ struct FavoriteItemView: View {
.prefix(favoritesModel.limit(item))
)
let last = watches.last
watches.forEach { watch in
for watch in watches {
player.loadHistoryVideoDetails(watch) {
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
visibleWatches.append(watch)

View File

@@ -4,8 +4,6 @@ import Siesta
struct OpenURLHandler {
static var firstHandle = true
static let yatteeProtocol = "yattee://"
var accounts: AccountsModel { .shared }
var navigation: NavigationModel { .shared }
var recents: RecentsModel { .shared }

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

@@ -142,7 +142,6 @@ struct PlaybackSettings: View {
}
.animation(nil, value: player.activeBackend)
.frame(alignment: .topLeading)
.ignoresSafeArea(.all, edges: .bottom)
.backport
.playbackSettingsPresentationDetents()
@@ -235,7 +234,6 @@ struct PlaybackSettings: View {
#if os(iOS)
.padding(12)
.frame(width: 40, height: 40)
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
.contentShape(Rectangle())
#endif
@@ -261,7 +259,6 @@ struct PlaybackSettings: View {
#if os(iOS)
.padding(12)
.frame(width: 40, height: 40)
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
.contentShape(Rectangle())
#endif
@@ -433,12 +430,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)
}
}
@@ -52,7 +57,6 @@ import SwiftUI
}
.indicator(.activity)
.frame(width: Self.thumbnailWidth, height: Self.thumbnailHeight)
.mask(RoundedRectangle(cornerRadius: 6))
}
@@ -72,7 +76,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 +130,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

@@ -88,7 +88,7 @@ struct VideoDescription: View {
var formattedString: AttributedString {
var attrString = AttributedString(description)
let words = description.unicodeScalars.split(whereSeparator: separators.contains).map(String.init)
words.forEach { word in
for word in words {
if word.hasPrefix("https://") || word.hasPrefix("http://"), let url = URL(string: String(word)) {
if let range = attrString.range(of: word) {
attrString[range].link = url

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

@@ -263,7 +263,6 @@ struct VideoPlayerView: View {
#else
GeometryReader { geometry in
player.playerBackendView
.modifier(
VideoPlayerSizeModifier(
geometry: geometry,

View File

@@ -198,7 +198,7 @@ struct PlaylistFormView: View {
}
}
#else
Button(self.visibility.name) {
Button(visibility.name) {
self.visibility = self.visibility.next()
}
.contextMenu {

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

@@ -61,23 +61,23 @@ struct HistorySettings: View {
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)
}
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)
}
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)
@@ -196,7 +196,7 @@ struct HistorySettings: View {
private func counterButtons(for _value: Binding<Int>) -> some View {
var value: Binding<Int> {
Binding(
get: { return _value.wrappedValue },
get: { _value.wrappedValue },
set: {
if $0 < 1 {
_value.wrappedValue = 1

View File

@@ -68,7 +68,7 @@ struct HomeSettings: View {
}
.help("Add to Favorites")
#if !os(tvOS)
.buttonStyle(.borderless)
.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

@@ -261,7 +261,6 @@ struct PlayerControlsSettings: View {
TextField("Duration", text: value)
.frame(width: textFieldWidth, alignment: .trailing)
.multilineTextAlignment(.center)
.labelsHidden()
#if !os(macOS)
.keyboardType(.numberPad)

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,10 @@ 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 +62,8 @@ struct QualityProfileForm: View {
#endif
}
// swiftlint:enable trailing_closure
var header: some View {
HStack {
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
@@ -124,9 +135,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 +221,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 +252,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 +295,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 +351,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,6 +1,7 @@
import Defaults
import Foundation
import SwiftUI
struct SettingsView: View {
static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")!
static let discordURL = URL(string: "https://yattee.stream/discord")!

View File

@@ -1,15 +1,23 @@
import Defaults
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
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 +43,75 @@ 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)
}
Section(header: SettingsHeader(text: "Categories to Skip".localized())) {
categoryRows
}
#if os(iOS)
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)
}
#endif
Section(footer: categoriesDetails) {
EmptyView()
}
}
}
#if os(iOS)
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) }
)
)
}
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
}
}
}
#endif
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 +121,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 +141,42 @@ 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
}
#if canImport(UIKit)
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)
}
#endif
private func resetColors() {
sponsorBlockColors = SponsorBlockColors.dictionary
}
}
struct SponsorBlockSettings_Previews: PreviewProvider {

3
Shared/Strings.swift Normal file
View File

@@ -0,0 +1,3 @@
enum Strings {
static let yatteeProtocol = "yattee://"
}

View File

@@ -176,7 +176,7 @@ struct URLParser {
private func removePrefixes(_ value: String, _ prefixes: [String]) -> String {
var value = value
prefixes.forEach { prefix in
for prefix in prefixes {
if value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}

View File

@@ -77,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, frontendURLString: "https://www.youtube.com") {
Button(labelForShareURL("YouTube")) {
shareAction(url)
}
@@ -87,7 +87,7 @@ struct ShareButton<LabelView: View>: View {
shareAction(
accounts.api.shareURL(
contentItem,
frontendHost: "www.youtube.com",
frontendURLString: "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

@@ -108,7 +108,7 @@
"Enter fullscreen in landscape" = "Enter fullscreen in landscape";
"Error" = "Error";
"Error when accessing playlist" = "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)." = "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).\n";
"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).";
"Favorites" = "Favorites";
"Filter" = "Filter";
"Filter: active" = "Filter: active";

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

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

@@ -26,7 +26,7 @@ extension UIView {
}
private func viewsInHierarchy<ViewType: UIView>(views: inout [ViewType]) {
subviews.forEach { eachSubView in
for eachSubView in subviews {
if let matchingView = eachSubView as? ViewType {
views.append(matchingView)
}

View File

@@ -82,7 +82,6 @@
3709528A29283E14001ECA40 /* NoDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3709528929283E14001ECA40 /* NoDocumentsView.swift */; };
37095E82291DC85400301883 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37095E81291DC85400301883 /* ShareViewController.swift */; };
37095E89291DC85400301883 /* Open in Yattee.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37095E7F291DC85400301883 /* Open in Yattee.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
37095E8D291DD5DA00301883 /* URLBookmarkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F5E8B5291BE9D0006C15F5 /* URLBookmarkModel.swift */; };
370B79C9286279810045DB77 /* NSObject+Swizzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370B79C8286279810045DB77 /* NSObject+Swizzle.swift */; };
370B79CC286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370B79CB286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift */; };
370E990A2A1EA8C500D144E9 /* WatchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370E99092A1EA8C500D144E9 /* WatchModel.swift */; };
@@ -378,6 +377,12 @@
3761ABFD26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3762C46D2BF66CDD008E50B8 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */; };
3762C4772BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
3762C4782BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
3762C4792BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
3762C47A2BF66F04008E50B8 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3762C4762BF66F04008E50B8 /* Strings.swift */; };
3762C47D2BF66FF7008E50B8 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 3762C47C2BF66FF7008E50B8 /* Defaults */; };
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */; };
37635FE4291EA6CF00C11E79 /* AccentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37635FE3291EA6CF00C11E79 /* AccentButton.swift */; };
@@ -1059,8 +1064,6 @@
37FD77002932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
37FD77012932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
37FD77022932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
37FD77032932C5EC00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD76FF2932C4DA00D91A5F /* URL+ByReplacingYatteeProtocol.swift */; };
37FD77042932C5FC00D91A5F /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3754B01428B7F84D009717C8 /* Constants.swift */; };
37FEF11327EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
37FEF11427EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
37FEF11527EFD8580033912F /* PlaceholderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEF11227EFD8580033912F /* PlaceholderCell.swift */; };
@@ -1265,6 +1268,7 @@
375EC971289F2ABF00751258 /* MultiselectRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiselectRow.swift; sourceTree = "<group>"; };
375F740F289DC35A00747050 /* PlayerBackendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerBackendView.swift; sourceTree = "<group>"; };
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; };
3762C4762BF66F04008E50B8 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
3763495026DFF59D00B9A393 /* AppSidebarRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecents.swift; sourceTree = "<group>"; };
37635FE3291EA6CF00C11E79 /* AccentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentButton.swift; sourceTree = "<group>"; };
3763C988290C7A50004D3B5F /* OpenVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosView.swift; sourceTree = "<group>"; };
@@ -1542,6 +1546,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3762C47D2BF66FF7008E50B8 /* Defaults in Frameworks */,
378CC2E32B669489006309F4 /* Logging in Frameworks */,
377F9F85294417FA0043F856 /* SwiftyJSON in Frameworks */,
377F9F83294417B40043F856 /* Cache in Frameworks */,
@@ -2276,6 +2281,7 @@
371AAE2726CEBF4700901972 /* Videos */,
371AAE2826CEC7D900901972 /* Views */,
3754B01428B7F84D009717C8 /* Constants.swift */,
3762C4762BF66F04008E50B8 /* Strings.swift */,
375168D52700FAFF008F96A6 /* Debounce.swift */,
372915E52687E3B900F5A35B /* Defaults.swift */,
37D2E0D328B67EFC00F64D52 /* Delay.swift */,
@@ -2501,6 +2507,7 @@
377F9F82294417B40043F856 /* Cache */,
377F9F84294417FA0043F856 /* SwiftyJSON */,
378CC2E22B669489006309F4 /* Logging */,
3762C47C2BF66FF7008E50B8 /* Defaults */,
);
productName = "Open in Yattee";
productReference = 37095E7F291DC85400301883 /* Open in Yattee.appex */;
@@ -3000,10 +3007,9 @@
buildActionMask = 2147483647;
files = (
3738535729451E0C00D2D0CB /* BookmarksCacheModel.swift in Sources */,
37FD77042932C5FC00D91A5F /* Constants.swift in Sources */,
3762C46D2BF66CDD008E50B8 /* EnvironmentValues.swift in Sources */,
37095E82291DC85400301883 /* ShareViewController.swift in Sources */,
37FD77032932C5EC00D91A5F /* URL+ByReplacingYatteeProtocol.swift in Sources */,
37095E8D291DD5DA00301883 /* URLBookmarkModel.swift in Sources */,
3762C47A2BF66F04008E50B8 /* Strings.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3330,6 +3336,7 @@
37D4B0E42671614900C925CA /* YatteeApp.swift in Sources */,
37C3A241272359900087A57A /* Double+Format.swift in Sources */,
3784CDE227772EE40055BBF2 /* Watch.swift in Sources */,
3762C4772BF66F04008E50B8 /* Strings.swift in Sources */,
37FB285E272225E800A57617 /* ContentItemView.swift in Sources */,
3797758B2689345500DD52A8 /* Store.swift in Sources */,
3773B80D2ADC076800B5FEF3 /* FramePreferenceKey.swift in Sources */,
@@ -3401,6 +3408,7 @@
37192D5828B179D60012EEDD /* ChaptersView.swift in Sources */,
37E75CCC2B6AEB01003A6237 /* RecentlyOpenedExporter.swift in Sources */,
3784CDE327772EE40055BBF2 /* Watch.swift in Sources */,
3762C4782BF66F04008E50B8 /* Strings.swift in Sources */,
371AC0B7294D1D6E0085989E /* PlayingIndicatorView.swift in Sources */,
3773B8182ADC081300B5FEF3 /* VisualEffectBlur-macOS.swift in Sources */,
37E80F3D287B107F00561799 /* VideoDetailsOverlay.swift in Sources */,
@@ -3826,6 +3834,7 @@
37769250294630110055EC18 /* ChannelAvatarView.swift in Sources */,
37030FFD27B0398000ECDDAA /* MPVClient.swift in Sources */,
378E9C4229455A5800B2D696 /* ChannelsView.swift in Sources */,
3762C4792BF66F04008E50B8 /* Strings.swift in Sources */,
37192D5928B179D60012EEDD /* ChaptersView.swift in Sources */,
37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
@@ -4062,7 +4071,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
@@ -4093,7 +4102,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -4124,7 +4133,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
@@ -4144,7 +4153,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
@@ -4308,7 +4317,7 @@
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
@@ -4361,7 +4370,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
@@ -4413,7 +4422,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -4452,7 +4461,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
DEAD_CODE_STRIPPING = YES;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -4487,7 +4496,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4511,7 +4520,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4537,7 +4546,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4562,7 +4571,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4588,7 +4597,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -4628,7 +4637,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
DEVELOPMENT_ASSET_PATHS = "";
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
@@ -4669,7 +4678,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -4693,7 +4702,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 182;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -4867,8 +4876,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" */ = {
@@ -4963,8 +4972,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SDWebImage/SDWebImage";
requirement = {
branch = master;
kind = branch;
kind = upToNextMajorVersion;
minimumVersion = 5.19.1;
};
};
37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
@@ -5003,8 +5012,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/cxfksword/MPVKit.git";
requirement = {
kind = revision;
revision = dca1e345a26d09a3d621d7656a94e6427f3f7b83;
kind = upToNextMajorVersion;
minimumVersion = 0.38.0;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -5110,6 +5119,11 @@
package = 375B8AAF28B57F4200397B31 /* XCRemoteSwiftPackageReference "KeychainAccess" */;
productName = KeychainAccess;
};
3762C47C2BF66FF7008E50B8 /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = 372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
3765917B27237D21009F956E /* PINCache */ = {
isa = XCSwiftPackageProductDependency;
package = 3765917827237D07009F956E /* XCRemoteSwiftPackageReference "PINCache" */;

View File

@@ -60,7 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/cxfksword/MPVKit.git",
"state" : {
"revision" : "dca1e345a26d09a3d621d7656a94e6427f3f7b83"
"revision" : "f646e4b625e9c8a2ff22a7e0bb5557306300be5d",
"version" : "0.38.0"
}
},
{
@@ -69,7 +70,7 @@
"location" : "https://github.com/pinterest/PINCache",
"state" : {
"branch" : "master",
"revision" : "97a5dbd3f1e69605bcd4103fdb32ca855887c47a"
"revision" : "2fb85948463292c2e824148cf17dc62a4c217a94"
}
},
{
@@ -77,8 +78,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pinterest/PINOperation.git",
"state" : {
"revision" : "40504c156a68b20f98f7ddc73a115cbb7893be25",
"version" : "1.2.2"
"revision" : "a74f978733bdaf982758bfa23d70a189f4b4c1b6",
"version" : "1.2.3"
}
},
{
@@ -86,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ashleymills/Reachability.swift",
"state" : {
"revision" : "7b7018a69c84ea94ac2a38dff626e16ea81d1db9",
"version" : "5.2.1"
"revision" : "57da4b1270cab7c2228919eabc0e4e1bf93e48ea",
"version" : "5.2.2"
}
},
{
@@ -104,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"branch" : "master",
"revision" : "f6afa0132961d593f07970d84e2d8b588c29ea04"
"revision" : "5642d1ffe3dbe628592443bd14154e31929727b4",
"version" : "5.19.2"
}
},
{
@@ -131,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"
}
},
{
@@ -158,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"
}
},
{

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(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
if url.scheme == "yattee" {
OpenURLHandler(navigationStyle: Constants.defaultNavigationStyle).handle(url)
return true
}
return false
}
}

View File

@@ -153,7 +153,6 @@ struct InstancesSettings: View {
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.onAppear {
selectedInstanceID = instances.first?.id
frontendURL = selectedInstanceFrontendURL

View File

@@ -31,7 +31,7 @@ struct VerticalScrollingFixWrapper<Content>: View where Content: View {
}
var body: some View {
VerticalScrollingFixViewRepresentable(content: self.content())
VerticalScrollingFixViewRepresentable(content: content())
}
}