Compare commits

...

52 Commits

Author SHA1 Message Date
Arkadiusz Fal
d6cfadab9a Bump build number to 185 2024-05-23 12:11:58 +02:00
Arkadiusz Fal
5b917ef91d Update CHANGELOG 2024-05-23 11:55:31 +02:00
Arkadiusz Fal
34cb7860b3 Fix build issues 2024-05-23 11:55:31 +02:00
Arkadiusz Fal
934bd65752 Fix swiftformat offenses 2024-05-23 11:44:58 +02:00
Arkadiusz Fal
e53985534e Update packages 2024-05-23 11:44:26 +02:00
Arkadiusz Fal
03e4c6d4e6 Merge pull request #689 from stonerl/more-mpv-settings
MPV: speed up playback start
2024-05-23 11:41:56 +02:00
Arkadiusz Fal
335e99cb7b Merge pull request #680 from stonerl/add-user-agent-to-header
Add User-Agent to request
2024-05-23 11:37:53 +02:00
Arkadiusz Fal
ae9aa6fac7 Merge branch 'main' into add-user-agent-to-header 2024-05-23 11:37:46 +02:00
Arkadiusz Fal
2f4fb9fc67 Merge pull request #684 from stonerl/better-caption-handling
Improved Captions handling
2024-05-23 11:37:03 +02:00
Arkadiusz Fal
f6bea6e045 Merge pull request #685 from stonerl/chapter-images-for-invidious
Invidious: add images to chapters
2024-05-23 11:35:51 +02:00
Arkadiusz Fal
fa712d8177 Merge pull request #688 from patelhiren/main
Fix thumbnails failing to load on tvOS
2024-05-23 11:34:19 +02:00
Arkadiusz Fal
03d24fbc42 Merge pull request #682 from stonerl/faster-chapter-extraction
faster chapter extraction
2024-05-23 11:34:07 +02:00
Arkadiusz Fal
4fd3a37705 Merge pull request #681 from stonerl/speed-up-sorting
speed up sorting for Stream
2024-05-23 11:33:51 +02:00
Arkadiusz Fal
a66857b1fb Merge pull request #683 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2024-05-23 11:33:14 +02:00
Toni Förster
e44c7f84c8 MPV: speed up playback start
We initialize MPV with more options so it does not have to guess. This dramatically speeds up playback start.
2024-05-23 10:53:45 +02:00
Hiren Patel
6b5ecbdd8b - Fix thumbnails failing to load on tvOS
Thumbnails fail to load on tvOS when using SDImageAWebPCoder. Use SDImageWebPCoder on tvOS.
2024-05-21 22:58:14 -04:00
Toni Förster
15ce82a686 added en-GB to LanguageCodes 2024-05-20 21:53:48 +02:00
Toni Förster
7e3e393c65 Invidious: add images to chapters
Invidious, by design, has no images attached to chapters, in contrast to Piped.

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

- Always show captions
- Default language

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

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

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

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

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

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

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

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

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

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

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

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

This should fix numerous playback issues: #666, #626, #590, #585, #498, #457, #400
2024-05-16 19:35:31 +02:00
59 changed files with 1463 additions and 345 deletions

View File

@@ -1,4 +1,26 @@
## Build 181
## Build 185
* 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
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/683
## Previous builds
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
* Added Settings Import/Export
* Export all settings, instances and accounts
* Import selected elements from the file
* Include unencrypted passwords in the export or provide them during the import
* Import via URL for tvOS
* Added Controls setting "Action button labels" icon or icon and text
* Added Advanced setting for MPV: "deinterlace"
* Add help text to all header buttons (by @rickykresslein)
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
* Fix issues with empty comments (by @stonerl)
* Improved Invidious comments (by @stonerl)
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
@@ -18,26 +40,16 @@
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
* Updated localizations
* Upgraded dependencies
## Previous builds
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
* Added Settings Import/Export
* Export all settings, instances and accounts
* Import selected elements from the file
* Include unencrypted passwords in the export or provide them during the import
* Import via URL for tvOS
* Added Controls setting "Action button labels" icon or icon and text
* Added Advanced setting for MPV: "deinterlace"
* Add help text to all header buttons (by @rickykresslein)
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
* Fix issues with empty comments (by @stonerl)
* Improved Invidious comments (by @stonerl)
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
* 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
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
* Add Chinese (Traditional) localization (by @rexcsk)
* Localization fixes
* Updated localizations
* Upgraded dependencies
* Fixed reported crash
* Other minor changes and improvements

View File

@@ -57,13 +57,13 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.929.0)
aws-partitions (1.933.0)
aws-sdk-core (3.196.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.81.0)
aws-sdk-kms (1.82.0)
aws-sdk-core (~> 3, >= 3.193.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.151.0)

View File

@@ -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 {
@@ -655,7 +671,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
kind: .adaptive,
encoding: videoStream["encoding"].string,
videoFormat: videoStream["type"].string,
bitrate: videoStream["bitrate"].int
bitrate: videoStream["bitrate"].int,
requestRange: videoStream["init"].string ?? videoStream["index"].string
)
}
}

View File

