mirror of
https://github.com/yattee/yattee.git
synced 2025-12-14 03:58:14 +00:00
Compare commits
98 Commits
1.5.2-179
...
revert-652
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d67e375f16 | ||
|
|
db2417e455 | ||
|
|
7f8aa51c78 | ||
|
|
4038f7fdb9 | ||
|
|
8a7e4c84b5 | ||
|
|
eb491a890d | ||
|
|
c6724472a6 | ||
|
|
201de81351 | ||
|
|
ae12eefafc | ||
|
|
46f89db11a | ||
|
|
c9d20d28de | ||
|
|
094461d359 | ||
|
|
b9649b6356 | ||
|
|
3d8feda808 | ||
|
|
d9aa5105fa | ||
|
|
42110f32da | ||
|
|
a6c5c3905a | ||
|
|
7b484e80b8 | ||
|
|
68f3d5c631 | ||
|
|
9d291cca28 | ||
|
|
7c108f18ff | ||
|
|
1a3012853d | ||
|
|
5b64290bc5 | ||
|
|
34989a4f0f | ||
|
|
4ab60080f6 | ||
|
|
a3a198a32d | ||
|
|
b54044cbc5 | ||
|
|
ebc48fde90 | ||
|
|
7c50db426f | ||
|
|
a1bde07ee1 | ||
|
|
fba01e35a3 | ||
|
|
16bf83274f | ||
|
|
d8c8f8084b | ||
|
|
2590f041c2 | ||
|
|
790cb5ce1d | ||
|
|
7b7f877fa5 | ||
|
|
1d86154012 | ||
|
|
03fbb4933a | ||
|
|
bb1d3cd273 | ||
|
|
fa978dc6d0 | ||
|
|
3da081b40c | ||
|
|
0d5a907517 | ||
|
|
ef7a486fd4 | ||
|
|
54915dcea1 | ||
|
|
86b91916a7 | ||
|
|
4144a29608 | ||
|
|
c41b635276 | ||
|
|
c118c77c14 | ||
|
|
626652c421 | ||
|
|
fb42b80276 | ||
|
|
00baf60970 | ||
|
|
0230106a1e | ||
|
|
169b9451ed | ||
|
|
ae65acdd16 | ||
|
|
b5ac760af2 | ||
|
|
321eaecd21 | ||
|
|
0e7d66849d | ||
|
|
25208c2b5c | ||
|
|
f3637e2426 | ||
|
|
dd6106447f | ||
|
|
d1cf45c6a1 | ||
|
|
07f3d841b3 | ||
|
|
b488f86160 | ||
|
|
e64c3a3c77 | ||
|
|
576a993faf | ||
|
|
c77c5a6d21 | ||
|
|
ae16680fc2 | ||
|
|
807c0a1e2e | ||
|
|
96a2119a05 | ||
|
|
7e940d6304 | ||
|
|
11402cc2a6 | ||
|
|
975d8b0ba0 | ||
|
|
e349898d9e | ||
|
|
a8802da5a7 | ||
|
|
19993dfc04 | ||
|
|
e99dd442e1 | ||
|
|
d886113f27 | ||
|
|
ea9b759887 | ||
|
|
0e784be231 | ||
|
|
d0ab73eeb2 | ||
|
|
2193129818 | ||
|
|
f84c6d319a | ||
|
|
1f667818db | ||
|
|
784893048d | ||
|
|
6ec516dc3d | ||
|
|
1c7da30caf | ||
|
|
87337f31a5 | ||
|
|
cf5262a86e | ||
|
|
d6be0ffa5b | ||
|
|
1df8241a01 | ||
|
|
43e5eae658 | ||
|
|
71b4560ff8 | ||
|
|
f6bb2fe5d1 | ||
|
|
272aafe504 | ||
|
|
580d782c56 | ||
|
|
238ddc7ad9 | ||
|
|
5559e78bc0 | ||
|
|
6cc38df4e9 |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '14.3.1'
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '14.3.1'
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,9 +1,13 @@
|
||||
## Build 179
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
## Build 181
|
||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
||||
* Fix issues with empty comments (by @stonerl)
|
||||
* Improved Invidious comments (by @stonerl)
|
||||
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
|
||||
* Updated localizations
|
||||
* Updated dependencies
|
||||
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
* Added Settings Import/Export
|
||||
* Export all settings, instances and accounts
|
||||
* Import selected elements from the file
|
||||
@@ -11,10 +15,11 @@
|
||||
* Import via URL for tvOS
|
||||
* Added Controls setting "Action button labels" icon or icon and text
|
||||
* Added Advanced setting for MPV: "deinterlace"
|
||||
* Updated dependencies (mpvkit 0.37.0)
|
||||
* Add help text to all header buttons (by @rickykresslein)
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
* Fixed reported crash
|
||||
* Other minor changes and improvements
|
||||
|
||||
**Big thanks to the past, current and future project contributors!**
|
||||
**Big thanks to the current, past and future project contributors!**
|
||||
|
||||
24
Gemfile.lock
24
Gemfile.lock
@@ -54,19 +54,19 @@ GEM
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.15)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.894.0)
|
||||
aws-sdk-core (3.191.3)
|
||||
aws-partitions (1.906.0)
|
||||
aws-sdk-core (3.191.6)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-kms (1.78.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-s3 (1.146.1)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
@@ -85,7 +85,7 @@ GEM
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.109.0)
|
||||
excon (0.110.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@@ -114,7 +114,7 @@ GEM
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.0)
|
||||
fastimage (2.3.1)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
@@ -132,12 +132,12 @@ GEM
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-core (1.7.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-errors (1.4.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
@@ -158,7 +158,7 @@ GEM
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.1)
|
||||
jwt (2.8.0)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
@@ -170,8 +170,8 @@ GEM
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.4)
|
||||
rake (13.1.0)
|
||||
public_suffix (5.0.5)
|
||||
rake (13.2.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
|
||||
@@ -123,7 +123,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"].forEach { type in
|
||||
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
@@ -654,7 +654,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -691,6 +692,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
||||
let decodedContent = decodeHtml(htmlContent)
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
@@ -699,12 +702,25 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: details["content"]?.string ?? "",
|
||||
text: decodedContent,
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .invidious, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
||||
if let data = htmlEncodedString.data(using: .utf8) {
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
return htmlEncodedString
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
|
||||
@@ -113,8 +113,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
||||
guard let details = content?.json.dictionaryValue else {
|
||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
||||
}
|
||||
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
@@ -663,16 +666,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
for videoStream in videoStreams {
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
@@ -684,6 +687,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
@@ -693,7 +697,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -724,15 +729,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let commentorUrl = details["commentorUrl"]?.string
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
|
||||
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
|
||||
let commentId = details["commentId"]?.string ?? UUID().uuidString
|
||||
|
||||
// Sanity checks: return nil if required data is missing
|
||||
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Comment(
|
||||
id: details["commentId"]?.string ?? UUID().uuidString,
|
||||
id: commentId,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||
time: details["commentedTime"]?.string ?? "",
|
||||
pinned: details["pinned"]?.bool ?? false,
|
||||
hearted: details["hearted"]?.bool ?? false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: extractCommentText(from: details["commentText"]?.stringValue),
|
||||
text: commentText,
|
||||
repliesPage: details["repliesPage"]?.string,
|
||||
channel: Channel(app: .piped, id: channelId, name: author)
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ protocol VideosAPI {
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
func shareURL(_ item: ContentItem, frontendURL: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
@@ -108,15 +108,19 @@ extension VideosAPI {
|
||||
.onFailure { failureHandler?($0) }
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString) {
|
||||
urlComponents = URLComponents(URL: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
guard var urlComponents else {
|
||||
return nil
|
||||
}
|
||||
|
||||
urlComponents.host = frontendHost
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
switch item.contentType {
|
||||
|
||||
@@ -35,26 +35,22 @@ final class CommentsModel: ObservableObject {
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard let video = player.currentVideo else { return }
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
guard firstPage || nextPageAvailable else { return }
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
guard let self = self else { return }
|
||||
if let commentsPage: CommentsPage = response.typedContent() {
|
||||
self.all += commentsPage.comments
|
||||
self.nextPage = commentsPage.nextPage
|
||||
self.disabled = commentsPage.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] requestError in
|
||||
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
|
||||
.onFailure { [weak self] _ in
|
||||
self?.disabled = true
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
|
||||
@@ -15,7 +15,11 @@ final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton],
|
||||
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,17 @@ struct HistorySettingsGroupImporter {
|
||||
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
|
||||
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
|
||||
}
|
||||
|
||||
if let showRecents = json["showRecents"].bool {
|
||||
Defaults[.showRecents] = showRecents
|
||||
}
|
||||
|
||||
if let limitRecents = json["limitRecents"].bool {
|
||||
Defaults[.limitRecents] = limitRecents
|
||||
}
|
||||
|
||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -201,29 +201,6 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||
.max { lhs, rhs in
|
||||
let predicates: [AreInIncreasingOrder] = [
|
||||
{ $0.resolution < $1.resolution },
|
||||
{ $0.format > $1.format }
|
||||
]
|
||||
|
||||
for predicate in predicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
} ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
}
|
||||
@@ -254,7 +231,18 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.handleAudioSessionInterruption(_:)),
|
||||
name: AVAudioSession.interruptionNotification,
|
||||
object: nil
|
||||
)
|
||||
} catch {
|
||||
self.logger.error("Error setting up audio session: \(error)")
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -264,6 +252,10 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
// Captions should only be displayed when selected by the user,
|
||||
// not when the video starts. So, we remove them.
|
||||
self.client?.removeSubs()
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@@ -309,7 +301,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
@@ -321,7 +313,7 @@ final class MPVBackend: PlayerBackend {
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||
|
||||
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
@@ -330,7 +322,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
@@ -354,6 +346,13 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
setRate(model.currentRate)
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
@@ -519,8 +518,6 @@ final class MPVBackend: PlayerBackend {
|
||||
guard client.eofReached else {
|
||||
return
|
||||
}
|
||||
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
@@ -627,4 +624,31 @@ final class MPVBackend: PlayerBackend {
|
||||
logger.info("MPV backend received unhandled property: \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
logger.info("Audio session interruption received.")
|
||||
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
else {
|
||||
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
||||
return
|
||||
}
|
||||
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
|
||||
logger.info("Interruption type received: \(String(describing: type))")
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
pause()
|
||||
logger.info("Audio session interrupted.")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum SeekType: Equatable {
|
||||
case chapterSkip(String)
|
||||
case segmentSkip(String)
|
||||
case segmentRestore
|
||||
case userInteracted
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -54,6 +54,32 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||
}
|
||||
|
||||
// These values are an approximation.
|
||||
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
||||
|
||||
var bitrate: Int {
|
||||
switch self {
|
||||
case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30:
|
||||
return 56000000 // 56 Mbit/s
|
||||
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30:
|
||||
return 24000000 // 24 Mbit/s
|
||||
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30:
|
||||
return 12000000 // 12 Mbit/s
|
||||
case .hd720p60, .hd720p50, .hd720p48, .hd720p30:
|
||||
return 9500000 // 9.5 Mbit/s
|
||||
case .sd480p30:
|
||||
return 4000000 // 4 Mbit/s
|
||||
case .sd360p30:
|
||||
return 1500000 // 1.5 Mbit/s
|
||||
case .sd240p30:
|
||||
return 1000000 // 1 Mbit/s
|
||||
case .sd144p30:
|
||||
return 600000 // 0.6 Mbit/s
|
||||
case .unknown:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
||||
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
||||
}
|
||||
@@ -64,7 +90,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
enum Kind: String, Comparable {
|
||||
case stream, adaptive, hls
|
||||
case hls, adaptive, stream
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -82,37 +108,23 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
enum Format: String {
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
case av1
|
||||
case webm
|
||||
case hls
|
||||
case stream
|
||||
case unknown
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .mp4:
|
||||
return 0
|
||||
case .avc1:
|
||||
return 1
|
||||
case .av1:
|
||||
return 2
|
||||
case .webm:
|
||||
return 3
|
||||
case .unknown:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
case .hls:
|
||||
return "adaptive (HLS)"
|
||||
case .stream:
|
||||
return "Stream"
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -121,17 +133,23 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
}
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
}
|
||||
if lowercased.contains("av01") {
|
||||
return .av1
|
||||
}
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("stream") {
|
||||
return .stream
|
||||
}
|
||||
if lowercased.contains("hls") {
|
||||
return .hls
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
@@ -151,6 +169,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var encoding: String?
|
||||
var videoFormat: String?
|
||||
var bitrate: Int?
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@@ -161,7 +180,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil
|
||||
videoFormat: String? = nil,
|
||||
bitrate: Int? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@@ -172,6 +192,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
@@ -184,22 +205,31 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var quality: String {
|
||||
guard localURL.isNil else { return "Opened File" }
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
|
||||
return resolution.name
|
||||
}
|
||||
|
||||
var shortQuality: String {
|
||||
guard localURL.isNil else { return "File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "HLS"
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
return resolution?.name ?? "?"
|
||||
|
||||
if kind == .stream {
|
||||
return resolution.name
|
||||
}
|
||||
return resolutionAndFormat
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard localURL.isNil else { return resolutionAndFormat }
|
||||
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
||||
return "\(resolutionAndFormat)\(instanceString)"
|
||||
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
|
||||
}
|
||||
|
||||
var resolutionAndFormat: String {
|
||||
|
||||
@@ -5,6 +5,23 @@ import SwiftUI
|
||||
enum Constants {
|
||||
static let yatteeProtocol = "yattee://"
|
||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||
|
||||
static var isAppleTV: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .tv
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isMac: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .mac
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isIPhone: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .phone
|
||||
|
||||
@@ -77,6 +77,8 @@ extension Defaults.Keys {
|
||||
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
|
||||
|
||||
static let showChapters = Key<Bool>("showChapters", default: true)
|
||||
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
|
||||
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: true)
|
||||
static let expandChapters = Key<Bool>("expandChapters", default: true)
|
||||
static let showRelated = Key<Bool>("showRelated", default: true)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
@@ -165,11 +167,11 @@ extension Defaults.Keys {
|
||||
|
||||
// MARK: GROUP - Quality
|
||||
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream])
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream])
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
|
||||
|
||||
#if os(iOS)
|
||||
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
|
||||
@@ -225,6 +227,9 @@ extension Defaults.Keys {
|
||||
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showRecents = Key<Bool>("showRecents", default: true)
|
||||
static let limitRecents = Key<Bool>("limitRecents", default: false)
|
||||
static let limitRecentsAmount = Key<Int>("limitRecentsAmount", default: 10)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
|
||||
|
||||
@@ -240,6 +245,10 @@ extension Defaults.Keys {
|
||||
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
|
||||
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
|
||||
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
|
||||
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
|
||||
|
||||
// MARK: GROUP - Locations
|
||||
|
||||
@@ -577,3 +586,26 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case horizontalCells
|
||||
case list
|
||||
}
|
||||
|
||||
enum SponsorBlockColors: String {
|
||||
case sponsor = "#00D400" // Green
|
||||
case selfpromo = "#FFFF00" // Yellow
|
||||
case interaction = "#CC00FF" // Purple
|
||||
case intro = "#00FFFF" // Cyan
|
||||
case outro = "#0202ED" // Dark Blue
|
||||
case preview = "#008FD6" // Light Blue
|
||||
case filler = "#7300FF" // Violet
|
||||
case music_offtopic = "#FF9900" // Orange
|
||||
|
||||
// Define all cases, can be used to iterate over the colors
|
||||
static let allCases: [SponsorBlockColors] = [.sponsor, .selfpromo, .interaction, .intro, .outro, .preview, .filler, .music_offtopic]
|
||||
|
||||
// Create a dictionary with the category names as keys and colors as values
|
||||
static let dictionary: [String: String] = {
|
||||
var dict = [String: String]()
|
||||
for item in allCases {
|
||||
dict[String(describing: item)] = item.rawValue
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ struct AppSidebarRecents: View {
|
||||
var recents = RecentsModel.shared
|
||||
|
||||
@Default(.recentlyOpened) private var recentItems
|
||||
@Default(.limitRecents) private var limitRecents
|
||||
@Default(.limitRecentsAmount) private var limitRecentsAmount
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !recentItems.isEmpty {
|
||||
Section(header: Text("Recents")) {
|
||||
ForEach(recentItems.reversed()) { recent in
|
||||
ForEach(recentItems.reversed().prefix(limitRecents ? limitRecentsAmount : recentItems.count)) { recent in
|
||||
Group {
|
||||
switch recent.type {
|
||||
case .channel:
|
||||
|
||||
@@ -13,6 +13,7 @@ struct Sidebar: View {
|
||||
@Default(.showDocuments) private var showDocuments
|
||||
#endif
|
||||
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
||||
@Default(.showRecents) private var showRecents
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scrollView in
|
||||
@@ -20,8 +21,10 @@ struct Sidebar: View {
|
||||
mainNavigationLinks
|
||||
|
||||
if !accounts.isEmpty {
|
||||
AppSidebarRecents()
|
||||
.id("recentlyOpened")
|
||||
if showRecents {
|
||||
AppSidebarRecents()
|
||||
.id("recentlyOpened")
|
||||
}
|
||||
|
||||
if accounts.api.signedIn {
|
||||
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -433,12 +433,12 @@ struct PlaybackSettings: View {
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions".localized(), selection: $player.captions) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available")
|
||||
Text("Not available").tag(Captions?.none)
|
||||
} else {
|
||||
Text("Disabled").tag(Captions?.none)
|
||||
}
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
|
||||
@@ -9,13 +9,18 @@ import SwiftUI
|
||||
var chapterIndex: Int
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var showThumbnail: Bool
|
||||
|
||||
var isCurrentChapter: Bool {
|
||||
player.currentChapterIndex == chapterIndex
|
||||
if let currentChapterIndex = player.currentChapterIndex {
|
||||
return currentChapterIndex == chapterIndex
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
|
||||
}) {
|
||||
Group {
|
||||
verticalChapter
|
||||
@@ -27,7 +32,7 @@ import SwiftUI
|
||||
|
||||
var verticalChapter: some View {
|
||||
VStack(spacing: 12) {
|
||||
if !chapter.image.isNil {
|
||||
if !chapter.image.isNil, showThumbnail {
|
||||
smallImage(chapter)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -40,7 +45,7 @@ import SwiftUI
|
||||
.font(.system(.subheadline).monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: !chapter.image.isNil ? Self.thumbnailWidth : nil, alignment: .leading)
|
||||
.frame(maxWidth: !chapter.image.isNil && showThumbnail ? Self.thumbnailWidth : nil, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ import SwiftUI
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
|
||||
} label: {
|
||||
Group {
|
||||
horizontalChapter
|
||||
@@ -126,7 +131,7 @@ struct ChapterView_Preview: PreviewProvider {
|
||||
ChapterViewTVOS(chapter: .init(title: "Chapter", start: 30))
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#else
|
||||
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0)
|
||||
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0, showThumbnail: true)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,9 @@ struct HistorySettings: View {
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveLastPlayed) private var saveLastPlayed
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showRecents) private var showRecents
|
||||
@Default(.limitRecents) private var limitRecents
|
||||
@Default(.limitRecentsAmount) private var limitRecentsAmount
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
||||
@@ -56,6 +59,26 @@ struct HistorySettings: View {
|
||||
Section(header: SettingsHeader(text: "History".localized())) {
|
||||
Toggle("Save history of searches, channels and playlists", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
Toggle("Show recents in sidebar", isOn: $showRecents)
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
.disabled(!showRecents)
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
#else
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.disabled(!showRecents)
|
||||
HStack {
|
||||
Text("Recents shown")
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
#endif
|
||||
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
Toggle("Keep last played video in the queue after restart", isOn: $saveLastPlayed)
|
||||
@@ -169,6 +192,71 @@ struct HistorySettings: View {
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func counterButtons(for _value: Binding<Int>) -> some View {
|
||||
var value: Binding<Int> {
|
||||
Binding(
|
||||
get: { return _value.wrappedValue },
|
||||
set: {
|
||||
if $0 < 1 {
|
||||
_value.wrappedValue = 1
|
||||
} else {
|
||||
_value.wrappedValue = $0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return HStack {
|
||||
#if !os(tvOS)
|
||||
Label("Minus", systemImage: "minus")
|
||||
.imageScale(.large)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(limitRecents ? .accentColor : .gray)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
#if os(iOS)
|
||||
.frame(minHeight: 35)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
value.wrappedValue -= 1
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
let textFieldWidth = 100.00
|
||||
#else
|
||||
let textFieldWidth = 30.00
|
||||
#endif
|
||||
|
||||
TextField("Duration", value: value, formatter: NumberFormatter())
|
||||
.frame(width: textFieldWidth, alignment: .trailing)
|
||||
.multilineTextAlignment(.center)
|
||||
.labelsHidden()
|
||||
.foregroundColor(limitRecents ? .accentColor : .gray)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
Label("Plus", systemImage: "plus")
|
||||
.imageScale(.large)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(limitRecents ? .accentColor : .gray)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
#if os(iOS)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
value.wrappedValue += 1
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HistorySettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -66,6 +66,7 @@ struct HomeSettings: View {
|
||||
.font(.system(size: 30))
|
||||
#endif
|
||||
}
|
||||
.help("Add to Favorites")
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettings: View {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct FormatState: Equatable {
|
||||
let format: QualityProfile.Format
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
struct QualityProfileForm: View {
|
||||
@Binding var qualityProfileID: QualityProfile.ID?
|
||||
|
||||
@@ -15,6 +20,7 @@ struct QualityProfileForm: View {
|
||||
@State private var backend = PlayerBackendType.mpv
|
||||
@State private var resolution = ResolutionSetting.hd1080p60
|
||||
@State private var formats = [QualityProfile.Format]()
|
||||
@State private var orderedFormats: [FormatState] = []
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfiles
|
||||
|
||||
@@ -26,6 +32,7 @@ struct QualityProfileForm: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
// swiftlint:disable trailing_closure
|
||||
var body: some View {
|
||||
VStack {
|
||||
Group {
|
||||
@@ -40,8 +47,11 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
|
||||
.onAppear(perform: initializeForm)
|
||||
.onChange(of: backend, perform: backendChanged)
|
||||
.onChange(of: formats) { _ in validate() }
|
||||
.onChange(of: backend, perform: { _ in backendChanged(self.backend); updateActiveFormats(); validate() })
|
||||
|
||||
.onChange(of: name, perform: { _ in validate() })
|
||||
.onChange(of: resolution, perform: { _ in validate() })
|
||||
.onChange(of: orderedFormats, perform: { _ in validate() })
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
@@ -53,6 +63,8 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// swiftlint:enable trailing_closure
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
|
||||
@@ -124,9 +136,20 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
|
||||
var formatsFooter: some View {
|
||||
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Formats can be reordered and will be selected in this order.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("**Note:** HLS is an adaptive format where specific resolution settings don't apply.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top)
|
||||
Text("Yattee attempts to match the quality that is closest to the set resolution, but exact results cannot be guaranteed.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 0.1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@ViewBuilder var qualityPicker: some View {
|
||||
@@ -199,17 +222,25 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var filteredFormatList: some View {
|
||||
ForEach(Array(orderedFormats.enumerated()), id: \.element.format) { idx, element in
|
||||
let format = element.format
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: element.isActive
|
||||
) { value in
|
||||
orderedFormats[idx].isActive = value
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
orderedFormats.move(fromOffsets: source, toOffset: destination)
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var formatsPicker: some View {
|
||||
#if os(macOS)
|
||||
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
let list = filteredFormatList
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
@@ -222,28 +253,19 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
filteredFormatList
|
||||
#endif
|
||||
}
|
||||
|
||||
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
|
||||
(initialized || qualityProfile.isNil ? formats : qualityProfile.formats).contains(format)
|
||||
return orderedFormats.first { $0.format == format }?.isActive ?? false
|
||||
}
|
||||
|
||||
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
|
||||
if let index = formats.firstIndex(where: { $0 == format }), !value {
|
||||
formats.remove(at: index)
|
||||
} else if value {
|
||||
formats.append(format)
|
||||
if let index = orderedFormats.firstIndex(where: { $0.format == format }) {
|
||||
orderedFormats[index].isActive = value
|
||||
}
|
||||
validate() // Check validity after a toggle operation
|
||||
}
|
||||
|
||||
var footer: some View {
|
||||
@@ -274,34 +296,52 @@ struct QualityProfileForm: View {
|
||||
return !avPlayerFormats.contains(format)
|
||||
}
|
||||
|
||||
func updateActiveFormats() {
|
||||
for (index, format) in orderedFormats.enumerated() where isFormatDisabled(format.format) {
|
||||
orderedFormats[index].isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
||||
guard backend == .appleAVPlayer else { return false }
|
||||
|
||||
return resolution.value > .hd720p30
|
||||
return resolution.value > .hd1080p60
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
guard editing else {
|
||||
validate()
|
||||
return
|
||||
if editing {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.orderedFormats = qualityProfile.order.map { order in
|
||||
let format = QualityProfile.Format.allCases[order]
|
||||
let isActive = qualityProfile.formats.contains(format)
|
||||
return FormatState(format: format, isActive: isActive)
|
||||
}
|
||||
self.initialized = true
|
||||
}
|
||||
} else {
|
||||
name = ""
|
||||
backend = .mpv
|
||||
resolution = .hd720p60
|
||||
orderedFormats = QualityProfile.Format.allCases.map {
|
||||
FormatState(format: $0, isActive: true)
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.formats = .init(qualityProfile.formats)
|
||||
self.initialized = true
|
||||
}
|
||||
|
||||
validate()
|
||||
}
|
||||
|
||||
func backendChanged(_: PlayerBackendType) {
|
||||
formats.filter { isFormatDisabled($0) }.forEach { format in
|
||||
if let index = formats.firstIndex(where: { $0 == format }) {
|
||||
formats.remove(at: index)
|
||||
}
|
||||
let defaultFormats = QualityProfile.Format.allCases.map {
|
||||
FormatState(format: $0, isActive: true)
|
||||
}
|
||||
|
||||
if backend == .appleAVPlayer {
|
||||
orderedFormats = orderedFormats.filter { !isFormatDisabled($0.format) }
|
||||
} else {
|
||||
orderedFormats = defaultFormats
|
||||
}
|
||||
|
||||
if isResolutionDisabled(resolution),
|
||||
@@ -312,20 +352,33 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
|
||||
func validate() {
|
||||
valid = !formats.isEmpty
|
||||
if !initialized {
|
||||
valid = false
|
||||
} else if editing {
|
||||
let savedOrderFormats = qualityProfile.order.map { order in
|
||||
let format = QualityProfile.Format.allCases[order]
|
||||
let isActive = qualityProfile.formats.contains(format)
|
||||
return FormatState(format: format, isActive: isActive)
|
||||
}
|
||||
valid = name != qualityProfile.name
|
||||
|| backend != qualityProfile.backend
|
||||
|| resolution != qualityProfile.resolution
|
||||
|| orderedFormats != savedOrderFormats
|
||||
} else { valid = true }
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else { return }
|
||||
|
||||
formats = formats.unique()
|
||||
let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format }
|
||||
|
||||
let formProfile = QualityProfile(
|
||||
id: qualityProfile?.id ?? UUID().uuidString,
|
||||
name: name,
|
||||
backend: backend,
|
||||
resolution: resolution,
|
||||
formats: Array(formats)
|
||||
formats: activeFormats,
|
||||
order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! }
|
||||
)
|
||||
|
||||
if editing {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SponsorBlockSettings: View {
|
||||
@ObservedObject private var settings = SettingsModel.shared
|
||||
|
||||
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
|
||||
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
|
||||
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
|
||||
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
@@ -35,41 +41,70 @@ struct SponsorBlockSettings: View {
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Categories to Skip".localized()), footer: categoriesDetails) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
Section(header: Text("Playback")) {
|
||||
Toggle("Categories in timeline", isOn: $showCategoriesInTimeline)
|
||||
Toggle("Post-skip notice", isOn: $showNoticeAfterSkip)
|
||||
Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved)
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Section(header: SettingsHeader(text: "Categories to Skip".localized())) {
|
||||
categoryRows
|
||||
}
|
||||
colorSection
|
||||
|
||||
Button {
|
||||
settings.presentAlert(
|
||||
Alert(
|
||||
title: Text("Restore Default Colors?"),
|
||||
message: Text("This action will reset all custom colors back to their original defaults. " +
|
||||
"Any custom color changes you've made will be lost."),
|
||||
primaryButton: .destructive(Text("Restore")) {
|
||||
resetColors()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Restore Default Colors …")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section(footer: categoriesDetails) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var colorSection: some View {
|
||||
Section(header: SettingsHeader(text: "Colors for Categories")) {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
LazyVStack(alignment: .leading) {
|
||||
ColorPicker(
|
||||
SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selection: Binding(
|
||||
get: { getColor(for: category) },
|
||||
set: { setColor($0, for: category) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var categoryRows: some View {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
LazyVStack(alignment: .leading) {
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,17 +114,17 @@ struct SponsorBlockSettings: View {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
Text(SponsorBlockAPI.categoryDescription(category) ?? "Category")
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 0.5)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
Text(SponsorBlockAPI.categoryDetails(category) ?? "Details")
|
||||
.padding(.bottom, 3)
|
||||
.padding(.bottom, 10)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
@@ -99,6 +134,40 @@ struct SponsorBlockSettings: View {
|
||||
sponsorBlockCategories.insert(category)
|
||||
}
|
||||
}
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
private func setColor(_ color: Color, for category: String) {
|
||||
let uiColor = UIColor(color)
|
||||
|
||||
// swiftlint:disable no_cgfloat
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
// swiftlint:enable no_cgfloat
|
||||
|
||||
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
let r = Int(red * 255.0)
|
||||
let g = Int(green * 255.0)
|
||||
let b = Int(blue * 255.0)
|
||||
|
||||
let rgbValue = (r << 16) | (g << 8) | b
|
||||
sponsorBlockColors[category] = String(format: "#%06x", rgbValue)
|
||||
}
|
||||
|
||||
private func resetColors() {
|
||||
sponsorBlockColors = SponsorBlockColors.dictionary
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -113,6 +113,7 @@ struct SubscriptionsView: View {
|
||||
} label: {
|
||||
Label("Play all unwatched", systemImage: "play")
|
||||
}
|
||||
.help("Play all unwatched")
|
||||
.disabled(!feed.canPlayUnwatchedFeed)
|
||||
}
|
||||
|
||||
@@ -130,6 +131,7 @@ struct SubscriptionsView: View {
|
||||
} label: {
|
||||
Label("Mark all as watched", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.help("Mark all as watched")
|
||||
.disabled(!feed.canMarkAllFeedAsWatched)
|
||||
}
|
||||
|
||||
@@ -139,6 +141,7 @@ struct SubscriptionsView: View {
|
||||
} label: {
|
||||
Label("Mark all as unwatched", systemImage: "checkmark.circle")
|
||||
}
|
||||
.help("Mark all as unwatched")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ struct FavoriteButton: View {
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
}
|
||||
.help(isFavorite ? "Remove from Favorites" : "Add to Favorites")
|
||||
.disabled(item.isNil)
|
||||
.onAppear {
|
||||
isFavorite = item.isNil ? false : favorites.contains(item)
|
||||
|
||||
@@ -11,6 +11,7 @@ struct HomeSettingsButton: View {
|
||||
}
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
.help("Home Settings")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ struct ListingStyleButtons: View {
|
||||
.imageScale(.small)
|
||||
#endif
|
||||
}
|
||||
.help(listingStyle == .cells ? "List" : "Cells")
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
label
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.help("Share")
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 60)
|
||||
#endif
|
||||
@@ -76,7 +77,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
|
||||
private var youtubeActions: some View {
|
||||
Group {
|
||||
if let url = accounts.api.shareURL(contentItem, frontendHost: "www.youtube.com") {
|
||||
if let url = accounts.api.shareURL(contentItem, frontendURL: "https://www.youtube.com") {
|
||||
Button(labelForShareURL("YouTube")) {
|
||||
shareAction(url)
|
||||
}
|
||||
@@ -86,7 +87,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
shareAction(
|
||||
accounts.api.shareURL(
|
||||
contentItem,
|
||||
frontendHost: "www.youtube.com",
|
||||
frontendURL: "https://www.youtube.com",
|
||||
time: player.backend.currentTime
|
||||
)!
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"Add Account" = "إضافة حساب";
|
||||
"Add Account..." = "إضافة حساب…";
|
||||
"Add Location" = "إضافة موقع";
|
||||
"Add Location..." = "إضافة موقع...";
|
||||
"Add Location..." = "أضِف موقع..";
|
||||
"%@ Playlist" = "قائمة تشغيل %@";
|
||||
"%@ Channel" = "قناة %@";
|
||||
"%@ subscribers" = "مشتركين %@";
|
||||
@@ -387,7 +387,7 @@
|
||||
"Backend" = "الواجهة الخلفية";
|
||||
"Badge" = "الشارة";
|
||||
"Close PiP when starting playing other video" = "غلق الفيديو المصغر عند بدء تشغيل فيديو آخر";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).\n";
|
||||
"Filter" = " عامل التصفية";
|
||||
"Frontend URL" = "عنوان URL للواجهة الأمامية";
|
||||
"Fullscreen size" = "حجم ملء الشاشة";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"Add Account" = "Add Account";
|
||||
"Add Account..." = "Add Account...";
|
||||
"Add Location" = "Add Location";
|
||||
"Add Location..." = "Add Location...";
|
||||
"Add Location..." = "Add Location..";
|
||||
"Add profile..." = "Add profile...";
|
||||
"Add Quality Profile" = "Add Quality Profile";
|
||||
"Add to %@" = "Add to %@";
|
||||
|
||||
@@ -112,12 +112,12 @@
|
||||
"Help" = "Ayuda";
|
||||
"Hide sidebar" = "Ocultar barra lateral";
|
||||
"Add Location" = "Añadir ubicación";
|
||||
"Add Location..." = "Añadir ubicación...";
|
||||
"Add Location..." = "Añadir ubicación..";
|
||||
"Decrease rate" = "Tasa de disminución";
|
||||
"Decreased opacity" = "Opacidad disminuida";
|
||||
"High" = "Alto";
|
||||
"%lld videos" = "%lld videos";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Recordatorios explícitos para marquen \"me gusta\", se suscriban o interactúen con ellos en cualquier plataforma de pago o gratuita (por ejemplo, haciendo clic en un vídeo).";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Recordatorios explícitos para que indiquen les guste, se suscriban o interactúen con ellos en cualquier plataforma de pago o gratuita (por ejemplo, haciendo clic en un vídeo).\n";
|
||||
"Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply)." = "Los formatos se seleccionarán en orden como se indica.\nHLS es un formato adaptable (no aplica la configuración de resolución).";
|
||||
"Fullscreen size" = "Tamaño de pantalla completa";
|
||||
"Badge & Decreased opacity" = "Insignia y opacidad dosminuída";
|
||||
|
||||
@@ -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" = "پیوند %@ با مهرزمان کپی کنید";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
" subscribers" = " abonnés";
|
||||
"Add Location..." = "Ajouter une instance…";
|
||||
"Add Location..." = "Ajouter une instance..";
|
||||
"Add profile..." = "Ajouter un profil…";
|
||||
"Add Quality Profile" = "Ajouter un profil de qualité";
|
||||
"Delete" = "Supprimer";
|
||||
@@ -264,7 +264,7 @@
|
||||
"Don't use public locations" = "Ne pas utiliser d'instances publiques";
|
||||
"Enable Return YouTube Dislike" = "Activer Return YouTube Dislike";
|
||||
"Enter fullscreen in landscape" = "Entrer en plein écran en mode paysage";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.\n";
|
||||
"Frontend URL" = "URL frontale";
|
||||
"Public Locations" = "Instances publiques";
|
||||
"Public Manifest" = "Manifeste publique";
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
"For custom locations you can configure Frontend URL in Locations settings" = "場所を指定するには、場所の設定からフロントエンドのURLを設定します";
|
||||
"Public Locations" = "公開された場所";
|
||||
"Switch to public locations" = "公開された場所に切り替え";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "有料/無料のプラットフォームかを問わず、いいね、登録などを明示的に操作を促す(例: 動画をクリック)。";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "有料/無料のプラットフォームかを問わず、いいね、登録などを明示的に操作を促す(例: 動画をクリック)。\n";
|
||||
"Proxy videos" = "動画閲覧にプロキシ使用";
|
||||
"Sections" = "表示するボタン";
|
||||
"System controls show buttons for %@" = "システム制御「%@」用のボタンを表示";
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"I want to ask a question" = "Ik wil een vraag stellen";
|
||||
"If you are interested what's coming in future updates, you can track project Milestones." = "Als je geïnteresseerd bent in toekomstige updates, kan je Milestones van het project volgen.";
|
||||
"Increase rate" = "Verhoog tempo";
|
||||
"Info" = "";
|
||||
"Info" = "Info";
|
||||
"Instance of current account" = "Instantie van huidig account";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@@ -259,3 +259,38 @@
|
||||
"Save history of played videos" = "Sla geschiedenis van afgespeelde videos op";
|
||||
"Save history of searches, channels and playlists" = "Sla geschiedenis van zoekopdrachten, kanalen en afspeellijsten op";
|
||||
"Search" = "Zoeken";
|
||||
"Segments typically found at the start of a video that include an animation, still frame or clip which are also seen in other videos by the same creator." = "Stukken normaal in het begin van een video met een animatie, stil plaatje, of stukje van een andere video van dezelfde maker.";
|
||||
"Discord Server" = "Discord Server";
|
||||
"Enable logging" = "Loggen inschakelen";
|
||||
"Interface" = "Interface";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Intro" = "Intro";
|
||||
"LIVE" = "LIVE";
|
||||
"Matrix Chat" = "Matrix Chat";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "Outro";
|
||||
"Proxy videos" = "Video's door proxyserver leiden";
|
||||
"Reset" = "Herstellen";
|
||||
"Search history is empty" = "Zoekgeschiedenis is leeg";
|
||||
"Search..." = "Zoeken...";
|
||||
"Sections" = "Secties";
|
||||
"Seek gesture sensitivity" = "Zoek gebaar gevoeligheid";
|
||||
"Seek gesture speed" = "Zoek gebaar snelheid";
|
||||
"Seek with horizontal swipe on video" = "Scrollen met horizontale sleep op video";
|
||||
"Select location closest to you:" = "Selecteer de dichtstbijzijnde locatie:";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Self-promotion" = "Zelfpromotie";
|
||||
"Settings" = "Instellingen";
|
||||
"Share %@ link" = "%@ link delen";
|
||||
"Share %@ link with time" = "%@ link met tijd delen";
|
||||
"Share..." = "Delen...";
|
||||
|
||||
/* Video duration filter in search */
|
||||
"Short" = "Kort";
|
||||
"Show account username" = "Gebruikersnaam van account laten zien";
|
||||
"Show anonymous accounts" = "Anonieme accounts laten zien";
|
||||
"Show channel name" = "Naam van kanaal laten zien";
|
||||
"Show history" = "Geschiedenis laten zien";
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"Could not extract channel information" = "Não pôde extrair informação do canal";
|
||||
"For custom locations you can configure Frontend URL in Locations settings" = "Para localizações personalizadas você pode configurar URL do frontend nas configurações de localização";
|
||||
"Add Location" = "Adicionar Localização";
|
||||
"Add Location..." = "Adicionar Localização…";
|
||||
"Add Location..." = "Adicionar Localização..";
|
||||
"For videos which feature music as the primary content." = "Para vídeos que têm música como conteúdo principal.";
|
||||
"Close video after playing last in the queue" = "Fechar vídeo depois de tocar o último na fila";
|
||||
"Clear Search History" = "Limpar Histórico de Busca";
|
||||
@@ -406,7 +406,7 @@
|
||||
"Country" = "País";
|
||||
"Clear All" = "Limpar Tudo";
|
||||
"Clear All Recents" = "Limpar Todos os Recentes";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clique em um vídeo).";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar em um vídeo).\n";
|
||||
"Duration" = "Duração";
|
||||
"Edit Quality Profile" = "Editar Perfil de Qualidade";
|
||||
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "Discussões acontecem no Discord e no Matrix. É um bom lugar para perguntas gerais.";
|
||||
|
||||
@@ -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…";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"Accounts are not supported for the application of this instance" = "Conturile nu sunt acceptate pentru aplicaţia acestei instanțe";
|
||||
"%lld videos" = "%lld videoclipuri";
|
||||
"Add Location" = "Adaugă locație";
|
||||
"Add Location..." = "Adaugă locație...";
|
||||
"Add Location..." = "Adaugă locație..";
|
||||
"Add profile..." = "Adaugă profil...";
|
||||
"Add to %@" = "Adaugă la %@";
|
||||
"Add to Playlist" = "Adaugă la playlist";
|
||||
@@ -62,7 +62,7 @@
|
||||
"Edit" = "Editați";
|
||||
"Edit Playlist" = "Editați Playlist";
|
||||
"Enter fullscreen in landscape" = "Introduceți ecranul complet în peisaj";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).\n";
|
||||
"Find Other" = "Găsiți alte";
|
||||
"Finding something to play..." = "Să găsești ceva de jucat...";
|
||||
"For videos which feature music as the primary content." = "Pentru videoclipurile care includ muzica ca conținut principal.";
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"Delete" = "删除";
|
||||
"Disabled" = "禁用";
|
||||
"Discord Server" = "Discord 服务器";
|
||||
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "讨论在 Discord 以及 Matrix 中进行,您可以在里面询问一些普通的问题。";
|
||||
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "討論在 Discord 及 Matrix 中進行,您可以在裡面詢問一些簡單的問題。";
|
||||
"Don't use public locations" = "不要使用公开地址";
|
||||
"Donations" = "捐赠";
|
||||
"Done" = "完成";
|
||||
|
||||
631
Shared/zh-Hant.lproj/Localizable.strings
Normal file
631
Shared/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,631 @@
|
||||
|
||||
|
||||
"Format" = "格式";
|
||||
"Driver" = "驅動";
|
||||
"Audio" = "音效";
|
||||
"Show only icons" = "只顯示圖標";
|
||||
"Center" = "正中";
|
||||
"File" = "文件";
|
||||
"Documents" = "文件";
|
||||
"Video" = "視頻";
|
||||
"Codec" = "編碼";
|
||||
"Size" = "大小";
|
||||
"Sample Rate" = "取樣率";
|
||||
"Mark channel feed as watched" = "標記頻道為已觀看";
|
||||
"Clear all" = "清除所有";
|
||||
"\"%@\" will be irreversibly removed from this device." = "\"%@\" 將會於此裝置上被永久移除。";
|
||||
"Music Mode" = "音樂模式";
|
||||
"Close video" = "關閉視頻";
|
||||
"Play next item" = "播放下一項目";
|
||||
"Maximum width expanded" = "最大寬度已展開";
|
||||
"Show unwatched feed badges" = "顯示未觀看的\"最新影片\"標誌";
|
||||
"Gesture: fowards" = "手勢: 向前";
|
||||
"Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart." = "手勢設置控制遠程箭頭按鈕的跳過間隔(用於第二代 Siri Remote 或更新版本)。更改系統控制設置需要重新啓動。";
|
||||
"Opened File" = "已打開文件";
|
||||
"Landscape left" = "橫屏左邊";
|
||||
"Landscape right" = "橫屏右邊";
|
||||
"Gesture settings control skipping interval for double tap gesture on left/right side of the player. Changing system controls settings requires restart." = "手勢設置控制玩家左右兩側雙擊手勢的跳過間隔。更改系統控制設置需要重新啓動。";
|
||||
"Show scroll to top button in comments" = "在評論中顯示滾動到頂部按鈕";
|
||||
"(watched and shorts hidden)" = "(隱藏已觀看及短片)";
|
||||
"(watched hidden)" = "(隱藏已觀看)";
|
||||
"Show video context menu options to force selected backend" = "顯示視頻內容目錄選項來強制已選取的後端";
|
||||
"Other data" = "其他資料";
|
||||
"Other data include last used playback preferences and listing options" = "其他資料包括上次的播放喜好和清單選項";
|
||||
"File information" = "檔案資訊";
|
||||
"Build" = "版本";
|
||||
"Action button labels" = "動作按鈕標籤";
|
||||
"Icon and text" = "圖示及文字";
|
||||
"Password required to import" = "需要匯入的密碼";
|
||||
"Edit" = "編輯";
|
||||
"Enable Return YouTube Dislike" = "啟用YouTube 不喜歡回報";
|
||||
"Enter fullscreen in landscape" = "橫屏下進入全屏";
|
||||
"Error when accessing playlist" = "播放清單出錯";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "明確提醒在任何付費或免費平台上按讚、訂閱或與他們互動(例如點擊影片)。\n";
|
||||
"For videos which feature music as the primary content." = "以音樂為主要內容的視頻。";
|
||||
"I like this app!" = "我喜歡這app!";
|
||||
"If you are interested what's coming in future updates, you can track project Milestones." = "如果您對將來的功能更新感興趣,您可以追蹤我們的專案里程碑。";
|
||||
"Increase rate" = "增长率";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Intro" = "簡介";
|
||||
"Issues Tracker" = "問題追蹤介面";
|
||||
|
||||
/* Selected video has just finished playing */
|
||||
"Just watched" = "已觀看";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Large" = "大";
|
||||
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "大佈局並不適合所有設備,使用它可能導致控制按鈕在屏幕上並不貼合。";
|
||||
"LIVE" = "直播";
|
||||
"Low quality" = "低畫質";
|
||||
"Low" = "低";
|
||||
"Mark video as watched after playing" = "播放後標記為已觀看";
|
||||
"MPV Documentation" = "MPV 文檔";
|
||||
"Orientation" = "方向";
|
||||
"Music" = "音樂";
|
||||
"Part of a video promoting a product or service not directly related to the creator. The creator will receive payment or compensation in the form of money or free products." = "視頻宣傳產品或服務的一部分,與創作者沒有直接關係。創作者將以金錢或免費產品的形式獲得報酬或補償。";
|
||||
"Remove from Playlist" = "從播放清單中移除";
|
||||
"Replies" = "回覆";
|
||||
"Reset" = "重設";
|
||||
"Restart the app to apply the settings above." = "重啟app 以應用以上設置。";
|
||||
"Sections" = "章節";
|
||||
"Share..." = "分享...";
|
||||
"Show account username" = "顯示帳戶名稱";
|
||||
"Show channel name" = "顯示頻道名稱";
|
||||
"This cannot be reverted. You might need to switch between views or restart the app to see changes." = "這不能復原。你可能需要轉換顯示或重啟app 才能顯示變更。";
|
||||
|
||||
/* Player controls layout size for TV */
|
||||
"TV" = "電視";
|
||||
"unknown" = "不明";
|
||||
"Unsubscribe" = "取消訂閱";
|
||||
"Typically near or at the end of the video when the credits pop up and/or endcards are shown." = "通常在視頻結束時或接近視頻結尾時,出現 Credits Pop Up 和結束卡片。";
|
||||
"Upload date" = "上載日期";
|
||||
"URL" = "網址";
|
||||
"Used to create links from videos, channels and playlists" = "用於從視頻、頻道和播放清單創建連結";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Very Large" = "非常大";
|
||||
"Videos" = "視頻";
|
||||
|
||||
/* Video sort order in search */
|
||||
"Views" = "觀看次數";
|
||||
"Watched" = "已觀看";
|
||||
"No chapters information available" = "沒有章節資訊";
|
||||
"Share Logs..." = "分享日誌…";
|
||||
"Any format" = "任何格式";
|
||||
"%@ formats" = "%@ 格式";
|
||||
"Keep last played video in the queue after restart" = "重啟後保留最後播放視頻至隊列";
|
||||
"Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"" = "播放清單空白\n\n點擊並按住視頻然後\n\"加至播放清單\"";
|
||||
"Could not refresh Subscriptions" = "無法更新訂閱列表";
|
||||
"Could not load streams" = "無法加載視頻";
|
||||
"Could not open video" = "無法開啟視頻";
|
||||
"Channel could not be found" = "無法找到頻道";
|
||||
"For custom locations you can configure Frontend URL in Locations settings" = "對於自定義站台,您可以在設置中配置前端 URL";
|
||||
"This URL could not be opened" = "無法打開此URL";
|
||||
"Could not open channel" = "無法打開頻道";
|
||||
|
||||
/* Selected video is being played */
|
||||
"Watching now" = "觀看中";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Week" = "星期";
|
||||
"Yattee" = "Yattee";
|
||||
"Could not update your token." = "無法更新你的權仗(Token)。";
|
||||
"Could not refresh Trending" = "無法更新趨勢";
|
||||
"Could not extract channel information" = "無法獲取頻道資訊";
|
||||
"Yattee %@ (build %@)" = "Yattee %@ (版本 %@)";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Year" = "年";
|
||||
"You can find information about using Yattee in the Wiki pages." = "您可以在 GitHub 相關頁面中找到有關使用 Yattee 的信息。";
|
||||
"You can use automatic profile selection based on current device status or switch it in video playback settings controls." = "您可以使用基於當前設備狀態的自動配置文件選擇,或在視頻播放設置控件中進行切換。";
|
||||
"Could not extract playlist ID" = "無法提取播放清單ID";
|
||||
"Could not load video" = "無法載入視頻";
|
||||
"You need to create an instance and accounts\nto access %@ section" = "你需要建立站台及帳戶\n來存取 %@ 部分";
|
||||
"You need to select an account\nto access %@ section" = "你需要選擇帳戶\n來存取 %@ 部分";
|
||||
"If you want this app to be available in your language, join translation project." = "如果你想此app 以你的語言顯示,請加入翻譯專案。";
|
||||
"Private" = "私人";
|
||||
"Playback queue is empty" = "播放隊列空白";
|
||||
"Playing Next" = "播放下一個";
|
||||
"You can switch between profiles in playback settings controls." = "您可以在播放設置控件中切換配置文件。";
|
||||
"Current Playlist" = "當前播放清單";
|
||||
"Stream & Player" = "串流及播放器";
|
||||
"Statistics" = "數據";
|
||||
"Hardware decoder" = "硬體解碼";
|
||||
"Stream FPS" = "串流 FPS";
|
||||
"Cached time" = "緩存時間";
|
||||
"Rate & Captions" = "評分及字幕";
|
||||
"Dropped frames" = "損失幀數";
|
||||
"Could not create share link" = "無法建立分享連結";
|
||||
"%@ Channel" = "%@ 頻道";
|
||||
"%@ Playlist" = "%@ 播放清單";
|
||||
"%@ subscribers" = "%@ 訂閱者";
|
||||
"Accounts" = "帳戶";
|
||||
"Accounts are not supported for the application of this instance" = "本站並不支持帳戶";
|
||||
"Add Account" = "新增帳戶";
|
||||
"%lld videos" = "%lld 視頻";
|
||||
"Add Account..." = "新增帳戶...";
|
||||
"Add Location" = "新增站點";
|
||||
"Add Location..." = "新增站點..";
|
||||
"Add profile..." = "新增配置...";
|
||||
"Add Quality Profile" = "新增畫質配置";
|
||||
"Add to %@" = "添加到 %@";
|
||||
"Add to Favorites" = "加至我的最愛";
|
||||
"Add to Playlist" = "加至播放清單";
|
||||
"Add to Playlist..." = "加至播放清單...";
|
||||
"Advanced" = "高級";
|
||||
|
||||
/* Trending category, section containing all kinds of videos */
|
||||
"All" = "所有";
|
||||
"Always use AVPlayer for live videos" = "總是使用AVPlayer(直播)";
|
||||
"Anonymous" = "匿名";
|
||||
|
||||
/* Video date filter in search
|
||||
Video duration filter in search */
|
||||
"Any" = "任何";
|
||||
"Apply to all" = "套用至全部";
|
||||
"Are you sure you want to unsubscribe from %@?" = "確定要取消訂閱 %@?";
|
||||
"Automatic" = "自動";
|
||||
"Autoplaying Next" = "自動播放下一個";
|
||||
"Backend" = "後台";
|
||||
"Categories to Skip" = "要跳過的類別";
|
||||
"Cellular" = "流動網絡";
|
||||
"Chapters" = "章節";
|
||||
"Charging" = "充電中";
|
||||
"Clear" = "清除";
|
||||
"Clear All" = "清除所有";
|
||||
"Clear All Recents" = "清除所有最近";
|
||||
"Clear History" = "清除記錄";
|
||||
"Clear Search History" = "清除搜尋記錄";
|
||||
"Clear Search History..." = "清除搜尋記錄...";
|
||||
"Clear the queue" = "清除隊列";
|
||||
"Close" = "關閉";
|
||||
"Close PiP when player is opened" = "當播放器打開時,關閉 PiP";
|
||||
"Close player when closing video" = "當關閉視頻時,關閉播放器";
|
||||
"Close player when starting PiP" = "當啟動PiP時,關閉播放器";
|
||||
"Close Video" = "關閉視頻";
|
||||
"Close video after playing last in the queue" = "播放完最後隊列後關閉視頻";
|
||||
"Connected successfully (%@)" = "連接成功 (%@)";
|
||||
"Connection failed" = "連接失敗";
|
||||
"Contact" = "聯繫";
|
||||
"Continue" = "繼續";
|
||||
"Continue from %@" = "從 %@繼續";
|
||||
"Contributing" = "貢獻";
|
||||
"Controls" = "控制";
|
||||
"Copy %@ link" = "複製 %@ 連結";
|
||||
"Copy %@ link with time" = "複製 %@ 連結(含時間)";
|
||||
"Could not load locations manifest" = "無法加載站台列表";
|
||||
"Country Name or Code" = "國家名稱或代碼";
|
||||
"Create Playlist" = "新建播放清單";
|
||||
"Current: %@\n%@" = "現在: %@\n%@";
|
||||
|
||||
/* Locations settings, custom instance is selected as current */
|
||||
"Custom" = "自定義";
|
||||
"Custom Locations" = "自定義站台";
|
||||
|
||||
/* Video sort order in search */
|
||||
"Date" = "日期";
|
||||
"Decrease rate" = "下降率";
|
||||
"Decreased opacity" = "減少透明度";
|
||||
"Delete" = "刪除";
|
||||
"Disabled" = "禁用";
|
||||
"Discord Server" = "Discord 伺服器";
|
||||
"Discussions take place in Discord and Matrix. It's a good spot for general questions." = "討論在 Discord 及 Matrix 中進行,您可以在裡面詢問一些簡單的問題。";
|
||||
"Don't use public locations" = "不使用公開站台";
|
||||
"Donations" = "捐贈";
|
||||
"Done" = "完成";
|
||||
"Duration" = "時長";
|
||||
"Edit Playlist" = "編輯播放清單";
|
||||
"Edit Quality Profile" = "編輯質量配置";
|
||||
"Edit..." = "編輯...";
|
||||
"Enable logging" = "啟用日誌";
|
||||
"Error" = "錯誤";
|
||||
"Favorites" = "我的最愛";
|
||||
"Filter" = "篩選";
|
||||
"Filter: active" = "篩選: 啟用";
|
||||
"Find Other" = "搜尋其他";
|
||||
"Finding something to play..." = "正在尋找視頻...";
|
||||
"Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply)." = "格式將按列出的順序選擇。\nHLS是一種自適應格式(解析度設定不適用)。";
|
||||
"Frontend URL" = "前端網址";
|
||||
"Fullscreen size" = "全屏大小";
|
||||
"Gaming" = "遊戲";
|
||||
"Help" = "幫助";
|
||||
"Hide sidebar" = "隱藏側邊欄";
|
||||
"High" = "高";
|
||||
"Highest" = "最高";
|
||||
"Highest quality" = "最高畫質";
|
||||
"History" = "歷史";
|
||||
"Honor orientation lock" = "方向鎖";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Hour" = "小時";
|
||||
"I am lost" = "我迷失了";
|
||||
"I found a bug /" = "我發現bug";
|
||||
"I have a feature request" = "我有一個功能需要";
|
||||
"I want to ask a question" = "我想問問題";
|
||||
"If you are reporting a bug, include all relevant details (especially: app version, used device and system version, steps to reproduce)." = "如果你要反饋一個程式錯誤,請包括所有相關資料(特別是:App 版本,使用設備以及系統版本,重現步驟)。";
|
||||
"Info" = "資訊";
|
||||
"Instance of current account" = "此帳戶站台";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Interaction" = "交互";
|
||||
"Interface" = "介面";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "加載中...";
|
||||
"Loading..." = "加載中...";
|
||||
"Locations" = "地址";
|
||||
"Lock portrait mode" = "鎖定直屏";
|
||||
|
||||
/* Video duration filter in search */
|
||||
"Long" = "長";
|
||||
"Lowest" = "最低";
|
||||
"Mark as watched" = "標記為已觀看";
|
||||
"Mark watched videos with" = "標記已觀看視頻";
|
||||
"Matrix Channel" = "Matrix 頻道";
|
||||
"Matrix Chat" = "Matrix 聊天";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Medium" = "中";
|
||||
"Medium quality" = "中畫質";
|
||||
"Milestones" = "里程碑";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Month" = "月";
|
||||
"More info can be found in:" = "更多資訊可在:";
|
||||
"Movies" = "電影";
|
||||
"Name" = "名稱";
|
||||
"New Playlist" = "新播放清單";
|
||||
"Next" = "下一個";
|
||||
"No description" = "無簡介";
|
||||
"No Playlists" = "沒有播放清單";
|
||||
"No results" = "沒有結果";
|
||||
"Normal" = "正常";
|
||||
"Not available" = "不可用";
|
||||
"Not Playing" = "沒有播放";
|
||||
"Nothing" = "沒有東西";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Offtopic in Music Videos" = "在音樂視頻中的無關內容";
|
||||
"Only when signed in" = "僅當登錄後";
|
||||
"Open \"Playlists\" tab to create new one" = "打開「播放清單」 頁面來創建新的";
|
||||
"Open Settings" = "打開設置";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "正在打開 %@ ...";
|
||||
"Opening audio stream..." = "正在打開音訊...";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "結尾";
|
||||
"Password" = "密碼";
|
||||
"Pause" = "暫停";
|
||||
"Pause when entering background" = "進入後台時暫停";
|
||||
"Pause when player is closed" = "播放器關閉時暫停";
|
||||
"Picture in Picture" = "畫中畫";
|
||||
"Play" = "播放";
|
||||
"Play All" = "全部播放";
|
||||
"Play in PiP" = "在畫中畫播放";
|
||||
"Play Last" = "播放最後";
|
||||
"Play Music" = "播放音樂";
|
||||
"Play Next" = "播放下一部";
|
||||
"Play Now" = "即時播放";
|
||||
"Playback" = "播放";
|
||||
"Player" = "播放器";
|
||||
"Playlist" = "播放清單";
|
||||
"Playlist \"%@\" will be deleted.\nIt cannot be reverted." = "播放清單 “%@” 將被删除。\n此操作不可恢復。";
|
||||
"Playlists" = "播放清單";
|
||||
"Popular" = "熱播";
|
||||
"Preferred Formats" = "喜好格式";
|
||||
"Profiles" = "配置";
|
||||
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "推廣與創作者本身直接相關的產品或服務。這通常包括商品或盈利平台的推廣。";
|
||||
"Proxy videos" = "代理視頻";
|
||||
"Public Locations" = "公共站台";
|
||||
"Public Manifest" = "公共清單";
|
||||
"Quality" = "畫質";
|
||||
"Quality Profile" = "畫質配置";
|
||||
"Queue" = "隊列";
|
||||
"Queue is empty" = "隊列為空";
|
||||
"Rate" = "速度";
|
||||
|
||||
/* Video sort order in search */
|
||||
"Rating" = "評級";
|
||||
"Recents" = "最近";
|
||||
"Red" = "紅";
|
||||
"Refresh" = "更新";
|
||||
"Regular size" = "正常大小";
|
||||
"Regular Size" = "正常大小";
|
||||
"Related" = "相關";
|
||||
|
||||
/* Video sort order in search */
|
||||
"Relevance" = "相關度";
|
||||
"Remove" = "移除";
|
||||
"Remove from Favorites" = "從我的最愛中移除";
|
||||
"Remove from history" = "從歷史中移除";
|
||||
"Remove from the queue" = "從隊列中移除";
|
||||
"Reset search filters" = "重設搜尋篩選";
|
||||
"Reset watched status when playing again" = "重新播放後重設播放狀態";
|
||||
"Resolution" = "分辨率";
|
||||
"Restart" = "重新啟動";
|
||||
"Restart/Play next" = "重新播放/播放下一個";
|
||||
"Restore default profiles..." = "重置默認配置文件...";
|
||||
"Rotate to portrait when exiting fullscreen" = "退出全屏後旋轉為直屏";
|
||||
"Round corners" = "圓角";
|
||||
"Save" = "儲存";
|
||||
"Save history of played videos" = "儲存已播放視頻記錄";
|
||||
"Save history of searches, channels and playlists" = "儲存搜尋,頻道及播放清單記錄";
|
||||
"Search" = "搜尋";
|
||||
"Search history is empty" = "搜尋歷史為空";
|
||||
"Search..." = "搜尋...";
|
||||
"Seek gesture sensitivity" = "手勢靈敏度";
|
||||
"Seek gesture speed" = "手勢速度";
|
||||
"Seek with horizontal swipe on video" = "視頻水平滑動搜索";
|
||||
"Segments typically found at the start of a video that include an animation, still frame or clip which are also seen in other videos by the same creator." = "通常在視頻開頭找到的片段,包括動畫、靜止幀或剪輯,這些片段也可以由同一創作者在其他視頻中看到。";
|
||||
"Select location closest to you:" = "選取離你最近的站台:";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Self-promotion" = "自我推銷";
|
||||
"Settings" = "設置";
|
||||
"Share %@ link" = "分享 %@ 連結";
|
||||
"Share %@ link with time" = "分享 %@ 連結(含時間)";
|
||||
|
||||
/* Video duration filter in search */
|
||||
"Short" = "短";
|
||||
"Show anonymous accounts" = "顯示匿名帳戶";
|
||||
"Show history" = "顯示歷史";
|
||||
"Show keywords" = "顯示關鍵字";
|
||||
"Show playback statistics" = "顯示播放統計";
|
||||
"Show progress of watching on thumbnails" = "縮圖顯示播放進度";
|
||||
"Show sidebar when space permits" = "空間充裕時顯示側邊欄";
|
||||
"Show video length" = "顯示視頻長度";
|
||||
"Shuffle" = "隨機播放";
|
||||
"Shuffle All" = "隨機播放全部";
|
||||
"Sidebar" = "側邊欄";
|
||||
"Sign In Required" = "需要登錄";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Small" = "小";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Smaller" = "更小";
|
||||
"Sort" = "排序";
|
||||
"Sort: %@" = "排序: %@";
|
||||
"Source" = "源";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Sponsor" = "宣傳";
|
||||
"SponsorBlock" = "SponsorBlock (跳過贊助廣告)";
|
||||
"SponsorBlock API Instance" = "SponsorBlock API 站台";
|
||||
"Subscribe" = "訂閱";
|
||||
|
||||
/* Subscriptions title */
|
||||
"Subscriptions" = "訂閱";
|
||||
"Switch to other public location" = "轉換至其他公共站台";
|
||||
"Switch to public locations" = "轉換至公共站台";
|
||||
"System controls buttons" = "系統控制鍵";
|
||||
"System controls show buttons for %@" = "系統控制%@鍵";
|
||||
"That's nice to hear. It is fun to deliver apps other people want to use. You can consider donating to the project or help by contributing to new features development." = "很高興聽到您這麼說。提供人們想要的應用程序是一件很有趣的事情。您可以考慮為項目捐款,或為新功能開發做出貢獻。";
|
||||
"This cannot be reverted" = "這不能復原";
|
||||
"This information will be processed only on your device and used to connect you to the server in the specified country." = "此信息將僅在您的設備上處理,並用於將您連接到指定國家/地區的服務器。";
|
||||
"This will remove all your custom profiles and return their default values. This cannot be reverted." = "這將刪除所有自定義配置文件並還原為其默認值。此操作無法復原。";
|
||||
"Thumbnails" = "縮圖";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Today" = "今日";
|
||||
"Trending" = "趨勢";
|
||||
"Username" = "用戶名稱";
|
||||
|
||||
/* Selected video was played on given date */
|
||||
"Watched %@" = "已觀看 %@";
|
||||
"Welcome" = "歡迎";
|
||||
"Wi-Fi" = "無線網絡";
|
||||
"Wiki" = "GitHub";
|
||||
"You have no Playlists" = "你沒有播放清單";
|
||||
"You have no playlists\n\nTap on \"New Playlist\" to create one" = "你沒有播放清單\n\n點擊\"新建播放清單\"建立";
|
||||
|
||||
|
||||
"Public" = "公開";
|
||||
"Unlisted" = "未列出";
|
||||
"Now Playing" = "現正播放";
|
||||
"Current Location" = "現在位置";
|
||||
"Add Channels, Playlists and Searches to Favorites using" = "添加頻道、播放清單和搜尋到我的最愛";
|
||||
"Make default" = "設為預設";
|
||||
"Visibility" = "可見度";
|
||||
"It can be changed later in settings. You can use your own locations too." = "稍後可以在設置中更改。你也可以使用自己的地址。";
|
||||
"Press and hold remote button to open captions and quality menus" = "按住遙控按鈕打開字幕和畫質功能表";
|
||||
"Comments are disabled" = "留言被關閉";
|
||||
"No comments" = "沒有留言";
|
||||
"Open logs in Finder" = "在Finder 中開啟日誌";
|
||||
"Could not extract SID from received cookies: %@" = "無法從Cookies 提取SID: %@";
|
||||
"Could not refresh Popular" = "無法更新熱播";
|
||||
"Could not open playlist" = "無法打開播放清單";
|
||||
"Could not extract video ID" = "無法提取視頻ID";
|
||||
"This video could not be opened" = "這視頻無法打開";
|
||||
"No locations available at the moment" = "現時沒有可用地址";
|
||||
"Could not refresh Playlists" = "無法更新播放清單";
|
||||
"Translations" = "翻譯";
|
||||
"No documents" = "沒有文件";
|
||||
"Recent Documents" = "最近文件";
|
||||
"Home" = "主頁";
|
||||
"Share files from Finder on a Mac\nor iTunes on Windows" = "在 Windows iTunes 或 Mac 上\n共享 Finder 中的文件";
|
||||
"Show Home" = "顯示主頁";
|
||||
"Show Open Videos quick actions" = "顯示打開視頻快速操作";
|
||||
"Recent History" = "最近歷史";
|
||||
"Show Favorites" = "顯示我的最愛";
|
||||
"Inspector visibility" = "檢查器可見度";
|
||||
"Edit Favorites…" = "編輯我的最愛…";
|
||||
"Show Open Videos toolbar button" = "顯示打開視頻工具按鈕";
|
||||
"Buttons labels" = "按鈕標簽";
|
||||
"Files" = "文件";
|
||||
"Show Documents" = "顯示文件";
|
||||
"Pages toolbar position" = "頁面工具列位置";
|
||||
"Video Details" = "視頻詳情";
|
||||
"Show Inspector" = "顯示檢查器";
|
||||
"Reload manifest" = "重新載入清單";
|
||||
"Clear Queue before opening" = "開啟前清除隊列";
|
||||
"Open" = "打開";
|
||||
"Video actions buttons" = "視頻操作按鈕";
|
||||
"Pages buttons" = "頁面按鈕";
|
||||
"URL to Open" = "要打開的URL";
|
||||
"Enter link to open" = "輸入要打開的連結";
|
||||
"Could not open Files" = "無法打開文件";
|
||||
"Paste" = "貼上";
|
||||
"Open Videos" = "打開視頻";
|
||||
"Enter links to open, one per line" = "輸入需要打開的連結,每行一個";
|
||||
"Playback Mode" = "播放模式";
|
||||
"Add" = "添加";
|
||||
"Hide" = "隱藏";
|
||||
"Always" = "總是";
|
||||
"Only for local files and URLs" = "僅針對本地文件以及連結";
|
||||
"Right" = "右";
|
||||
"Channels" = "頻道";
|
||||
"Open Files" = "打開文件";
|
||||
"Share" = "分享";
|
||||
"Show icons and text when space permits" = "在空間允許時顯示圖標和文字";
|
||||
"Left" = "左";
|
||||
"FPS" = "FPS";
|
||||
"Address" = "地址";
|
||||
"Remove…" = "移除…";
|
||||
"Show sidebar" = "顯示側邊欄";
|
||||
"Locations Manifest" = "地址清單";
|
||||
"Remove Location" = "移除地址";
|
||||
"Open Video" = "打開視頻";
|
||||
"Default Profile" = "預設配置";
|
||||
"Playback history is empty" = "播放記錄空白";
|
||||
"Copy%@link" = "複製%@連結";
|
||||
"Share%@link" = "分享%@連結";
|
||||
"Are you sure you want to remove this document?" = "你確定要移除文件?";
|
||||
"Could not delete document" = "無法刪除文件";
|
||||
"Live Streams" = "直播";
|
||||
"Shorts" = "短片";
|
||||
"Channel" = "頻道";
|
||||
"Mark channel feed as unwatched" = "標記頻道為未觀看";
|
||||
"Could not find any links to open in your clipboard" = "無法在你的剪輯版中找到任何可以打開的連結";
|
||||
"Actions buttons" = "動作按鈕";
|
||||
"Are you sure you want to remove %@ location?" = "你確定想要刪除 %@ 地址?";
|
||||
"Verified" = "已認證";
|
||||
"Open expanded" = "展開";
|
||||
"Short videos: visible" = "短片: 可見";
|
||||
"Player Bar" = "播放器控制條";
|
||||
"Short videos: hidden" = "短片: 隱藏";
|
||||
"Play all unwatched" = "播放所有未觀看";
|
||||
"Double tap gesture" = "雙擊手勢";
|
||||
"Tap and hold channel thumbnail to open context menu with more actions" = "點擊並按住頻道縮圖以打開包含更多操作的選單";
|
||||
"Always show controls buttons" = "總是顯示控制按鈕";
|
||||
"Single tap gesture" = "單擊手勢";
|
||||
"Seeking" = "搜索中";
|
||||
"Right click channel thumbnail to open context menu with more actions" = "右鍵單擊頻道縮圖以打開具有更多操作的選單";
|
||||
"Controls Buttons" = "控制按鈕";
|
||||
"System controls" = "系統控制";
|
||||
"Controls button: backwards" = "控制按鈕: 向後";
|
||||
"Controls button: forwards" = "控制按鈕: 向前";
|
||||
"Gesture: backwards" = "手勢: 向後";
|
||||
"Hide player" = "隱藏播放器";
|
||||
"Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart." = "手勢設置控制雙擊播放器左/右的跳躍間隔。更改系統控制設置需要重新啓動。";
|
||||
"Lock orientation" = "鎖定方向";
|
||||
"Cache" = "緩存";
|
||||
"Actions Buttons" = "動作按鈕";
|
||||
"Total size: %@" = "總大小: %@";
|
||||
"Open channels with description expanded" = "打開頻道(含描述展開)";
|
||||
"Subscribe/Unsubscribe" = "訂閱/取消訂閱";
|
||||
"Show cache status" = "顯示緩存狀態";
|
||||
"Maximum feed items" = "最大\"最新影片\"數目";
|
||||
"Open channel" = "打開頻道";
|
||||
"Inspector" = "檢查器";
|
||||
"Open video description expanded" = "打開視頻描述";
|
||||
"Mark all as unwatched" = "標記所有為未觀看";
|
||||
"Mark all as watched" = "標記所有為已觀看";
|
||||
"Playback Settings" = "播放設定";
|
||||
"Replay" = "重播";
|
||||
"Fullscreen" = "全屏幕";
|
||||
"Lock" = "鎖定";
|
||||
"Description" = "描述";
|
||||
"Autoplay next" = "自動播放下一個";
|
||||
"Stream" = "串流";
|
||||
"Are you sure you want to clear cache?" = "你確定要清除緩存嗎?";
|
||||
"Show Next in Queue" = "在隊列中顯示下一個";
|
||||
"Show toggle watch status button" = "顯示切換觀看狀態按鈕";
|
||||
"Next in Queue" = "隊列中下一個";
|
||||
"List" = "列表";
|
||||
"Cells" = "網格";
|
||||
"Toggle size" = "替換大小";
|
||||
"Toggle player" = "替換播放器";
|
||||
"Do nothing" = "不做";
|
||||
"Feed" = "最新影片";
|
||||
"Queue - shuffled" = "隊列 - 隨機";
|
||||
"Loop one" = "單個循環";
|
||||
"File Extension" = "副檔名";
|
||||
"Opening file..." = "正在打開文件...";
|
||||
"Public account" = "公共帳戶";
|
||||
"Enter account credentials to connect..." = "輸入帳號密碼來連接...";
|
||||
"Enter location address to connect..." = "輸入站台地址來連接...";
|
||||
"Seek" = "搜索";
|
||||
"Your Accounts" = "你的帳戶";
|
||||
"Browse without account" = "匿名瀏覽";
|
||||
"Close video and player on end" = "播放結束時關閉視頻及播放器";
|
||||
"Use system controls with AVPlayer" = "在AVPlayer 時使用系統控制按鈕";
|
||||
"Rotate when entering fullscreen on landscape video" = "觀看橫向全屏視頻時旋轉";
|
||||
"Available" = "可用";
|
||||
"Home Settings" = "主頁設置";
|
||||
"Watched: hidden" = "已觀看: 隱藏";
|
||||
"No rotation" = "不要旋轉";
|
||||
"Startup section" = "啟動部分";
|
||||
"Watched: visible" = "已觀看: 可見";
|
||||
"No videos to show" = "沒有視頻顯示";
|
||||
"(shorts hidden)" = "(隱藏短片)";
|
||||
"Disable filters" = "禁用過濾";
|
||||
"Limit" = "上限";
|
||||
"Are you sure you want to remove %@ from Favorites?" = "你確定要從我的最愛中刪除 %@ 嗎?";
|
||||
"Keep channels with unwatched videos on top of subscriptions list" = "保留頻道內未觀看視頻在訂閱列表頂端";
|
||||
"Play Now in MPV" = "在MPV 中播放";
|
||||
"Play Now in AVPlayer" = "在AVPlayer 中播放";
|
||||
"Show channel avatars in videos lists" = "在視頻列表中顯示頻道頭像";
|
||||
"Show channel avatars in channels lists" = "在頻道列表中顯示頻道頭像";
|
||||
"Podcasts" = "播客";
|
||||
"Releases" = "發布";
|
||||
"Add %@" = "添加 %@";
|
||||
"Description preview" = "描述預覽";
|
||||
"No preview" = "沒有預覽";
|
||||
"Open vertical chapters expanded" = "展開垂直章節";
|
||||
"Chapters (if available)" = "章節(如有)";
|
||||
"Import Settings..." = "匯入設定...";
|
||||
"Export Settings" = "匯出設定";
|
||||
"Accounts passwords (unencrypted)" = "帳戶密碼 (非加密)";
|
||||
"Other" = "其他";
|
||||
"Export..." = "匯出…";
|
||||
"Are you sure you want to export unencrypted passwords?" = "你確定要匯出未加密的密碼?";
|
||||
"Icon only" = "僅圖示";
|
||||
"Export" = "匯出";
|
||||
"Import" = "匯入";
|
||||
"Platform" = "平台";
|
||||
"Custom Location already exists" = "自定義站台已存在";
|
||||
"Custom Location selected for import" = "選擇導入的自定義站台";
|
||||
"Custom Location not selected for import" = "未選擇導入的自定義站台";
|
||||
"Account already exists" = "帳戶已存在";
|
||||
"Password saved in import file" = "密碼已儲存在匯入文件";
|
||||
"Export in progress..." = "匯出中...";
|
||||
"In progress..." = "進行中…";
|
||||
" subscribers" = " 訂閱者";
|
||||
"10 seconds forwards/backwards" = "前放/回放10秒";
|
||||
"Are you sure you want to clear history of watched videos?" = "確定要清除播放歷史?";
|
||||
"Are you sure you want to clear search history?" = "確定要清除搜尋歷史?";
|
||||
"Are you sure you want to delete playlist?" = "確定要刪除播放清單?";
|
||||
"Are you sure you want to restore default quality profiles?" = "確定要回復預設質量配置?";
|
||||
"Badge" = "標記";
|
||||
"Badge & Decreased opacity" = "標記及降低透明度";
|
||||
"Badge color" = "標記顏色";
|
||||
"Based on system color scheme" = "根據系統配置";
|
||||
"Battery" = "電池";
|
||||
"Blue" = "藍";
|
||||
"Browsing" = "瀏覽";
|
||||
"Buffering stream..." = "緩衝中...";
|
||||
"Bugs and great feature ideas can be sent to the GitHub issues tracker. " = "程式錯誤及出色的功能構思也可以在 GitHub 問題追蹤介面提出。 ";
|
||||
"Cancel" = "取消";
|
||||
"Button" = "按鈕";
|
||||
"Captions" = "字幕";
|
||||
"Category" = "類別";
|
||||
"Close PiP and open player when application enters foreground" = "當應用程式進入前台時,關閉 PiP 並打開播放器";
|
||||
"Close PiP when starting playing other video" = "當播放其他視頻時,關閉 PiP";
|
||||
"Comments" = "留言";
|
||||
"Country" = "國家";
|
||||
"When partially watched video is played" = "播放未完全觀看視頻時";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "請勿與任何人共用此文件,否則您可能會失去對帳戶的存取權限。如果您不選擇匯出密碼,系統將要求您在匯入過程中提供密碼";
|
||||
@@ -1196,6 +1196,7 @@
|
||||
3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
|
||||
373197D82732015300EF734F /* RelatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedView.swift; sourceTree = "<group>"; };
|
||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; };
|
||||
37367E582B8F63C200436163 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCacheModel.swift; sourceTree = "<group>"; };
|
||||
373C8FE3275B955100CB5936 /* CommentsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsPage.swift; sourceTree = "<group>"; };
|
||||
373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
|
||||
@@ -2773,6 +2774,7 @@
|
||||
es,
|
||||
tr,
|
||||
ru,
|
||||
"zh-Hant",
|
||||
);
|
||||
mainGroup = 37D4B0BC2671614700C925CA;
|
||||
packageReferences = (
|
||||
@@ -4045,6 +4047,7 @@
|
||||
3767F3322B25053B00F257BC /* es */,
|
||||
3767F3332B25058300F257BC /* tr */,
|
||||
3767F3342B2505EF00F257BC /* ru */,
|
||||
37367E582B8F63C200436163 /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
@@ -4059,7 +4062,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
|
||||
@@ -4090,7 +4093,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -4121,7 +4124,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
@@ -4141,7 +4144,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
@@ -4305,7 +4308,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
@@ -4358,7 +4361,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
||||
@@ -4410,7 +4413,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -4449,7 +4452,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -4484,7 +4487,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4508,7 +4511,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4534,7 +4537,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4559,7 +4562,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4585,7 +4588,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -4625,7 +4628,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -4666,7 +4669,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -4690,7 +4693,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 179;
|
||||
CURRENT_PROJECT_VERSION = 181;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -4864,8 +4867,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.1.1;
|
||||
};
|
||||
};
|
||||
372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */ = {
|
||||
@@ -4960,8 +4963,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SDWebImage/SDWebImage";
|
||||
requirement = {
|
||||
branch = master;
|
||||
kind = branch;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 5.19.1;
|
||||
};
|
||||
};
|
||||
37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
|
||||
@@ -5000,8 +5003,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/cxfksword/MPVKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.36.0;
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 0.38.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Alamofire/Alamofire.git",
|
||||
"state" : {
|
||||
"revision" : "3dc6a42c7727c49bf26508e29b0a0b35f9c7e1ad",
|
||||
"version" : "5.8.1"
|
||||
"revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a",
|
||||
"version" : "5.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -25,7 +25,7 @@
|
||||
"location" : "https://github.com/hyperoslo/Cache.git",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "a73f7d09534c35a509d2914849a75c15c12fbbbd"
|
||||
"revision" : "f44a8f6b5ec27730198725ccc542fef0d1cc6b3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -60,8 +60,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/cxfksword/MPVKit.git",
|
||||
"state" : {
|
||||
"revision" : "645f430ff0b99ccc2c61062727ad7e8bf32ca72a",
|
||||
"version" : "0.37.0"
|
||||
"revision" : "f646e4b625e9c8a2ff22a7e0bb5557306300be5d",
|
||||
"version" : "0.38.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@
|
||||
"location" : "https://github.com/pinterest/PINCache",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "97a5dbd3f1e69605bcd4103fdb32ca855887c47a"
|
||||
"revision" : "f856226e8bee58d75cb6be1707ae0cb2f5801150"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ashleymills/Reachability.swift",
|
||||
"state" : {
|
||||
"revision" : "c01127cb51f591045696128effe43c16840d08bf",
|
||||
"version" : "5.2.0"
|
||||
"revision" : "57da4b1270cab7c2228919eabc0e4e1bf93e48ea",
|
||||
"version" : "5.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -105,8 +105,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "80c8b2023a5efb4415a2c615acfec075e5c243d2"
|
||||
"revision" : "f6afa0132961d593f07970d84e2d8b588c29ea04",
|
||||
"version" : "5.19.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -123,8 +123,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
|
||||
"state" : {
|
||||
"revision" : "261b6cec35686d2dc192b809ab50742b4502a73b",
|
||||
"version" : "2.2.6"
|
||||
"revision" : "53573d6dd017e354c0e7d8f1c86b77ef1383c996",
|
||||
"version" : "2.2.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -132,8 +132,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git",
|
||||
"state" : {
|
||||
"revision" : "8a33fb3ca75a01267f775f891f7d61f675e95072",
|
||||
"version" : "0.14.5"
|
||||
"revision" : "f534cfe830a7807ecc3d0332127a502426cfa067",
|
||||
"version" : "0.14.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -159,8 +159,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "2abb11839f80ebb07a58ac5e146a1da664260c16"
|
||||
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -177,10 +177,10 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SwiftyJSON/SwiftyJSON.git",
|
||||
"state" : {
|
||||
"revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
|
||||
"version" : "5.0.1"
|
||||
"revision" : "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828",
|
||||
"version" : "5.0.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
ReferencedContainer = "container:Yattee.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "IDEPreferLogStreaming"
|
||||
value = "Yes"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -19,4 +19,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
|
||||
if url.scheme == "yattee" {
|
||||
OpenURLHandler.handle(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user