mirror of
https://github.com/yattee/yattee.git
synced 2025-12-23 05:00:13 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af75afa912 | ||
|
|
f32bcae5eb | ||
|
|
c55161b6b6 | ||
|
|
3fd99f464a | ||
|
|
43c8484514 | ||
|
|
7755b392b7 | ||
|
|
e0998638b1 | ||
|
|
511a528eb6 | ||
|
|
89dfbdb5c7 | ||
|
|
f88a1d17d6 | ||
|
|
9e05909659 | ||
|
|
b966f4509a | ||
|
|
dc7073dcb5 | ||
|
|
a258ee3be4 | ||
|
|
b626f50adc | ||
|
|
78dc47dc24 | ||
|
|
46725bf4d9 | ||
|
|
8697ec8faf | ||
|
|
8a015d29c3 | ||
|
|
4097d11b5e | ||
|
|
5323d53f9e | ||
|
|
e3e0c4a92f | ||
|
|
9e5efc1aa6 | ||
|
|
1ed4c20c3a | ||
|
|
ced9eb28d7 | ||
|
|
ea49758ed2 | ||
|
|
65ec675859 | ||
|
|
9a650799d3 | ||
|
|
ddd1f243f7 | ||
|
|
94f19d55c8 | ||
|
|
30cdaf88e1 | ||
|
|
8139bba31e | ||
|
|
d6cfadab9a | ||
|
|
5b917ef91d | ||
|
|
34cb7860b3 | ||
|
|
934bd65752 | ||
|
|
e53985534e | ||
|
|
03e4c6d4e6 | ||
|
|
335e99cb7b | ||
|
|
ae9aa6fac7 | ||
|
|
2f4fb9fc67 | ||
|
|
f6bea6e045 | ||
|
|
fa712d8177 | ||
|
|
03d24fbc42 | ||
|
|
4fd3a37705 | ||
|
|
a66857b1fb | ||
|
|
e44c7f84c8 | ||
|
|
6b5ecbdd8b | ||
|
|
15ce82a686 | ||
|
|
7e3e393c65 | ||
|
|
108b4de483 | ||
|
|
7c9810ddf0 | ||
|
|
96df7fdec5 | ||
|
|
4fa5a15ad4 | ||
|
|
c9125644ed | ||
|
|
4db02b2638 | ||
|
|
9c5f066e55 | ||
|
|
c7908d08ae | ||
|
|
c9fb41c8e8 | ||
|
|
2e9cceafa5 | ||
|
|
fa09b2021c | ||
|
|
90777d91f6 | ||
|
|
6959778775 | ||
|
|
0f43efef6f | ||
|
|
959fb0d1fc | ||
|
|
81be57904b | ||
|
|
a42345896d | ||
|
|
43fc9e20c0 | ||
|
|
1a1bd1ba5b | ||
|
|
99aca8e23c | ||
|
|
ddee3b74f0 | ||
|
|
b271aed52b | ||
|
|
1c608c78a1 | ||
|
|
0ec227ba80 | ||
|
|
2a93ff52a3 | ||
|
|
896d46d0cf | ||
|
|
ad79180530 | ||
|
|
101f20c538 | ||
|
|
f28cec79ba | ||
|
|
a12755ec4b | ||
|
|
38c4ddbe43 | ||
|
|
e35f8b7892 | ||
|
|
c3e4c074d6 | ||
|
|
6eba2a45c8 |
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,4 +1,27 @@
|
||||
## Build 181
|
||||
## Build 187
|
||||
* Allow import of accounts to manually added (not imported) instances
|
||||
* Add import export of missing settings
|
||||
* tvOS: Allow account picker by long pressing channels button in subscriptions view by @patelhiren in https://github.com/yattee/yattee/pull/704
|
||||
* macOS: Fix settings windows layout
|
||||
* Fix seek OSD layout on tvOS, revert OSD position
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/694
|
||||
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
* 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)
|
||||
* tvOS: Refined Subscriptions View by @patelhiren in https://github.com/yattee/yattee/pull/697
|
||||
* More responsive UI when Favorites are used. by @stonerl in https://github.com/yattee/yattee/pull/695
|
||||
* Improved conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/696
|
||||
* 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 +41,23 @@
|
||||
* 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
|
||||
* Fix thumbnails failing to load on tvOS by @patelhiren in https://github.com/yattee/yattee/pull/688
|
||||
* speed up sorting for Stream by @stonerl in https://github.com/yattee/yattee/pull/681
|
||||
* faster chapter extraction by @stonerl in https://github.com/yattee/yattee/pull/682
|
||||
* Invidious: add images to chapters by @stonerl in https://github.com/yattee/yattee/pull/685
|
||||
* Improved Captions handling by @stonerl in https://github.com/yattee/yattee/pull/684
|
||||
* Add User-Agent to request by @stonerl in https://github.com/yattee/yattee/pull/680
|
||||
* MPV: speed up playback start by @stonerl in https://github.com/yattee/yattee/pull/689
|
||||
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
|
||||
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
* Upgraded dependencies
|
||||
* Fixed reported crash
|
||||
* Other minor changes and improvements
|
||||
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -1,6 +1,6 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem 'fastlane', git: 'https://github.com/nekrich/fastlane.git', branch: 'fix/match-tvos-devices-fetch'
|
||||
gem 'fastlane'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
|
||||
124
Gemfile.lock
124
Gemfile.lock
@@ -1,50 +1,3 @@
|
||||
GIT
|
||||
remote: https://github.com/nekrich/fastlane.git
|
||||
revision: d2d51a9af37f9b04a157e78fd25d147cecc89980
|
||||
branch: fix/match-tvos-devices-fetch
|
||||
specs:
|
||||
fastlane (2.219.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
@@ -52,24 +5,24 @@ GEM
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.929.0)
|
||||
aws-sdk-core (3.196.1)
|
||||
aws-partitions (1.952.0)
|
||||
aws-sdk-core (3.201.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-core (~> 3, >= 3.193.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.151.0)
|
||||
aws-sdk-core (~> 3, >= 3.194.0)
|
||||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.156.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
@@ -115,6 +68,47 @@ GEM
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.1)
|
||||
fastlane (2.221.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
@@ -153,14 +147,14 @@ GEM
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
http-cookie (1.0.6)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
jwt (2.8.1)
|
||||
jwt (2.8.2)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_magick (4.13.1)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
@@ -170,19 +164,19 @@ GEM
|
||||
optparse (0.5.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.5)
|
||||
public_suffix (6.0.0)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rexml (3.2.9)
|
||||
strscan
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
security (0.1.5)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
@@ -222,7 +216,7 @@ PLATFORMS
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane!
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.6
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +415,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -491,6 +492,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 +609,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 +719,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 +742,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate
|
||||
bitrate: bitrate,
|
||||
requestRange: requestRange
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious
|
||||
self == .invidious || self == .piped
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
|
||||
@@ -9,7 +9,11 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"mpvHWdec": Defaults[.mpvHWdec],
|
||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
|
||||
@@ -6,6 +6,9 @@ final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount],
|
||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
||||
|
||||
@@ -15,11 +18,7 @@ final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton],
|
||||
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount]
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"showChapterThumbnails": Defaults[.showChapterThumbnails],
|
||||
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
|
||||
"expandChapters": Defaults[.expandChapters],
|
||||
"showRelated": Defaults[.showRelated],
|
||||
"showInspector": Defaults[.showInspector].rawValue,
|
||||
@@ -18,7 +20,12 @@ final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP]
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
|
||||
"captionsAutoShow": Defaults[.captionsAutoShow],
|
||||
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
|
||||
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
|
||||
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
|
||||
"captionsFontColor": Defaults[.captionsFontColor]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories])
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
|
||||
"sponsorBlockColors": Defaults[.sponsorBlockColors],
|
||||
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
|
||||
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
|
||||
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,26 @@ struct AdvancedSettingsGroupImporter {
|
||||
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
|
||||
}
|
||||
|
||||
if let mpvCachePauseInital = json["mpvCachePauseInital"].bool {
|
||||
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
|
||||
}
|
||||
|
||||
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
|
||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
||||
}
|
||||
|
||||
if let mpvHWdec = json["mpvHWdec"].string {
|
||||
Defaults[.mpvHWdec] = mpvHWdec
|
||||
}
|
||||
|
||||
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
|
||||
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
||||
}
|
||||
|
||||
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
||||
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
||||
}
|
||||
|
||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
||||
Defaults[.showCacheStatus] = showCacheStatus
|
||||
}
|
||||
|
||||
@@ -13,6 +13,18 @@ struct HistorySettingsGroupImporter {
|
||||
Defaults[.saveHistory] = saveHistory
|
||||
}
|
||||
|
||||
if let showRecents = json["showRecents"].bool {
|
||||
Defaults[.showRecents] = showRecents
|
||||
}
|
||||
|
||||
if let limitRecents = json["limitRecents"].bool {
|
||||
Defaults[.limitRecents] = limitRecents
|
||||
}
|
||||
|
||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
||||
}
|
||||
|
||||
if let showWatchingProgress = json["showWatchingProgress"].bool {
|
||||
Defaults[.showWatchingProgress] = showWatchingProgress
|
||||
}
|
||||
@@ -50,17 +62,5 @@ struct HistorySettingsGroupImporter {
|
||||
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
|
||||
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
|
||||
}
|
||||
|
||||
if let showRecents = json["showRecents"].bool {
|
||||
Defaults[.showRecents] = showRecents
|
||||
}
|
||||
|
||||
if let limitRecents = json["limitRecents"].bool {
|
||||
Defaults[.limitRecents] = limitRecents
|
||||
}
|
||||
|
||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ struct LocationsSettingsGroupImporter {
|
||||
if let password,
|
||||
!password.isEmpty,
|
||||
let instanceID = account.instanceID,
|
||||
let instance = InstancesModel.shared.find(instanceID)
|
||||
let instance = InstancesModel.shared.find(instanceID) ?? InstancesModel.shared.findByURLString(account.urlString)
|
||||
{
|
||||
if !instance.accounts.contains(where: { instanceAccount in
|
||||
let (username, _) = instanceAccount.credentials
|
||||
|
||||
@@ -29,6 +29,14 @@ struct PlayerSettingsGroupImporter {
|
||||
Defaults[.showChapters] = showChapters
|
||||
}
|
||||
|
||||
if let showChapterThumbnails = json["showChapterThumbnails"].bool {
|
||||
Defaults[.showChapterThumbnails] = showChapterThumbnails
|
||||
}
|
||||
|
||||
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
|
||||
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
|
||||
}
|
||||
|
||||
if let expandChapters = json["expandChapters"].bool {
|
||||
Defaults[.expandChapters] = expandChapters
|
||||
}
|
||||
@@ -96,5 +104,25 @@ struct PlayerSettingsGroupImporter {
|
||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
||||
}
|
||||
#endif
|
||||
|
||||
if let captionsAutoShow = json["captionsAutoShow"].bool {
|
||||
Defaults[.captionsAutoShow] = captionsAutoShow
|
||||
}
|
||||
|
||||
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
|
||||
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
|
||||
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
|
||||
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
|
||||
}
|
||||
|
||||
if let captionsFontColor = json["captionsFontColor"].string {
|
||||
Defaults[.captionsFontColor] = captionsFontColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,22 @@ struct SponsorBlockSettingsGroupImporter {
|
||||
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
|
||||
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap { $0.string })
|
||||
}
|
||||
|
||||
if let sponsorBlockColors = json["sponsorBlockColors"].dictionary {
|
||||
let colors = sponsorBlockColors.mapValues { json in json.stringValue }
|
||||
Defaults[.sponsorBlockColors] = colors
|
||||
}
|
||||
|
||||
if let sponsorBlockShowTimeWithSkipsRemoved = json["sponsorBlockShowTimeWithSkipsRemoved"].bool {
|
||||
Defaults[.sponsorBlockShowTimeWithSkipsRemoved] = sponsorBlockShowTimeWithSkipsRemoved
|
||||
}
|
||||
|
||||
if let sponsorBlockShowCategoriesInTimeline = json["sponsorBlockShowCategoriesInTimeline"].bool {
|
||||
Defaults[.sponsorBlockShowCategoriesInTimeline] = sponsorBlockShowCategoriesInTimeline
|
||||
}
|
||||
|
||||
if let sponsorBlockShowNoticeAfterSkip = json["sponsorBlockShowNoticeAfterSkip"].bool {
|
||||
Defaults[.sponsorBlockShowNoticeAfterSkip] = sponsorBlockShowNoticeAfterSkip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -527,6 +542,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failed:
|
||||
DispatchQueue.main.async {
|
||||
self.model.playerError = item.error
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -60,14 +60,44 @@ 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))
|
||||
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
|
||||
|
||||
// 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 +435,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,172 @@ 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")
|
||||
// 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]()
|
||||
let instance = instance
|
||||
|
||||
var hasForbiddenAsset = false
|
||||
var hasAllowedAsset = false
|
||||
|
||||
for stream in streams {
|
||||
streamProcessingQueue.async(group: streamProcessingGroup) {
|
||||
let forbiddenAssetTestGroup = DispatchGroup()
|
||||
if !hasAllowedAsset, !hasForbiddenAsset, !instance.proxiesVideos, stream.format != Stream.Format.unknown {
|
||||
let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
|
||||
if let firstStream = nonHLSAssets.first {
|
||||
let asset = firstStream.0
|
||||
let url = firstStream.1
|
||||
let requestRange = firstStream.2
|
||||
|
||||
if instance.app == .invidious {
|
||||
self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if instance.app == .piped {
|
||||
self.testPipedAssets(asset: asset!, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let firstHLS = hlsURLs.first {
|
||||
let asset = AVURLAsset(url: firstHLS)
|
||||
if instance.app == .piped {
|
||||
self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
forbiddenAssetTestGroup.wait()
|
||||
|
||||
// Post-processing code
|
||||
if instance.app == .invidious, hasForbiddenAsset || instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
}
|
||||
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
|
||||
if let hlsURL = stream.hlsURL {
|
||||
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
|
||||
if let nonProxiedURL = possibleNonProxiedURL {
|
||||
stream.hlsURL = nonProxiedURL.url
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if 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: [(AVURLAsset?, URL, String?)], hlsURLs: [URL]) {
|
||||
var nonHLSAssets = [(AVURLAsset?, URL, String?)]()
|
||||
var hlsURLs = [URL]()
|
||||
|
||||
for stream in streams {
|
||||
if stream.isHLS {
|
||||
if let url = stream.hlsURL?.url {
|
||||
hlsURLs.append(url)
|
||||
}
|
||||
} else {
|
||||
if let asset = stream.audioAsset {
|
||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
||||
}
|
||||
if let asset = stream.videoAsset {
|
||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (nonHLSAssets, hlsURLs)
|
||||
}
|
||||
|
||||
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
||||
// In case the range is nil, generate a random one.
|
||||
let randomEnd = Int.random(in: 200 ... 800)
|
||||
let requestRange = range ?? "0-\(randomEnd)"
|
||||
|
||||
forbiddenAssetTestGroup.enter()
|
||||
URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
|
||||
completion(statusCode)
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
||||
PipedAPI.nonProxiedAsset(asset: asset) { possibleNonProxiedAsset in
|
||||
if let nonProxiedAsset = possibleNonProxiedAsset {
|
||||
self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
|
||||
} else {
|
||||
completion(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -103,9 +103,8 @@ enum Constants {
|
||||
#elseif os(iOS)
|
||||
if isIPad {
|
||||
return .sidebar
|
||||
} else {
|
||||
return .tab
|
||||
}
|
||||
return .tab
|
||||
#else
|
||||
return .tab
|
||||
#endif
|
||||
|
||||
@@ -106,6 +106,12 @@ extension Defaults.Keys {
|
||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||
#endif
|
||||
|
||||
static let captionsAutoShow = Key<Bool>("captionsAutoShow", default: false)
|
||||
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")
|
||||
|
||||
// MARK: GROUP - Controls
|
||||
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||
@@ -170,7 +176,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 +272,11 @@ 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 mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
|
||||
|
||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||
@@ -301,7 +311,6 @@ extension Defaults.Keys {
|
||||
|
||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
||||
|
||||
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
||||
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
||||
|
||||
|
||||
81
Shared/HTTPStatus.swift
Normal file
81
Shared/HTTPStatus.swift
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -86,21 +88,36 @@ struct FavoriteItemView: View {
|
||||
reloadVisibleWatches()
|
||||
} else {
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource()
|
||||
DispatchQueue.main.async {
|
||||
self.loadCacheAndResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
}
|
||||
.onChange(of: player.currentVideo) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
.onChange(of: hideShorts) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
.onChange(of: hideWatched) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
|
||||
// Delay is necessary to update the list with the new items.
|
||||
.onChange(of: favoritesChanged) { _ in if !player.presentingPlayer { Delay.by(1.0) { reloadVisibleWatches() } } }
|
||||
.onChange(of: player.presentingPlayer) { _ in
|
||||
if player.presentingPlayer {
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
} else {
|
||||
resource?.addObserver(store)
|
||||
}
|
||||
}
|
||||
.onChange(of: player.currentVideo) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: hideShorts) { _ in reloadVisibleWatches() }
|
||||
.onChange(of: hideWatched) { _ in reloadVisibleWatches() }
|
||||
}
|
||||
}
|
||||
.id(watchModel.historyToken)
|
||||
.onChange(of: accounts.current) { _ in
|
||||
resource?.addObserver(store)
|
||||
loadCacheAndResource(force: true)
|
||||
DispatchQueue.main.async {
|
||||
loadCacheAndResource(force: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: watchModel.historyToken) { _ in
|
||||
Delay.by(0.5) {
|
||||
if !player.presentingPlayer {
|
||||
reloadVisibleWatches()
|
||||
}
|
||||
}
|
||||
@@ -154,22 +171,27 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
|
||||
func reloadVisibleWatches() {
|
||||
guard item.section == .history else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard item.section == .history else { return }
|
||||
|
||||
visibleWatches = []
|
||||
visibleWatches = []
|
||||
|
||||
let watches = Array(
|
||||
watches
|
||||
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
|
||||
.prefix(favoritesModel.limit(item))
|
||||
)
|
||||
let last = watches.last
|
||||
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() }
|
||||
let watches = Array(
|
||||
watches
|
||||
.filter { $0.videoID != player.currentVideo?.videoID && itemVisible(.init(video: $0.video)) }
|
||||
.prefix(favoritesModel.limit(item))
|
||||
)
|
||||
let last = watches.last
|
||||
|
||||
for watch in watches {
|
||||
player.loadHistoryVideoDetails(watch) {
|
||||
guard let video = player.historyVideo(watch.videoID), itemVisible(.init(video: video)) else { return }
|
||||
visibleWatches.append(watch)
|
||||
|
||||
if watch == last {
|
||||
visibleWatches.sort { $0.watchedAt ?? Date() > $1.watchedAt ?? Date() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +241,9 @@ struct FavoriteItemView: View {
|
||||
onSuccess = { response in
|
||||
if let videos: [Video] = response.typedContent() {
|
||||
FeedCacheModel.shared.storeFeed(account: accounts.current, videos: videos)
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .channel(_, id, name):
|
||||
@@ -231,19 +256,21 @@ struct FavoriteItemView: View {
|
||||
}
|
||||
|
||||
onSuccess = { response in
|
||||
if let channel: Channel = response.typedContent() {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: channel.videos)
|
||||
} else if let videos: [Video] = response.typedContent() {
|
||||
channel.videos = videos
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: videos)
|
||||
} else if let channelPage: ChannelPage = response.typedContent() {
|
||||
if let channel = channelPage.channel {
|
||||
DispatchQueue.main.async {
|
||||
if let channel: Channel = response.typedContent() {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
}
|
||||
store.contentItems = ContentItem.array(of: channel.videos)
|
||||
} else if let videos: [Video] = response.typedContent() {
|
||||
channel.videos = videos
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
store.contentItems = ContentItem.array(of: videos)
|
||||
} else if let channelPage: ChannelPage = response.typedContent() {
|
||||
if let channel = channelPage.channel {
|
||||
ChannelsCacheModel.shared.store(channel)
|
||||
}
|
||||
|
||||
store.contentItems = channelPage.results
|
||||
store.contentItems = channelPage.results
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .channelPlaylist(_, id, title):
|
||||
@@ -256,6 +283,9 @@ struct FavoriteItemView: View {
|
||||
onSuccess = { response in
|
||||
if let playlist: ChannelPlaylist = response.typedContent() {
|
||||
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist)
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .playlist(_, id):
|
||||
@@ -264,12 +294,16 @@ struct FavoriteItemView: View {
|
||||
if let playlist = playlists.first(where: { $0.id == id }) {
|
||||
contentItems = ContentItem.array(of: playlist.videos)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
default:
|
||||
contentItems = []
|
||||
}
|
||||
|
||||
if !contentItems.isEmpty {
|
||||
store.contentItems = contentItems
|
||||
DispatchQueue.main.async {
|
||||
store.contentItems = contentItems
|
||||
}
|
||||
}
|
||||
|
||||
if force {
|
||||
@@ -435,6 +469,7 @@ struct FavoriteItemView: View {
|
||||
switch item.section {
|
||||
case .history:
|
||||
return nil
|
||||
|
||||
case .subscriptions:
|
||||
if accounts.app.supportsSubscriptions {
|
||||
return accounts.api.feed(1)
|
||||
@@ -486,14 +521,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import UniformTypeIdentifiers
|
||||
|
||||
struct HomeView: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@State private var presentingHomeSettings = false
|
||||
@State private var favoritesChanged = false
|
||||
@State private var updateTask: Task<Void, Never>?
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
@@ -16,8 +18,6 @@ struct HomeView: View {
|
||||
@State private var recentDocumentsID = UUID()
|
||||
#endif
|
||||
|
||||
var favoritesObserver: Any?
|
||||
|
||||
#if !os(tvOS)
|
||||
@Default(.favorites) private var favorites
|
||||
@Default(.widgetsSettings) private var widgetsSettings
|
||||
@@ -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
|
||||
@@ -124,6 +124,24 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
updateTask?.cancel()
|
||||
}
|
||||
|
||||
.onChange(of: player.presentingPlayer) { _ in
|
||||
if player.presentingPlayer {
|
||||
updateTask?.cancel()
|
||||
} else {
|
||||
Task {
|
||||
for await _ in Defaults.updates(.favorites) {
|
||||
favoritesChanged.toggle()
|
||||
}
|
||||
for await _ in Defaults.updates(.widgetsSettings) {
|
||||
favoritesChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.redrawOn(change: favoritesChanged)
|
||||
|
||||
|
||||
109
Shared/LanguageCodes.swift
Normal file
109
Shared/LanguageCodes.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
enum LanguageCodes: String, CaseIterable {
|
||||
case Afrikaans = "af"
|
||||
case Arabic = "ar"
|
||||
case Azerbaijani = "az"
|
||||
case Bengali = "bn"
|
||||
case Catalan = "ca"
|
||||
case Czech = "cs"
|
||||
case Welsh = "cy"
|
||||
case Danish = "da"
|
||||
case German = "de"
|
||||
case Greek = "el"
|
||||
case English = "en"
|
||||
case English_GB = "en-GB"
|
||||
case Spanish = "es"
|
||||
case Persian = "fa"
|
||||
case Finnish = "fi"
|
||||
case Filipino = "fil"
|
||||
case French = "fr"
|
||||
case Irish = "ga"
|
||||
case Hebrew = "he"
|
||||
case Hindi = "hi"
|
||||
case Hungarian = "hu"
|
||||
case Indonesian = "id"
|
||||
case Italian = "it"
|
||||
case Japanese = "ja"
|
||||
case Javanese = "jv"
|
||||
case Korean = "ko"
|
||||
case Lithuanian = "lt"
|
||||
case Malay = "ms"
|
||||
case Maltese = "mt"
|
||||
case Dutch = "nl"
|
||||
case Norwegian = "no"
|
||||
case Polish = "pl"
|
||||
case Portuguese = "pt"
|
||||
case Romanian = "ro"
|
||||
case Russian = "ru"
|
||||
case Slovak = "sk"
|
||||
case Slovene = "sl"
|
||||
case Swedish = "sv"
|
||||
case Swahili = "sw"
|
||||
case Thai = "th"
|
||||
case Tagalog = "tl"
|
||||
case Turkish = "tr"
|
||||
case Ukrainian = "uk"
|
||||
case Urdu = "ur"
|
||||
case Uzbek = "uz"
|
||||
case Vietnamese = "vi"
|
||||
case Xhosa = "xh"
|
||||
case Chinese = "zh"
|
||||
case Zulu = "zu"
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .Afrikaans: return "Afrikaans"
|
||||
case .Arabic: return "Arabic"
|
||||
case .Azerbaijani: return "Azerbaijani"
|
||||
case .Bengali: return "Bengali"
|
||||
case .Catalan: return "Catalan"
|
||||
case .Czech: return "Czech"
|
||||
case .Welsh: return "Welsh"
|
||||
case .Danish: return "Danish"
|
||||
case .German: return "German"
|
||||
case .Greek: return "Greek"
|
||||
case .English: return "English"
|
||||
case .English_GB: return "English (United Kingdom)"
|
||||
case .Spanish: return "Spanish"
|
||||
case .Persian: return "Persian"
|
||||
case .Finnish: return "Finnish"
|
||||
case .Filipino: return "Filipino"
|
||||
case .French: return "French"
|
||||
case .Irish: return "Irish"
|
||||
case .Hebrew: return "Hebrew"
|
||||
case .Hindi: return "Hindi"
|
||||
case .Hungarian: return "Hungarian"
|
||||
case .Indonesian: return "Indonesian"
|
||||
case .Italian: return "Italian"
|
||||
case .Japanese: return "Japanese"
|
||||
case .Javanese: return "Javanese"
|
||||
case .Korean: return "Korean"
|
||||
case .Lithuanian: return "Lithuanian"
|
||||
case .Malay: return "Malay"
|
||||
case .Maltese: return "Maltese"
|
||||
case .Dutch: return "Dutch"
|
||||
case .Norwegian: return "Norwegian"
|
||||
case .Polish: return "Polish"
|
||||
case .Portuguese: return "Portuguese"
|
||||
case .Romanian: return "Romanian"
|
||||
case .Russian: return "Russian"
|
||||
case .Slovak: return "Slovak"
|
||||
case .Slovene: return "Slovene"
|
||||
case .Swedish: return "Swedish"
|
||||
case .Swahili: return "Swahili"
|
||||
case .Thai: return "Thai"
|
||||
case .Tagalog: return "Tagalog"
|
||||
case .Turkish: return "Turkish"
|
||||
case .Ukrainian: return "Ukrainian"
|
||||
case .Urdu: return "Urdu"
|
||||
case .Uzbek: return "Uzbek"
|
||||
case .Vietnamese: return "Vietnamese"
|
||||
case .Xhosa: return "Xhosa"
|
||||
case .Chinese: return "Chinese"
|
||||
case .Zulu: return "Zulu"
|
||||
}
|
||||
}
|
||||
|
||||
static func languageName(for code: String) -> String {
|
||||
return LanguageCodes(rawValue: code)?.description ?? "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -333,9 +333,19 @@ struct ControlsOverlay: View {
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = captionsBinding.wrappedValue {
|
||||
Text(captions.code)
|
||||
.foregroundColor(.primary)
|
||||
if let captions = captionsBinding.wrappedValue,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if captionsBinding.wrappedValue == nil {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 240)
|
||||
@@ -351,8 +361,18 @@ struct ControlsOverlay: View {
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = captionsBinding.wrappedValue {
|
||||
Text(captions.code)
|
||||
if let captions = captionsBinding.wrappedValue,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if captionsBinding.wrappedValue == nil {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -30,7 +30,6 @@ enum PlayerControlsLayout: String, CaseIterable, Defaults.Serializable {
|
||||
#else
|
||||
return isIPad
|
||||
#endif
|
||||
|
||||
case .large:
|
||||
return true
|
||||
case .medium:
|
||||
@@ -264,7 +263,7 @@ enum PlayerControlsLayout: String, CaseIterable, Defaults.Serializable {
|
||||
var seekOSDWidth: Double {
|
||||
switch self {
|
||||
case .tvRegular:
|
||||
return 240
|
||||
return 280
|
||||
case .veryLarge:
|
||||
return 240
|
||||
case .large:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
@@ -383,23 +384,35 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private var captionsButton: some View {
|
||||
let videoCaptions = player.currentVideo?.captions
|
||||
#if os(macOS)
|
||||
captionsPicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
captionsPicker
|
||||
if videoCaptions?.isEmpty == false {
|
||||
captionsPicker
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = player.captions {
|
||||
Text(captions.code)
|
||||
if let captions = player.captions,
|
||||
let language = LanguageCodes(rawValue: captions.code)
|
||||
{
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
if videoCaptions?.isEmpty == true {
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
.disabled(videoCaptions?.isEmpty == true)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -65,7 +65,7 @@ import SwiftUI
|
||||
}
|
||||
|
||||
static var thumbnailHeight: Double {
|
||||
thumbnailWidth / 1.7777
|
||||
thumbnailWidth / (16 / 9)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ struct VideoActions: View {
|
||||
}
|
||||
case .musicMode:
|
||||
actionButton("Music", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
|
||||
|
||||
case .settings:
|
||||
actionButton("Settings", systemImage: "gear") {
|
||||
withAnimation(ControlOverlaysModel.animation) {
|
||||
@@ -179,7 +178,6 @@ struct VideoActions: View {
|
||||
actionButton("Hide", systemImage: "chevron.down") {
|
||||
player.hide(animate: true)
|
||||
}
|
||||
|
||||
case .close:
|
||||
actionButton("Close", systemImage: "xmark") {
|
||||
player.closeCurrentItem()
|
||||
|
||||
@@ -5,8 +5,12 @@ 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(.mpvInitialAudioSync) private var mpvInitialAudioSync
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
@Default(.feedCacheSize) private var feedCacheSize
|
||||
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
|
||||
@@ -68,9 +72,45 @@ 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)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-initial")!)
|
||||
}
|
||||
.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)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-secs")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
|
||||
#endif
|
||||
TextField("cache-secs", text: $mpvCacheSecs)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -79,8 +119,25 @@ 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)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-wait")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}.frame(minWidth: 140, alignment: .leading)
|
||||
|
||||
TextField("cache-pause-wait", text: $mpvCachePauseWait)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -88,7 +145,105 @@ 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)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-deinterlace")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Toggle(isOn: $mpvInitialAudioSync) {
|
||||
HStack {
|
||||
Text("initial-audio-sync")
|
||||
#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-initial-audio-sync")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-initial-audio-sync")!)
|
||||
}
|
||||
.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)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-hwdec")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
|
||||
Picker("", selection: $mpvHWdec) {
|
||||
ForEach(["auto", "auto-safe", "auto-copy"], id: \.self) {
|
||||
Text($0)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("demuxer-lavf-probe-info")
|
||||
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-demuxer-lavf-probe-info")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-demuxer-lavf-probe-info")!)
|
||||
}
|
||||
.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 +258,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ struct ImportSettingsAccountRow: View {
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
if InstancesModel.shared.find(instanceID) != nil {
|
||||
if InstancesModel.shared.find(instanceID) != nil || InstancesModel.shared.findByURLString(account.urlString) != nil {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
|
||||
@@ -42,7 +42,7 @@ final class ImportSettingsSheetViewModel: ObservableObject {
|
||||
|
||||
return ((account.password != nil && !account.password!.isEmpty) ||
|
||||
importableAccounts.contains(account.id)) && (
|
||||
(InstancesModel.shared.find(instanceID) != nil) ||
|
||||
(InstancesModel.shared.find(instanceID) != nil || InstancesModel.shared.findByURLString(account.urlString) != nil) ||
|
||||
selectedInstances.contains(instanceID)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -57,7 +57,7 @@ struct QualityProfileForm: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.frame(width: 400, height: 400)
|
||||
.frame(width: 400, height: 450)
|
||||
.padding(.vertical, 10)
|
||||
#endif
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -316,19 +316,19 @@ struct SettingsView: View {
|
||||
case .browsing:
|
||||
return 800
|
||||
case .player:
|
||||
return 550
|
||||
return 800
|
||||
case .controls:
|
||||
return 920
|
||||
case .quality:
|
||||
return 420
|
||||
case .history:
|
||||
return 500
|
||||
return 600
|
||||
case .sponsorBlock:
|
||||
return 700
|
||||
return 970
|
||||
case .locations:
|
||||
return 600
|
||||
case .advanced:
|
||||
return 500
|
||||
return 550
|
||||
case .importExport:
|
||||
return 580
|
||||
case .help:
|
||||
|
||||
@@ -74,7 +74,7 @@ struct SponsorBlockSettings: View {
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Restore Default Colors …")
|
||||
Text("Restore Default Colors…")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -4,18 +4,183 @@ import SwiftUI
|
||||
|
||||
struct FeedView: View {
|
||||
@ObservedObject private var feed = FeedModel.shared
|
||||
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
|
||||
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
|
||||
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
|
||||
#if os(tvOS)
|
||||
@Default(.subscriptionsListingStyle) private var subscriptionsListingStyle
|
||||
@StateObject private var accountsModel = AccountsViewModel()
|
||||
#endif
|
||||
|
||||
var videos: [ContentItem] {
|
||||
ContentItem.array(of: feed.videos)
|
||||
guard let selectedChannel = selectedChannel else {
|
||||
return ContentItem.array(of: feed.videos)
|
||||
}
|
||||
return ContentItem.array(of: feed.videos.filter {
|
||||
$0.channel.id == selectedChannel.id
|
||||
})
|
||||
}
|
||||
|
||||
var channels: [Channel] {
|
||||
feed.videos.map {
|
||||
$0.channel
|
||||
}.unique()
|
||||
}
|
||||
|
||||
@State private var selectedChannel: Channel?
|
||||
#if os(tvOS)
|
||||
@FocusState private var focusedChannel: String?
|
||||
#endif
|
||||
@State private var feedChannelsViewVisible = false
|
||||
private var navigation = NavigationModel.shared
|
||||
private let dismiss_channel_list_id = "dismiss_channel_list_id"
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// selected channel feed view
|
||||
HStack(spacing: 0) {
|
||||
// sidebar - show channels
|
||||
if feedChannelsViewVisible {
|
||||
Spacer()
|
||||
.frame(width: geometry.size.width * 0.3)
|
||||
}
|
||||
selectedFeedView
|
||||
}
|
||||
.disabled(feedChannelsViewVisible)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
|
||||
if feedChannelsViewVisible {
|
||||
HStack(spacing: 0) {
|
||||
// sidebar - show channels
|
||||
feedChannelsView
|
||||
.padding(.all)
|
||||
.frame(width: geometry.size.width * 0.3)
|
||||
.background()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.id(dismiss_channel_list_id)
|
||||
.focusable()
|
||||
.focused(self.$focusedChannel, equals: dismiss_channel_list_id)
|
||||
}
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
selectedFeedView
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
var accountsPicker: some View {
|
||||
ForEach(accountsModel.sortedAccounts.filter { $0.anonymous == false }) { account in
|
||||
Button(action: {
|
||||
AccountsModel.shared.setCurrent(account)
|
||||
}) {
|
||||
HStack {
|
||||
Text("\(account.description) (\(account.instance.app.rawValue))")
|
||||
if account == accountsModel.currentAccount {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
var feedChannelsView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack {
|
||||
Text("Channels")
|
||||
.font(.subheadline)
|
||||
if #available(tvOS 17.0, *) {
|
||||
List(selection: $selectedChannel) {
|
||||
Button(action: {
|
||||
self.selectedChannel = nil
|
||||
self.feedChannelsViewVisible = false
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: RecentsModel.symbolSystemImage("A"))
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 35, height: 35)
|
||||
Text("All")
|
||||
Spacer()
|
||||
feedCount.unwatchedText
|
||||
}
|
||||
}
|
||||
.padding(.all)
|
||||
.background(RoundedRectangle(cornerRadius: 8.0)
|
||||
.fill(self.selectedChannel == nil ? Color.secondary : Color.clear))
|
||||
.font(.caption)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.focused(self.$focusedChannel, equals: "all")
|
||||
|
||||
ForEach(channels, id: \.self) { channel in
|
||||
Button(action: {
|
||||
self.selectedChannel = channel
|
||||
self.feedChannelsViewVisible = false
|
||||
}) {
|
||||
HStack(spacing: 16) {
|
||||
ChannelAvatarView(channel: channel, subscribedBadge: false)
|
||||
.frame(width: 50, height: 50)
|
||||
Text(channel.name)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let unwatchedCount = feedCount.unwatchedByChannelText(channel) {
|
||||
unwatchedCount
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.all)
|
||||
.background(RoundedRectangle(cornerRadius: 8.0)
|
||||
.fill(self.selectedChannel == channel ? Color.secondary : Color.clear))
|
||||
.font(.caption)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.focused(self.$focusedChannel, equals: channel.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.focusedChannel) {
|
||||
if self.focusedChannel == "all" {
|
||||
withAnimation {
|
||||
self.selectedChannel = nil
|
||||
}
|
||||
} else if self.focusedChannel == dismiss_channel_list_id {
|
||||
self.feedChannelsViewVisible = false
|
||||
} else {
|
||||
withAnimation {
|
||||
self.selectedChannel = channels.first {
|
||||
$0.id == self.focusedChannel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard let selectedChannel = self.selectedChannel else {
|
||||
return
|
||||
}
|
||||
proxy.scrollTo(selectedChannel, anchor: .top)
|
||||
}
|
||||
.onExitCommand {
|
||||
withAnimation {
|
||||
self.feedChannelsViewVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var selectedFeedView: some View {
|
||||
VerticalCells(items: videos) { if shouldDisplayHeader { header } }
|
||||
.environment(\.loadMoreContentHandler) { feed.loadNextPage() }
|
||||
.onAppear {
|
||||
@@ -49,33 +214,55 @@ struct FeedView: View {
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
HStack(spacing: 16) {
|
||||
#if os(tvOS)
|
||||
SubscriptionsPageButton()
|
||||
ListingStyleButtons(listingStyle: $subscriptionsListingStyle)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
#endif
|
||||
|
||||
if showCacheStatus {
|
||||
Spacer()
|
||||
|
||||
CacheStatusHeader(
|
||||
refreshTime: feed.formattedFeedTime,
|
||||
isLoading: feed.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
feed.loadResources(force: true)
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
if #available(tvOS 17.0, *) {
|
||||
Menu {
|
||||
accountsPicker
|
||||
} label: {
|
||||
Label("Channels", systemImage: "filemenu.and.selection")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
} primaryAction: {
|
||||
withAnimation {
|
||||
self.feedChannelsViewVisible = true
|
||||
self.focusedChannel = selectedChannel?.id ?? "all"
|
||||
}
|
||||
}
|
||||
.opacity(feedChannelsViewVisible ? 0 : 1)
|
||||
.frame(minWidth: feedChannelsViewVisible ? 0 : nil, maxWidth: feedChannelsViewVisible ? 0 : nil)
|
||||
}
|
||||
channelHeaderView
|
||||
if selectedChannel == nil {
|
||||
Spacer()
|
||||
}
|
||||
if feedChannelsViewVisible == false {
|
||||
ListingStyleButtons(listingStyle: $subscriptionsListingStyle)
|
||||
HideWatchedButtons()
|
||||
HideShortsButtons()
|
||||
}
|
||||
#endif
|
||||
|
||||
if feedChannelsViewVisible == false {
|
||||
if showCacheStatus {
|
||||
CacheStatusHeader(
|
||||
refreshTime: feed.formattedFeedTime,
|
||||
isLoading: feed.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
feed.loadResources(force: true)
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.small)
|
||||
.font(.caption)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.leading, 30)
|
||||
#if os(tvOS)
|
||||
@@ -84,6 +271,46 @@ struct FeedView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var channelHeaderView: some View {
|
||||
guard let selectedChannel = selectedChannel else {
|
||||
return AnyView(
|
||||
Text("All Channels")
|
||||
.font(.caption)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.padding(0)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
}
|
||||
|
||||
return AnyView(
|
||||
HStack(spacing: 16) {
|
||||
ChannelAvatarView(channel: selectedChannel, subscribedBadge: false)
|
||||
.id("channel-avatar-\(selectedChannel.id)")
|
||||
.frame(width: 80, height: 80)
|
||||
Text("\(selectedChannel.name)")
|
||||
.font(.caption)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Spacer()
|
||||
if feedChannelsViewVisible == false {
|
||||
Button(action: {
|
||||
navigation.openChannel(selectedChannel, navigationStyle: .tab)
|
||||
}) {
|
||||
Text("Visit Channel")
|
||||
.font(.caption)
|
||||
.frame(alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(0)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
}
|
||||
|
||||
var shouldDisplayHeader: Bool {
|
||||
#if os(tvOS)
|
||||
true
|
||||
|
||||
110
Shared/URLTester.swift
Normal file
110
Shared/URLTester.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
37
Shared/UserAgentManager.swift
Normal file
37
Shared/UserAgentManager.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Logging
|
||||
#if !os(tvOS)
|
||||
import WebKit
|
||||
#endif
|
||||
|
||||
final class UserAgentManager {
|
||||
static let shared = UserAgentManager()
|
||||
|
||||
private(set) var userAgent: String
|
||||
#if !os(tvOS)
|
||||
private var webView: WKWebView?
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
/*
|
||||
In case an error occurs while retrieving the actual User-Agent, and on tvOS,
|
||||
we set a default User-Agent value that represents a commonly used User-Agent.
|
||||
*/
|
||||
|
||||
userAgent = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)"
|
||||
#if !os(tvOS)
|
||||
webView = WKWebView()
|
||||
webView?.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in
|
||||
if let userAgent = result as? String {
|
||||
DispatchQueue.main.async {
|
||||
self?.userAgent = userAgent
|
||||
Logger(label: "stream.yattee.userAgentManager").info("User-Agent: \(userAgent)")
|
||||
}
|
||||
} else {
|
||||
Logger(label: "stream.yattee.userAgentManager").warning("Failed to update User-Agent.")
|
||||
}
|
||||
}
|
||||
#else
|
||||
Logger(label: "stream.yattee.userAgentManager.tvOS").info("User-Agent: \(userAgent)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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..." = "في طور الأجراء…";
|
||||
|
||||
@@ -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ı açılır...";
|
||||
"Opening %@ stream…" = "%@ yayımı açı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ı açılır...";
|
||||
"Opening audio stream…" = "Səs yayımı açılır…";
|
||||
"Password" = "Şifrə";
|
||||
"Preferred Formats" = "Üstünlük Verilən Formatlar";
|
||||
"Quality Profile" = "Profil Keyfiyyəti";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "بخش آغازین";
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"Don't use public locations" = "Ne pas utiliser d'instances publiques";
|
||||
"Enable Return YouTube Dislike" = "Activer Return YouTube Dislike";
|
||||
"Enter fullscreen in landscape" = "Entrer en plein écran en mode paysage";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.\n";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.";
|
||||
"Frontend URL" = "URL frontale";
|
||||
"Public Locations" = "Instances publiques";
|
||||
"Public Manifest" = "Manifeste publique";
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "पोर्ट्रेट मोड लॉक करें";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "アカウントなしで閲覧";
|
||||
|
||||
70
Shared/ko.lproj/Localizable.strings
Normal file
70
Shared/ko.lproj/Localizable.strings
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
|
||||
"%@ Playlist" = "%@ 플레이리스트";
|
||||
"%@ subscribers" = "%@ 구독자";
|
||||
"10 seconds forwards/backwards" = "10초 건너뛰기/뒤로 가기";
|
||||
"Accounts" = "계정";
|
||||
"Accounts are not supported for the application of this instance" = "해당 애플리케이션 인스턴스는 계정을 지원하지 않습니다";
|
||||
"Add Location" = "주소 추가";
|
||||
"Add Location..." = "주소 추가...";
|
||||
"Add profile..." = "프로필 추가...";
|
||||
"Add Quality Profile" = "품질 프로필 추가";
|
||||
"Add to %@" = "%@에 추가";
|
||||
"Add to Favorites" = "즐겨찾기에 추가";
|
||||
"Add to Playlist" = "플레이리스트에 추가";
|
||||
"Add to Playlist..." = "플레이리스트에 추가...";
|
||||
"Advanced" = "고급";
|
||||
|
||||
/* Trending category, section containing all kinds of videos */
|
||||
"All" = "전체";
|
||||
"Always use AVPlayer for live videos" = "라이브 동영상에 항상 AVPlayer 사용";
|
||||
"Anonymous" = "익명";
|
||||
|
||||
/* Video date filter in search
|
||||
Video duration filter in search */
|
||||
"Any" = "모두";
|
||||
"Apply to all" = "모두 적용";
|
||||
"Are you sure you want to clear search history?" = "검색 기록을 삭제하시겠습니까?";
|
||||
"Are you sure you want to delete playlist?" = "플레이리스트를 삭제하시겠습니까?";
|
||||
"Are you sure you want to restore default quality profiles?" = "기본 품질 프로필로 복구하시겠습니까?";
|
||||
"Are you sure you want to unsubscribe from %@?" = "%@을/를 구독 해제하시겠습니까?";
|
||||
"Autoplaying Next" = "다음으로 자동재생";
|
||||
"Backend" = "백엔드";
|
||||
"Badge" = "배지";
|
||||
"Battery" = "배터리";
|
||||
"Cellular" = "셀룰러";
|
||||
" subscribers" = " 구독자";
|
||||
"%@ Channel" = "%@ 채널";
|
||||
"%lld videos" = "%lld 동영상";
|
||||
"Are you sure you want to clear history of watched videos?" = "동영상 시청 기록을 삭제하시겠습니까?";
|
||||
"Add Account" = "계정 추가";
|
||||
"Add Account..." = "계정 추가...";
|
||||
"Automatic" = "자동";
|
||||
"Close" = "닫기";
|
||||
"Badge color" = "배지 색상";
|
||||
"Bugs and great feature ideas can be sent to the GitHub issues tracker. " = "깃허브 이슈 트래커를 통해 버그 제보 또는 개발 아이디어를 제공해주실 수 있습니다. ";
|
||||
"Category" = "카테고리";
|
||||
"Captions" = "자막";
|
||||
"Close Video" = "동영상 닫기";
|
||||
"Close player when closing video" = "동영상 종료 시 플레이어 닫기";
|
||||
"Close player when starting PiP" = "PiP 시작 시 플레이어 닫기";
|
||||
"Button" = "버튼";
|
||||
"Cancel" = "취소";
|
||||
"Based on system color scheme" = "시스템 색 구성표 기반";
|
||||
"Blue" = "파란색";
|
||||
"Categories to Skip" = "건너뛸 카테고리";
|
||||
"Chapters" = "챕터";
|
||||
"Charging" = "충전중";
|
||||
"Badge & Decreased opacity" = "배지와 불투명도 감소";
|
||||
"Browsing" = "탐색";
|
||||
"Buffering stream..." = "스트림 버퍼링...";
|
||||
"Clear" = "지우기";
|
||||
"Clear All" = "모두 지우기";
|
||||
"Clear All Recents" = "최근내역 모두 지우기";
|
||||
"Clear History" = "기록 지우기";
|
||||
"Clear Search History" = "검색기록 지우기";
|
||||
"Clear Search History..." = "검색기록 지우기...";
|
||||
"Clear the queue" = "대기열 지우기";
|
||||
"Close PiP and open player when application enters foreground" = "애플리케이션이 포그라운드에 진입하면 PiP를 닫고 플레이어를 열기";
|
||||
"Close PiP when player is opened" = "플레이어가 열리면 PiP 닫기";
|
||||
"Close PiP when starting playing other video" = "다른 동영상 재생을 시작하면 PiP 닫기";
|
||||
@@ -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.";
|
||||
|
||||
@@ -294,3 +294,240 @@
|
||||
"Show anonymous accounts" = "Anonieme accounts laten zien";
|
||||
"Show channel name" = "Naam van kanaal laten zien";
|
||||
"Show history" = "Geschiedenis laten zien";
|
||||
"Show keywords" = "Toon sleutelwoorden";
|
||||
"Show progress of watching on thumbnails" = "Toon vooruitgang bovenop voorvertoning";
|
||||
"Show playback statistics" = "Toon afspeelstatistieken";
|
||||
"Show sidebar when space permits" = "Toon zijbalk wanneer de ruimte dit toelaat";
|
||||
"Show video length" = "Toon videoduur";
|
||||
"Shuffle" = "Shuffle";
|
||||
"Shuffle All" = "Shuffle alles";
|
||||
"Sidebar" = "Zijbalk";
|
||||
"Sign In Required" = "Aanmelden vereist";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Small" = "Klein";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Smaller" = "Kleiner";
|
||||
"Subscribe" = "Abonneer";
|
||||
|
||||
/* Subscriptions title */
|
||||
"Subscriptions" = "Abonnementen";
|
||||
"Switch to other public location" = "Schakel naar andere publieke locatie";
|
||||
"System controls show buttons for %@" = "Knoppen systeemcontrole voor %@";
|
||||
"That's nice to hear. It is fun to deliver apps other people want to use. You can consider donating to the project or help by contributing to new features development." = "Dat is fijn om te horen. Het is leuk om apps aan te bieden die mensen willen gebruiken. Je kan een donatie overwegen of een bijdrage leveren in de ontwikkeling van nieuwe functionaliteiten.";
|
||||
"This cannot be reverted" = "Dit kan niet ongedaan worden gemaakt";
|
||||
"Switch to public locations" = "Schakel naar publieke locatie";
|
||||
"System controls buttons" = "Knoppen voor systeemcontrole";
|
||||
"This cannot be reverted. You might need to switch between views or restart the app to see changes." = "Dit kan niet ongedaan worden gemaakt. Je zou mogelijks tussen de weergave moeten schakelen of de app opnieuw te openen om de wijzigingen te zien.";
|
||||
"This information will be processed only on your device and used to connect you to the server in the specified country." = "Deze informatie zal enkel verwerkt worden binnen het toestel zelf en gebruikt worden om te verbinden met de server in het gekozen land.";
|
||||
"This will remove all your custom profiles and return their default values. This cannot be reverted." = "Dit zal alle profielen verwijderen en terug naar de initiële waarden brengen. Dit kan niet ongedaan worden gemaakt.";
|
||||
"Thumbnails" = "Voorvertoningen";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Today" = "Vandaag";
|
||||
"Trending" = "Trending";
|
||||
|
||||
/* Player controls layout size for TV */
|
||||
"TV" = "TV";
|
||||
"unknown" = "onbekend";
|
||||
"Unsubscribe" = "Afmelden";
|
||||
"Upload date" = "Datum van opladen";
|
||||
"URL" = "URL";
|
||||
"Videos" = "Videos";
|
||||
|
||||
/* Video sort order in search */
|
||||
"Views" = "Views";
|
||||
|
||||
/* Selected video is being played */
|
||||
"Watching now" = "Aan het bekijken";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Week" = "Week";
|
||||
"Welcome" = "Welkom";
|
||||
"When partially watched video is played" = "Wanneer een gedeeltelijk bekeken video wordt afgespeeld";
|
||||
"Wi-Fi" = "Wi-Fi";
|
||||
"Wiki" = "Wiki";
|
||||
"Yattee" = "Yattee";
|
||||
"Yattee %@ (build %@)" = "Yattee %@ (build %@)";
|
||||
"You can use automatic profile selection based on current device status or switch it in video playback settings controls." = "Je kan gebruik maken van een automatische profielselectie gebaseerd op de status van het toestel of maak een keuze in de aspeelinstellingen.";
|
||||
"You have no Playlists" = "Je hebt geen afspeellijsten";
|
||||
"You have no playlists\n\nTap on \"New Playlist\" to create one" = "Je hebt geen afspeellijsten\n\nTik op \"Nieuwe afspeellijst\" om er een aan te maken";
|
||||
"You need to create an instance and accounts\nto access %@ section" = "Je moet een instance en een account aanmaken\nom toegang te krijgen tot het onderdeel %@";
|
||||
"You need to select an account\nto access %@ section" = "Je moet een account selecteren\nom toegang te krijgen tot het onderdeel %@";
|
||||
|
||||
|
||||
"Public" = "Publiek";
|
||||
"Unlisted" = "Onopgelijst";
|
||||
"Now Playing" = "Wordt nu afgespeeld";
|
||||
"Current Location" = "Huidige Locatie";
|
||||
"Private" = "Privé";
|
||||
"Playback queue is empty" = "Afspeellijst is leeg";
|
||||
"Playing Next" = "Speelt Hierna";
|
||||
"You can switch between profiles in playback settings controls." = "Je kan schakelen tussen profielen via de afspeelinstellingen.";
|
||||
"Add Channels, Playlists and Searches to Favorites using" = "Voeg Kanalen, Afspeellijsten en Zoekopdrachten toe aan Favorieten gebruik makend van";
|
||||
"Make default" = "Stel in als standaard";
|
||||
"Visibility" = "Zichtbaarheid";
|
||||
"Current Playlist" = "Huidige Afspeellijst";
|
||||
"Stream & Player" = "Stream & Speler";
|
||||
"Statistics" = "Statistieken";
|
||||
"Hardware decoder" = "Hardware decoder";
|
||||
"Stream FPS" = "Stream FPS";
|
||||
"Cached time" = "Cache duurtijd";
|
||||
"Rate & Captions" = "Beoordelen & Ondertiteling";
|
||||
"Dropped frames" = "Verloren frames";
|
||||
"Any format" = "Elk formaat";
|
||||
"%@ formats" = "%@ formaten";
|
||||
"Keep last played video in the queue after restart" = "Behoud de laatst gespeelde video in de afspeellijst na herstart";
|
||||
"Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"" = "Afspeellijst is leeg\n\nTik en houd de video vast en\nkies dan \"Voeg toe aan afspeellijst\"";
|
||||
"It can be changed later in settings. You can use your own locations too." = "Dit kan later aangepast worden in de instellingen. Je kan tevens je eigen locatie gebruiken.";
|
||||
"Press and hold remote button to open captions and quality menus" = "Tik en houd de knop van de afstandsbediening ingedrukt om het menu van de ondertitelingen en streamkwaliteit te openen";
|
||||
"Comments are disabled" = "Commentaar is uitgeschakeld";
|
||||
"No comments" = "Geen commentaren";
|
||||
"No chapters information available" = "Geen hoofdstukinformatie beschikbaar";
|
||||
"Share Logs..." = "Deel logboek…";
|
||||
"Open logs in Finder" = "Open logboek in Finder";
|
||||
"Channel could not be found" = "Kanaal kan niet gevonden worden";
|
||||
"Could not extract channel information" = "Kan geen informatie van het kanaal vinden";
|
||||
"Could not extract SID from received cookies: %@" = "Kon de SID niet bepalen van de ontvangen cookies: %@";
|
||||
"Could not update your token." = "Kan jouw token niet updaten.";
|
||||
"Could not refresh Trending" = "Kon Trending niet verversen";
|
||||
"Could not create share link" = "Kon geen link aanmaken om te delen";
|
||||
"Could not open playlist" = "Kon de afspeellijst niet openen";
|
||||
"Could not extract video ID" = "Kon de video ID niet bepalen";
|
||||
"This video could not be opened" = "Deze video kon niet worden geopend";
|
||||
"Could not extract playlist ID" = "Kon de playlist ID niet bepalen";
|
||||
"No locations available at the moment" = "Geen locaties beschikbaar op dit moment";
|
||||
"Could not refresh Playlists" = "Kon de Afspeellijsten niet verversen";
|
||||
"If you want this app to be available in your language, join translation project." = "Als je deze app in jouw taal wenst te gebruiken, neem deel aan het vertalingsproject.";
|
||||
"Translations" = "Vertalingen";
|
||||
"No documents" = "Geen documenten";
|
||||
"Recent Documents" = "Recente Documenten";
|
||||
"Home" = "Home";
|
||||
"Pages buttons" = "Paginas knoppen";
|
||||
"Only for local files and URLs" = "Enkel voor lokale bestanden en URLs";
|
||||
"Right" = "Rechts";
|
||||
"Playback Mode" = "Afspeelmodus";
|
||||
"Add" = "Voeg toe";
|
||||
"Hide" = "Verberg";
|
||||
"Always" = "Altijd";
|
||||
"Channels" = "Kanalen";
|
||||
"Open Files" = "Open Bestanden";
|
||||
"Share" = "Deel";
|
||||
"Show icons and text when space permits" = "Toon iconen en tekst wanneer de ruimte dit toelaat";
|
||||
"Left" = "Links";
|
||||
"Format" = "Formaat";
|
||||
"Driver" = "Driver";
|
||||
"Show only icons" = "Toon enkel iconen";
|
||||
"Audio" = "Audio";
|
||||
"File" = "Bestand";
|
||||
"Documents" = "Documenten";
|
||||
"Video" = "Video";
|
||||
"Codec" = "Codec";
|
||||
"Size" = "Grootte";
|
||||
"FPS" = "FPS";
|
||||
"Sample Rate" = "Sample Rate";
|
||||
"Could not find any links to open in your clipboard" = "Kon geen links vinden om te openen in het klembord";
|
||||
"Address" = "Adres";
|
||||
"Show sidebar" = "Toon zijbalk";
|
||||
"Locations Manifest" = "Locatie Manifest";
|
||||
"Remove Location" = "Verwijder Locatie";
|
||||
"Could not delete document" = "Kon het document niet verwijderen";
|
||||
"Verified" = "Geverifiëerd";
|
||||
"Channel" = "Kanaal";
|
||||
"Open expanded" = "Open uitgeklapt";
|
||||
"Mark channel feed as unwatched" = "Markeer het kanaal als onbekeken";
|
||||
"Mark channel feed as watched" = "Markeer het kanaal als bekeken";
|
||||
"Short videos: visible" = "Korte videos: zichtbaar";
|
||||
"Player Bar" = "Afspeelbalk";
|
||||
"Short videos: hidden" = "Korte videos: verborgen";
|
||||
"Play all unwatched" = "Speel alle onbekeken af";
|
||||
"Seeking" = "Scrollen";
|
||||
"System controls" = "Systeembediening";
|
||||
"Controls button: backwards" = "Bedieningsknop: terug";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams…" = "Streams laden…";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream…" = "Stream %@ openen…";
|
||||
"Opening audio stream…" = "Audio stream openen…";
|
||||
"Sort" = "Sorteer";
|
||||
"Sort: %@" = "Sorteer: %@";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Sponsor" = "Sponsor";
|
||||
"SponsorBlock" = "SponsorBlock";
|
||||
"Source" = "Bron";
|
||||
"SponsorBlock API Instance" = "SponsorBlock API Instance";
|
||||
"Username" = "Gebruikersnaam";
|
||||
|
||||
/* Player controls layout size */
|
||||
"Very Large" = "Heel Groot";
|
||||
"Watched" = "Bekeken";
|
||||
|
||||
/* Selected video was played on given date */
|
||||
"Watched %@" = "%@ Bekeken";
|
||||
|
||||
/* Video date filter in search */
|
||||
"Year" = "Jaar";
|
||||
"Typically near or at the end of the video when the credits pop up and/or endcards are shown." = "Typisch dicht bij of op het einde van een video wanneer de aftiteling getoond wordt.";
|
||||
"Used to create links from videos, channels and playlists" = "Gebruikt om links te maken van videos, kanalen en afspeellijsten";
|
||||
"You can find information about using Yattee in the Wiki pages." = "Je vindt informatie over het gebruik van Yattee in de Wiki.";
|
||||
"Share files from Finder on a Mac\nor iTunes on Windows" = "Deel bestanden van Finder op Mac\nof iTunes op Windows";
|
||||
"Show Favorites" = "Toon Favorieten";
|
||||
"Inspector visibility" = "Inspector zichtbaarheid";
|
||||
"Edit Favorites…" = "Pas Favorieten aan…";
|
||||
"Show Open Videos toolbar button" = "Toon Open Videos werkbalkknop";
|
||||
"Show Inspector" = "Toon Inspector";
|
||||
"Open" = "Open";
|
||||
"Open Videos" = "Open Videos";
|
||||
"Enter links to open, one per line" = "Voeg links toe om te openen, één per lijn";
|
||||
"Could not refresh Subscriptions" = "Kan Abonnementen niet verversen";
|
||||
"Show Home" = "Toon Home";
|
||||
"Recent History" = "Recente Geschiedenis";
|
||||
"Enter link to open" = "Voeg de link in om te openen";
|
||||
"Show Open Videos quick actions" = "Toon Open Videos snelle acties";
|
||||
"Files" = "Bestanden";
|
||||
"Could not open Files" = "Kon Bestanden niet openen";
|
||||
"Could not load streams" = "Kan de streams niet laden";
|
||||
"Could not open video" = "Kan de video niet openen";
|
||||
"Buttons labels" = "Knoppen labels";
|
||||
"Show Documents" = "Toon Documenten";
|
||||
"Video Details" = "Video Eigenschappen";
|
||||
"Clear Queue before opening" = "Maak de wachtrij leeg voor het openen";
|
||||
"For custom locations you can configure Frontend URL in Locations settings" = "Voor aangepaste locaties kan je de URL instellen in de locatie instellingen";
|
||||
"This URL could not be opened" = "Deze URL kon niet geopend worden";
|
||||
"Could not load video" = "Kon de video niet laden";
|
||||
"Pages toolbar position" = "Paginas werkbalk positie";
|
||||
"Reload manifest" = "Ververs het manifest";
|
||||
"Video actions buttons" = "Video actie knoppen";
|
||||
"URL to Open" = "URL om te Openen";
|
||||
"Paste" = "Plakken";
|
||||
"Share%@link" = "Deel %@link";
|
||||
"Could not open channel" = "Kan het Kanaal niet openen";
|
||||
"Could not refresh Popular" = "Kon Populair niet verversen";
|
||||
"Center" = "Midden";
|
||||
"Open Video" = "Open Video";
|
||||
"Default Profile" = "Standaard Profiel";
|
||||
"Playback history is empty" = "Afspeelgeschiedenis is leeg";
|
||||
"Shorts" = "Shorts";
|
||||
"Double tap gesture" = "Dubbele tik gebaar";
|
||||
"Tap and hold channel thumbnail to open context menu with more actions" = "Tik en houd voorvertoningsafbeelding vast om het contextmenu te openen met meer acties";
|
||||
"Always show controls buttons" = "Toon altijd bedieningsknoppen";
|
||||
"Single tap gesture" = "Enkel tikgebaar";
|
||||
"Maximum width expanded" = "Maximale breedte uitgeklapt";
|
||||
"Clear all" = "Verwijder alle";
|
||||
"Show unwatched feed badges" = "Toon onbekeken feed badges";
|
||||
"Controls button: forwards" = "Bedieningsknop: voorwaarts";
|
||||
"Remove…" = "Verwijder…";
|
||||
"Actions buttons" = "Actieknoppen";
|
||||
"\"%@\" will be irreversibly removed from this device." = "\"%@\" zal onherroepelijk verwijderd worden van dit toestel.";
|
||||
"Right click channel thumbnail to open context menu with more actions" = "Rechts klikken op voorvertoningsafbeelding om de contextmenu te openen met meer acties";
|
||||
"Gesture: backwards" = "Gebaar: achteruit";
|
||||
"Copy%@link" = "Kopieer %@link";
|
||||
"Live Streams" = "Livestreams";
|
||||
"Gesture: fowards" = "Gebaar: voorwaarts";
|
||||
"Controls Buttons" = "Bedieningsknoppen";
|
||||
"Are you sure you want to remove this document?" = "Ben je zeker om dit document te verwijderen?";
|
||||
"Are you sure you want to remove %@ location?" = "Ben je zeker om locatie %a te verwijderen?";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "Закрыть видео и плеер в конце";
|
||||
|
||||
@@ -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ışı açılıyor...";
|
||||
"Opening audio stream…" = "Ses akışı açılıyor…";
|
||||
"Rate" = "Derecelendir";
|
||||
"Orientation" = "Yönlendirme";
|
||||
"No results" = "Sonuç yok";
|
||||
@@ -396,7 +396,7 @@
|
||||
"Open" = "Aç";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "%@ akışı açılıyor...";
|
||||
"Opening %@ stream…" = "%@ akışı açı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";
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 标志";
|
||||
|
||||
@@ -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..." = "輸入站台地址來連接...";
|
||||
|
||||
@@ -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 = 187;
|
||||
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 = 187;
|
||||
"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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
"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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
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 = 187;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"location" : "https://github.com/hyperoslo/Cache.git",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "f44a8f6b5ec27730198725ccc542fef0d1cc6b3d"
|
||||
"revision" : "dff9930c559aa2d1f7ed818d490d30c8852f57a6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ashleymills/Reachability.swift",
|
||||
"state" : {
|
||||
"revision" : "57da4b1270cab7c2228919eabc0e4e1bf93e48ea",
|
||||
"version" : "5.2.2"
|
||||
"revision" : "7cbd73f46a7dfaeca079e18df7324c6de6d1834a",
|
||||
"version" : "5.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -105,8 +105,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "5642d1ffe3dbe628592443bd14154e31929727b4",
|
||||
"version" : "5.19.2"
|
||||
"revision" : "be0bcd7823ce56629948491f2eaeaa19979514f7",
|
||||
"version" : "5.19.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user