@@ -491,6 +491,35 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
)
}
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
completion(asset)
return
}
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
let hostValue = hostItem.value
else {
completion(asset)
return
}
urlComponents.host = hostValue
guard let newUrl = urlComponents.url else {
completion(asset)
return
}
completion(AVURLAsset(url: newUrl))
}
// Overload used for hlsURLS
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
let asset = AVURLAsset(url: url)
nonProxiedAsset(asset: asset, completion: completion)
}
private func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
@@ -579,10 +608,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return nil
}
return URL(string: thumbnailURL
.absoluteString
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
return URL(
string: thumbnailURL
.absoluteString
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
)
}
@@ -688,6 +718,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
let videoFormat = videoStream.dictionaryValue["format"]?.string
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
var requestRange: String?
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
{
requestRange = "\(initStart)-\(initEnd)"
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
{
requestRange = "\(indexStart)-\(indexEnd)"
} else {
requestRange = nil
}
if videoOnly {
streams.append(
@@ -698,7 +741,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resolution: resolution,
kind: .adaptive,
videoFormat: videoFormat,
bitrate: bitrate
bitrate: bitrate,
requestRange: requestRange
)
)
} else {

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,11 @@ final class AVPlayerBackend: PlayerBackend {
var isLoadingVideo = false
var hasStarted = false
var isPaused: Bool {
avPlayer.timeControlStatus == .paused
}
var isPlaying: Bool {
avPlayer.timeControlStatus == .playing
}
@@ -158,6 +163,12 @@ final class AVPlayerBackend: PlayerBackend {
}
avPlayer.play()
// Setting hasStarted to true the first time player started
if !hasStarted {
hasStarted = true
}
model.objectWillChange.send()
}
@@ -180,6 +191,7 @@ final class AVPlayerBackend: PlayerBackend {
func stop() {
avPlayer.replaceCurrentItem(with: nil)
hasStarted = false
}
func cancelLoads() {
@@ -220,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) {

View File

@@ -10,7 +10,7 @@ import SwiftUI
final class MPVBackend: PlayerBackend {
static var timeUpdateInterval = 0.5
static var networkStateUpdateInterval = 1.0
static var networkStateUpdateInterval = 0.1
private var logger = Logger(label: "mpv-backend")
@@ -44,6 +44,8 @@ final class MPVBackend: PlayerBackend {
}
}}
var hasStarted = false
var isPaused = false
var isPlaying = true { didSet {
networkStateTimer.start()
@@ -215,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 = {
@@ -252,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,
@@ -337,7 +351,6 @@ final class MPVBackend: PlayerBackend {
}
func play() {
isPlaying = true
startClientUpdates()
if controls.presentingControls {
@@ -354,13 +367,22 @@ final class MPVBackend: PlayerBackend {
}
client?.play()
isPlaying = true
isPaused = false
// Setting hasStarted to true the first time player started
if !hasStarted {
hasStarted = true
}
}
func pause() {
isPlaying = false
stopClientUpdates()
client?.pause()
isPaused = true
isPlaying = false
}
func togglePlay() {
@@ -377,6 +399,9 @@ final class MPVBackend: PlayerBackend {
func stop() {
client?.stop()
isPlaying = false
isPaused = false
hasStarted = false
}
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
@@ -392,8 +417,8 @@ final class MPVBackend: PlayerBackend {
}
func closeItem() {
client?.pause()
client?.stop()
pause()
stop()
self.video = nil
self.stream = nil
}

View File

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

View File

@@ -19,6 +19,8 @@ protocol PlayerBackend {
var loadedVideo: Bool { get }
var isLoadingVideo: Bool { get }
var hasStarted: Bool { get }
var isPaused: Bool { get }
var isPlaying: Bool { get }
var isSeeking: Bool { get }
var playerItemDuration: CMTime? { get }
@@ -131,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

View File

@@ -298,6 +298,14 @@ final class PlayerModel: ObservableObject {
backend.isPlaying
}
var isPaused: Bool {
backend.isPaused
}
var hasStarted: Bool {
backend.hasStarted
}
var playerItemDuration: CMTime? {
guard !currentItem.isNil else {
return nil
@@ -675,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 {
@@ -690,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 {
@@ -718,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()
}
}

View File

@@ -74,7 +74,7 @@ extension PlayerModel {
preservedTime = currentItem.playbackTime
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let self = self else { return }
guard let video = item.video else {
return
}
@@ -94,7 +94,9 @@ extension PlayerModel {
}
} else {
self.videoBeingOpened = nil
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
self.availableStreams = processedStreams
}
}
}
}

View File

@@ -1,3 +1,4 @@
import AVFoundation
import Foundation
import Siesta
import SwiftUI
@@ -41,7 +42,9 @@ extension PlayerModel {
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
return
}
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
self.availableStreams = processedStreams
}
} else {
self.logger.critical("no streams available from \(instance.description)")
}
@@ -53,28 +56,153 @@ extension PlayerModel {
}
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in
stream.instance = instance
func streamsWithInstance(instance _: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
// Queue for stream processing
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue", qos: .userInitiated)
// Queue for accessing the processedStreams array
let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
// DispatchGroup for managing multiple tasks
let streamProcessingGroup = DispatchGroup()
if instance.app == .invidious, instance.proxiesVideos {
if let audio = stream.audioAsset {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
var processedStreams = [Stream]()
for stream in streams {
streamProcessingQueue.async(group: streamProcessingGroup) {
let forbiddenAssetTestGroup = DispatchGroup()
var hasForbiddenAsset = false
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
}
} else if instance.app == .piped {
self.testPipedAssets(asset: asset, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
hasForbiddenAsset = isForbidden
}
}
}
} 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
}
}
}
if let video = stream.videoAsset {
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
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)
}
}
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
if let hlsURL = stream.hlsURL {
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
if let nonProxiedURL = possibleNonProxiedURL {
stream.hlsURL = nonProxiedURL.url
}
}
} else {
if let audio = stream.audioAsset {
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
stream.audioAsset = nonProxiedAudioAsset
}
}
if let video = stream.videoAsset {
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
stream.videoAsset = nonProxiedVideoAsset
}
}
}
}
}
// Append to processedStreams within the processedStreamsQueue
processedStreamsQueue.sync {
processedStreams.append(stream)
}
}
}
return stream
streamProcessingGroup.notify(queue: .main) {
// Access and pass processedStreams within the processedStreamsQueue block
processedStreamsQueue.sync {
completion(processedStreams)
}
}
}
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
if lhs.resolution.isNil || rhs.resolution.isNil {
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(Instance?, AVURLAsset?, URL, String?)], hlsURLs: [(Instance?, URL)]) {
var nonHLSAssets = [(Instance?, AVURLAsset?, URL, String?)]()
var hlsURLs = [(Instance?, URL)]()
for stream in streams {
if stream.isHLS {
if let url = stream.hlsURL?.url {
hlsURLs.append((stream.instance, url))
}
} else {
if let asset = stream.audioAsset {
nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
}
if let asset = stream.videoAsset {
nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
}
}
}
return (nonHLSAssets, hlsURLs)
}
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> 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)
forbiddenAssetTestGroup.leave()
}
}
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> 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)
}
}
}
func streamsSorter(lhs: Stream, rhs: Stream) -> Bool {
// Use optional chaining to simplify nil handling
guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else {
return lhs.kind < rhs.kind
}
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
// Compare either kind or resolution based on conditions
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
}
}

View File

