mirror of
https://github.com/yattee/yattee.git
synced 2025-12-14 12:08:15 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46725bf4d9 | ||
|
|
8697ec8faf | ||
|
|
8a015d29c3 | ||
|
|
4097d11b5e | ||
|
|
5323d53f9e | ||
|
|
e3e0c4a92f | ||
|
|
9e5efc1aa6 | ||
|
|
1ed4c20c3a | ||
|
|
ced9eb28d7 | ||
|
|
ea49758ed2 | ||
|
|
65ec675859 | ||
|
|
9a650799d3 | ||
|
|
ddd1f243f7 | ||
|
|
94f19d55c8 | ||
|
|
30cdaf88e1 | ||
|
|
8139bba31e | ||
|
|
d6cfadab9a | ||
|
|
5b917ef91d | ||
|
|
34cb7860b3 | ||
|
|
934bd65752 | ||
|
|
e53985534e | ||
|
|
03e4c6d4e6 | ||
|
|
335e99cb7b | ||
|
|
ae9aa6fac7 | ||
|
|
2f4fb9fc67 | ||
|
|
f6bea6e045 | ||
|
|
fa712d8177 | ||
|
|
03d24fbc42 | ||
|
|
4fd3a37705 | ||
|
|
a66857b1fb | ||
|
|
e44c7f84c8 | ||
|
|
6b5ecbdd8b | ||
|
|
15ce82a686 | ||
|
|
7e3e393c65 | ||
|
|
108b4de483 | ||
|
|
7c9810ddf0 | ||
|
|
96df7fdec5 | ||
|
|
4fa5a15ad4 | ||
|
|
c9125644ed | ||
|
|
4db02b2638 | ||
|
|
9c5f066e55 | ||
|
|
c7908d08ae | ||
|
|
c9fb41c8e8 | ||
|
|
2e9cceafa5 | ||
|
|
fa09b2021c | ||
|
|
90777d91f6 | ||
|
|
6959778775 | ||
|
|
0f43efef6f | ||
|
|
959fb0d1fc | ||
|
|
81be57904b | ||
|
|
a42345896d | ||
|
|
43fc9e20c0 | ||
|
|
1a1bd1ba5b | ||
|
|
99aca8e23c |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,8 +1,8 @@
|
||||
## Build 183
|
||||
* Conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/662
|
||||
* HomeView: Changes to Favourites and History Widget by @stonerl in https://github.com/yattee/yattee/pull/672
|
||||
* Snappy UI - Offloading non UI task to background threads by @stonerl in https://github.com/yattee/yattee/pull/671
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/673
|
||||
## Build 186
|
||||
* tvOS: Refined Subscriptions View by @patelhiren in https://github.com/yattee/yattee/pull/697
|
||||
* More responsive UI when Favorites are used. by @stonerl in https://github.com/yattee/yattee/pull/695
|
||||
* Improved conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/696
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/694
|
||||
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
@@ -36,6 +36,19 @@
|
||||
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
|
||||
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
|
||||
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
|
||||
* Conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/662
|
||||
* HomeView: Changes to Favourites and History Widget by @stonerl in https://github.com/yattee/yattee/pull/672
|
||||
* Snappy UI - Offloading non UI task to background threads by @stonerl in https://github.com/yattee/yattee/pull/671
|
||||
* Fix PiP Mode Not Working Using MPV by @stonerl in https://github.com/yattee/yattee/pull/676
|
||||
* Fix thumbnails failing to load on tvOS by @patelhiren in https://github.com/yattee/yattee/pull/688
|
||||
* speed up sorting for Stream by @stonerl in https://github.com/yattee/yattee/pull/681
|
||||
* faster chapter extraction by @stonerl in https://github.com/yattee/yattee/pull/682
|
||||
* Invidious: add images to chapters by @stonerl in https://github.com/yattee/yattee/pull/685
|
||||
* Improved Captions handling by @stonerl in https://github.com/yattee/yattee/pull/684
|
||||
* Add User-Agent to request by @stonerl in https://github.com/yattee/yattee/pull/680
|
||||
* MPV: speed up playback start by @stonerl in https://github.com/yattee/yattee/pull/689
|
||||
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
|
||||
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
|
||||
18
Gemfile.lock
18
Gemfile.lock
@@ -57,17 +57,17 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.931.0)
|
||||
aws-sdk-core (3.196.1)
|
||||
aws-partitions (1.944.0)
|
||||
aws-sdk-core (3.197.0)
|
||||
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.81.0)
|
||||
aws-sdk-core (~> 3, >= 3.193.0)
|
||||
aws-sdk-kms (1.83.0)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.151.0)
|
||||
aws-sdk-core (~> 3, >= 3.194.0)
|
||||
aws-sdk-s3 (1.152.2)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
@@ -153,7 +153,7 @@ GEM
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
http-cookie (1.0.6)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
@@ -177,8 +177,8 @@ GEM
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rexml (3.2.9)
|
||||
strscan
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
|
||||
@@ -502,7 +502,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: extractChapters(from: description),
|
||||
chapters: createChapters(from: description, thumbnails: json),
|
||||
captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
@@ -575,6 +575,22 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
||||
var chapters = extractChapters(from: description)
|
||||
|
||||
if !chapters.isEmpty {
|
||||
let thumbnailsData = extractThumbnails(from: thumbnails)
|
||||
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
||||
|
||||
for chapter in chapters.indices {
|
||||
if let url = thumbnailURL {
|
||||
chapters[chapter].image = url
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
||||
|
||||
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||
|
||||
@@ -152,58 +152,94 @@ extension VideosAPI {
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
start - end - title / start - end: Title / start - end title
|
||||
start - title / start: title / start title / [start] - title / [start]: title / [start] title
|
||||
index. title - start / index. title start
|
||||
title: (start)
|
||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
||||
3) "index. title - start" / "index. title start"
|
||||
4) "title: (start)"
|
||||
5) "(start) title"
|
||||
|
||||
The order is important!
|
||||
These represent:
|
||||
|
||||
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
||||
- "title" is the name of the chapter
|
||||
- "index" is the chapter's position in a list
|
||||
|
||||
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
||||
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
||||
*/
|
||||
let patterns = [
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)"
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
||||
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
||||
]
|
||||
|
||||
for pattern in patterns {
|
||||
guard let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue }
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractChaptersGroup = DispatchGroup()
|
||||
var capturedChapters: [Int: [Chapter]] = [:]
|
||||
let lock = NSLock()
|
||||
|
||||
if !chapterLines.isEmpty {
|
||||
return chapterLines.compactMap { line in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
for (index, pattern) in patterns.enumerated() {
|
||||
extractChaptersGroup.enter()
|
||||
DispatchQueue.global().async {
|
||||
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return Chapter(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return .init(title: titleCapture, start: startSeconds)
|
||||
if !extractedChapters.isEmpty {
|
||||
lock.lock()
|
||||
capturedChapters[index] = extractedChapters
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
extractChaptersGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
extractChaptersGroup.wait()
|
||||
|
||||
// Now we sort the keys of the capturedChapters dictionary.
|
||||
// These keys correspond to the priority of each pattern.
|
||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
||||
|
||||
// Return first non-empty result in the order of patterns
|
||||
for key in sortedKeys {
|
||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
return []
|
||||
|
||||
@@ -232,7 +232,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset = AVURLAsset(
|
||||
url: url,
|
||||
options: ["AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "\(UserAgentManager.shared.userAgent)"]]
|
||||
)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
|
||||
@@ -217,9 +217,22 @@ final class MPVBackend: PlayerBackend {
|
||||
#endif
|
||||
|
||||
var captions: Captions?
|
||||
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
|
||||
captions = video.captions.first { $0.code == captionsLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsLanguageCode) }
|
||||
|
||||
if Defaults[.captionsAutoShow] == true {
|
||||
let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode],
|
||||
captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode]
|
||||
|
||||
// Try to get captions with the default language code first
|
||||
captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsDefaultLanguageCode) }
|
||||
|
||||
// If there are still no captions, try to get captions with the fallback language code
|
||||
if captions.isNil && !captionsFallbackLanguageCode.isEmpty {
|
||||
captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsFallbackLanguageCode) }
|
||||
}
|
||||
} else {
|
||||
captions = nil
|
||||
}
|
||||
|
||||
let updateCurrentStream = {
|
||||
@@ -254,9 +267,8 @@ 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 Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() }
|
||||
PlayerModel.shared.captions = self.captions
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
|
||||
@@ -60,14 +60,43 @@ final class MPVClient: ObservableObject {
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#endif
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
|
||||
// CACHING //
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", Defaults[.mpvCachePauseInital] ? "yes" : "no"))
|
||||
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
|
||||
|
||||
// PLAYBACK //
|
||||
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
||||
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
|
||||
checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize]))
|
||||
checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor]))
|
||||
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
||||
|
||||
// GPU //
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
|
||||
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
|
||||
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
||||
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "0"))
|
||||
|
||||
#if !os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
||||
#endif
|
||||
|
||||
// We set this to ordered since we use OpenGL and Apple's implementation is ancient.
|
||||
checkError(mpv_set_option_string(mpv, "dither", "ordered"))
|
||||
|
||||
// DEMUXER //
|
||||
|
||||
// We request to test for lavf first and skip probing other demuxer.
|
||||
checkError(mpv_set_option_string(mpv, "demuxer", "lavf"))
|
||||
checkError(mpv_set_option_string(mpv, "audio-demuxer", "lavf"))
|
||||
checkError(mpv_set_option_string(mpv, "sub-demuxer", "lavf"))
|
||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-probe-info", Defaults[.mpvDemuxerLavfProbeInfo]))
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
@@ -405,6 +434,22 @@ final class MPVClient: ObservableObject {
|
||||
setString("video", "no")
|
||||
}
|
||||
|
||||
func setSubToAuto() {
|
||||
setString("sub", "auto")
|
||||
}
|
||||
|
||||
func setSubToNo() {
|
||||
setString("sub", "no")
|
||||
}
|
||||
|
||||
func setSubFontSize(scaleSize: String) {
|
||||
setString("sub-scale", scaleSize)
|
||||
}
|
||||
|
||||
func setSubFontColor(color: String) {
|
||||
setString("sub-color", color)
|
||||
}
|
||||
|
||||
var tracksCount: Int {
|
||||
Int(getString("track-list/count") ?? "-1") ?? -1
|
||||
}
|
||||
|
||||
@@ -133,23 +133,22 @@ extension PlayerBackend {
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
||||
// filter out non HLS streams
|
||||
let nonHLSStreams = streams.filter { $0.kind != .hls }
|
||||
// filter out non-HLS streams and streams with resolution more than maxResolution
|
||||
let nonHLSStreams = streams.filter {
|
||||
$0.kind != .hls && $0.resolution <= maxResolution.value
|
||||
}
|
||||
|
||||
// find max resolution from non HLS streams
|
||||
let bestResolution = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.resolution < $1.resolution }
|
||||
// find max resolution and bitrate from non-HLS streams
|
||||
let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution }
|
||||
let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
|
||||
// finde max bitrate from non HLS streams
|
||||
let bestBitrate = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value
|
||||
let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate
|
||||
|
||||
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.resolution = bestResolution
|
||||
stream.bitrate = bestBitrate
|
||||
stream.format = .hls
|
||||
} else if stream.kind == .stream {
|
||||
stream.format = .stream
|
||||
|
||||
@@ -683,10 +683,11 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard let video = currentVideo else { return }
|
||||
guard let stream = backend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: qualityProfile!.formats) else { return }
|
||||
// First, we need to create an array with supported formats.
|
||||
let formatOrderPiP: [QualityProfile.Format] = [.hls, .stream, .mp4]
|
||||
|
||||
exitFullScreen()
|
||||
guard let video = currentVideo else { return }
|
||||
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: formatOrderPiP) else { return }
|
||||
|
||||
if avPlayerBackend.video == video {
|
||||
if activeBackend != .appleAVPlayer {
|
||||
@@ -698,7 +699,19 @@ final class PlayerModel: ObservableObject {
|
||||
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
|
||||
}
|
||||
|
||||
controls.objectWillChange.send()
|
||||
var retryCount = 0
|
||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||
if let pipController = self?.pipController, pipController.isPictureInPictureActive, self?.avPlayerBackend.isPlaying == true {
|
||||
self?.exitFullScreen()
|
||||
self?.controls.objectWillChange.send()
|
||||
timer.invalidate()
|
||||
} else if retryCount < 3, self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.startPictureInPictureOnSwitch == false {
|
||||
// If PiP didn't start, try starting it again up to 3 times,
|
||||
self?.avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||
self?.avPlayerBackend.tryStartingPictureInPicture()
|
||||
retryCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var transitioningToPiP: Bool {
|
||||
@@ -726,12 +739,19 @@ final class PlayerModel: ObservableObject {
|
||||
show()
|
||||
#endif
|
||||
|
||||
backend.closePiP()
|
||||
if previousActiveBackend == .mpv {
|
||||
saveTime {
|
||||
self.changeActiveBackend(from: self.activeBackend, to: .mpv, isInClosePip: true)
|
||||
self.controls.resetTimer()
|
||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||
if self?.activeBackend == .mpv, self?.mpvBackend.isPlaying == true {
|
||||
self?.backend.closePiP()
|
||||
self?.controls.resetTimer()
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
backend.closePiP()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,82 +56,101 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance _: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
|
||||
// Queue for stream processing
|
||||
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue", qos: .userInitiated)
|
||||
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue")
|
||||
// Queue for accessing the processedStreams array
|
||||
let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
|
||||
// DispatchGroup for managing multiple tasks
|
||||
let streamProcessingGroup = DispatchGroup()
|
||||
|
||||
var processedStreams = [Stream]()
|
||||
let instance = instance
|
||||
|
||||
var hasForbiddenAsset = false
|
||||
var hasAllowedAsset = false
|
||||
|
||||
for stream in streams {
|
||||
streamProcessingQueue.async(group: streamProcessingGroup) {
|
||||
let forbiddenAssetTestGroup = DispatchGroup()
|
||||
var hasForbiddenAsset = false
|
||||
if !hasAllowedAsset, !hasForbiddenAsset, !instance.proxiesVideos, stream.format != Stream.Format.unknown {
|
||||
let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
|
||||
if let firstStream = nonHLSAssets.first {
|
||||
let asset = firstStream.0
|
||||
let url = firstStream.1
|
||||
let requestRange = firstStream.2
|
||||
|
||||
let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
|
||||
|
||||
if let randomStream = nonHLSAssets.randomElement() {
|
||||
let instance = randomStream.0
|
||||
let asset = randomStream.1
|
||||
let url = randomStream.2
|
||||
let requestRange = randomStream.3
|
||||
|
||||
// swiftlint:disable:next shorthand_optional_binding
|
||||
if let asset = asset, let instance = instance, !instance.proxiesVideos {
|
||||
if instance.app == .invidious {
|
||||
self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
|
||||
hasForbiddenAsset = isForbidden
|
||||
self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if instance.app == .piped {
|
||||
self.testPipedAssets(asset: asset, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
|
||||
hasForbiddenAsset = isForbidden
|
||||
self.testPipedAssets(asset: asset!, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let randomHLS = hlsURLs.randomElement() {
|
||||
let instance = randomHLS.0
|
||||
let asset = AVURLAsset(url: randomHLS.1)
|
||||
|
||||
if instance?.app == .piped {
|
||||
self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
|
||||
hasForbiddenAsset = isForbidden
|
||||
} else if let firstHLS = hlsURLs.first {
|
||||
let asset = AVURLAsset(url: firstHLS)
|
||||
if instance.app == .piped {
|
||||
self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forbiddenAssetTestGroup.wait()
|
||||
|
||||
// Post-processing code
|
||||
if let instance = stream.instance {
|
||||
if instance.app == .invidious {
|
||||
if hasForbiddenAsset || instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
if instance.app == .invidious, hasForbiddenAsset || instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
}
|
||||
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
|
||||
if let hlsURL = stream.hlsURL {
|
||||
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
|
||||
if let nonProxiedURL = possibleNonProxiedURL {
|
||||
stream.hlsURL = nonProxiedURL.url
|
||||
}
|
||||
}
|
||||
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
|
||||
if let hlsURL = stream.hlsURL {
|
||||
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
|
||||
if let nonProxiedURL = possibleNonProxiedURL {
|
||||
stream.hlsURL = nonProxiedURL.url
|
||||
}
|
||||
} else {
|
||||
if let audio = stream.audioAsset {
|
||||
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
|
||||
stream.audioAsset = nonProxiedAudioAsset
|
||||
}
|
||||
} else {
|
||||
if let audio = stream.audioAsset {
|
||||
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
|
||||
stream.audioAsset = nonProxiedAudioAsset
|
||||
}
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
|
||||
stream.videoAsset = nonProxiedVideoAsset
|
||||
}
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
|
||||
stream.videoAsset = nonProxiedVideoAsset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,21 +171,21 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(Instance?, AVURLAsset?, URL, String?)], hlsURLs: [(Instance?, URL)]) {
|
||||
var nonHLSAssets = [(Instance?, AVURLAsset?, URL, String?)]()
|
||||
var hlsURLs = [(Instance?, URL)]()
|
||||
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(AVURLAsset?, URL, String?)], hlsURLs: [URL]) {
|
||||
var nonHLSAssets = [(AVURLAsset?, URL, String?)]()
|
||||
var hlsURLs = [URL]()
|
||||
|
||||
for stream in streams {
|
||||
if stream.isHLS {
|
||||
if let url = stream.hlsURL?.url {
|
||||
hlsURLs.append((stream.instance, url))
|
||||
hlsURLs.append(url)
|
||||
}
|
||||
} else {
|
||||
if let asset = stream.audioAsset {
|
||||
nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
|
||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
||||
}
|
||||
if let asset = stream.videoAsset {
|
||||
nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
|
||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,33 +193,35 @@ extension PlayerModel {
|
||||
return (nonHLSAssets, hlsURLs)
|
||||
}
|
||||
|
||||
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> Void) {
|
||||
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
||||
// In case the range is nil, generate a random one.
|
||||
let randomEnd = Int.random(in: 200 ... 800)
|
||||
let requestRange = range ?? "0-\(randomEnd)"
|
||||
|
||||
forbiddenAssetTestGroup.enter()
|
||||
URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
|
||||
completion(statusCode == HTTPStatus.Forbidden)
|
||||
completion(statusCode)
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> Void) {
|
||||
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
||||
PipedAPI.nonProxiedAsset(asset: asset) { possibleNonProxiedAsset in
|
||||
if let nonProxiedAsset = possibleNonProxiedAsset {
|
||||
self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
|
||||
} else {
|
||||
completion(false)
|
||||
completion(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||
if lhs.resolution.isNil || rhs.resolution.isNil {
|
||||
func streamsSorter(lhs: Stream, rhs: Stream) -> Bool {
|
||||
// Use optional chaining to simplify nil handling
|
||||
guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else {
|
||||
return lhs.kind < rhs.kind
|
||||
}
|
||||
|
||||
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||
// Compare either kind or resolution based on conditions
|
||||
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,8 @@ enum Constants {
|
||||
#elseif os(iOS)
|
||||
if isIPad {
|
||||
return .sidebar
|
||||
} else {
|
||||
return .tab
|
||||
}
|
||||
return .tab
|
||||
#else
|
||||
return .tab
|
||||
#endif
|
||||
|
||||
@@ -170,7 +170,7 @@ extension Defaults.Keys {
|
||||
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 hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p30, 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)
|
||||
@@ -266,7 +266,10 @@ extension Defaults.Keys {
|
||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
|
||||
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
|
||||
static let mpvCachePauseInital = Key<Bool>("mpvCachePauseInitial", default: false)
|
||||
static let mpvDeinterlace = Key<Bool>("mpvDeinterlace", default: false)
|
||||
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
|
||||
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
|
||||
|
||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||
@@ -300,7 +303,12 @@ extension Defaults.Keys {
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
|
||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||
static let captionsAutoShow = Key<Bool>("captionsAutoShow", default: false)
|
||||
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
||||
static let captionsDefaultLanguageCode = Key<String>("captionsDefaultLanguageCode", default: LanguageCodes.English.rawValue)
|
||||
static let captionsFallbackLanguageCode = Key<String>("captionsDefaultFallbackCode", default: LanguageCodes.English.rawValue)
|
||||
static let captionsFontScaleSize = Key<String>("captionsFontScale", default: "1.0")
|
||||
static let captionsFontColor = Key<String>("captionsFontColor", default: "#FFFFFF")
|
||||
|
||||
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
||||
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
||||
|
||||
@@ -88,26 +88,38 @@ struct FavoriteItemView: View {
|
||||
reloadVisibleWatches()
|
||||
} else {
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource()
|
||||
DispatchQueue.main.async {
|
||||
self.loadCacheAndResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
}
|
||||
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: hideShorts) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: hideWatched) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: favoritesChanged) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: player.currentVideo) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
.onChange(of: hideShorts) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
.onChange(of: hideWatched) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
// Delay is necessary to update the list with the new items.
|
||||
.onChange(of: favoritesChanged) { _ in if !player.presentingPlayer { Delay.by(1.0) { reloadVisibleWatches() } } }
|
||||
.onChange(of: player.presentingPlayer) { _ in
|
||||
if player.presentingPlayer {
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
} else {
|
||||
resource?.addObserver(store)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.id(watchModel.historyToken)
|
||||
.onChange(of: accounts.current) { _ in
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource(force: true)
|
||||
DispatchQueue.main.async {
|
||||
loadCacheAndResource(force: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: watchModel.historyToken) { _ in
|
||||
reloadVisibleWatches()
|
||||
if !player.presentingPlayer {
|
||||
reloadVisibleWatches()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,24 +171,26 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
|
||||
func reloadVisibleWatches() {
|
||||
guard item.section == .history else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard item.section == .history else { return }
|
||||
|
||||
visibleWatches = []
|
||||
visibleWatches = []
|
||||
|
||||
let watches = Array(
|
||||
watches
|
||||
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
|
||||
.prefix(favoritesModel.limit(item))
|
||||
)
|
||||
let last = watches.last
|
||||
let watches = Array(
|
||||
watches
|
||||
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
|
||||
.prefix(favoritesModel.limit(item))
|
||||
)
|
||||
let last = watches.last
|
||||
|
||||
for watch in watches {
|
||||
player.loadHistoryVideoDetails(watch) {
|
||||
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
|
||||
visibleWatches.append(watch)
|
||||
for watch in watches {
|
||||
player.loadHistoryVideoDetails(watch) {
|
||||
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
|
||||
visibleWatches.append(watch)
|
||||
|
||||
if watch == last {
|
||||
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
|
||||
if watch == last {
|
||||
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +241,9 @@ struct FavoriteItemView: View {
|
||||
onSuccess = { response in
|
||||
if let videos: [Video] = response.typedContent() {
|
||||
FeedCacheModel.shared.storeFeed(account: accounts.current, videos: videos)
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .channel(_, id, name):
|
||||
@@ -239,19 +256,21 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
|
||||
onSuccess = { response in
|
||||
if let channel: Channel = response.typedContent() {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: channel.videos)
|
||||
} else if let videos: [Video] = response.typedContent() {
|
||||
channel.videos = videos
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: videos)
|
||||
} else if let channelPage: ChannelPage = response.typedContent() {
|
||||
if let channel = channelPage.channel {
|
||||
DispatchQueue.main.async {
|
||||
if let channel: Channel = response.typedContent() {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
}
|
||||
store.contentItems = ContentItem.array(of: channel.videos)
|
||||
} else if let videos: [Video] = response.typedContent() {
|
||||
channel.videos = videos
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: videos)
|
||||
} else if let channelPage: ChannelPage = response.typedContent() {
|
||||
if let channel = channelPage.channel {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
}
|
||||
|
||||
store.contentItems = channelPage.results
|
||||
store.contentItems = channelPage.results
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .channelPlaylist(_, id, title):
|
||||
@@ -264,6 +283,9 @@ struct FavoriteItemView: View {
|
||||
onSuccess = { response in
|
||||
if let playlist: ChannelPlaylist = response.typedContent() {
|
||||
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .playlist(_, id):
|
||||
@@ -272,12 +294,16 @@ struct FavoriteItemView: View {
|
||||
if let playlist = playlists.first(where: { $0.id == id }) {
|
||||
contentItems = ContentItem.array(of: playlist.videos)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
default:
|
||||
contentItems = []
|
||||
}
|
||||
|
||||
if !contentItems.isEmpty {
|
||||
store.contentItems = contentItems
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
}
|
||||
|
||||
if force {
|
||||
|
||||
@@ -5,9 +5,11 @@ import UniformTypeIdentifiers
|
||||
|
||||
struct HomeView: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@State private var presentingHomeSettings = false
|
||||
@State private var favoritesChanged = false
|
||||
@State private var updateTask: Task<Void, Never>?
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
@@ -16,8 +18,6 @@ struct HomeView: View {
|
||||
@State private var recentDocumentsID = UUID()
|
||||
#endif
|
||||
|
||||
var favoritesObserver: Any?
|
||||
|
||||
#if !os(tvOS)
|
||||
@Default(.favorites) private var favorites
|
||||
@Default(.widgetsSettings) private var widgetsSettings
|
||||
@@ -124,6 +124,24 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
updateTask?.cancel()
|
||||
}
|
||||
|
||||
.onChange(of: player.presentingPlayer) { _ in
|
||||
if player.presentingPlayer {
|
||||
updateTask?.cancel()
|
||||
} else {
|
||||
Task {
|
||||
for await _ in Defaults.updates(.favorites) {
|
||||
favoritesChanged.toggle()
|
||||
}
|
||||
for await _ in Defaults.updates(.widgetsSettings) {
|
||||
favoritesChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.redrawOn(change: favoritesChanged)
|
||||
|
||||
|
||||
109
Shared/LanguageCodes.swift
Normal file
109
Shared/LanguageCodes.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
enum LanguageCodes: String, CaseIterable {
|
||||
case Afrikaans = "af"
|
||||
case Arabic = "ar"
|
||||
case Azerbaijani = "az"
|
||||
case Bengali = "bn"
|
||||
case Catalan = "ca"
|
||||
case Czech = "cs"
|
||||
case Welsh = "cy"
|
||||
case Danish = "da"
|
||||
case German = "de"
|
||||
case Greek = "el"
|
||||
case English = "en"
|
||||
case English_GB = "en-GB"
|
||||
case Spanish = "es"
|
||||
case Persian = "fa"
|
||||
case Finnish = "fi"
|
||||
case Filipino = "fil"
|
||||
case French = "fr"
|
||||
case Irish = "ga"
|
||||
case Hebrew = "he"
|
||||
case Hindi = "hi"
|
||||
case Hungarian = "hu"
|
||||
case Indonesian = "id"
|
||||
case Italian = "it"
|
||||
case Japanese = "ja"
|
||||
case Javanese = "jv"
|
||||
case Korean = "ko"
|
||||
case Lithuanian = "lt"
|
||||
case Malay = "ms"
|
||||
case Maltese = "mt"
|
||||
case Dutch = "nl"
|
||||
case Norwegian = "no"
|
||||
case Polish = "pl"
|
||||
case Portuguese = "pt"
|
||||
case Romanian = "ro"
|
||||
case Russian = "ru"
|
||||
case Slovak = "sk"
|
||||
case Slovene = "sl"
|
||||
case Swedish = "sv"
|
||||
case Swahili = "sw"
|
||||
case Thai = "th"
|
||||
case Tagalog = "tl"
|
||||
case Turkish = "tr"
|
||||
case Ukrainian = "uk"
|
||||
case Urdu = "ur"
|
||||
case Uzbek = "uz"
|
||||
case Vietnamese = "vi"
|
||||
case Xhosa = "xh"
|
||||
case Chinese = "zh"
|
||||
case Zulu = "zu"
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .Afrikaans: return "Afrikaans"
|
||||
case .Arabic: return "Arabic"
|
||||
case .Azerbaijani: return "Azerbaijani"
|
||||
case .Bengali: return "Bengali"
|
||||
case .Catalan: return "Catalan"
|
||||
case .Czech: return "Czech"
|
||||
case .Welsh: return "Welsh"
|
||||
case .Danish: return "Danish"
|
||||
case .German: return "German"
|
||||
case .Greek: return "Greek"
|
||||
case .English: return "English"
|
||||
case .English_GB: return "English (United Kingdom)"
|
||||
case .Spanish: return "Spanish"
|
||||
case .Persian: return "Persian"
|
||||
case .Finnish: return "Finnish"
|
||||
case .Filipino: return "Filipino"
|
||||
case .French: return "French"
|
||||
case .Irish: return "Irish"
|
||||
case .Hebrew: return "Hebrew"
|
||||
case .Hindi: return "Hindi"
|
||||
case .Hungarian: return "Hungarian"
|
||||
case .Indonesian: return "Indonesian"
|
||||
case .Italian: return "Italian"
|
||||
case .Japanese: return "Japanese"
|
||||
case .Javanese: return "Javanese"
|
||||
case .Korean: return "Korean"
|
||||
case .Lithuanian: return "Lithuanian"
|
||||
case .Malay: return "Malay"
|
||||
case .Maltese: return "Maltese"
|
||||
case .Dutch: return "Dutch"
|
||||
case .Norwegian: return "Norwegian"
|
||||
case .Polish: return "Polish"
|
||||
case .Portuguese: return "Portuguese"
|
||||
case .Romanian: return "Romanian"
|
||||
case .Russian: return "Russian"
|
||||
case .Slovak: return "Slovak"
|
||||
case .Slovene: return "Slovene"
|
||||
case .Swedish: return "Swedish"
|
||||
case .Swahili: return "Swahili"
|
||||
case .Thai: return "Thai"
|
||||
case .Tagalog: return "Tagalog"
|
||||
case .Turkish: return "Turkish"
|
||||
case .Ukrainian: return "Ukrainian"
|
||||
case .Urdu: return "Urdu"
|
||||
case .Uzbek: return "Uzbek"
|
||||
case .Vietnamese: return "Vietnamese"
|
||||
case .Xhosa: return "Xhosa"
|
||||
case .Chinese: return "Chinese"
|
||||
case .Zulu: return "Zulu"
|
||||
}
|
||||
}
|
||||
|
||||
static func languageName(for code: String) -> String {
|
||||
return LanguageCodes(rawValue: code)?.description ?? "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -333,9 +333,19 @@ struct ControlsOverlay: View {
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = captionsBinding.wrappedValue {
|
||||
Text(captions.code)
|
||||
.foregroundColor(.primary)
|
||||
if let captions = captionsBinding.wrappedValue,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if captionsBinding.wrappedValue == nil {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 240)
|
||||
@@ -351,8 +361,18 @@ struct ControlsOverlay: View {
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = captionsBinding.wrappedValue {
|
||||
Text(captions.code)
|
||||
if let captions = captionsBinding.wrappedValue,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if captionsBinding.wrappedValue == nil {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
@@ -383,23 +384,35 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var captionsButton: some View {
|
||||
let videoCaptions = player.currentVideo?.captions
|
||||
#if os(macOS)
|
||||
captionsPicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
captionsPicker
|
||||
if videoCaptions?.isEmpty == false {
|
||||
captionsPicker
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = player.captions {
|
||||
Text(captions.code)
|
||||
if let captions = player.captions,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if videoCaptions?.isEmpty == true {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
.disabled(videoCaptions?.isEmpty == true)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -65,7 +65,7 @@ import SwiftUI
|
||||
}
|
||||
|
||||
static var thumbnailHeight: Double {
|
||||
thumbnailWidth / 1.7777
|
||||
thumbnailWidth / (16 / 9)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ struct AdvancedSettings: View {
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
@Default(.mpvCacheSecs) private var mpvCacheSecs
|
||||
@Default(.mpvCachePauseWait) private var mpvCachePauseWait
|
||||
@Default(.mpvCachePauseInital) private var mpvCachePauseInital
|
||||
@Default(.mpvDeinterlace) private var mpvDeinterlace
|
||||
@Default(.mpvEnableLogging) private var mpvEnableLogging
|
||||
@Default(.mpvHWdec) private var mpvHWdec
|
||||
@Default(.mpvDemuxerLavfProbeInfo) private var mpvDemuxerLavfProbeInfo
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
@Default(.feedCacheSize) private var feedCacheSize
|
||||
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
|
||||
@@ -68,9 +71,39 @@ struct AdvancedSettings: View {
|
||||
mpvEnableLoggingToggle
|
||||
#endif
|
||||
|
||||
Toggle(isOn: $mpvCachePauseInital) {
|
||||
HStack {
|
||||
Text("cache-pause-initial")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-initial")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("cache-secs")
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-secs")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
|
||||
#endif
|
||||
TextField("cache-secs", text: $mpvCacheSecs)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -79,8 +112,22 @@ struct AdvancedSettings: View {
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
HStack {
|
||||
Text("cache-pause-wait")
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
Group {
|
||||
Text("cache-pause-wait")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-wait")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}.frame(minWidth: 140, alignment: .leading)
|
||||
|
||||
TextField("cache-pause-wait", text: $mpvCachePauseWait)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -88,7 +135,75 @@ struct AdvancedSettings: View {
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
Toggle("deinterlace", isOn: $mpvDeinterlace)
|
||||
Toggle(isOn: $mpvDeinterlace) {
|
||||
HStack {
|
||||
Text("deinterlace")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-deinterlace")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("hwdec")
|
||||
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-hwdec")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
|
||||
Picker("", selection: $mpvHWdec) {
|
||||
ForEach(["auto", "auto-safe", "auto-copy"], id: \.self) {
|
||||
Text($0)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("demuxer-lavf-probe-info")
|
||||
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-demuxer-lavf-probe-info")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
|
||||
Picker("", selection: $mpvDemuxerLavfProbeInfo) {
|
||||
ForEach(["yes", "no", "auto", "nostreams"], id: \.self) {
|
||||
Text($0)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
if mpvEnableLogging {
|
||||
logButton
|
||||
@@ -103,20 +218,19 @@ struct AdvancedSettings: View {
|
||||
}
|
||||
|
||||
@ViewBuilder var mpvFooter: some View {
|
||||
let url = "https://mpv.io/manual/master"
|
||||
let url = "https://mpv.io/manual/stable/"
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Restart the app to apply the settings above.")
|
||||
.padding(.bottom, 1)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
#if os(tvOS)
|
||||
Text("More info can be found in MPV Documentation:")
|
||||
Text("More info can be found in MPV reference manual:")
|
||||
Text(url)
|
||||
#else
|
||||
Text("More info can be found in:")
|
||||
Link("MPV Documentation", destination: URL(string: url)!)
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
Text("Further information can be found in the ")
|
||||
+ Text("MPV reference manual").underline().bold()
|
||||
+ Text(" by clicking on the link icon next to the option.")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,19 @@ struct PlayerSettings: View {
|
||||
|
||||
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
||||
|
||||
@Default(.showRelated) private var showRelated
|
||||
@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
|
||||
|
||||
@Default(.captionsAutoShow) private var captionsAutoShow
|
||||
@Default(.captionsDefaultLanguageCode) private var captionsDefaultLanguageCode
|
||||
@Default(.captionsFallbackLanguageCode) private var captionsFallbackLanguageCode
|
||||
@Default(.captionsFontScaleSize) private var captionsFontScaleSize
|
||||
@Default(.captionsFontColor) private var captionsFontColor
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
@@ -45,6 +52,11 @@ struct PlayerSettings: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@State private var isShowingDefaultLanguagePicker = false
|
||||
@State private var isShowingFallbackLanguagePicker = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
@@ -93,7 +105,54 @@ struct PlayerSettings: View {
|
||||
inspectorVisibilityPicker
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(header: SettingsHeader(text: "Captions".localized())) {
|
||||
#if os(tvOS)
|
||||
Text("Size").font(.subheadline)
|
||||
#endif
|
||||
captionsFontScaleSizePicker
|
||||
#if os(tvOS)
|
||||
Text("Color").font(.subheadline)
|
||||
#endif
|
||||
captionsFontColorPicker
|
||||
showCaptionsAutoShowToggle
|
||||
|
||||
#if !os(tvOS)
|
||||
captionDefaultLanguagePicker
|
||||
captionFallbackLanguagePicker
|
||||
#else
|
||||
Button(action: { isShowingDefaultLanguagePicker = true }) {
|
||||
HStack {
|
||||
Text("Default language")
|
||||
Spacer()
|
||||
Text("\(LanguageCodes(rawValue: captionsDefaultLanguageCode)!.description.capitalized) (\(captionsDefaultLanguageCode))").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) {
|
||||
defaultLanguagePickerTVOS(
|
||||
selectedLanguage: $captionsDefaultLanguageCode,
|
||||
isShowing: $isShowingDefaultLanguagePicker
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { isShowingFallbackLanguagePicker = true }) {
|
||||
HStack {
|
||||
Text("Fallback language")
|
||||
Spacer()
|
||||
Text("\(LanguageCodes(rawValue: captionsFallbackLanguageCode)!.description.capitalized) (\(captionsFallbackLanguageCode))").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) {
|
||||
fallbackLanguagePickerTVOS(
|
||||
selectedLanguage: $captionsFallbackLanguageCode,
|
||||
isShowing: $isShowingFallbackLanguagePicker
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
Section(header: SettingsHeader(text: "Chapters".localized())) {
|
||||
showChaptersToggle
|
||||
showThumbnailsToggle
|
||||
@@ -279,6 +338,103 @@ struct PlayerSettings: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
private var showCaptionsAutoShowToggle: some View {
|
||||
Toggle("Always show captions", isOn: $captionsAutoShow)
|
||||
}
|
||||
|
||||
private var captionsFontScaleSizePicker: some View {
|
||||
Picker("Size", selection: $captionsFontScaleSize) {
|
||||
Text("Small").tag(String("0.5"))
|
||||
Text("Medium").tag(String("1.0"))
|
||||
Text("Large").tag(String("2.0"))
|
||||
}
|
||||
.onChange(of: captionsFontScaleSize) { _ in
|
||||
PlayerModel.shared.mpvBackend.client.setSubFontSize(scaleSize: captionsFontScaleSize)
|
||||
}
|
||||
#if os(macOS)
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var captionsFontColorPicker: some View {
|
||||
Picker("Color", selection: $captionsFontColor) {
|
||||
Text("White").tag(String("#FFFFFF"))
|
||||
Text("Yellow").tag(String("#FFFF00"))
|
||||
Text("Red").tag(String("#FF0000"))
|
||||
Text("Orange").tag(String("#FFA500"))
|
||||
Text("Green").tag(String("#008000"))
|
||||
Text("Blue").tag(String("#0000FF"))
|
||||
}
|
||||
.onChange(of: captionsFontColor) { _ in
|
||||
PlayerModel.shared.mpvBackend.client.setSubFontColor(color: captionsFontColor)
|
||||
}
|
||||
#if os(macOS)
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var captionDefaultLanguagePicker: some View {
|
||||
Picker("Default language", selection: $captionsDefaultLanguageCode) {
|
||||
ForEach(LanguageCodes.allCases, id: \.self) { language in
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var captionFallbackLanguagePicker: some View {
|
||||
Picker("Fallback language", selection: $captionsFallbackLanguageCode) {
|
||||
ForEach(LanguageCodes.allCases, id: \.self) { language in
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
struct defaultLanguagePickerTVOS: View {
|
||||
@Binding var selectedLanguage: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List(LanguageCodes.allCases, id: \.self) { language in
|
||||
Button(action: {
|
||||
selectedLanguage = language.rawValue
|
||||
isShowing = false
|
||||
}) {
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Default Language")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct fallbackLanguagePickerTVOS: View {
|
||||
@Binding var selectedLanguage: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List(LanguageCodes.allCases, id: \.self) { language in
|
||||
Button(action: {
|
||||
selectedLanguage = language.rawValue
|
||||
isShowing = false
|
||||
}) {
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Fallback Language")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
private var inspectorVisibilityPicker: some View {
|
||||
Picker("Inspector", selection: $showInspector) {
|
||||
|
||||
@@ -136,9 +136,20 @@ struct QualityProfileForm: View {
|
||||
|
||||
var formatsFooter: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Formats can be reordered and will be selected in this order.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if #available(iOS 16.0, *) {
|
||||
Text("Formats can be reordered and will be selected in this order.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if #available(iOS 14.0, *) {
|
||||
Text("Formats will be selected in the order they are listed.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
Text("Formats will be selected in the order they are listed.")
|
||||
.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)
|
||||
@@ -304,7 +315,7 @@ struct QualityProfileForm: View {
|
||||
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
||||
guard backend == .appleAVPlayer else { return false }
|
||||
|
||||
return resolution.value > .hd1080p60
|
||||
return resolution.value > .hd720p30
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftUI
|
||||
|
||||
struct FeedView: View {
|
||||
@ObservedObject private var feed = FeedModel.shared
|
||||
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
|
||||
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
|
||||
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
|
||||
@@ -12,10 +14,155 @@ struct FeedView: View {
|
||||
#endif
|
||||
|
||||
var videos: [ContentItem] {
|
||||
ContentItem.array(of: feed.videos)
|
||||
guard let selectedChannel = selectedChannel else {
|
||||
return ContentItem.array(of: feed.videos)
|
||||
}
|
||||
return ContentItem.array(of: feed.videos.filter {
|
||||
$0.channel.id == selectedChannel.id
|
||||
})
|
||||
}
|
||||
|
||||
var channels: [Channel] {
|
||||
feed.videos.map {
|
||||
$0.channel
|
||||
}.unique()
|
||||
}
|
||||
|
||||
@State private var selectedChannel: Channel?
|
||||
#if os(tvOS)
|
||||
@FocusState private var focusedChannel: String?
|
||||
#endif
|
||||
@State private var feedChannelsViewVisible = false
|
||||
private var navigation = NavigationModel.shared
|
||||
private let dismiss_channel_list_id = "dismiss_channel_list_id"
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// selected channel feed view
|
||||
HStack(spacing: 0) {
|
||||
// sidebar - show channels
|
||||
if feedChannelsViewVisible {
|
||||
Spacer()
|
||||
.frame(width: geometry.size.width * 0.3)
|
||||
}
|
||||
selectedFeedView
|
||||
}
|
||||
.disabled(feedChannelsViewVisible)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
|
||||
if feedChannelsViewVisible {
|
||||
HStack(spacing: 0) {
|
||||
// sidebar - show channels
|
||||
feedChannelsView
|
||||
.padding(.all)
|
||||
.frame(width: geometry.size.width * 0.3)
|
||||
.background()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.id(dismiss_channel_list_id)
|
||||
.focusable()
|
||||
.focused(self.$focusedChannel, equals: dismiss_channel_list_id)
|
||||
}
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
selectedFeedView
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
var feedChannelsView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack {
|
||||
Text("Channels")
|
||||
.font(.subheadline)
|
||||
if #available(tvOS 17.0, *) {
|
||||
List(selection: $selectedChannel) {
|
||||
Button(action: {
|
||||
self.selectedChannel = nil
|
||||
self.feedChannelsViewVisible = false
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: RecentsModel.symbolSystemImage("A"))
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 35, height: 35)
|
||||
Text("All")
|
||||
Spacer()
|
||||
feedCount.unwatchedText
|
||||
}
|
||||
}
|
||||
.padding(.all)
|
||||
.background(RoundedRectangle(cornerRadius: 8.0)
|
||||
.fill(self.selectedChannel == nil ? Color.secondary : Color.clear))
|
||||
.font(.caption)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.focused(self.$focusedChannel, equals: "all")
|
||||
|
||||
ForEach(channels, id: \.self) { channel in
|
||||
Button(action: {
|
||||
self.selectedChannel = channel
|
||||
self.feedChannelsViewVisible = false
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
ChannelAvatarView(channel: channel, subscribedBadge: false)
|
||||
.frame(width: 50, height: 50)
|
||||
Text(channel.name)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let unwatchedCount = feedCount.unwatchedByChannelText(channel) {
|
||||
unwatchedCount
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.all)
|
||||
.background(RoundedRectangle(cornerRadius: 8.0)
|
||||
.fill(self.selectedChannel == channel ? Color.secondary : Color.clear))
|
||||
.font(.caption)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.focused(self.$focusedChannel, equals: channel.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.focusedChannel) {
|
||||
if self.focusedChannel == "all" {
|
||||
withAnimation {
|
||||
self.selectedChannel = nil
|
||||
}
|
||||
} else if self.focusedChannel == dismiss_channel_list_id {
|
||||
self.feedChannelsViewVisible = false
|
||||
} else {
|
||||
withAnimation {
|
||||
self.selectedChannel = channels.first {
|
||||
$0.id == self.focusedChannel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard let selectedChannel = self.selectedChannel else {
|
||||
return
|
||||
}
|
||||
proxy.scrollTo(selectedChannel, anchor: .top)
|
||||
}
|
||||
.onExitCommand {
|
||||
withAnimation {
|
||||
self.feedChannelsViewVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var selectedFeedView: some View {
|
||||
VerticalCells(items: videos) { if shouldDisplayHeader { header } }
|
||||
.environment(\.loadMoreContentHandler) { feed.loadNextPage() }
|
||||
.onAppear {
|
||||
@@ -49,33 +196,51 @@ struct FeedView: View {
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
HStack(spacing: 16) {
|
||||
#if os(tvOS)
|
||||
SubscriptionsPageButton()
|
||||
ListingStyleButtons(listingStyle: $subscriptionsListingStyle)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
#endif
|
||||
|
||||
if showCacheStatus {
|
||||
Spacer()
|
||||
|
||||
CacheStatusHeader(
|
||||
refreshTime: feed.formattedFeedTime,
|
||||
isLoading: feed.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
feed.loadResources(force: true)
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
self.feedChannelsViewVisible = true
|
||||
self.focusedChannel = selectedChannel?.id ?? "all"
|
||||
}
|
||||
}) {
|
||||
Label("Channels", systemImage: "filemenu.and.selection")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
}
|
||||
.opacity(feedChannelsViewVisible ? 0 : 1)
|
||||
.frame(minWidth: feedChannelsViewVisible ? 0 : nil, maxWidth: feedChannelsViewVisible ? 0 : nil)
|
||||
channelHeaderView
|
||||
if selectedChannel == nil {
|
||||
Spacer()
|
||||
}
|
||||
if feedChannelsViewVisible == false {
|
||||
ListingStyleButtons(listingStyle: $subscriptionsListingStyle)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
#endif
|
||||
|
||||
if feedChannelsViewVisible == false {
|
||||
if showCacheStatus {
|
||||
CacheStatusHeader(
|
||||
refreshTime: feed.formattedFeedTime,
|
||||
isLoading: feed.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
feed.loadResources(force: true)
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.leading, 30)
|
||||
#if os(tvOS)
|
||||
@@ -84,6 +249,46 @@ struct FeedView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var channelHeaderView: some View {
|
||||
guard let selectedChannel = selectedChannel else {
|
||||
return AnyView(
|
||||
Text("All Channels")
|
||||
.font(.caption)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.padding(0)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
}
|
||||
|
||||
return AnyView(
|
||||
HStack(spacing: 16) {
|
||||
ChannelAvatarView(channel: selectedChannel, subscribedBadge: false)
|
||||
.id("channel-avatar-\(selectedChannel.id)")
|
||||
.frame(width: 80, height: 80)
|
||||
Text("\(selectedChannel.name)")
|
||||
.font(.caption)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Spacer()
|
||||
if feedChannelsViewVisible == false {
|
||||
Button(action: {
|
||||
navigation.openChannel(selectedChannel, navigationStyle: .tab)
|
||||
}) {
|
||||
Text("Visit Channel")
|
||||
.font(.caption)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(0)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
}
|
||||
|
||||
var shouldDisplayHeader: Bool {
|
||||
#if os(tvOS)
|
||||
true
|
||||
|
||||
@@ -20,6 +20,7 @@ enum URLTester {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.setValue("bytes=\(range)", forHTTPHeaderField: "Range")
|
||||
request.setValue(UserAgentManager.shared.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
var dataTask: URLSessionDataTask?
|
||||
dataTask = URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
|
||||
37
Shared/UserAgentManager.swift
Normal file
37
Shared/UserAgentManager.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Logging
|
||||
#if !os(tvOS)
|
||||
import WebKit
|
||||
#endif
|
||||
|
||||
final class UserAgentManager {
|
||||
static let shared = UserAgentManager()
|
||||
|
||||
private(set) var userAgent: String
|
||||
#if !os(tvOS)
|
||||
private var webView: WKWebView?
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
/*
|
||||
In case an error occurs while retrieving the actual User-Agent, and on tvOS,
|
||||
we set a default User-Agent value that represents a commonly used User-Agent.
|
||||
*/
|
||||
|
||||
userAgent = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)"
|
||||
#if !os(tvOS)
|
||||
webView = WKWebView()
|
||||
webView?.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in
|
||||
if let userAgent = result as? String {
|
||||
DispatchQueue.main.async {
|
||||
self?.userAgent = userAgent
|
||||
Logger(label: "stream.yattee.userAgentManager").info("User-Agent: \(userAgent)")
|
||||
}
|
||||
} else {
|
||||
Logger(label: "stream.yattee.userAgentManager").warning("Failed to update User-Agent.")
|
||||
}
|
||||
}
|
||||
#else
|
||||
Logger(label: "stream.yattee.userAgentManager.tvOS").info("User-Agent: \(userAgent)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,12 @@ struct YatteeApp: App {
|
||||
#if DEBUG
|
||||
SiestaLog.Category.enabled = .common
|
||||
#endif
|
||||
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
|
||||
#if os(tvOS)
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
#else
|
||||
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
|
||||
#endif
|
||||
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
|
||||
if !Defaults[.lastAccountIsPublic] {
|
||||
@@ -206,6 +211,9 @@ struct YatteeApp: App {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Initialize UserAgentManager
|
||||
_ = UserAgentManager.shared
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
URLBookmarkModel.shared.refreshAll()
|
||||
}
|
||||
|
||||
@@ -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)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).\n";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).";
|
||||
"Filter" = " عامل التصفية";
|
||||
"Frontend URL" = "عنوان URL للواجهة الأمامية";
|
||||
"Fullscreen size" = "حجم ملء الشاشة";
|
||||
@@ -628,4 +628,4 @@
|
||||
"Password required to import" = "كلمة المرور مطلوبة للإستيراد";
|
||||
"Password saved in import file" = "كلمة المرور محفوظة في ملف الإستيراد";
|
||||
"Export in progress..." = "جارِ التصدير...";
|
||||
"In progress..." = "في تَقَدم…";
|
||||
"In progress..." = "في طور الأجراء…";
|
||||
|
||||
@@ -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.\n";
|
||||
"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.";
|
||||
"Frontend URL" = "URL frontale";
|
||||
"Public Locations" = "Instances publiques";
|
||||
"Public Manifest" = "Manifeste publique";
|
||||
|
||||
43
Shared/ko.lproj/Localizable.strings
Normal file
43
Shared/ko.lproj/Localizable.strings
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
|
||||
"%@ Playlist" = "%@ 플레이리스트";
|
||||
"%@ subscribers" = "%@ 구독자";
|
||||
"10 seconds forwards/backwards" = "10초 건너뛰기/뒤로 가기";
|
||||
"Accounts" = "계정";
|
||||
"Accounts are not supported for the application of this instance" = "해당 애플리케이션 인스턴스는 계정을 지원하지 않습니다";
|
||||
"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 clear search history?" = "검색 기록을 삭제하시겠습니까?";
|
||||
"Are you sure you want to delete playlist?" = "플레이리스트를 삭제하시겠습니까?";
|
||||
"Are you sure you want to restore default quality profiles?" = "기본 품질 프로필로 복구하시겠습니까?";
|
||||
"Are you sure you want to unsubscribe from %@?" = "%@을/를 구독 해제하시겠습니까?";
|
||||
"Autoplaying Next" = "다음으로 자동재생";
|
||||
"Backend" = "백엔드";
|
||||
"Badge" = "배지";
|
||||
"Battery" = "배터리";
|
||||
"Cellular" = "셀룰러";
|
||||
" subscribers" = " 구독자";
|
||||
"%@ Channel" = "%@ 채널";
|
||||
"%lld videos" = "%lld 동영상";
|
||||
"Are you sure you want to clear history of watched videos?" = "동영상 시청 기록을 삭제하시겠습니까?";
|
||||
"Add Account" = "계정 추가";
|
||||
"Add Account..." = "계정 추가...";
|
||||
"Automatic" = "자동";
|
||||
"Close" = "닫기";
|
||||
@@ -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. clicar em um vídeo).\n";
|
||||
"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).";
|
||||
"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.";
|
||||
|
||||
@@ -1070,12 +1070,18 @@
|
||||
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
E24DC6582BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
|
||||
E24DC6592BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
|
||||
E24DC65A2BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
|
||||
E25028B02BF790F5002CB9FC /* HTTPStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */; };
|
||||
E25028B12BF790F5002CB9FC /* HTTPStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */; };
|
||||
E25028B22BF790F5002CB9FC /* HTTPStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */; };
|
||||
E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
|
||||
E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
|
||||
E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
|
||||
E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
|
||||
E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
|
||||
E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
|
||||
FA97174C2A494700001FF53D /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA97174B2A494700001FF53D /* MPVKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -1545,8 +1551,10 @@
|
||||
3DA101AD287C30F50027D920 /* DEVELOPMENT_TEAM.template.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.template.xcconfig; sourceTree = "<group>"; };
|
||||
3DA101AE287C30F50027D920 /* DEVELOPMENT_TEAM.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.xcconfig; sourceTree = "<group>"; };
|
||||
3DA101AF287C30F50027D920 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
|
||||
E24DC6572BFA124100BF6187 /* UserAgentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentManager.swift; sourceTree = "<group>"; };
|
||||
E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatus.swift; sourceTree = "<group>"; };
|
||||
E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = "<group>"; };
|
||||
E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCodes.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -2295,6 +2303,7 @@
|
||||
37D2E0D328B67EFC00F64D52 /* Delay.swift */,
|
||||
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
|
||||
E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */,
|
||||
E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */,
|
||||
375B537828DF6CBB004C1D19 /* Localizable.strings */,
|
||||
3729037D2739E47400EA99F6 /* MenuCommands.swift */,
|
||||
37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */,
|
||||
@@ -2303,6 +2312,7 @@
|
||||
37FFC43F272734C3009FFD26 /* Throttle.swift */,
|
||||
378FFBC328660172009E3FBE /* URLParser.swift */,
|
||||
E258F3892BF61BD2005B8C28 /* URLTester.swift */,
|
||||
E24DC6572BFA124100BF6187 /* UserAgentManager.swift */,
|
||||
37D4B0C22671614700C925CA /* YatteeApp.swift */,
|
||||
37D4B0C42671614800C925CA /* Assets.xcassets */,
|
||||
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
|
||||
@@ -3149,6 +3159,7 @@
|
||||
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
|
||||
3752069D285E910600CA655F /* ChapterView.swift in Sources */,
|
||||
375EC96A289F232600751258 /* QualityProfilesModel.swift in Sources */,
|
||||
E24DC6582BFA124100BF6187 /* UserAgentManager.swift in Sources */,
|
||||
E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */,
|
||||
3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */,
|
||||
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
||||
@@ -3225,6 +3236,7 @@
|
||||
37C3A24D272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */,
|
||||
37484C2D26FC844700287258 /* InstanceSettings.swift in Sources */,
|
||||
37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */,
|
||||
E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */,
|
||||
377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
||||
37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */,
|
||||
377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */,
|
||||
@@ -3405,6 +3417,7 @@
|
||||
371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
||||
3756C2AB2861151C00E4B059 /* NetworkStateModel.swift in Sources */,
|
||||
375EC95A289EEB8200751258 /* QualityProfileForm.swift in Sources */,
|
||||
E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */,
|
||||
37001564271B1F250049C794 /* AccountsModel.swift in Sources */,
|
||||
378FFBC528660172009E3FBE /* URLParser.swift in Sources */,
|
||||
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
|
||||
@@ -3413,6 +3426,7 @@
|
||||
3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
||||
374C053C2724614F009BDDBE /* PlayerTVMenu.swift in Sources */,
|
||||
377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */,
|
||||
E24DC6592BFA124100BF6187 /* UserAgentManager.swift in Sources */,
|
||||
374924DB2921050B0017D862 /* LocationsSettings.swift in Sources */,
|
||||
371AC0A0294D13AA0085989E /* UnwatchedFeedCountModel.swift in Sources */,
|
||||
379E7C342A20FE3900AF8118 /* FocusableSearchTextField.swift in Sources */,
|
||||
@@ -3878,6 +3892,7 @@
|
||||
37BA221329526A19000DAD1F /* ControlsGradientView.swift in Sources */,
|
||||
374C0541272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */,
|
||||
37130A61277657300033018A /* PersistenceController.swift in Sources */,
|
||||
E24DC65A2BFA124100BF6187 /* UserAgentManager.swift in Sources */,
|
||||
37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */,
|
||||
3717407F2949D40800FDDBC7 /* ChannelLinkView.swift in Sources */,
|
||||
E25028B22BF790F5002CB9FC /* HTTPStatus.swift in Sources */,
|
||||
@@ -4000,6 +4015,7 @@
|
||||
37FB28432721B22200A57617 /* ContentItem.swift in Sources */,
|
||||
37A7D6EF2B67E3BF009CB1ED /* BrowsingSettingsGroupImporter.swift in Sources */,
|
||||
37D2E0D228B67DBC00F64D52 /* AnimationCompletionObserverModifier.swift in Sources */,
|
||||
E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */,
|
||||
37A7D6EB2B67E334009CB1ED /* BrowsingSettingsGroupExporter.swift in Sources */,
|
||||
37AAF2A226741C97007FC770 /* FeedView.swift in Sources */,
|
||||
37484C1B26FC837400287258 /* PlayerSettings.swift in Sources */,
|
||||
@@ -4087,7 +4103,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
|
||||
@@ -4118,7 +4134,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Open in Yattee/Info.plist";
|
||||
@@ -4149,7 +4165,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
@@ -4169,7 +4185,7 @@
|
||||
buildSettings = {
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
@@ -4333,7 +4349,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
@@ -4386,7 +4402,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
|
||||
@@ -4438,7 +4454,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -4477,7 +4493,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -4512,7 +4528,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4536,7 +4552,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4562,7 +4578,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4587,7 +4603,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -4613,7 +4629,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -4653,7 +4669,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -4694,7 +4710,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -4718,7 +4734,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 186;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"location" : "https://github.com/hyperoslo/Cache.git",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "f44a8f6b5ec27730198725ccc542fef0d1cc6b3d"
|
||||
"revision" : "fd97438a85d44aa4f7186145cdba90e3c36f1c94"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ashleymills/Reachability.swift",
|
||||
"state" : {
|
||||
"revision" : "57da4b1270cab7c2228919eabc0e4e1bf93e48ea",
|
||||
"version" : "5.2.2"
|
||||
"revision" : "7cbd73f46a7dfaeca079e18df7324c6de6d1834a",
|
||||
"version" : "5.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -105,8 +105,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "5642d1ffe3dbe628592443bd14154e31929727b4",
|
||||
"version" : "5.19.2"
|
||||
"revision" : "b8523c1642f3c142b06dd98443ea7c48343a4dfd",
|
||||
"version" : "5.19.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user