@@ -170,6 +170,7 @@ class Stream: Equatable, Hashable, Identifiable {
var encoding: String?
var videoFormat: String?
var bitrate: Int?
var requestRange: String?
init(
instance: Instance? = nil,
@@ -181,7 +182,8 @@ class Stream: Equatable, Hashable, Identifiable {
kind: Kind = .hls,
encoding: String? = nil,
videoFormat: String? = nil,
bitrate: Int? = nil
bitrate: Int? = nil,
requestRange: String? = nil
) {
self.instance = instance
self.audioAsset = audioAsset
@@ -193,6 +195,7 @@ class Stream: Equatable, Hashable, Identifiable {
self.encoding = encoding
format = .from(videoFormat ?? "")
self.bitrate = bitrate
self.requestRange = requestRange
}
var isLocal: Bool {

View File

@@ -103,9 +103,8 @@ enum Constants {
#elseif os(iOS)
if isIPad {
return .sidebar
} else {
return .tab
}
return .tab
#else
return .tab
#endif

View File

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

81
Shared/HTTPStatus.swift Normal file
View File

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

View File

@@ -5,6 +5,7 @@ import UniformTypeIdentifiers
struct FavoriteItemView: View {
var item: FavoriteItem
@Binding var favoritesChanged: Bool
@Environment(\.navigationStyle) private var navigationStyle
@StateObject private var store = FavoriteResourceObserver()
@@ -25,8 +26,9 @@ struct FavoriteItemView: View {
@Default(.widgetsSettings) private var widgetsSettings
@Default(.visibleSections) private var visibleSections
init(item: FavoriteItem) {
init(item: FavoriteItem, favoritesChanged: Binding<Bool>) {
self.item = item
_favoritesChanged = favoritesChanged
}
var body: some View {
@@ -89,20 +91,23 @@ struct FavoriteItemView: View {
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() }
}
}
.id(watchModel.historyToken)
.onChange(of: accounts.current) { _ in
resource?.removeObservers(ownedBy: store)
resource?.addObserver(store)
loadCacheAndResource(force: true)
}
.onChange(of: watchModel.historyToken) { _ in
Delay.by(0.5) {
reloadVisibleWatches()
}
reloadVisibleWatches()
}
}
@@ -164,12 +169,15 @@ struct FavoriteItemView: View {
.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)
guard watch == last else { return }
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
if watch == last {
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
}
}
}
}
@@ -486,14 +494,22 @@ struct FavoriteItemView: View {
}
struct FavoriteItemView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
VStack {
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Search: resistance body upper band workout")))
.environment(\.navigationStyle, .tab)
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Marques")))
.environment(\.navigationStyle, .sidebar)
struct PreviewWrapper: View {
@State private var favoritesChanged = false
var body: some View {
NavigationView {
VStack {
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Search: resistance body upper band workout")), favoritesChanged: $favoritesChanged)
.environment(\.navigationStyle, .tab)
FavoriteItemView(item: .init(section: .channel("peerTube", "a", "Marques")), favoritesChanged: $favoritesChanged)
.environment(\.navigationStyle, .sidebar)
}
}
}
}
static var previews: some View {
PreviewWrapper()
}
}

View File

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

View File

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

View File

@@ -97,11 +97,11 @@ struct HomeView: View {
VStack(alignment: .leading) {
#if os(tvOS)
ForEach(Defaults[.favorites]) { item in
FavoriteItemView(item: item)
FavoriteItemView(item: item, favoritesChanged: $favoritesChanged)
}
#else
ForEach(favorites) { item in
FavoriteItemView(item: item)
FavoriteItemView(item: item, favoritesChanged: $favoritesChanged)
#if os(macOS)
.workaroundForVerticalScrollingBug()
#endif

109
Shared/LanguageCodes.swift Normal file
View 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"
}
}

View File

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

View File

@@ -10,26 +10,28 @@ struct OpeningStream: View {
}
var visible: Bool {
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) || (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking)
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) ||
(player.isLoadingVideo && !model.pausedForCache && !player.isSeeking) ||
!player.hasStarted
}
var reason: String {
guard player.videoBeingOpened == nil else {
return "Loading streams...".localized()
return "Loading streams".localized()
}
if player.musicMode {
return "Opening audio stream...".localized()
return "Opening audio stream".localized()
}
if let selection = player.streamSelection {
if selection.isLocal {
return "Opening file...".localized()
return "Opening file".localized()
}
return String(format: "Opening %@ stream...".localized(), selection.shortQuality)
return String(format: "Opening %@ stream".localized(), selection.shortQuality)
}
return "Loading streams...".localized()
return "Loading streams".localized()
}
var state: String? {

View File

@@ -75,16 +75,20 @@ struct PlayerControls: View {
}
VStack {
Spacer()
ZStack {
VStack(spacing: 0) {
ZStack {
OpeningStream()
NetworkState()
GeometryReader { geometry in
VStack(spacing: 0) {
ZStack {
OpeningStream()
NetworkState()
}
}
Spacer()
.position(
x: geometry.size.width / 2,
y: geometry.size.height / 2
)
}
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
if showControls {
Section {

View File

@@ -278,10 +278,6 @@ enum PlayerControlsLayout: String, CaseIterable, Defaults.Serializable {
}
}
var osdVerticalOffset: Double {
buttonSize
}
var osdProgressBarHeight: Double {
switch self {
case .tvRegular:

View File

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

View File

@@ -65,7 +65,7 @@ import SwiftUI
}
static var thumbnailHeight: Double {
thumbnailWidth / 1.7777
thumbnailWidth / (16 / 9)
}
}

View File

@@ -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,79 @@ 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
}
if mpvEnableLogging {
logButton
}
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 +222,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
}
}

View File

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

View File

@@ -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() {

110
Shared/URLTester.swift Normal file
View File

@@ -0,0 +1,110 @@
import Foundation
import Logging
enum URLTester {
private static let hlsMediaPrefix = "#EXT-X-MEDIA:"
private static let hlsInfPrefix = "#EXTINF:"
private static let uriRegex = "(?<=URI=\")(.*?)(?=\")"
static func testURLResponse(url: URL, range: String, isHLS: Bool, completion: @escaping (Int) -> Void) {
if isHLS {
parseAndTestHLSManifest(manifestUrl: url, range: range, completion: completion)
} else {
httpRequest(url: url, range: range) { statusCode, _ in
completion(statusCode)
}
}
}
private static func httpRequest(url: URL, range: String, completion: @escaping (Int, URLSessionDataTask?) -> Void) {
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
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? HTTPStatus.Forbidden
Logger(label: "stream.yattee.httpRequest").info("URL: \(url) | Status Code: \(statusCode)")
completion(statusCode, dataTask)
}
dataTask?.resume()
}
static func parseAndTestHLSManifest(manifestUrl: URL, range: String, completion: @escaping (Int) -> Void) {
recursivelyParseManifest(manifestUrl: manifestUrl) { allURLs in
if let url = allURLs.randomElement() {
httpRequest(url: url, range: range) { statusCode, _ in
completion(statusCode)
}
} else {
completion(HTTPStatus.NotFound)
}
}
}
private static func recursivelyParseManifest(manifestUrl: URL, fullyParsed: @escaping ([URL]) -> Void) {
parseHLSManifest(manifestUrl: manifestUrl) { urls in
var allURLs = [URL]()
let group = DispatchGroup()
for url in urls {
if url.pathExtension == "m3u8" {
group.enter()
recursivelyParseManifest(manifestUrl: url) { subUrls in
allURLs += subUrls
group.leave()
}
} else {
allURLs.append(url)
}
}
group.notify(queue: .main) {
fullyParsed(allURLs)
}
}
}
private static func parseHLSManifest(manifestUrl: URL, completion: @escaping ([URL]) -> Void) {
URLSession.shared.dataTask(with: manifestUrl) { data, _, _ in
// swiftlint:disable:next shorthand_optional_binding
guard let data = data else {
Logger(label: "stream.yattee.httpRequest").error("Data is nil")
completion([])
return
}
// swiftlint:disable:next non_optional_string_data_conversion
guard let manifest = String(data: data, encoding: .utf8), !manifest.isEmpty else {
Logger(label: "stream.yattee.httpRequest").error("Cannot read or empty HLS manifest")
completion([])
return
}
let lines = manifest.split(separator: "\n")
var mediaURLs: [URL] = []
for index in 0 ..< lines.count {
let lineString = String(lines[index])
if lineString.hasPrefix(hlsMediaPrefix),
let uriRange = lineString.range(of: uriRegex, options: .regularExpression)
{
let uri = lineString[uriRange]
if let url = URL(string: String(uri)) {
mediaURLs.append(url)
}
} else if lineString.hasPrefix(hlsInfPrefix), index < lines.count - 1 {
let possibleURL = String(lines[index + 1])
let baseURL = manifestUrl.deletingLastPathComponent()
if let relativeURL = URL(string: possibleURL, relativeTo: baseURL),
relativeURL.scheme != nil
{
mediaURLs.append(relativeURL)
}
}
}
completion(mediaURLs)
}
.resume()
}
}

View 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
}
}

View File

@@ -2,7 +2,7 @@ import SwiftUI
struct ListView: View {
var items: [ContentItem]
var limit: Int? = 10
var limit: Int?
var body: some View {
LazyVStack(alignment: .leading) {
@@ -16,16 +16,12 @@ struct ListView: View {
}
var limitedItems: [ContentItem] {
if let limit, limit >= 0 {
return Array(items.prefix(limit))
}
return items
Array(items.prefix(limit ?? items.count))
}
}
struct ListView_Previews: PreviewProvider {
static var previews: some View {
ListView(items: [.init(video: .fixture)])
ListView(items: [.init(video: .fixture)], limit: 10)
}
}

View File

@@ -150,61 +150,82 @@ struct YatteeApp: App {
}
configured = true
#if DEBUG
SiestaLog.Category.enabled = .common
#endif
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
DispatchQueue.main.async {
#if DEBUG
SiestaLog.Category.enabled = .common
#endif
#if os(tvOS)
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
#else
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
#endif
if !Defaults[.lastAccountIsPublic] {
AccountsModel.shared.configureAccount()
}
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
if let countryOfPublicInstances = Defaults[.countryOfPublicInstances] {
InstancesManifest.shared.setPublicAccount(countryOfPublicInstances, asCurrent: AccountsModel.shared.current.isNil)
}
if !AccountsModel.shared.current.isNil {
player.restoreQueue()
}
if !Defaults[.saveRecents] {
recents.clear()
}
let startupSection = Defaults[.startupSection]
var section: TabSelection? = startupSection.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
if !Defaults[.lastAccountIsPublic] {
AccountsModel.shared.configureAccount()
}
#endif
NavigationModel.shared.tabSelection = section ?? .search
if let countryOfPublicInstances = Defaults[.countryOfPublicInstances] {
InstancesManifest.shared.setPublicAccount(countryOfPublicInstances, asCurrent: AccountsModel.shared.current.isNil)
}
playlists.load()
if !AccountsModel.shared.current.isNil {
player.restoreQueue()
}
#if !os(macOS)
player.updateRemoteCommandCenter()
#endif
if player.presentingPlayer {
player.presentingPlayer = false
}
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
DispatchQueue.global(qos: .userInitiated).async {
if !Defaults[.saveRecents] {
recents.clear()
}
}
#endif
URLBookmarkModel.shared.refreshAll()
let startupSection = Defaults[.startupSection]
var section: TabSelection? = startupSection.tabSelection
migrateHomeHistoryItems()
migrateQualityProfiles()
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
NavigationModel.shared.tabSelection = section ?? .search
DispatchQueue.global(qos: .userInitiated).async {
playlists.load()
}
#if !os(macOS)
player.updateRemoteCommandCenter()
#endif
if player.presentingPlayer {
player.presentingPlayer = false
}
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.all, andRotateTo: .portrait)
}
}
#endif
// Initialize UserAgentManager
_ = UserAgentManager.shared
DispatchQueue.global(qos: .userInitiated).async {
URLBookmarkModel.shared.refreshAll()
}
DispatchQueue.global(qos: .userInitiated).async {
self.migrateHomeHistoryItems()
}
DispatchQueue.global(qos: .userInitiated).async {
self.migrateQualityProfiles()
}
}
}
func migrateHomeHistoryItems() {

View File

@@ -129,7 +129,7 @@
"LIVE" = "مباشر";
/* Loading stream OSD */
"Loading streams..." = "تحميل بثوث...";
"Loading streams" = "تحميل بثوث";
"Loading..." = "تحميل...";
/* Video duration filter in search */
@@ -163,8 +163,8 @@
"Open Settings" = "فتح الإعدادات";
/* Loading stream OSD */
"Opening %@ stream..." = "فتح بث %@ ...";
"Opening audio stream..." = "فتح بث صوتي...";
"Opening %@ stream" = "فتح بث %@ ";
"Opening audio stream" = "فتح بث صوتي";
"Orientation" = "اتجاه";
"Play in PiP" = "تشغيل في الفيديو المصغر";
"Play Last" = "تشغيل الأخير";
@@ -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" = "حجم ملء الشاشة";
@@ -558,7 +558,7 @@
"Are you sure you want to clear cache?" = "هل أنت متأكد من أنك تريد مسح ذاكرة التخزين المؤقت؟";
"Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart." = "التحكم في إعدادات إيماءة الفاصل الزمني للتخطي لأزرار الأسهم عن بعد (للجيل الثاني من Siri Remote أو أحدث). تغيير إعدادات نظام عناصر التحكم يتطلب إعادة بدء التشغيل.";
"Opened File" = "ملف مفتوح";
"Opening file..." = "فتح الملف...";
"Opening file" = "فتح الملف";
"Short videos: hidden" = "مقاطع الفيديو القصيرة: مخفية";
"Mark channel feed as watched" = "وضع علامة تمت المشاهدة على محتوى القناة";
"Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart." = "التحكم في إعدادات إيماءة فترة التخطي للنقر المزدوج على الجانب الأيسر/الأيمن من المشغل. تغيير إعدادات نظام عناصر التحكم يتطلب إعادة بدء التشغيل.";
@@ -628,4 +628,4 @@
"Password required to import" = "كلمة المرور مطلوبة للإستيراد";
"Password saved in import file" = "كلمة المرور محفوظة في ملف الإستيراد";
"Export in progress..." = "جارِ التصدير...";
"In progress..." = "في تَقَدم…";
"In progress..." = "في طور الأجراء…";

View File

@@ -277,11 +277,11 @@
"Large" = "Böyük";
/* Loading stream OSD */
"Loading streams..." = "Yayımlar yüklənilir...";
"Loading streams" = "Yayımlar yüklənilir";
"Only when signed in" = "Yalnız daxil olduqda";
/* Loading stream OSD */
"Opening %@ stream..." = "%@ yayımıılır...";
"Opening %@ stream" = "%@ yayımıılır";
"Matrix Chat" = "Matrix Söhbət";
/* Player controls layout size */
@@ -296,7 +296,7 @@
"Open Settings" = "Tənzimləmələri Aç";
"Movies" = "Filmlər";
"No description" = "Açıqlama yoxdur";
"Opening audio stream..." = "Səs yayımıılır...";
"Opening audio stream" = "Səs yayımıılır";
"Password" = "Şifrə";
"Preferred Formats" = "Üstünlük Verilən Formatlar";
"Quality Profile" = "Profil Keyfiyyəti";

View File

@@ -113,7 +113,7 @@
"LIVE" = "EN VIU";
/* Loading stream OSD */
"Loading streams..." = "S'estan carregant els fluxos...";
"Loading streams" = "S'estan carregant els fluxos";
"Loading..." = "Carregant...";
"Locations" = "Ubicacions";
"Lock portrait mode" = "Bloqueja el mode vertical";
@@ -147,7 +147,7 @@
"Offtopic in Music Videos" = "Offtopic als vídeos musicals";
"Open \"Playlists\" tab to create new one" = "Obriu la pestanya \"Llistes de reproducció\" per crear-ne una de nova";
"Open Settings" = "Obriu Configuració";
"Opening audio stream..." = "Obrint la reproducció d'àudio...";
"Opening audio stream" = "Obrint la reproducció d'àudio";
"Orientation" = "Orientació";
/* SponsorBlock category name */
@@ -454,7 +454,7 @@
"Low" = "Baix";
/* Loading stream OSD */
"Opening %@ stream..." = "S'està obrint %@...";
"Opening %@ stream" = "S'està obrint %@";
"Only when signed in" = "Només quan s'ha iniciat la sessió";
"Password" = "Contrasenya";
"Part of a video promoting a product or service not directly related to the creator. The creator will receive payment or compensation in the form of money or free products." = "Part d'un vídeo que promociona un producte o servei no relacionat directament amb el creador. El creador rebrà un pagament o compensació en forma de diners o productes gratuïts.";

View File

@@ -166,7 +166,7 @@
"Only when signed in" = "Pouze když přihlášený";
"Open \"Playlists\" tab to create new one" = "Otevřete kartu \"Playlisty\", aby jste vytvořili nový";
"Open Settings" = "Otevřete Nastavení";
"Opening audio stream..." = "Otevírám audio stream...";
"Opening audio stream" = "Otevírám audio stream";
"Orientation" = "Orientace";
"Password" = "Heslo";
"Pause" = "Pauza";
@@ -401,11 +401,11 @@
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "Propagace produktu nebo služby, která přímo souvisí s tvůrcem samotným. Obvykle se jedná o zboží nebo zpeněžené platformy.";
/* Loading stream OSD */
"Loading streams..." = "Načítaní streamu...";
"Loading streams" = "Načítaní streamu";
"Matrix Chat" = "Matrix Chat";
/* Loading stream OSD */
"Opening %@ stream..." = "Otevírám %@ stream...";
"Opening %@ stream" = "Otevírám %@ stream";
/* SponsorBlock category name */
"Outro" = "Zakončení";
@@ -543,7 +543,7 @@
"Available" = "Dostupné";
"Are you sure you want to remove %@ from Favorites?" = "Opravdu chcete odstranit %@ z oblíbených položek?";
"Use system controls with AVPlayer" = "Použití systémových ovládacích prvků s AVPlayerem";
"Opening file..." = "Otvírání souboru...";
"Opening file" = "Otvírání souboru";
"No videos to show" = "Žádná videa k zobrazení";
"Autoplay next" = "Automaticky přehrát další";
"Inspector" = "Inspektor";

View File

@@ -182,7 +182,7 @@
"Large" = "Groß";
/* Loading stream OSD */
"Loading streams..." = "Lädt Streams …";
"Loading streams" = "Lädt Streams …";
/* Video duration filter in search */
"Long" = "Lang";
@@ -210,7 +210,7 @@
"Only when signed in" = "Nur wenn Sie eingeloggt sind";
/* Loading stream OSD */
"Opening %@ stream..." = "Öffne %@-stream …";
"Opening %@ stream" = "Öffne %@-Stream …";
"Connection failed" = "Verbindung fehlgeschlagen";
"Continue from %@" = "Ab %@ fortsetzen";
"Contributing" = "Beitragen";
@@ -227,7 +227,7 @@
"I want to ask a question" = "Ich möchte eine Frage stellen";
"If you are interested what's coming in future updates, you can track project Milestones." = "Wenn Sie sich für künftige Updates interessieren, können Sie die Meilensteine des Projekts verfolgen.";
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "Das große Layout ist nicht für alle Geräte geeignet und kann dazu führen, dass die Bedienelemente nicht auf den Bildschirm passen.";
"Opening audio stream..." = "Audiostream wird geöffnet …";
"Opening audio stream" = "Audiostream wird geöffnet …";
"Orientation" = "Ausrichtung";
"Playlist \"%@\" will be deleted.\nIt cannot be reverted." = "Die Wiedergabeliste \"%@\" wird gelöscht.\nDies kann nicht rückgängig gemacht werden.";
"Preferred Formats" = "Bevorzugte Formate";
@@ -569,7 +569,7 @@
"Enter location address to connect..." = "Geben Sie die Internetadresse ein, um eine Verbindung herzustellen …";
"Opened File" = "Geöffnete Datei";
"File Extension" = "Dateierweiterung";
"Opening file..." = "Datei öffnen …";
"Opening file" = "Datei öffnen …";
"Close video and player on end" = "Video und Player am Ende beenden";
"Use system controls with AVPlayer" = "Systemsteuerung mit AVPlayer verwenden";
"Public account" = "Öffentliches Konto";

View File

@@ -157,7 +157,7 @@
"LIVE" = "LIVE";
/* Loading stream OSD */
"Loading streams..." = "Loading streams...";
"Loading streams" = "Loading streams";
"Loading..." = "Loading...";
"Locations" = "Locations";
"Lock portrait mode" = "Lock portrait mode";
@@ -202,8 +202,8 @@
"Open Settings" = "Open Settings";
/* Loading stream OSD */
"Opening %@ stream..." = "Opening %@ stream...";
"Opening audio stream..." = "Opening audio stream...";
"Opening %@ stream" = "Opening %@ stream";
"Opening audio stream" = "Opening audio stream";
"Orientation" = "Orientation";
/* SponsorBlock category name */
@@ -567,7 +567,7 @@
"Seek" = "Seek";
"Opened File" = "Opened File";
"File Extension" = "File Extension";
"Opening file..." = "Opening file...";
"Opening file" = "Opening file";
"Public account" = "Public account";
"Your Accounts" = "Your Accounts";
"Browse without account" = "Browse without account";

View File

@@ -117,7 +117,7 @@
"Decreased opacity" = "Opacidad disminuida";
"High" = "Alto";
"%lld videos" = "%lld videos";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Recordatorios explícitos para que indiquen les guste, se suscriban o interactúen con ellos en cualquier plataforma de pago o gratuita (por ejemplo, haciendo clic en un vídeo).\n";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Solicitudes explícitas para dar me gusta, suscribirse o interactuar con ellos en una o más plataformas gratuitas o de pago (por ejemplo, hacer clic en un vídeo).";
"Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply)." = "Los formatos se seleccionarán en orden como se indica.\nHLS es un formato adaptable (no aplica la configuración de resolución).";
"Fullscreen size" = "Tamaño de pantalla completa";
"Badge & Decreased opacity" = "Insignia y opacidad dosminuída";
@@ -138,7 +138,7 @@
/* Video date filter in search */
"Today" = "Hoy";
"Opening audio stream..." = "Abriendo transmisión de audio...";
"Opening audio stream" = "Abriendo transmisión de audio";
"Open Video" = "Abrir Video";
"I want to ask a question" = "Quiero hacer una pregunta";
"Save history of played videos" = "Guardar historial de videos reproducidos";
@@ -208,7 +208,7 @@
"No documents" = "Sin documentos";
/* Loading stream OSD */
"Opening %@ stream..." = "Abriendo %@ emisión...";
"Opening %@ stream" = "Abriendo %@ emisión";
"Documents" = "Documentos";
"Thumbnails" = "Miniaturas";
"Password" = "Contraseña";
@@ -265,7 +265,7 @@
"Shuffle" = "Mezclar";
/* Loading stream OSD */
"Loading streams..." = "Cargando secuencias...";
"Loading streams" = "Cargando secuencias";
"Public Locations" = "Ubicaciones públicas";
"Yattee" = "Yattee";
"No results" = "No hay resultados";
@@ -558,7 +558,7 @@
"Available" = "Disponible";
"Loop one" = "Bucle uno";
"Use system controls with AVPlayer" = "Utilizar los controles del sistema con AVPlayer";
"Opening file..." = "Abriendo el archivo...";
"Opening file" = "Abriendo el archivo";
"No videos to show" = "No hay vídeos que mostrar";
"Autoplay next" = "Reproducir automáticamente la siguiente";
"Home Settings" = "Ajustes iniciales";

View File

@@ -83,7 +83,7 @@
"Profiles" = "نمایه‌ها";
"New Playlist" = "فهرست پخش جدید";
"Automatic" = "خودکار";
"Opening file..." = "در حال باز کردن فایل…";
"Opening file" = "در حال باز کردن فایل…";
"Add Quality Profile" = "افزودن نمایهٔ کیفیت";
"Close video after playing last in the queue" = "ویدیو را پس از پخش آخرین مورد فهرست ببند";
@@ -279,14 +279,14 @@
"Controls" = "کنترل‌ها";
"This URL could not be opened" = "این نشانی باز نمی‌شود";
"Trending" = "پرطرفدار";
"Opening audio stream..." = "باز کردن استریم صوتی…";
"Opening audio stream" = "باز کردن استریم صوتی…";
"Statistics" = "آمار";
"Pause when player is closed" = "پس از بسته شدن پخش‌کننده مکث کن";
"Play All" = "همه را پخش کن";
"Sort: %@" = "ترتیب: %@";
/* Loading stream OSD */
"Opening %@ stream..." = "باز کردن استریم %@…";
"Opening %@ stream" = "باز کردن استریم %@…";
"Next in Queue" = "مورد بعد در صف";
"Honor orientation lock" = "قفل چرخش صفحه را در نظر بگیر";
"Rate" = "امتیاز";
@@ -405,7 +405,7 @@
"Info" = "اطلاعات";
/* Loading stream OSD */
"Loading streams..." = "درحال دریافت استریم…";
"Loading streams" = "درحال دریافت استریم…";
"No rotation" = "بدون چرخش";
"Codec" = "کدک (Codec)";
"Startup section" = "بخش آغازین";

View File

@@ -282,11 +282,11 @@
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "Le grand format n'est pas adapté à tous les appareils et son utilisation peut empêcher certains contrôles de s'afficher à l'écran.";
/* Loading stream OSD */
"Loading streams..." = "Chargement des flux...";
"Loading streams" = "Chargement des flux";
"Lock portrait mode" = "Verrouille l'orientation en mode portrait";
"Matrix Channel" = "Salon Matrix";
"Only when signed in" = "Uniquement lorsque vous êtes connecté";
"Opening audio stream..." = "Ouverture du flux audio…";
"Opening audio stream" = "Ouverture du flux audio…";
"Orientation" = "Orientation";
/* SponsorBlock category name */
@@ -424,7 +424,7 @@
"Milestones" = "Étapes";
/* Loading stream OSD */
"Opening %@ stream..." = "Ouverture du flux %@…";
"Opening %@ stream" = "Ouverture du flux %@…";
"Regular size" = "Taille normale";
"Regular Size" = "Taille normale";
"Related" = "En relation";
@@ -567,7 +567,7 @@
"Seek" = "Recherche";
"Show scroll to top button in comments" = "Afficher le bouton de retour en haut de la page dans les commentaires";
"Opened File" = "Fichier ouvert";
"Opening file..." = "Ouverture du fichier...";
"Opening file" = "Ouverture du fichier";
"Enter location address to connect..." = "Entrez l'adresse de l'instance pour se connecter...";
"File Extension" = "Extension de fichier";
"Public account" = "Compte publique";

View File

@@ -84,8 +84,8 @@
"Open Settings" = "सेटिंग खोलें";
/* Loading stream OSD */
"Opening %@ stream..." = "%@ स्ट्रीम खुल रहा…";
"Opening audio stream..." = "ऑडियो स्ट्रीम खुल रहा…";
"Opening %@ stream" = "%@ स्ट्रीम खुल रहा…";
"Opening audio stream" = "ऑडियो स्ट्रीम खुल रहा…";
"If you are reporting a bug, include all relevant details (especially: app version, used device and system version, steps to reproduce)." = "यदि आप किसी बग की रिपोर्ट कर रहे हैं, तो सभी प्रासंगिक विवरण शामिल करें (विशेषकर: ऐप संस्करण, प्रयुक्त डिवाइस और सिस्टम संस्करण, पुन: पेश करने के चरण)।";
"Increase rate" = "दर बढ़ाएँ";
"Info" = "जानकारी";
@@ -104,7 +104,7 @@
"LIVE" = "लाइव";
/* Loading stream OSD */
"Loading streams..." = "स्ट्रीम लोड हो रहें…";
"Loading streams" = "स्ट्रीम लोड हो रहें…";
"Loading..." = "लोड हो रहा…";
"Locations" = "स्थान";
"Lock portrait mode" = "पोर्ट्रेट मोड लॉक करें";

View File

@@ -158,8 +158,8 @@
"Open Settings" = "Apri Impostazioni";
/* Loading stream OSD */
"Opening %@ stream..." = "Apertura stream %@...";
"Opening audio stream..." = "Apertura stream audio...";
"Opening %@ stream" = "Apertura stream %@";
"Opening audio stream" = "Apertura stream audio";
"Orientation" = "Orientamento";
/* SponsorBlock category name */
@@ -170,7 +170,7 @@
"LIVE" = "DIRETTA";
/* Loading stream OSD */
"Loading streams..." = "Caricamento stream...";
"Loading streams" = "Caricamento stream";
"Loading..." = "Caricamento...";
"Locations" = "Posizioni";
"Low quality" = "Qualità bassa";
@@ -569,7 +569,7 @@
"Enter location address to connect..." = "Inserisci posizione per connetterti...";
"Opened File" = "File aperto";
"File Extension" = "Estensione file";
"Opening file..." = "Apro file...";
"Opening file" = "Apro file";
"Close video and player on end" = "Chiudi video e riproduttore alla fine";
"Use system controls with AVPlayer" = "Usa controlli di sistema con AVPlayer";
"Public account" = "Account pubblico";

View File

@@ -87,7 +87,7 @@
"Large" = "大";
/* Loading stream OSD */
"Loading streams..." = "ストリーム読込中...";
"Loading streams" = "ストリーム読込中";
"Lock portrait mode" = "縦モードをロック";
"LIVE" = "ライブ";
"Locations" = "場所";
@@ -108,10 +108,10 @@
"Offtopic in Music Videos" = "音楽動画の非音楽部分";
"Only when signed in" = "ログイン時のみ";
"Orientation" = "向き";
"Opening audio stream..." = "音声ストリーム 開始中...";
"Opening audio stream" = "音声ストリーム 開始中";
/* Loading stream OSD */
"Opening %@ stream..." = "%@ ストリーム 開始中...";
"Opening %@ stream" = "%@ ストリーム 開始中";
/* SponsorBlock category name */
"Outro" = "終了シーン";
@@ -566,7 +566,7 @@
"Show scroll to top button in comments" = "コメント欄に「上に戻る」表示";
"Opened File" = "開いたファイル";
"File Extension" = "ファイル拡張子";
"Opening file..." = "ファイルを読み込み中...";
"Opening file" = "ファイルを読み込み中";
"Your Accounts" = "アカウントを使用";
"Public account" = "公開アカウント";
"Browse without account" = "アカウントなしで閲覧";

View File

@@ -82,7 +82,7 @@
"LIVE" = "Direkte";
/* Loading stream OSD */
"Loading streams..." = "Laster inn strømmer …";
"Loading streams" = "Laster inn strømmer …";
/* Video duration filter in search */
"Long" = "Lang";
@@ -223,7 +223,7 @@
"Rate" = "Takt";
"Not Playing" = "Spiller ikke";
"Open \"Playlists\" tab to create new one" = "Åpne «Spillelister»-fanen for å opprette ny";
"Opening audio stream..." = "Åpner lydstrøm …";
"Opening audio stream" = "Åpner lydstrøm …";
"Password" = "Passord";
"Nothing" = "Ingenting";
"Picture in Picture" = "Bilde-i-bilde";
@@ -303,7 +303,7 @@
"Offtopic in Music Videos" = "Urelaterte ting i musikkvideoer";
/* Loading stream OSD */
"Opening %@ stream..." = "Åpner %@-strøm …";
"Opening %@ stream" = "Åpner %@-strøm …";
"Play in PiP" = "Bilde-i-bilde";
"Pause when entering background" = "Pause ved forsendelse til bakgrunnen";
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "Promoterer noe som har å gjøre med skaperen direkte. Vanligvis effekter eller betalte plattformer.";

View File

@@ -157,7 +157,7 @@
"LIVE" = "LIVE";
/* Loading stream OSD */
"Loading streams..." = "Ładowanie strumieni...";
"Loading streams" = "Ładowanie strumieni";
"Loading..." = "Ładowanie…";
"Locations" = "Lokalizacje";
"Lock portrait mode" = "Zablokuj tryb portretowy";
@@ -202,8 +202,8 @@
"Open Settings" = "Otwórz Ustawienia";
/* Loading stream OSD */
"Opening %@ stream..." = "Otwieranie strumienia %@…";
"Opening audio stream..." = "Otwieranie strumienia audio...";
"Opening %@ stream" = "Otwieranie strumienia %@…";
"Opening audio stream" = "Otwieranie strumienia audio";
"Orientation" = "Orientacja";
/* SponsorBlock category name */
@@ -570,7 +570,7 @@
"Enter location address to connect..." = "Wprowadź adres lokalizacji, aby połączyć...";
"Opened File" = "Otwarty plik";
"File Extension" = "Rozszerzenie pliku";
"Opening file..." = "Otwieranie pliku...";
"Opening file" = "Otwieranie pliku";
"Public account" = "Konto publiczne";
"Your Accounts" = "Twoje konta";
"Browse without account" = "Przeglądanie bez konta";

View File

@@ -102,7 +102,7 @@
"Just watched" = "Acabou de assistir";
/* Loading stream OSD */
"Loading streams..." = "Carregando streams…";
"Loading streams" = "Carregando streams…";
"Medium quality" = "Qualidade média";
"No description" = "Sem descrição";
"No Playlists" = "Sem playlists";
@@ -114,8 +114,8 @@
"Open Settings" = "Abrir Ajustes";
/* Loading stream OSD */
"Opening %@ stream..." = "Abrindo stream %@…";
"Opening audio stream..." = "Abrindo stream de áudio…";
"Opening %@ stream" = "Abrindo stream %@…";
"Opening audio stream" = "Abrindo stream de áudio…";
"Orientation" = "Orientação";
/* SponsorBlock category name */
@@ -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.";
@@ -569,7 +569,7 @@
"Enter account credentials to connect..." = "Insira as credenciais da conta para conectar…";
"Opened File" = "Arquivo Aberto";
"File Extension" = "Extensão do Arquivo";
"Opening file..." = "Abrindo arquivo…";
"Opening file" = "Abrindo arquivo…";
"Browse without account" = "Navegar sem uma conta";
"Rotate when entering fullscreen on landscape video" = "Girar quando entrar no modo tela cheia em vídeo em paisagem";
"Landscape left" = "Paisagem à esquerda";

View File

@@ -196,7 +196,7 @@
"LIVE" = "AO VIVO";
/* Loading stream OSD */
"Loading streams..." = "Carregando streams…";
"Loading streams" = "Carregando streams…";
"Loading..." = "Carregando…";
"Locations" = "Localizações";
"Lock portrait mode" = "Travar modo retrato";
@@ -237,8 +237,8 @@
"Open Settings" = "Abrir Ajustes";
/* Loading stream OSD */
"Opening %@ stream..." = "Abrindo stream %@…";
"Opening audio stream..." = "Abrindo stream de áudio…";
"Opening %@ stream" = "Abrindo stream %@…";
"Opening audio stream" = "Abrindo stream de áudio…";
"Orientation" = "Orientação";
/* SponsorBlock category name */
@@ -557,7 +557,7 @@
"Keep channels with unwatched videos on top of subscriptions list" = "Manter canais com vídeos não vistos no topo da lista de inscrições";
"Opened File" = "Ficheiro Aberto";
"File Extension" = "Extensão do Ficheiro";
"Opening file..." = "A abrir ficheiro…";
"Opening file" = "A abrir ficheiro…";
"Close video and player on end" = "Fechar vídeo e player ao final";
"Use system controls with AVPlayer" = "Usar controles do sistema com o AVPlayer";
"Public account" = "Conta pública";

View File

@@ -62,7 +62,7 @@
"Edit" = "Editați";
"Edit Playlist" = "Editați Playlist";
"Enter fullscreen in landscape" = "Introduceți ecranul complet în peisaj";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).\n";
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).";
"Find Other" = "Găsiți alte";
"Finding something to play..." = "Să găsești ceva de jucat...";
"For videos which feature music as the primary content." = "Pentru videoclipurile care includ muzica ca conținut principal.";
@@ -89,7 +89,7 @@
"LIVE" = "LIVE";
/* Loading stream OSD */
"Loading streams..." = "Se încarcă fluxurile...";
"Loading streams" = "Se încarcă fluxurile";
"Locations" = "Locații";
"Mark watched videos with" = "Marcați videoclipurile vizionate cu";
"Matrix Channel" = "Canal Matrix";
@@ -100,7 +100,7 @@
"Nothing" = "Nimic";
/* Loading stream OSD */
"Opening %@ stream..." = "Se deschide %@ flux...";
"Opening %@ stream" = "Se deschide %@ flux";
"Play Last" = "Reda ultimul";
"Player" = "Player";
"Playlist" = "Playlist";
@@ -243,7 +243,7 @@
/* SponsorBlock category name */
"Outro" = "Outro";
"Orientation" = "Orientare";
"Opening audio stream..." = "Se deschide fluxul audio...";
"Opening audio stream" = "Se deschide fluxul audio";
"Password" = "Parolă";
"Pause" = "Pauză";
"Pause when entering background" = "Pauză când intrați în fundal";
@@ -568,7 +568,7 @@
"Enter account credentials to connect..." = "Introduceți acreditările contului pentru a vă conecta...";
"Enter location address to connect..." = "Introdu adresa locației pentru a te conecta...";
"Opened File" = "Fișier deschis";
"Opening file..." = "Deschiderea fișierului...";
"Opening file" = "Deschiderea fișierului";
"File Extension" = "Extensie fișier";
"Use system controls with AVPlayer" = "Utilizați controalele de sistem cu AVPlayer";
"Rotate when entering fullscreen on landscape video" = "Rotiți când intrați pe ecran complet în videoclipul peisaj";

View File

@@ -370,7 +370,7 @@
"LIVE" = "ПРЯМАЯ ТРАНСЛЯЦИЯ";
/* Loading stream OSD */
"Loading streams..." = "Загрузка прямой трансляции...";
"Loading streams" = "Загрузка прямой трансляции";
"Loading..." = "Загрузка...";
"Lock portrait mode" = "Блокировка портретного режима";
"Low" = "Низкое";
@@ -405,8 +405,8 @@
"Open Settings" = "Отрыть настройки";
/* Loading stream OSD */
"Opening %@ stream..." = "Открытие %@ прямой трансляции...";
"Opening audio stream..." = "Открытие прямой трансляции аудио...";
"Opening %@ stream" = "Открытие %@ прямой трансляции";
"Opening audio stream" = "Открытие прямой трансляции аудио";
"Orientation" = "Ориентация";
/* SponsorBlock category name */
@@ -591,7 +591,7 @@
"Actions buttons" = "Кнопки действия";
"Show sidebar" = "Показать боковую панель";
"Browse without account" = "Искать без аккаунта";
"Opening file..." = "Отрытие файла...";
"Opening file" = "Отрытие файла";
"Public account" = "Публичный аккаунт";
"Your Accounts" = "Ваши аккаунты";
"Close video and player on end" = "Закрыть видео и плеер в конце";

View File

@@ -110,7 +110,7 @@
"I want to ask a question" = "Bir soru sormak istiyorum";
/* Loading stream OSD */
"Loading streams..." = "Akışlar yükleniyor...";
"Loading streams" = "Akışlar yükleniyor";
"Edit Quality Profile" = "Kalite Profilini Düzenle";
"Frontend URL" = "Ön uç URL'si";
"Close player when starting PiP" = "Resim içinde Resim modu başlatılırken oynatıcıyı kapat";
@@ -227,7 +227,7 @@
"No description" = "Açıklama yok";
"Normal" = "Normal";
"Open \"Playlists\" tab to create new one" = "Yeni bir tane oluşturmak için \"Oynatma Listeleri\" sekmesini açın";
"Opening audio stream..." = "Ses akışıılıyor...";
"Opening audio stream" = "Ses akışıılıyor";
"Rate" = "Derecelendir";
"Orientation" = "Yönlendirme";
"No results" = "Sonuç yok";
@@ -396,7 +396,7 @@
"Open" = "Aç";
/* Loading stream OSD */
"Opening %@ stream..." = "%@ akışıılıyor...";
"Opening %@ stream" = "%@ akışıılıyor";
"Clear Queue before opening" = "Açmadan önce Bekleme Listesini temizle";
"Copy %@ link with time" = "Bağlantıyı %@ zaman ile kopyala";
"Show Inspector" = "Denetleyiciyi Göster";

View File

@@ -259,7 +259,7 @@
"LIVE" = "В ЕФІРІ";
/* Loading stream OSD */
"Loading streams..." = "Завантаження трансляції...";
"Loading streams" = "Завантаження трансляції";
"Loading..." = "Завантаження...";
"Locations" = "Локації";
"Lock portrait mode" = "Заблокувати портретний режим";
@@ -298,8 +298,8 @@
"Open Settings" = "Відрити налаштування";
/* Loading stream OSD */
"Opening %@ stream..." = "Запуск трансляції %@...";
"Opening audio stream..." = "Запуск аудіо трансляції...";
"Opening %@ stream" = "Запуск трансляції %@";
"Opening audio stream" = "Запуск аудіо трансляції";
"Orientation" = "Орієнтація";
/* SponsorBlock category name */

View File

@@ -152,7 +152,7 @@
"Loading..." = "加载中...";
/* Loading stream OSD */
"Loading streams..." = "加载流中...";
"Loading streams" = "加载流中";
"Locations" = "地址";
"Lock portrait mode" = "锁定竖屏模式";
@@ -191,8 +191,8 @@
"Open Settings" = "打开设置";
/* Loading stream OSD */
"Opening %@ stream..." = "正在打开 %@ 的流...";
"Opening audio stream..." = "正在打开音频流...";
"Opening %@ stream" = "正在打开 %@ 的流";
"Opening audio stream" = "正在打开音频流";
"Orientation" = "方向";
/* SponsorBlock category name */
@@ -530,7 +530,7 @@
"Show scroll to top button in comments" = "在评论中显示“滚动到顶部”按钮";
"Opened File" = "打开的文件";
"File Extension" = "文件扩展";
"Opening file..." = "打开文件中...";
"Opening file" = "打开文件中";
"Single tap gesture" = "单击手势";
"Right click channel thumbnail to open context menu with more actions" = "右键单击频道缩略图以打开具有更多操作的上下文菜单";
"Show unwatched feed badges" = "显示未观看的 Feed 标志";

View File

@@ -252,7 +252,7 @@
"Interface" = "介面";
/* Loading stream OSD */
"Loading streams..." = "加載中...";
"Loading streams" = "加載中";
"Loading..." = "加載中...";
"Locations" = "地址";
"Lock portrait mode" = "鎖定直屏";
@@ -292,8 +292,8 @@
"Open Settings" = "打開設置";
/* Loading stream OSD */
"Opening %@ stream..." = "正在打開 %@ ...";
"Opening audio stream..." = "正在打開音訊...";
"Opening %@ stream" = "正在打開 %@ ";
"Opening audio stream" = "正在打開音訊";
/* SponsorBlock category name */
"Outro" = "結尾";
@@ -554,7 +554,7 @@
"Queue - shuffled" = "隊列 - 隨機";
"Loop one" = "單個循環";
"File Extension" = "副檔名";
"Opening file..." = "正在打開文件...";
"Opening file" = "正在打開文件";
"Public account" = "公共帳戶";
"Enter account credentials to connect..." = "輸入帳號密碼來連接...";
"Enter location address to connect..." = "輸入站台地址來連接...";

View File

@@ -1070,6 +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 */
@@ -1539,6 +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 */
@@ -2286,6 +2302,8 @@
372915E52687E3B900F5A35B /* Defaults.swift */,
37D2E0D328B67EFC00F64D52 /* Delay.swift */,
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */,
E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */,
375B537828DF6CBB004C1D19 /* Localizable.strings */,
3729037D2739E47400EA99F6 /* MenuCommands.swift */,
37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */,
@@ -2293,6 +2311,8 @@
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */,
37FFC43F272734C3009FFD26 /* Throttle.swift */,
378FFBC328660172009E3FBE /* URLParser.swift */,
E258F3892BF61BD2005B8C28 /* URLTester.swift */,
E24DC6572BFA124100BF6187 /* UserAgentManager.swift */,
37D4B0C22671614700C925CA /* YatteeApp.swift */,
37D4B0C42671614800C925CA /* Assets.xcassets */,
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
@@ -3115,6 +3135,7 @@
37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
37F7D82C289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */,
375EC95D289EEEE000751258 /* QualityProfile.swift in Sources */,
E25028B02BF790F5002CB9FC /* HTTPStatus.swift in Sources */,
3773B8102ADC076800B5FEF3 /* ScrollViewMatcher.swift in Sources */,
371B7E662759786B00D21217 /* Comment+Fixtures.swift in Sources */,
37BE0BD326A1D4780092E2DB /* AppleAVPlayerView.swift in Sources */,
@@ -3138,6 +3159,8 @@
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 */,
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */,
@@ -3213,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 */,
@@ -3393,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 */,
@@ -3401,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 */,
@@ -3587,6 +3613,7 @@
37D4B19826717E1500C925CA /* Video.swift in Sources */,
371B7E5D27596B8400D21217 /* Comment.swift in Sources */,
37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */,
E25028B12BF790F5002CB9FC /* HTTPStatus.swift in Sources */,
37A7D71C2B680E66009CB1ED /* LocationsSettingsGroupExporter.swift in Sources */,
37BC50A92778A84700510953 /* HistorySettings.swift in Sources */,
374DE88128BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
@@ -3621,6 +3648,7 @@
378E9C39294552A700B2D696 /* ThumbnailView.swift in Sources */,
370F4FAB27CC164D001B35DC /* PlayerControlsModel.swift in Sources */,
37E8B0ED27B326C00024006F /* TimelineView.swift in Sources */,
E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */,
370E990B2A1EA8C500D144E9 /* WatchModel.swift in Sources */,
3717407E2949D40800FDDBC7 /* ChannelLinkView.swift in Sources */,
37FB28422721B22200A57617 /* ContentItem.swift in Sources */,
@@ -3864,8 +3892,10 @@
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 */,
370F4FAA27CC163B001B35DC /* PlayerBackend.swift in Sources */,
376A33E62720CB35000C1D6B /* Account.swift in Sources */,
3763C98B290C7A50004D3B5F /* OpenVideosView.swift in Sources */,
@@ -3908,6 +3938,7 @@
37B2631C2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
37484C2B26FC83FF00287258 /* AccountForm.swift in Sources */,
37FB2860272225E800A57617 /* ContentItemView.swift in Sources */,
E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */,
37F7D82E289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */,
374AB3DD28BCAF7E00DF56FB /* SeekType.swift in Sources */,
374C053727242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */,
@@ -3984,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 */,
@@ -4071,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 = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
@@ -4102,7 +4134,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -4133,7 +4165,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
@@ -4153,7 +4185,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 11.0;
@@ -4317,7 +4349,7 @@
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
@@ -4370,7 +4402,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
@@ -4422,7 +4454,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -4461,7 +4493,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
DEAD_CODE_STRIPPING = YES;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -4496,7 +4528,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4520,7 +4552,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4546,7 +4578,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4571,7 +4603,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4597,7 +4629,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -4637,7 +4669,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
DEVELOPMENT_ASSET_PATHS = "";
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
@@ -4678,7 +4710,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -4702,7 +4734,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 182;
CURRENT_PROJECT_VERSION = 185;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",