mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 03:28:14 +00:00
Compare commits
13 Commits
revert-652
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2df1b7138 | ||
|
|
72c0597c06 | ||
|
|
b056f5b608 | ||
|
|
e676830ead | ||
|
|
46317cc2bf | ||
|
|
65347eb1ec | ||
|
|
dd5e0e7eb2 | ||
|
|
3c3244239d | ||
|
|
ffc9862c75 | ||
|
|
6e1f2630ca | ||
|
|
282d63400e | ||
|
|
cd1da69d83 | ||
|
|
6f002545cf |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
# lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
lane: ['ios beta', 'tvos beta']
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-13
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
@@ -38,9 +38,6 @@ jobs:
|
||||
run: |
|
||||
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
@@ -51,7 +48,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
mac_notarized:
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-13
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
@@ -62,9 +59,6 @@ jobs:
|
||||
run: |
|
||||
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,25 +1,6 @@
|
||||
## Build 181
|
||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
||||
* Fix issues with empty comments (by @stonerl)
|
||||
* Improved Invidious comments (by @stonerl)
|
||||
* Downgrade MPVKit to 0.36.0-1 due to issues with WebVTT subtitles
|
||||
## Build 177
|
||||
* Updated dependencies (mpvkit 0.37.0)
|
||||
* Updated localizations
|
||||
* Updated dependencies
|
||||
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
* Added Settings Import/Export
|
||||
* Export all settings, instances and accounts
|
||||
* Import selected elements from the file
|
||||
* 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)
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
* Fixed reported crash
|
||||
* Other minor changes and improvements
|
||||
|
||||
**Big thanks to the current, past and future project contributors!**
|
||||
**Big thanks to the past, current and future project contributors!**
|
||||
|
||||
37
Gemfile.lock
37
Gemfile.lock
@@ -48,32 +48,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
CFPropertyList (3.0.6)
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.17)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.906.0)
|
||||
aws-sdk-core (3.191.6)
|
||||
aws-partitions (1.883.0)
|
||||
aws-sdk-core (3.191.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.78.0)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.146.1)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -85,7 +82,7 @@ GEM
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.110.0)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@@ -114,7 +111,7 @@ GEM
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.1)
|
||||
fastimage (2.3.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
@@ -132,12 +129,12 @@ GEM
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.7.0)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.4.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
@@ -158,20 +155,18 @@ GEM
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.1)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
jwt (2.7.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.0)
|
||||
multipart-post (2.3.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.5)
|
||||
rake (13.2.0)
|
||||
public_suffix (5.0.4)
|
||||
rake (13.1.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
@@ -182,7 +177,7 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.19.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
@@ -201,7 +196,7 @@ GEM
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.24.0)
|
||||
xcodeproj (1.23.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
|
||||
@@ -64,10 +64,6 @@ final class AccountsModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func find(_ id: Account.ID) -> Account? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
func configureAccount() {
|
||||
if let account = lastUsed ??
|
||||
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
||||
@@ -112,8 +108,8 @@ final class AccountsModel: ObservableObject {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
static func add(instance: Instance, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
setCredentials(account, username: username, password: password)
|
||||
|
||||
@@ -68,8 +68,4 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(apiURL)
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
AccountsModel.shared.all.filter { $0.instanceID == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,23 +42,15 @@ final class InstancesModel: ObservableObject {
|
||||
Defaults[.accounts].filter { $0.instanceID == id }
|
||||
}
|
||||
|
||||
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
func add(app: VideosApp, name: String, url: String) -> Instance {
|
||||
let instance = Instance(
|
||||
app: app, id: id, name: name, apiURLString: standardizedURL(url)
|
||||
app: app, id: UUID().uuidString, name: name, apiURLString: standardizedURL(url)
|
||||
)
|
||||
Defaults[.instances].append(instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func insert(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
if let instance = Defaults[.instances].first(where: { $0.apiURL.absoluteString == standardizedURL(url) }) {
|
||||
return instance
|
||||
}
|
||||
|
||||
return add(id: id, app: app, name: name, url: url)
|
||||
}
|
||||
|
||||
func setFrontendURL(_ instance: Instance, _ url: String) {
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
var instance = Defaults[.instances][index]
|
||||
|
||||
@@ -123,7 +123,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
||||
["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"].forEach { type in
|
||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
@@ -654,8 +654,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int
|
||||
videoFormat: videoStream["type"].string
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -692,8 +691,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
||||
let decodedContent = decodeHtml(htmlContent)
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
@@ -702,25 +699,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: decodedContent,
|
||||
text: details["content"]?.string ?? "",
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .invidious, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
||||
if let data = htmlEncodedString.data(using: .utf8) {
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
return htmlEncodedString
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
|
||||
@@ -113,11 +113,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
||||
guard let details = content?.json.dictionaryValue else {
|
||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
@@ -157,8 +154,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
method: .post,
|
||||
parameters: ["username": username, "password": password],
|
||||
encoding: JSONEncoding.default
|
||||
)
|
||||
.responseDecodable(of: JSON.self) { [weak self] response in
|
||||
).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
@@ -666,16 +662,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
for videoStream in videoStreams {
|
||||
videoStreams.forEach { videoStream in
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
@@ -687,7 +683,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
@@ -697,8 +692,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate
|
||||
videoFormat: videoFormat
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -729,23 +723,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let commentorUrl = details["commentorUrl"]?.string
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
|
||||
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
|
||||
let commentId = details["commentId"]?.string ?? UUID().uuidString
|
||||
|
||||
// Sanity checks: return nil if required data is missing
|
||||
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Comment(
|
||||
id: commentId,
|
||||
id: details["commentId"]?.string ?? UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||
time: details["commentedTime"]?.string ?? "",
|
||||
pinned: details["pinned"]?.bool ?? false,
|
||||
hearted: details["hearted"]?.bool ?? false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: commentText,
|
||||
text: extractCommentText(from: details["commentText"]?.stringValue),
|
||||
repliesPage: details["repliesPage"]?.string,
|
||||
channel: Channel(app: .piped, id: channelId, name: author)
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ protocol VideosAPI {
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
func shareURL(_ item: ContentItem, frontendURL: String?, time: CMTime?) -> URL?
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
@@ -108,19 +108,15 @@ extension VideosAPI {
|
||||
.onFailure { failureHandler?($0) }
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString) {
|
||||
urlComponents = URLComponents(URL: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
guard var urlComponents else {
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
urlComponents.host = frontendHost
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
switch item.contentType {
|
||||
|
||||
@@ -35,22 +35,26 @@ final class CommentsModel: ObservableObject {
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard let video = player.currentVideo else { return }
|
||||
guard firstPage || nextPageAvailable else { return }
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
if let commentsPage: CommentsPage = response.typedContent() {
|
||||
self.all += commentsPage.comments
|
||||
self.nextPage = commentsPage.nextPage
|
||||
self.disabled = commentsPage.disabled
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.disabled = true
|
||||
.onFailure { [weak self] requestError in
|
||||
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
|
||||
@@ -25,7 +25,6 @@ struct FavoritesModel {
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
if contains(item) { return }
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
@@ -123,12 +122,4 @@ struct FavoritesModel {
|
||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||
}
|
||||
|
||||
func updateWidgetSettings(_ settings: WidgetSettings) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showHome": Defaults[.showHome],
|
||||
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
|
||||
"showQueueInHome": Defaults[.showQueueInHome],
|
||||
"showFavoritesInHome": Defaults[.showFavoritesInHome],
|
||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
||||
"startupSection": Defaults[.startupSection].rawValue,
|
||||
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
|
||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
||||
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
|
||||
"expandChannelDescription": Defaults[.expandChannelDescription],
|
||||
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
|
||||
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
|
||||
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
|
||||
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
|
||||
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
|
||||
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
|
||||
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
|
||||
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
|
||||
"channelOnThumbnail": Defaults[.channelOnThumbnail],
|
||||
"timeOnThumbnail": Defaults[.timeOnThumbnail],
|
||||
"roundedThumbnails": Defaults[.roundedThumbnails],
|
||||
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if os(iOS)
|
||||
export["showDocuments"].bool = Defaults[.showDocuments]
|
||||
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
|
||||
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
||||
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
|
||||
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
|
||||
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
|
||||
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
|
||||
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
|
||||
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
|
||||
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
|
||||
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
|
||||
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
|
||||
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
|
||||
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
|
||||
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
|
||||
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
|
||||
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
|
||||
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
|
||||
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
|
||||
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
|
||||
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
|
||||
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
|
||||
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
|
||||
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
|
||||
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
||||
|
||||
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
|
||||
"watchedThreshold": Defaults[.watchedThreshold],
|
||||
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton],
|
||||
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
|
||||
var includePublicInstances = true
|
||||
var includeInstances = true
|
||||
var includeAccounts = true
|
||||
var includeAccountsUnencryptedPasswords = false
|
||||
|
||||
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
|
||||
self.includePublicInstances = includePublicInstances
|
||||
self.includeInstances = includeInstances
|
||||
self.includeAccounts = includeAccounts
|
||||
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
|
||||
}
|
||||
|
||||
override var globalJSON: JSON {
|
||||
var json = JSON()
|
||||
|
||||
if includePublicInstances {
|
||||
json["instancesManifest"].string = Defaults[.instancesManifest]
|
||||
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
|
||||
}
|
||||
|
||||
if includeInstances {
|
||||
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
|
||||
}
|
||||
|
||||
if includeAccounts {
|
||||
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
|
||||
var account = account
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
account.username = username ?? ""
|
||||
if includeAccountsUnencryptedPasswords {
|
||||
account.password = password ?? ""
|
||||
}
|
||||
|
||||
return accountJSON(account).dictionaryObject
|
||||
}
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
private func instanceJSON(_ instance: Instance) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = InstancesBridge().serialize(instance)
|
||||
return json
|
||||
}
|
||||
|
||||
private func accountJSON(_ account: Account) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = AccountsBridge().serialize(account)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"lastAccountID": Defaults[.lastAccountID] ?? "",
|
||||
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
|
||||
|
||||
"playerRate": Defaults[.playerRate],
|
||||
|
||||
"trendingCategory": Defaults[.trendingCategory].rawValue,
|
||||
"trendingCountry": Defaults[.trendingCountry].rawValue,
|
||||
|
||||
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
|
||||
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
|
||||
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
|
||||
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
|
||||
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
|
||||
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
|
||||
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
|
||||
|
||||
"hideShorts": Defaults[.hideShorts],
|
||||
"hideWatched": Defaults[.hideWatched]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"expandChapters": Defaults[.expandChapters],
|
||||
"showRelated": Defaults[.showRelated],
|
||||
"showInspector": Defaults[.showInspector].rawValue,
|
||||
"playerSidebar": Defaults[.playerSidebar].rawValue,
|
||||
"showKeywords": Defaults[.showKeywords],
|
||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP]
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if !os(macOS)
|
||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
export["honorSystemOrientationLock"].bool = Defaults[.honorSystemOrientationLock]
|
||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class QualitySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"batteryCellularProfile": Defaults[.batteryCellularProfile],
|
||||
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
|
||||
"chargingCellularProfile": Defaults[.chargingCellularProfile],
|
||||
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
|
||||
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
|
||||
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = QualityProfileBridge().serialize(profile)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class RecentlyOpenedExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
class SettingsGroupExporter { // swiftlint:disable:this final_class
|
||||
var globalJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var platformJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var exportJSON: JSON {
|
||||
var json = globalJSON
|
||||
|
||||
if !platformJSON.isEmpty {
|
||||
try? json.merge(with: platformJSON)
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
func jsonFromString(_ string: String?) -> JSON? {
|
||||
if let data = string?.data(using: .utf8, allowLossyConversion: false),
|
||||
let json = try? JSON(data: data)
|
||||
{
|
||||
return json
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories])
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportExportSettingsModel: ObservableObject {
|
||||
static let shared = ImportExportSettingsModel()
|
||||
|
||||
static var exportFile: URL {
|
||||
YatteeApp.settingsExportDirectory
|
||||
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
|
||||
}
|
||||
|
||||
static var settingsExtension: String {
|
||||
"yatteesettings"
|
||||
}
|
||||
|
||||
enum ExportGroup: String, Identifiable, CaseIterable {
|
||||
case browsingSettings
|
||||
case playerSettings
|
||||
case controlsSettings
|
||||
case qualitySettings
|
||||
case historySettings
|
||||
case sponsorBlockSettings
|
||||
case advancedSettings
|
||||
|
||||
case locationsSettings
|
||||
case instances
|
||||
case accounts
|
||||
case accountsUnencryptedPasswords
|
||||
|
||||
case recentlyOpened
|
||||
case otherData
|
||||
|
||||
static var settingsGroups: [Self] {
|
||||
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
|
||||
}
|
||||
|
||||
static var locationsGroups: [Self] {
|
||||
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
|
||||
}
|
||||
|
||||
static var otherGroups: [Self] {
|
||||
[.recentlyOpened, .otherData]
|
||||
}
|
||||
|
||||
var id: RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .browsingSettings:
|
||||
return "Browsing"
|
||||
case .playerSettings:
|
||||
return "Player"
|
||||
case .controlsSettings:
|
||||
return "Controls"
|
||||
case .qualitySettings:
|
||||
return "Quality"
|
||||
case .historySettings:
|
||||
return "History"
|
||||
case .sponsorBlockSettings:
|
||||
return "SponsorBlock"
|
||||
case .locationsSettings:
|
||||
return "Public Locations"
|
||||
case .instances:
|
||||
return "Custom Locations"
|
||||
case .accounts:
|
||||
return "Accounts"
|
||||
case .accountsUnencryptedPasswords:
|
||||
return "Accounts passwords (unencrypted)"
|
||||
case .advancedSettings:
|
||||
return "Advanced"
|
||||
case .recentlyOpened:
|
||||
return "Recents"
|
||||
case .otherData:
|
||||
return "Other data"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedExportGroups = Set<ExportGroup>()
|
||||
static var defaultExportGroups = Set<ExportGroup>([
|
||||
.browsingSettings,
|
||||
.playerSettings,
|
||||
.controlsSettings,
|
||||
.qualitySettings,
|
||||
.historySettings,
|
||||
.sponsorBlockSettings,
|
||||
.locationsSettings,
|
||||
.instances,
|
||||
.accounts,
|
||||
.advancedSettings
|
||||
])
|
||||
|
||||
@Published var isExportInProgress = false
|
||||
|
||||
private var navigation = NavigationModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
func toggleExportGroupSelection(_ group: ExportGroup) {
|
||||
if isGroupSelected(group) {
|
||||
selectedExportGroups.remove(group)
|
||||
} else {
|
||||
selectedExportGroups.insert(group)
|
||||
}
|
||||
|
||||
removeNotEnabledSelectedGroups()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isExportInProgress = false
|
||||
selectedExportGroups = Self.defaultExportGroups
|
||||
}
|
||||
|
||||
func reset(_ model: ImportSettingsFileModel? = nil) {
|
||||
reset()
|
||||
|
||||
guard let model else { return }
|
||||
|
||||
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
|
||||
}
|
||||
|
||||
func exportAction() {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
var writingOptions: JSONSerialization.WritingOptions = []
|
||||
#if DEBUG
|
||||
writingOptions.insert(.prettyPrinted)
|
||||
writingOptions.insert(.sortedKeys)
|
||||
#endif
|
||||
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.isExportInProgress = false
|
||||
}
|
||||
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var jsonForExport: JSON? {
|
||||
[
|
||||
"metadata": metadataJSON,
|
||||
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
|
||||
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
|
||||
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
|
||||
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
|
||||
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
|
||||
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
|
||||
"locationsSettings": LocationsSettingsGroupExporter(
|
||||
includePublicInstances: isGroupSelected(.locationsSettings),
|
||||
includeInstances: isGroupSelected(.instances),
|
||||
includeAccounts: isGroupSelected(.accounts),
|
||||
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
|
||||
).exportJSON,
|
||||
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
|
||||
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
|
||||
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
|
||||
]
|
||||
}
|
||||
|
||||
private var metadataJSON: JSON {
|
||||
[
|
||||
"build": YatteeApp.build,
|
||||
"timestamp": "\(Date().timeIntervalSince1970)",
|
||||
"platform": Constants.platform
|
||||
]
|
||||
}
|
||||
|
||||
func isGroupSelected(_ group: ExportGroup) -> Bool {
|
||||
selectedExportGroups.contains(group)
|
||||
}
|
||||
|
||||
func isGroupEnabled(_ group: ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .accounts:
|
||||
return selectedExportGroups.contains(.instances)
|
||||
case .accountsUnencryptedPasswords:
|
||||
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotEnabledSelectedGroups() {
|
||||
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
|
||||
}
|
||||
|
||||
var isExportAvailable: Bool {
|
||||
!selectedExportGroups.isEmpty && !isExportInProgress
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportSettingsFileModel: ObservableObject {
|
||||
static let shared = ImportSettingsFileModel()
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
if let locationsSettings = json.dictionaryValue["locationsSettings"] {
|
||||
return LocationsSettingsGroupImporter(
|
||||
json: locationsSettings,
|
||||
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
|
||||
includedInstancesIDs: sheetViewModel.selectedInstances,
|
||||
includedAccountsIDs: sheetViewModel.selectedAccounts,
|
||||
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var importExportModel = ImportExportSettingsModel.shared
|
||||
var sheetViewModel = ImportSettingsSheetViewModel.shared
|
||||
|
||||
var loadTask: URLSessionTask?
|
||||
|
||||
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .locationsSettings:
|
||||
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
|
||||
default:
|
||||
return !groupJSON(group).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var isPublicInstancesSettingsGroupInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
|
||||
}
|
||||
|
||||
var instancesOrAccountsInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
|
||||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
|
||||
}
|
||||
|
||||
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
|
||||
json.dictionaryValue[group.rawValue] ?? .init()
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
|
||||
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
|
||||
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
|
||||
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
|
||||
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
|
||||
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
|
||||
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
|
||||
}
|
||||
|
||||
locationsSettingsGroupImporter?.performImport()
|
||||
|
||||
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
|
||||
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
|
||||
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
|
||||
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var json = JSON()
|
||||
|
||||
func loadData(_ url: URL) {
|
||||
json = JSON()
|
||||
loadTask?.cancel()
|
||||
|
||||
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let data else { return }
|
||||
|
||||
if let json = try? JSON(data: data) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.json = json
|
||||
|
||||
self.sheetViewModel.reset(locationsSettingsGroupImporter)
|
||||
self.importExportModel.reset(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadTask?.resume()
|
||||
}
|
||||
|
||||
func filename(_ url: URL) -> String {
|
||||
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
|
||||
}
|
||||
|
||||
var metadataBuild: String? {
|
||||
if let build = json.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
|
||||
return build
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataPlatform: String? {
|
||||
if let platform = json.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
|
||||
return platform
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataDate: String? {
|
||||
if let timestamp = json.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
|
||||
let date = Date(timeIntervalSince1970: timestamp)
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct AdvancedSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
|
||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
||||
}
|
||||
|
||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||
}
|
||||
|
||||
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
|
||||
Defaults[.mpvEnableLogging] = mpvEnableLogging
|
||||
}
|
||||
|
||||
if let mpvCacheSecs = json["mpvCacheSecs"].string {
|
||||
Defaults[.mpvCacheSecs] = mpvCacheSecs
|
||||
}
|
||||
|
||||
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
|
||||
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
|
||||
}
|
||||
|
||||
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
|
||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
||||
}
|
||||
|
||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
||||
Defaults[.showCacheStatus] = showCacheStatus
|
||||
}
|
||||
|
||||
if let feedCacheSize = json["feedCacheSize"].string {
|
||||
Defaults[.feedCacheSize] = feedCacheSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct BrowsingSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showHome = json["showHome"].bool {
|
||||
Defaults[.showHome] = showHome
|
||||
}
|
||||
|
||||
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
|
||||
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
|
||||
}
|
||||
|
||||
if let showQueueInHome = json["showQueueInHome"].bool {
|
||||
Defaults[.showQueueInHome] = showQueueInHome
|
||||
}
|
||||
|
||||
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
|
||||
Defaults[.showFavoritesInHome] = showFavoritesInHome
|
||||
}
|
||||
|
||||
if let favorites = json["favorites"].array {
|
||||
favorites.forEach { favoriteJSON in
|
||||
if let jsonString = favoriteJSON.rawString(options: []),
|
||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
||||
{
|
||||
FavoritesModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
||||
widgetsFavorites.forEach { widgetJSON in
|
||||
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = WidgetSettingsBridge().deserialize(dict) {
|
||||
FavoritesModel.shared.updateWidgetSettings(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let startupSectionString = json["startupSection"].string,
|
||||
let startupSection = StartupSection(rawValue: startupSectionString)
|
||||
{
|
||||
Defaults[.startupSection] = startupSection
|
||||
}
|
||||
|
||||
if let visibleSections = json["visibleSections"].array {
|
||||
let sections = visibleSections.compactMap { visibleSectionJSON in
|
||||
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
||||
let section = VisibleSection(rawValue: visibleSectionString)
|
||||
{
|
||||
return section
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Defaults[.visibleSections] = Set(sections)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
|
||||
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
|
||||
}
|
||||
|
||||
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
|
||||
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
|
||||
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
|
||||
}
|
||||
#endif
|
||||
|
||||
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
|
||||
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
|
||||
}
|
||||
|
||||
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
|
||||
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
|
||||
}
|
||||
|
||||
if let expandChannelDescription = json["expandChannelDescription"].bool {
|
||||
Defaults[.expandChannelDescription] = expandChannelDescription
|
||||
}
|
||||
|
||||
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
|
||||
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
|
||||
}
|
||||
|
||||
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
|
||||
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
|
||||
}
|
||||
|
||||
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
|
||||
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
|
||||
}
|
||||
|
||||
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
|
||||
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
|
||||
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
|
||||
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
|
||||
}
|
||||
|
||||
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
|
||||
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
|
||||
}
|
||||
|
||||
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
|
||||
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
|
||||
}
|
||||
|
||||
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
|
||||
Defaults[.channelOnThumbnail] = channelOnThumbnail
|
||||
}
|
||||
|
||||
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
|
||||
Defaults[.timeOnThumbnail] = timeOnThumbnail
|
||||
}
|
||||
|
||||
if let roundedThumbnails = json["roundedThumbnails"].bool {
|
||||
Defaults[.roundedThumbnails] = roundedThumbnails
|
||||
}
|
||||
|
||||
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
|
||||
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
|
||||
{
|
||||
Defaults[.thumbnailsQuality] = thumbnailsQuality
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct ConstrolsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
|
||||
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
|
||||
}
|
||||
|
||||
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
|
||||
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
|
||||
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
|
||||
}
|
||||
|
||||
if let seekGestureSpeed = json["seekGestureSpeed"].double {
|
||||
Defaults[.seekGestureSpeed] = seekGestureSpeed
|
||||
}
|
||||
|
||||
if let playerControlsLayoutString = json["playerControlsLayout"].string,
|
||||
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
|
||||
{
|
||||
Defaults[.playerControlsLayout] = playerControlsLayout
|
||||
}
|
||||
|
||||
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
|
||||
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
|
||||
{
|
||||
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
||||
}
|
||||
|
||||
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
||||
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
||||
{
|
||||
Defaults[.systemControlsCommands] = systemControlsCommands
|
||||
}
|
||||
|
||||
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
|
||||
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
|
||||
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
|
||||
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
|
||||
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
|
||||
}
|
||||
|
||||
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
|
||||
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
|
||||
}
|
||||
|
||||
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
|
||||
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
|
||||
}
|
||||
|
||||
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
|
||||
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
|
||||
}
|
||||
|
||||
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
|
||||
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
|
||||
}
|
||||
|
||||
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
|
||||
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
|
||||
}
|
||||
|
||||
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
|
||||
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
|
||||
}
|
||||
|
||||
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
|
||||
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
|
||||
}
|
||||
|
||||
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
|
||||
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
|
||||
{
|
||||
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
|
||||
}
|
||||
|
||||
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
|
||||
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
|
||||
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
|
||||
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
|
||||
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
|
||||
}
|
||||
|
||||
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
|
||||
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
|
||||
}
|
||||
|
||||
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
|
||||
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
|
||||
}
|
||||
|
||||
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
|
||||
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
|
||||
}
|
||||
|
||||
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
|
||||
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
|
||||
}
|
||||
|
||||
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
|
||||
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
|
||||
}
|
||||
|
||||
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
|
||||
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
|
||||
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
|
||||
}
|
||||
|
||||
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
|
||||
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct HistorySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let saveRecents = json["saveRecents"].bool {
|
||||
Defaults[.saveRecents] = saveRecents
|
||||
}
|
||||
|
||||
if let saveHistory = json["saveHistory"].bool {
|
||||
Defaults[.saveHistory] = saveHistory
|
||||
}
|
||||
|
||||
if let showWatchingProgress = json["showWatchingProgress"].bool {
|
||||
Defaults[.showWatchingProgress] = showWatchingProgress
|
||||
}
|
||||
|
||||
if let saveLastPlayed = json["saveLastPlayed"].bool {
|
||||
Defaults[.saveLastPlayed] = saveLastPlayed
|
||||
}
|
||||
|
||||
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
|
||||
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
|
||||
{
|
||||
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
|
||||
}
|
||||
|
||||
if let watchedThreshold = json["watchedThreshold"].int {
|
||||
Defaults[.watchedThreshold] = watchedThreshold
|
||||
}
|
||||
|
||||
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
|
||||
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
|
||||
}
|
||||
|
||||
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
|
||||
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
|
||||
{
|
||||
Defaults[.watchedVideoStyle] = watchedVideoStyle
|
||||
}
|
||||
|
||||
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
|
||||
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
|
||||
{
|
||||
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct LocationsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
var includePublicLocations = true
|
||||
var includedInstancesIDs = Set<Instance.ID>()
|
||||
var includedAccountsIDs = Set<Account.ID>()
|
||||
var includedAccountsPasswords = [Account.ID: String]()
|
||||
|
||||
init(
|
||||
json: JSON,
|
||||
includePublicLocations: Bool = true,
|
||||
includedInstancesIDs: Set<Instance.ID> = [],
|
||||
includedAccountsIDs: Set<Account.ID> = [],
|
||||
includedAccountsPasswords: [Account.ID: String] = [:]
|
||||
) {
|
||||
self.json = json
|
||||
self.includePublicLocations = includePublicLocations
|
||||
self.includedInstancesIDs = includedInstancesIDs
|
||||
self.includedAccountsIDs = includedAccountsIDs
|
||||
self.includedAccountsPasswords = includedAccountsPasswords
|
||||
}
|
||||
|
||||
var instances: [Instance] {
|
||||
if let instances = json["instances"].array {
|
||||
return instances.compactMap { instanceJSON in
|
||||
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return InstancesBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
if let accounts = json["accounts"].array {
|
||||
return accounts.compactMap { accountJSON in
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return AccountsBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if includePublicLocations {
|
||||
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
|
||||
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
|
||||
}
|
||||
|
||||
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
|
||||
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
|
||||
}
|
||||
|
||||
if let accounts = json["accounts"].array {
|
||||
accounts.forEach { accountJSON in
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let account = AccountsBridge().deserialize(dict),
|
||||
includedAccountsIDs.contains(account.id)
|
||||
{
|
||||
var password = account.password
|
||||
if password?.isEmpty ?? true {
|
||||
password = includedAccountsPasswords[account.id]
|
||||
}
|
||||
if let password,
|
||||
!password.isEmpty,
|
||||
let instanceID = account.instanceID,
|
||||
let instance = InstancesModel.shared.find(instanceID)
|
||||
{
|
||||
if !instance.accounts.contains(where: { instanceAccount in
|
||||
let (username, _) = instanceAccount.credentials
|
||||
return username == account.username
|
||||
}) {
|
||||
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct OtherDataSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let lastAccountID = json["lastAccountID"].string {
|
||||
Defaults[.lastAccountID] = lastAccountID
|
||||
}
|
||||
|
||||
if let lastInstanceID = json["lastInstanceID"].string {
|
||||
Defaults[.lastInstanceID] = lastInstanceID
|
||||
}
|
||||
|
||||
if let playerRate = json["playerRate"].double {
|
||||
Defaults[.playerRate] = playerRate
|
||||
}
|
||||
|
||||
if let trendingCategoryString = json["trendingCategory"].string,
|
||||
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
|
||||
{
|
||||
Defaults[.trendingCategory] = trendingCategory
|
||||
}
|
||||
|
||||
if let trendingCountryString = json["trendingCountry"].string,
|
||||
let trendingCountry = Country(rawValue: trendingCountryString)
|
||||
{
|
||||
Defaults[.trendingCountry] = trendingCountry
|
||||
}
|
||||
|
||||
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
|
||||
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
|
||||
{
|
||||
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
|
||||
}
|
||||
|
||||
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
|
||||
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let popularListingStyle = json["popularListingStyle"].string {
|
||||
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let trendingListingStyle = json["trendingListingStyle"].string {
|
||||
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let playlistListingStyle = json["playlistListingStyle"].string {
|
||||
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
|
||||
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let searchListingStyle = json["searchListingStyle"].string {
|
||||
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let hideShorts = json["hideShorts"].bool {
|
||||
Defaults[.hideShorts] = hideShorts
|
||||
}
|
||||
|
||||
if let hideWatched = json["hideWatched"].bool {
|
||||
Defaults[.hideWatched] = hideWatched
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlayerSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let playerInstanceID = json["playerInstanceID"].string {
|
||||
Defaults[.playerInstanceID] = playerInstanceID
|
||||
}
|
||||
|
||||
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
|
||||
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
|
||||
}
|
||||
|
||||
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
|
||||
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
|
||||
}
|
||||
|
||||
if let expandVideoDescription = json["expandVideoDescription"].bool {
|
||||
Defaults[.expandVideoDescription] = expandVideoDescription
|
||||
}
|
||||
|
||||
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
|
||||
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
|
||||
}
|
||||
|
||||
if let showChapters = json["showChapters"].bool {
|
||||
Defaults[.showChapters] = showChapters
|
||||
}
|
||||
|
||||
if let expandChapters = json["expandChapters"].bool {
|
||||
Defaults[.expandChapters] = expandChapters
|
||||
}
|
||||
|
||||
if let showRelated = json["showRelated"].bool {
|
||||
Defaults[.showRelated] = showRelated
|
||||
}
|
||||
|
||||
if let showInspectorString = json["showInspector"].string,
|
||||
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
|
||||
{
|
||||
Defaults[.showInspector] = showInspector
|
||||
}
|
||||
|
||||
if let playerSidebarString = json["playerSidebar"].string,
|
||||
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
|
||||
{
|
||||
Defaults[.playerSidebar] = playerSidebar
|
||||
}
|
||||
|
||||
if let showKeywords = json["showKeywords"].bool {
|
||||
Defaults[.showKeywords] = showKeywords
|
||||
}
|
||||
|
||||
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
|
||||
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
|
||||
}
|
||||
|
||||
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
|
||||
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
|
||||
}
|
||||
|
||||
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
|
||||
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
|
||||
}
|
||||
|
||||
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
|
||||
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
|
||||
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
|
||||
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if let honorSystemOrientationLock = json["honorSystemOrientationLock"].bool {
|
||||
Defaults[.honorSystemOrientationLock] = honorSystemOrientationLock
|
||||
}
|
||||
|
||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
||||
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
|
||||
}
|
||||
|
||||
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
|
||||
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
|
||||
{
|
||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct QualitySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
|
||||
Defaults[.batteryCellularProfile] = batteryCellularProfileString
|
||||
}
|
||||
|
||||
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
|
||||
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
|
||||
Defaults[.chargingCellularProfile] = chargingCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
|
||||
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
|
||||
}
|
||||
|
||||
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
|
||||
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
|
||||
}
|
||||
|
||||
if let qualityProfiles = json["qualityProfiles"].array {
|
||||
qualityProfiles.forEach { qualityProfileJSON in
|
||||
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = QualityProfileBridge().deserialize(dict) {
|
||||
QualityProfilesModel.shared.update(item, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct RecentlyOpenedImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let recentlyOpened = json["recentlyOpened"].array {
|
||||
recentlyOpened.forEach { recentlyOpenedJSON in
|
||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = RecentItemBridge().deserialize(dict) {
|
||||
RecentsModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct SponsorBlockSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
|
||||
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
|
||||
}
|
||||
|
||||
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
|
||||
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap { $0.string })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,10 +107,6 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
@Published var presentingFileImporter = false
|
||||
|
||||
@Published var presentingSettingsImportSheet = false
|
||||
@Published var presentingSettingsFileImporter = false
|
||||
@Published var settingsImportURL: URL?
|
||||
|
||||
func openChannel(_ channel: Channel, navigationStyle: NavigationStyle) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
@@ -273,8 +269,6 @@ final class NavigationModel: ObservableObject {
|
||||
presentingChannel = false
|
||||
presentingPlaylist = false
|
||||
presentingOpenVideos = false
|
||||
presentingFileImporter = false
|
||||
presentingSettingsImportSheet = false
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
@@ -285,9 +279,8 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
func presentAlert(title: String, message: String? = nil) {
|
||||
let message = message.isNil ? nil : Text(message!)
|
||||
let alert = Alert(title: Text(title), message: message)
|
||||
|
||||
presentAlert(alert)
|
||||
alert = Alert(title: Text(title), message: message)
|
||||
presentingAlert = true
|
||||
}
|
||||
|
||||
func presentRequestErrorAlert(_ error: RequestError) {
|
||||
@@ -296,11 +289,6 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
|
||||
func presentAlert(_ alert: Alert) {
|
||||
guard !presentingSettings else {
|
||||
SettingsModel.shared.presentAlert(alert)
|
||||
return
|
||||
}
|
||||
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
@@ -323,16 +311,6 @@ final class NavigationModel: ObservableObject {
|
||||
print("not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
func presentSettingsImportSheet(_ url: URL, forceSettings: Bool = false) {
|
||||
guard !presentingSettings, !forceSettings else {
|
||||
ImportExportSettingsModel.shared.reset()
|
||||
SettingsModel.shared.presentSettingsImportSheet(url)
|
||||
return
|
||||
}
|
||||
settingsImportURL = url
|
||||
presentingSettingsImportSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
|
||||
@@ -116,6 +116,16 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
#endif
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
let sortedByResolution = streams
|
||||
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
|
||||
.sorted { $0.resolution > $1.resolution }
|
||||
|
||||
return streams.first { $0.kind == .hls } ??
|
||||
sortedByResolution.first { $0.kind == .stream } ??
|
||||
sortedByResolution.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
|
||||
}
|
||||
@@ -124,7 +134,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading: Bool
|
||||
upgrading _: Bool
|
||||
) {
|
||||
isLoadingVideo = true
|
||||
|
||||
@@ -135,7 +145,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
@@ -150,13 +160,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
|
||||
avPlayer.play()
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
@@ -216,8 +219,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
@@ -226,7 +228,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -301,17 +303,11 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
|
||||
if stream.isHLS {
|
||||
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
|
||||
}
|
||||
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
@@ -391,7 +387,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
|
||||
@@ -201,6 +201,29 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||
.max { lhs, rhs in
|
||||
let predicates: [AreInIncreasingOrder] = [
|
||||
{ $0.resolution < $1.resolution },
|
||||
{ $0.format > $1.format }
|
||||
]
|
||||
|
||||
for predicate in predicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
} ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
}
|
||||
@@ -231,18 +254,7 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.handleAudioSessionInterruption(_:)),
|
||||
name: AVAudioSession.interruptionNotification,
|
||||
object: nil
|
||||
)
|
||||
} catch {
|
||||
self.logger.error("Error setting up audio session: \(error)")
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -252,10 +264,6 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
// Captions should only be displayed when selected by the user,
|
||||
// not when the video starts. So, we remove them.
|
||||
self.client?.removeSubs()
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@@ -301,7 +309,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
@@ -313,7 +321,7 @@ final class MPVBackend: PlayerBackend {
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||
|
||||
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
@@ -322,7 +330,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
@@ -346,13 +354,6 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
setRate(model.currentRate)
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
@@ -518,6 +519,8 @@ final class MPVBackend: PlayerBackend {
|
||||
guard client.eofReached else {
|
||||
return
|
||||
}
|
||||
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
@@ -624,31 +627,4 @@ final class MPVBackend: PlayerBackend {
|
||||
logger.info("MPV backend received unhandled property: \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
logger.info("Audio session interruption received.")
|
||||
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
else {
|
||||
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
||||
return
|
||||
}
|
||||
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
|
||||
logger.info("Interruption type received: \(String(describing: type))")
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
pause()
|
||||
logger.info("Audio session interrupted.")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ final class MPVClient: ObservableObject {
|
||||
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_initialize(mpv))
|
||||
|
||||
@@ -128,8 +127,6 @@ final class MPVClient: ObservableObject {
|
||||
func loadFile(
|
||||
_ url: URL,
|
||||
audio: URL? = nil,
|
||||
bitrate: Int? = nil,
|
||||
kind: Stream.Kind,
|
||||
sub: URL? = nil,
|
||||
time: CMTime? = nil,
|
||||
forceSeekable: Bool = false,
|
||||
@@ -140,10 +137,6 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
args.append("replace")
|
||||
|
||||
// needed since mpvkit 0.38.0
|
||||
// https://github.com/mpv-player/mpv/issues/13806#issuecomment-2029818905
|
||||
args.append("-1")
|
||||
|
||||
if let time, time.seconds > 0 {
|
||||
options.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
@@ -166,10 +159,6 @@ final class MPVClient: ObservableObject {
|
||||
args.append(options.joined(separator: ","))
|
||||
}
|
||||
|
||||
if kind == .hls, bitrate != 0 {
|
||||
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ protocol PlayerBackend {
|
||||
var videoWidth: Double? { get }
|
||||
var videoHeight: Double? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
func canPlayAtRate(_ rate: Double) -> Bool
|
||||
|
||||
@@ -130,52 +131,6 @@ extension PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
||||
// filter out non HLS streams
|
||||
let nonHLSStreams = streams.filter { $0.kind != .hls }
|
||||
|
||||
// find max resolution from non HLS streams
|
||||
let bestResolution = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.resolution < $1.resolution }
|
||||
|
||||
// finde max bitrate from non HLS streams
|
||||
let bestBitrate = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
|
||||
return streams.map { stream in
|
||||
if stream.kind == .hls {
|
||||
stream.resolution = bestResolution?.resolution ?? maxResolution.value
|
||||
stream.bitrate = bestBitrate?.bitrate ?? (bestResolution?.resolution.bitrate ?? maxResolution.value.bitrate)
|
||||
stream.format = .hls
|
||||
} else if stream.kind == .stream {
|
||||
stream.format = .stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
.filter { stream in
|
||||
stream.resolution <= maxResolution.value
|
||||
}
|
||||
.max { lhs, rhs in
|
||||
if lhs.resolution == rhs.resolution {
|
||||
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
|
||||
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
|
||||
else {
|
||||
print("Failed to extract lhsFormat or rhsFormat")
|
||||
return false
|
||||
}
|
||||
|
||||
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
|
||||
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
|
||||
|
||||
return lhsFormatIndex > rhsFormatIndex
|
||||
}
|
||||
|
||||
return lhs.resolution < rhs.resolution
|
||||
}
|
||||
}
|
||||
|
||||
func updateControls(completionHandler: (() -> Void)? = nil) {
|
||||
print("updating controls")
|
||||
|
||||
|
||||
@@ -76,8 +76,6 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var previousActiveBackend: PlayerBackendType?
|
||||
|
||||
lazy var playerBackendView = PlayerBackendView()
|
||||
|
||||
@Published var playerSize: CGSize = .zero { didSet {
|
||||
@@ -179,11 +177,6 @@ final class PlayerModel: ObservableObject {
|
||||
@Default(.playerRate) var playerRate
|
||||
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
|
||||
|
||||
#if os(macOS)
|
||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
@@ -195,10 +188,6 @@ final class PlayerModel: ObservableObject {
|
||||
var rateToRestore: Float?
|
||||
private var remoteCommandCenterConfigured = false
|
||||
|
||||
#if os(macOS)
|
||||
var keyPressMonitor: Any?
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if !os(macOS)
|
||||
mpvBackend.controller = mpvController
|
||||
@@ -223,7 +212,6 @@ final class PlayerModel: ObservableObject {
|
||||
#if os(macOS)
|
||||
if presentingPlayer {
|
||||
Windows.player.focus()
|
||||
assignKeyPressMonitor()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
@@ -239,7 +227,6 @@ final class PlayerModel: ObservableObject {
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
assignKeyPressMonitor()
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -259,7 +246,6 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
destroyKeyPressMonitor()
|
||||
Windows.player.hide()
|
||||
#endif
|
||||
}
|
||||
@@ -534,7 +520,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true, isInClosePip: Bool = false) {
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
|
||||
guard activeBackend != to else {
|
||||
return
|
||||
}
|
||||
@@ -543,7 +529,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
|
||||
if to == .mpv && !isInClosePip {
|
||||
if to == .mpv {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
@@ -666,7 +652,6 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func startPiP() {
|
||||
previousActiveBackend = activeBackend
|
||||
avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
|
||||
@@ -676,7 +661,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
guard let video = currentVideo else { return }
|
||||
guard let stream = backend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: qualityProfile!.formats) else { return }
|
||||
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return }
|
||||
|
||||
exitFullScreen()
|
||||
|
||||
@@ -719,12 +704,6 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
|
||||
backend.closePiP()
|
||||
if previousActiveBackend == .mpv {
|
||||
saveTime {
|
||||
self.changeActiveBackend(from: self.activeBackend, to: .mpv, isInClosePip: true)
|
||||
self.controls.resetTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pipImage: String {
|
||||
@@ -780,12 +759,10 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func handleCurrentItemChange() {
|
||||
if currentItem == nil {
|
||||
captions = nil
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
}
|
||||
|
||||
// Captions need to be set to nil on item change, to clear the previus values.
|
||||
captions = nil
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
#endif
|
||||
@@ -946,10 +923,7 @@ final class PlayerModel: ObservableObject {
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
setNeedsDrawing(presentingPlayer)
|
||||
|
||||
if !musicMode, activeBackend == .appleAVPlayer {
|
||||
avPlayerBackend.bindPlayerToLayer()
|
||||
}
|
||||
avPlayerBackend.bindPlayerToLayer()
|
||||
|
||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||
return
|
||||
@@ -1172,47 +1146,4 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func assignKeyPressMonitor() {
|
||||
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { keyEvent -> NSEvent? in
|
||||
switch keyEvent.keyCode {
|
||||
case 124:
|
||||
if !self.liveStreamInAVPlayer {
|
||||
let interval = TimeInterval(self.buttonForwardSeekDuration) ?? 10
|
||||
self.backend.seek(
|
||||
relative: .secondsInDefaultTimescale(interval),
|
||||
seekType: .userInteracted
|
||||
)
|
||||
}
|
||||
case 123:
|
||||
if !self.liveStreamInAVPlayer {
|
||||
let interval = TimeInterval(self.buttonBackwardSeekDuration) ?? 10
|
||||
self.backend.seek(
|
||||
relative: .secondsInDefaultTimescale(-interval),
|
||||
seekType: .userInteracted
|
||||
)
|
||||
}
|
||||
case 3:
|
||||
self.toggleFullscreen(
|
||||
self.playingFullScreen,
|
||||
showControls: false
|
||||
)
|
||||
case 49:
|
||||
if !self.controls.isLoadingVideo {
|
||||
self.backend.togglePlay()
|
||||
}
|
||||
default:
|
||||
return keyEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func destroyKeyPressMonitor() {
|
||||
if let keyPressMonitor = keyPressMonitor {
|
||||
NSEvent.removeMonitor(keyPressMonitor)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -127,12 +127,12 @@ extension PlayerModel {
|
||||
|
||||
if let streamPreferredForProfile = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
maxResolution: profile.resolution
|
||||
) {
|
||||
return streamPreferredForProfile
|
||||
}
|
||||
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution, formatOrder: profile.formats)
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
|
||||
@@ -44,6 +44,22 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.pause()
|
||||
|
||||
self.backend.eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -53,14 +69,6 @@ extension PlayerModel {
|
||||
self?.segmentRestorationTime = time
|
||||
}
|
||||
logger.info("SponsorBlock skipping to: \(segment.end)")
|
||||
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("Segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.backend.eofPlaybackModeAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
|
||||
@@ -3,13 +3,13 @@ import Foundation
|
||||
|
||||
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
static var bridge = QualityProfileBridge()
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream], order: Array(Format.allCases.indices))
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream])
|
||||
|
||||
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case hls
|
||||
case stream
|
||||
case avc1
|
||||
case mp4
|
||||
case avc1
|
||||
case av1
|
||||
case webm
|
||||
|
||||
@@ -23,6 +23,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return "Stream"
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -34,14 +35,14 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return nil
|
||||
case .stream:
|
||||
return nil
|
||||
case .avc1:
|
||||
return .avc1
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .av1:
|
||||
return .av1
|
||||
case .webm:
|
||||
return .webm
|
||||
case .avc1:
|
||||
return .avc1
|
||||
case .av1:
|
||||
return .av1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +53,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
var backend: PlayerBackendType
|
||||
var resolution: ResolutionSetting
|
||||
var formats: [Format]
|
||||
var order: [Int]
|
||||
|
||||
var description: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
|
||||
@@ -100,8 +101,7 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
"name": value.name ?? "",
|
||||
"backend": value.backend.rawValue,
|
||||
"resolution": value.resolution.rawValue,
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator),
|
||||
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -116,8 +116,7 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
|
||||
let name = object["name"]
|
||||
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
|
||||
let order = (object["order"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { Int($0) }
|
||||
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats, order: order)
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
|
||||
func showOSD() {
|
||||
guard !presentingOSD else { return }
|
||||
|
||||
presentingOSD = true
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true }
|
||||
}
|
||||
|
||||
func hideOSD() {
|
||||
guard presentingOSD else { return }
|
||||
|
||||
presentingOSD = false
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false }
|
||||
}
|
||||
|
||||
func hideOSDWithDelay() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum SeekType: Equatable {
|
||||
case chapterSkip(String)
|
||||
case segmentSkip(String)
|
||||
case segmentRestore
|
||||
case userInteracted
|
||||
|
||||
@@ -7,9 +7,6 @@ final class SettingsModel: ObservableObject {
|
||||
@Published var presentingAlert = false
|
||||
@Published var alert = Alert(title: Text("Error"))
|
||||
|
||||
@Published var presentingSettingsImportSheet = false
|
||||
@Published var settingsImportURL: URL?
|
||||
|
||||
func presentAlert(title: String, message: String? = nil) {
|
||||
let message = message.isNil ? nil : Text(message!)
|
||||
alert = Alert(title: Text(title), message: message)
|
||||
@@ -20,9 +17,4 @@ final class SettingsModel: ObservableObject {
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
|
||||
func presentSettingsImportSheet(_ url: URL) {
|
||||
settingsImportURL = url
|
||||
presentingSettingsImportSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
static let categories = ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "filler", "music_offtopic"]
|
||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||
|
||||
let logger = Logger(label: "stream.yattee.app.sb")
|
||||
|
||||
@@ -21,19 +21,15 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "sponsor":
|
||||
return "Sponsor".localized()
|
||||
case "selfpromo":
|
||||
return "Unpaid/Self Promotion".localized()
|
||||
case "interaction":
|
||||
return "Interaction Reminder (Subscribe)".localized()
|
||||
return "Self-promotion".localized()
|
||||
case "intro":
|
||||
return "Intermission/Intro Animation".localized()
|
||||
return "Intro".localized()
|
||||
case "outro":
|
||||
return "Endcards/Credits".localized()
|
||||
case "preview":
|
||||
return "Preview/Recap/Hook".localized()
|
||||
case "filler":
|
||||
return "Filler Tangent/Jokes".localized()
|
||||
return "Outro".localized()
|
||||
case "interaction":
|
||||
return "Interaction".localized()
|
||||
case "music_offtopic":
|
||||
return "Music: Non-Music Section".localized()
|
||||
return "Offtopic in Music Videos".localized()
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
@@ -50,14 +46,9 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
"The creator will receive payment or compensation in the form of money or free products.").localized()
|
||||
|
||||
case "selfpromo":
|
||||
return ("The creator will not receive any payment in exchange for this promotion. " +
|
||||
"This includes charity drives or free shout outs for products or other people they like.\n\n" +
|
||||
"Promoting a product or service that is directly related to the creator themselves. " +
|
||||
return ("Promoting a product or service that is directly related to the creator themselves. " +
|
||||
"This usually includes merchandise or promotion of monetized platforms.").localized()
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
|
||||
case "intro":
|
||||
return ("Segments typically found at the start of a video that include an animation, " +
|
||||
"still frame or clip which are also seen in other videos by the same creator.").localized()
|
||||
@@ -65,11 +56,8 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "outro":
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
||||
|
||||
case "preview":
|
||||
return "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video".localized()
|
||||
|
||||
case "filler":
|
||||
return "Filler Tangent/ Jokes is only for tangential scenes added only for filler or humor that are not required to understand the main content of the video.".localized()
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
|
||||
case "music_offtopic":
|
||||
return "For videos which feature music as the primary content.".localized()
|
||||
@@ -112,8 +100,8 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||
|
||||
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
||||
for segment in self.segments {
|
||||
self.logger.info("\(segment.start) -> \(segment.end)")
|
||||
self.segments.forEach {
|
||||
self.logger.info("\($0.start) -> \($0.end)")
|
||||
}
|
||||
case let .failure(error):
|
||||
self.segments = []
|
||||
|
||||
@@ -54,32 +54,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||
}
|
||||
|
||||
// These values are an approximation.
|
||||
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
||||
|
||||
var bitrate: Int {
|
||||
switch self {
|
||||
case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30:
|
||||
return 56000000 // 56 Mbit/s
|
||||
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30:
|
||||
return 24000000 // 24 Mbit/s
|
||||
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30:
|
||||
return 12000000 // 12 Mbit/s
|
||||
case .hd720p60, .hd720p50, .hd720p48, .hd720p30:
|
||||
return 9500000 // 9.5 Mbit/s
|
||||
case .sd480p30:
|
||||
return 4000000 // 4 Mbit/s
|
||||
case .sd360p30:
|
||||
return 1500000 // 1.5 Mbit/s
|
||||
case .sd240p30:
|
||||
return 1000000 // 1 Mbit/s
|
||||
case .sd144p30:
|
||||
return 600000 // 0.6 Mbit/s
|
||||
case .unknown:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
||||
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
||||
}
|
||||
@@ -90,7 +64,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
enum Kind: String, Comparable {
|
||||
case hls, adaptive, stream
|
||||
case stream, adaptive, hls
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -108,23 +82,37 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String {
|
||||
case avc1
|
||||
case mp4
|
||||
case av1
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
case hls
|
||||
case stream
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
case unknown
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .mp4:
|
||||
return 0
|
||||
case .avc1:
|
||||
return 1
|
||||
case .av1:
|
||||
return 2
|
||||
case .webm:
|
||||
return 3
|
||||
case .unknown:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .webm:
|
||||
return "WebM"
|
||||
case .hls:
|
||||
return "adaptive (HLS)"
|
||||
case .stream:
|
||||
return "Stream"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -133,23 +121,17 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
}
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
}
|
||||
if lowercased.contains("av01") {
|
||||
return .av1
|
||||
}
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("stream") {
|
||||
return .stream
|
||||
}
|
||||
if lowercased.contains("hls") {
|
||||
return .hls
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
@@ -169,7 +151,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var encoding: String?
|
||||
var videoFormat: String?
|
||||
var bitrate: Int?
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@@ -180,8 +161,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil,
|
||||
bitrate: Int? = nil
|
||||
videoFormat: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@@ -192,7 +172,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
@@ -205,31 +184,22 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var quality: String {
|
||||
guard localURL.isNil else { return "Opened File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
|
||||
return resolution.name
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
||||
var shortQuality: String {
|
||||
guard localURL.isNil else { return "File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
return "HLS"
|
||||
}
|
||||
|
||||
if kind == .stream {
|
||||
return resolution.name
|
||||
}
|
||||
return resolutionAndFormat
|
||||
return resolution?.name ?? "?"
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard localURL.isNil else { return resolutionAndFormat }
|
||||
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
||||
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
|
||||
return "\(resolutionAndFormat)\(instanceString)"
|
||||
}
|
||||
|
||||
var resolutionAndFormat: String {
|
||||
|
||||
@@ -2,26 +2,9 @@ import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum Constants {
|
||||
struct Constants {
|
||||
static let yatteeProtocol = "yattee://"
|
||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||
|
||||
static var isAppleTV: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .tv
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isMac: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .mac
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isIPhone: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .phone
|
||||
@@ -78,26 +61,6 @@ enum Constants {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var deviceName: String {
|
||||
#if os(macOS)
|
||||
Host().localizedName ?? "Mac"
|
||||
#else
|
||||
UIDevice.current.name
|
||||
#endif
|
||||
}
|
||||
|
||||
static var platform: String {
|
||||
#if os(macOS)
|
||||
"macOS"
|
||||
#elseif os(iOS)
|
||||
"iOS"
|
||||
#elseif os(tvOS)
|
||||
"tvOS"
|
||||
#else
|
||||
"unknown"
|
||||
#endif
|
||||
}
|
||||
|
||||
static func seekIcon(_ type: String, _ interval: TimeInterval) -> String {
|
||||
let interval = Int(interval)
|
||||
let allVersions = [10, 15, 30, 45, 60, 75, 90]
|
||||
|
||||
@@ -6,22 +6,37 @@ import SwiftUI
|
||||
#endif
|
||||
|
||||
extension Defaults.Keys {
|
||||
// MARK: GROUP - Browsing
|
||||
static let instancesManifest = Key<String>("instancesManifest", default: "")
|
||||
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
|
||||
|
||||
static let instances = Key<[Instance]>("instances", default: [])
|
||||
static let accounts = Key<[Account]>("accounts", default: [])
|
||||
static let lastAccountID = Key<Account.ID?>("lastAccountID")
|
||||
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
|
||||
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
||||
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
||||
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
|
||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||
|
||||
static let showHome = Key<Bool>("showHome", default: true)
|
||||
static let showOpenActionsInHome = Key<Bool>("showOpenActionsInHome", default: true)
|
||||
static let showQueueInHome = Key<Bool>("showQueueInHome", default: true)
|
||||
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||
|
||||
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
||||
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
|
||||
#if os(iOS)
|
||||
static let showDocuments = Key<Bool>("showDocuments", default: false)
|
||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
#endif
|
||||
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||
|
||||
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
|
||||
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .nothing)
|
||||
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: false)
|
||||
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: false)
|
||||
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
|
||||
|
||||
#if !os(tvOS)
|
||||
#if os(macOS)
|
||||
@@ -31,147 +46,27 @@ extension Defaults.Keys {
|
||||
#endif
|
||||
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: accountPickerDisplaysUsernameDefault)
|
||||
#endif
|
||||
|
||||
static let accountPickerDisplaysAnonymousAccounts = Key<Bool>("accountPickerDisplaysAnonymousAccounts", default: true)
|
||||
#if os(iOS)
|
||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
#endif
|
||||
static let showUnwatchedFeedBadges = Key<Bool>("showUnwatchedFeedBadges", default: false)
|
||||
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
|
||||
|
||||
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
|
||||
static let showChannelAvatarInChannelsLists = Key<Bool>("showChannelAvatarInChannelsLists", default: true)
|
||||
static let showChannelAvatarInVideosListing = Key<Bool>("showChannelAvatarInVideosListing", default: true)
|
||||
|
||||
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
|
||||
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .nothing)
|
||||
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: false)
|
||||
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: false)
|
||||
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
|
||||
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
||||
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
|
||||
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
|
||||
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
|
||||
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
|
||||
static let thumbnailsQuality = Key<ThumbnailsQuality>("thumbnailsQuality", default: .highest)
|
||||
|
||||
// MARK: GROUP - Player
|
||||
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||
|
||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||
|
||||
#if os(tvOS)
|
||||
static let pauseOnHidingPlayerDefault = true
|
||||
#else
|
||||
static let pauseOnHidingPlayerDefault = false
|
||||
#endif
|
||||
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
|
||||
|
||||
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
||||
|
||||
#if !os(macOS)
|
||||
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
static let expandVideoDescriptionDefault = Constants.isIPad
|
||||
#else
|
||||
static let expandVideoDescriptionDefault = true
|
||||
#endif
|
||||
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
||||
|
||||
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
|
||||
|
||||
static let showChapters = Key<Bool>("showChapters", default: true)
|
||||
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
|
||||
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: true)
|
||||
static let expandChapters = Key<Bool>("expandChapters", default: true)
|
||||
static let showRelated = Key<Bool>("showRelated", default: true)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
|
||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
#if !os(tvOS)
|
||||
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
||||
#endif
|
||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||
|
||||
#if os(iOS)
|
||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
|
||||
"rotateToLandscapeOnEnterFullScreen",
|
||||
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
|
||||
)
|
||||
#endif
|
||||
|
||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
|
||||
#if !os(macOS)
|
||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||
#endif
|
||||
|
||||
// MARK: GROUP - Controls
|
||||
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||
#elseif os(tvOS)
|
||||
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||
#else
|
||||
static let playerControlsLayoutDefault = PlayerControlsLayout.medium
|
||||
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.medium
|
||||
#endif
|
||||
|
||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||
|
||||
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
||||
|
||||
static let buttonBackwardSeekDuration = Key<String>("buttonBackwardSeekDuration", default: "10")
|
||||
static let buttonForwardSeekDuration = Key<String>("buttonForwardSeekDuration", default: "10")
|
||||
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
|
||||
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
|
||||
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
static let playerControlsSettingsEnabledDefault = true
|
||||
#else
|
||||
static let playerControlsSettingsEnabledDefault = false
|
||||
#endif
|
||||
static let playerControlsSettingsEnabled = Key<Bool>("playerControlsSettingsEnabled", default: playerControlsSettingsEnabledDefault)
|
||||
static let playerControlsCloseEnabled = Key<Bool>("playerControlsCloseEnabled", default: true)
|
||||
static let playerControlsRestartEnabled = Key<Bool>("playerControlsRestartEnabled", default: false)
|
||||
static let playerControlsAdvanceToNextEnabled = Key<Bool>("playerControlsAdvanceToNextEnabled", default: false)
|
||||
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
|
||||
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
|
||||
|
||||
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
|
||||
|
||||
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
|
||||
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
||||
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
||||
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
||||
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
||||
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
||||
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
||||
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
||||
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
||||
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
||||
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
||||
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
||||
|
||||
// MARK: GROUP - Quality
|
||||
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream])
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream])
|
||||
|
||||
#if os(iOS)
|
||||
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
|
||||
@@ -214,74 +109,150 @@ extension Defaults.Keys {
|
||||
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||
#endif
|
||||
|
||||
static let playerRate = Key<Double>("playerRate", default: 1.0)
|
||||
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: qualityProfilesDefault)
|
||||
static let batteryCellularProfile = Key<QualityProfile.ID>("batteryCellularProfile", default: batteryCellularProfileDefault)
|
||||
static let batteryNonCellularProfile = Key<QualityProfile.ID>("batteryNonCellularProfile", default: batteryNonCellularProfileDefault)
|
||||
static let chargingCellularProfile = Key<QualityProfile.ID>("chargingCellularProfile", default: chargingCellularProfileDefault)
|
||||
static let chargingNonCellularProfile = Key<QualityProfile.ID>("chargingNonCellularProfile", default: chargingNonCellularProfileDefault)
|
||||
static let forceAVPlayerForLiveStreams = Key<Bool>("forceAVPlayerForLiveStreams", default: true)
|
||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
|
||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||
|
||||
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: qualityProfilesDefault)
|
||||
#if os(iOS)
|
||||
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||
#elseif os(tvOS)
|
||||
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||
#else
|
||||
static let playerControlsLayoutDefault = PlayerControlsLayout.medium
|
||||
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.medium
|
||||
#endif
|
||||
|
||||
// MARK: GROUP - History
|
||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
#if !os(tvOS)
|
||||
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
||||
#endif
|
||||
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showRecents = Key<Bool>("showRecents", default: true)
|
||||
static let limitRecents = Key<Bool>("limitRecents", default: false)
|
||||
static let limitRecentsAmount = Key<Int>("limitRecentsAmount", default: 10)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
#if os(iOS)
|
||||
static let expandVideoDescriptionDefault = Constants.isIPad
|
||||
#else
|
||||
static let expandVideoDescriptionDefault = true
|
||||
#endif
|
||||
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
||||
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
|
||||
|
||||
static let showChannelAvatarInChannelsLists = Key<Bool>("showChannelAvatarInChannelsLists", default: true)
|
||||
static let showChannelAvatarInVideosListing = Key<Bool>("showChannelAvatarInVideosListing", default: true)
|
||||
|
||||
#if os(tvOS)
|
||||
static let pauseOnHidingPlayerDefault = true
|
||||
#else
|
||||
static let pauseOnHidingPlayerDefault = false
|
||||
#endif
|
||||
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
|
||||
|
||||
#if !os(macOS)
|
||||
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
|
||||
#endif
|
||||
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||
#if !os(macOS)
|
||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||
#endif
|
||||
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
|
||||
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
|
||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
||||
|
||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
||||
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
||||
|
||||
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
||||
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
|
||||
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
||||
|
||||
// MARK: GROUP - SponsorBlock
|
||||
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
|
||||
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
|
||||
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
|
||||
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
|
||||
|
||||
// MARK: GROUP - Locations
|
||||
|
||||
static let instancesManifest = Key<String>("instancesManifest", default: "")
|
||||
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
|
||||
|
||||
static let instances = Key<[Instance]>("instances", default: [])
|
||||
static let accounts = Key<[Account]>("accounts", default: [])
|
||||
|
||||
// MARK: Group - Advanced
|
||||
|
||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
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 mpvDeinterlace = Key<Bool>("mpvDeinterlace", default: false)
|
||||
|
||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||
|
||||
// MARK: GROUP - Other exportable
|
||||
|
||||
static let lastAccountID = Key<Account.ID?>("lastAccountID")
|
||||
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
|
||||
|
||||
static let playerRate = Key<Double>("playerRate", default: 1.0)
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
|
||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||
|
||||
#if os(iOS)
|
||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
|
||||
"rotateToLandscapeOnEnterFullScreen",
|
||||
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
|
||||
)
|
||||
#endif
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||
|
||||
#if os(macOS)
|
||||
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
|
||||
#else
|
||||
static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? ButtonLabelStyle.iconOnly : .iconAndText
|
||||
#endif
|
||||
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
|
||||
|
||||
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
||||
|
||||
static let buttonBackwardSeekDuration = Key<String>("buttonBackwardSeekDuration", default: "10")
|
||||
static let buttonForwardSeekDuration = Key<String>("buttonForwardSeekDuration", default: "10")
|
||||
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
|
||||
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
|
||||
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
|
||||
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
|
||||
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
||||
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
||||
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
||||
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
||||
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
||||
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
||||
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
||||
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
||||
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
||||
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
||||
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
static let playerControlsSettingsEnabledDefault = true
|
||||
#else
|
||||
static let playerControlsSettingsEnabledDefault = false
|
||||
#endif
|
||||
static let playerControlsSettingsEnabled = Key<Bool>("playerControlsSettingsEnabled", default: playerControlsSettingsEnabledDefault)
|
||||
static let playerControlsCloseEnabled = Key<Bool>("playerControlsCloseEnabled", default: true)
|
||||
static let playerControlsRestartEnabled = Key<Bool>("playerControlsRestartEnabled", default: false)
|
||||
static let playerControlsAdvanceToNextEnabled = Key<Bool>("playerControlsAdvanceToNextEnabled", default: false)
|
||||
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
|
||||
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
|
||||
|
||||
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
|
||||
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
|
||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||
|
||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||
|
||||
static let subscriptionsViewPage = Key<SubscriptionsView.Page>("subscriptionsViewPage", default: .feed)
|
||||
|
||||
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
|
||||
@@ -292,22 +263,11 @@ extension Defaults.Keys {
|
||||
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
|
||||
static let hideShorts = Key<Bool>("hideShorts", default: false)
|
||||
static let hideWatched = Key<Bool>("hideWatched", default: false)
|
||||
|
||||
// MARK: GROUP - Not exportable
|
||||
|
||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
|
||||
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)
|
||||
|
||||
// MARK: LEGACY
|
||||
|
||||
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
static let showChapters = Key<Bool>("showChapters", default: true)
|
||||
static let expandChapters = Key<Bool>("expandChapters", default: true)
|
||||
static let showRelated = Key<Bool>("showRelated", default: true)
|
||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -441,15 +401,6 @@ enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
|
||||
var text: Bool {
|
||||
self == .iconAndText
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .iconOnly:
|
||||
return "Icon only".localized()
|
||||
case .iconAndText:
|
||||
return "Icon and text".localized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ThumbnailsQuality: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -586,26 +537,3 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case horizontalCells
|
||||
case list
|
||||
}
|
||||
|
||||
enum SponsorBlockColors: String {
|
||||
case sponsor = "#00D400" // Green
|
||||
case selfpromo = "#FFFF00" // Yellow
|
||||
case interaction = "#CC00FF" // Purple
|
||||
case intro = "#00FFFF" // Cyan
|
||||
case outro = "#0202ED" // Dark Blue
|
||||
case preview = "#008FD6" // Light Blue
|
||||
case filler = "#7300FF" // Violet
|
||||
case music_offtopic = "#FF9900" // Orange
|
||||
|
||||
// Define all cases, can be used to iterate over the colors
|
||||
static let allCases: [SponsorBlockColors] = [.sponsor, .selfpromo, .interaction, .intro, .outro, .preview, .filler, .music_offtopic]
|
||||
|
||||
// Create a dictionary with the category names as keys and colors as values
|
||||
static let dictionary: [String: String] = {
|
||||
var dict = [String: String]()
|
||||
for item in allCases {
|
||||
dict[String(describing: item)] = item.rawValue
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum Delay {
|
||||
struct Delay {
|
||||
@discardableResult static func by(_ interval: TimeInterval, block: @escaping () -> Void) -> Timer {
|
||||
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in block() }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct HomeView: View {
|
||||
@Default(.favorites) private var favorites
|
||||
@Default(.widgetsSettings) private var widgetsSettings
|
||||
#endif
|
||||
@Default(.homeHistoryItems) private var homeHistoryItems
|
||||
@Default(.showFavoritesInHome) private var showFavoritesInHome
|
||||
@Default(.showOpenActionsInHome) private var showOpenActionsInHome
|
||||
@Default(.showQueueInHome) private var showQueueInHome
|
||||
|
||||
@@ -5,14 +5,12 @@ struct AppSidebarRecents: View {
|
||||
var recents = RecentsModel.shared
|
||||
|
||||
@Default(.recentlyOpened) private var recentItems
|
||||
@Default(.limitRecents) private var limitRecents
|
||||
@Default(.limitRecentsAmount) private var limitRecentsAmount
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !recentItems.isEmpty {
|
||||
Section(header: Text("Recents")) {
|
||||
ForEach(recentItems.reversed().prefix(limitRecents ? limitRecentsAmount : recentItems.count)) { recent in
|
||||
ForEach(recentItems.reversed()) { recent in
|
||||
Group {
|
||||
switch recent.type {
|
||||
case .channel:
|
||||
|
||||
@@ -68,7 +68,6 @@ struct ContentView: View {
|
||||
SettingsView()
|
||||
}
|
||||
)
|
||||
.modifier(ImportSettingsSheetViewModifier(isPresented: $navigation.presentingSettingsImportSheet, settingsFile: $navigation.settingsImportURL))
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAccounts) {
|
||||
AccountsView()
|
||||
|
||||
@@ -13,7 +13,6 @@ struct Sidebar: View {
|
||||
@Default(.showDocuments) private var showDocuments
|
||||
#endif
|
||||
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
||||
@Default(.showRecents) private var showRecents
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scrollView in
|
||||
@@ -21,10 +20,8 @@ struct Sidebar: View {
|
||||
mainNavigationLinks
|
||||
|
||||
if !accounts.isEmpty {
|
||||
if showRecents {
|
||||
AppSidebarRecents()
|
||||
.id("recentlyOpened")
|
||||
}
|
||||
AppSidebarRecents()
|
||||
.id("recentlyOpened")
|
||||
|
||||
if accounts.api.signedIn {
|
||||
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {
|
||||
|
||||
@@ -21,11 +21,6 @@ struct OpenURLHandler {
|
||||
return
|
||||
}
|
||||
|
||||
if url.isFileURL, url.standardizedFileURL.absoluteString.hasSuffix(".\(ImportExportSettingsModel.settingsExtension)") {
|
||||
navigation.presentSettingsImportSheet(url)
|
||||
return
|
||||
}
|
||||
|
||||
if accounts.current.isNil {
|
||||
accounts.setCurrent(accounts.any)
|
||||
}
|
||||
|
||||
@@ -312,6 +312,7 @@ struct ControlsOverlay: View {
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 240, height: 40)
|
||||
@@ -373,12 +374,12 @@ struct ControlsOverlay: View {
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions", selection: captionsBinding) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available").tag(Captions?.none)
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled").tag(Captions?.none)
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
|
||||
@@ -13,18 +13,6 @@ struct Seek: View {
|
||||
|
||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -37,7 +25,6 @@ struct Seek: View {
|
||||
#endif
|
||||
}
|
||||
.opacity(visible || YatteeApp.isForPreviews ? 1 : 0)
|
||||
.animation(.easeIn)
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
@@ -64,8 +51,7 @@ struct Seek: View {
|
||||
if let segment = projectedSegment {
|
||||
Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor")
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.foregroundColor(getColor(for: segment.category))
|
||||
.padding(.bottom, 3)
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
}
|
||||
} else {
|
||||
#if !os(tvOS)
|
||||
@@ -83,16 +69,7 @@ struct Seek: View {
|
||||
Divider()
|
||||
Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor")
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.foregroundColor(getColor(for: category))
|
||||
.padding(.bottom, 3)
|
||||
case let .chapterSkip(chapter):
|
||||
Divider()
|
||||
Text(chapter)
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
.padding(.bottom, 3)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -140,7 +117,6 @@ struct Seek: View {
|
||||
var visible: Bool {
|
||||
guard !(model.lastSeekTime.isNil && !model.isSeeking) else { return false }
|
||||
if let type = model.lastSeekType, !type.presentable { return false }
|
||||
if !showNoticeAfterSkip { if case .segmentSkip? = model.lastSeekType { return false }}
|
||||
|
||||
return !controls.presentingControls && !controls.presentingOverlays && model.presentingOSD
|
||||
}
|
||||
|
||||
@@ -51,24 +51,11 @@ struct TimelineView: View {
|
||||
|
||||
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
|
||||
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
|
||||
|
||||
var playerControlsLayout: PlayerControlsLayout {
|
||||
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
|
||||
}
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
var chapters: [Chapter] {
|
||||
player.currentVideo?.chapters ?? []
|
||||
}
|
||||
@@ -86,15 +73,13 @@ struct TimelineView: View {
|
||||
Group {
|
||||
VStack(spacing: 3) {
|
||||
if dragging {
|
||||
if showCategoriesInTimeline {
|
||||
if let segment = projectedSegment,
|
||||
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
||||
{
|
||||
Text(description)
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.fixedSize()
|
||||
.foregroundColor(getColor(for: segment.category))
|
||||
}
|
||||
if let segment = projectedSegment,
|
||||
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
||||
{
|
||||
Text(description)
|
||||
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||
.fixedSize()
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
}
|
||||
if let chapter = projectedChapter {
|
||||
Text(chapter.title)
|
||||
@@ -160,10 +145,8 @@ struct TimelineView: View {
|
||||
.frame(width: (dragging ? projectedValue : current) * oneUnitWidth)
|
||||
.zIndex(1)
|
||||
|
||||
if showCategoriesInTimeline {
|
||||
segmentsLayers
|
||||
.zIndex(2)
|
||||
}
|
||||
segmentsLayers
|
||||
.zIndex(2)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
|
||||
@@ -253,7 +236,7 @@ struct TimelineView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(dragging || !showTimeWithSkipsRemoved ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
||||
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.frame(minWidth: 35)
|
||||
}
|
||||
@@ -316,7 +299,7 @@ struct TimelineView: View {
|
||||
ForEach(segments, id: \.uuid) { segment in
|
||||
Rectangle()
|
||||
.offset(x: segmentLayerHorizontalOffset(segment))
|
||||
.foregroundColor(getColor(for: segment.category))
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: segmentLayerWidth(segment))
|
||||
}
|
||||
@@ -331,9 +314,9 @@ struct TimelineView: View {
|
||||
}
|
||||
|
||||
var chaptersLayers: some View {
|
||||
ForEach(chapters.filter { $0.start != 0 }) { chapter in
|
||||
ForEach(chapters) { chapter in
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color("AppRedColor"))
|
||||
.fill(Color.orange)
|
||||
.frame(maxWidth: 2, maxHeight: height)
|
||||
.offset(x: (chapter.start * oneUnitWidth) - 1)
|
||||
}
|
||||
|
||||
@@ -433,12 +433,12 @@ struct PlaybackSettings: View {
|
||||
let captions = player.currentVideo?.captions ?? []
|
||||
Picker("Captions".localized(), selection: $player.captions) {
|
||||
if captions.isEmpty {
|
||||
Text("Not available").tag(Captions?.none)
|
||||
Text("Not available")
|
||||
} else {
|
||||
Text("Disabled").tag(Captions?.none)
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
ForEach(captions) { caption in
|
||||
Text(caption.description).tag(Optional(caption))
|
||||
}
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
|
||||
@@ -9,18 +9,13 @@ import SwiftUI
|
||||
var chapterIndex: Int
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
var showThumbnail: Bool
|
||||
|
||||
var isCurrentChapter: Bool {
|
||||
if let currentChapterIndex = player.currentChapterIndex {
|
||||
return currentChapterIndex == chapterIndex
|
||||
}
|
||||
return false
|
||||
player.currentChapterIndex == chapterIndex
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
}) {
|
||||
Group {
|
||||
verticalChapter
|
||||
@@ -32,7 +27,7 @@ import SwiftUI
|
||||
|
||||
var verticalChapter: some View {
|
||||
VStack(spacing: 12) {
|
||||
if !chapter.image.isNil, showThumbnail {
|
||||
if !chapter.image.isNil {
|
||||
smallImage(chapter)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -45,7 +40,7 @@ import SwiftUI
|
||||
.font(.system(.subheadline).monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: !chapter.image.isNil && showThumbnail ? Self.thumbnailWidth : nil, alignment: .leading)
|
||||
.frame(maxWidth: !chapter.image.isNil ? Self.thumbnailWidth : nil, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +72,7 @@ import SwiftUI
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
player.backend.seek(to: chapter.start, seekType: .chapterSkip(chapter.title))
|
||||
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||
} label: {
|
||||
Group {
|
||||
horizontalChapter
|
||||
@@ -131,7 +126,7 @@ struct ChapterView_Preview: PreviewProvider {
|
||||
ChapterViewTVOS(chapter: .init(title: "Chapter", start: 30))
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#else
|
||||
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0, showThumbnail: true)
|
||||
ChapterView(chapter: .init(title: "Chapter", start: 30), chapterIndex: 0)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@ import SwiftUI
|
||||
struct ChaptersView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
@Binding var expand: Bool
|
||||
let chaptersHaveImages: Bool
|
||||
let showThumbnails: Bool
|
||||
|
||||
var chapters: [Chapter] {
|
||||
player.videoForDisplay?.chapters ?? []
|
||||
}
|
||||
|
||||
var chaptersHaveImages: Bool {
|
||||
chapters.allSatisfy { $0.image != nil }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !chapters.isEmpty {
|
||||
if chaptersHaveImages, showThumbnails {
|
||||
if chaptersHaveImages {
|
||||
#if os(tvOS)
|
||||
List {
|
||||
Section {
|
||||
@@ -27,22 +29,7 @@ struct ChaptersView: View {
|
||||
.listStyle(.plain)
|
||||
#else
|
||||
ScrollView(.horizontal) {
|
||||
ScrollViewReader { scrollViewProxy in
|
||||
LazyHStack(spacing: 20) {
|
||||
chapterViews(for: chapters[...], scrollViewProxy: scrollViewProxy)
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.onAppear {
|
||||
if let currentChapterIndex = player.currentChapterIndex {
|
||||
scrollViewProxy.scrollTo(currentChapterIndex, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onChange(of: player.currentChapterIndex) { currentChapterIndex in
|
||||
if let index = currentChapterIndex {
|
||||
scrollViewProxy.scrollTo(index, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyHStack(spacing: 20) { chapterViews(for: chapters[...]) }.padding(.horizontal, 15)
|
||||
}
|
||||
#endif
|
||||
} else if expand {
|
||||
@@ -80,11 +67,10 @@ struct ChaptersView: View {
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true, scrollViewProxy: ScrollViewProxy? = nil) -> some View {
|
||||
private func chapterViews(for chaptersToShow: ArraySlice<Chapter>, opacity: Double = 1.0, clickable: Bool = true) -> some View {
|
||||
ForEach(Array(chaptersToShow.indices), id: \.self) { index in
|
||||
let chapter = chaptersToShow[index]
|
||||
ChapterView(chapter: chapter, chapterIndex: index, showThumbnail: showThumbnails)
|
||||
.id(index)
|
||||
ChapterView(chapter: chapter, chapterIndex: index)
|
||||
.opacity(index == 0 ? 1.0 : opacity)
|
||||
.allowsHitTesting(clickable)
|
||||
}
|
||||
@@ -94,7 +80,7 @@ struct ChaptersView: View {
|
||||
|
||||
struct ChaptersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChaptersView(expand: .constant(false), chaptersHaveImages: false, showThumbnails: true)
|
||||
ChaptersView(expand: .constant(false))
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ struct CommentView: View {
|
||||
}
|
||||
|
||||
private var authorAvatar: some View {
|
||||
WebImage(url: URL(string: comment.authorAvatarURL), options: [.lowPriority])
|
||||
WebImage(url: URL(string: comment.authorAvatarURL)!, options: [.lowPriority])
|
||||
.resizable()
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
|
||||
@@ -11,7 +11,6 @@ struct PlayerQueueView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showRelated) private var showRelated
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -20,7 +19,7 @@ struct PlayerQueueView: View {
|
||||
autoplaying
|
||||
}
|
||||
playingNext
|
||||
if sidebarQueue, showRelated {
|
||||
if sidebarQueue {
|
||||
related
|
||||
}
|
||||
}
|
||||
@@ -91,9 +90,10 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
|
||||
var queueHeader: some View {
|
||||
Text(sidebarQueue ? "Queue".localized() : "")
|
||||
Text("Queue".localized())
|
||||
#if !os(macOS)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -186,8 +186,6 @@ struct VideoDetails: View {
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showInspector) private var showInspector
|
||||
@Default(.showChapters) private var showChapters
|
||||
@Default(.showChapterThumbnails) private var showChapterThumbnails
|
||||
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showChapterThumbnailsOnlyWhenDifferent
|
||||
@Default(.showRelated) private var showRelated
|
||||
#if !os(tvOS)
|
||||
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
|
||||
@@ -289,63 +287,6 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
|
||||
func infoView(video: Video) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let description = video.description, !description.isEmpty {
|
||||
Section(header: descriptionHeader) {
|
||||
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if !video.isLocal {
|
||||
Text("No description")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil {
|
||||
if showChapters,
|
||||
!video.isLocal,
|
||||
!video.chapters.isEmpty
|
||||
{
|
||||
Section(header: chaptersHeader) {
|
||||
ChaptersView(expand: $chaptersExpanded, chaptersHaveImages: chaptersHaveImages, showThumbnails: showThumbnails)
|
||||
}
|
||||
}
|
||||
|
||||
if showInspector == .always || video.isLocal {
|
||||
InspectorView(video: player.videoForDisplay)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if showRelated,
|
||||
!sidebarQueue,
|
||||
!(player.videoForDisplay?.related.isEmpty ?? true)
|
||||
{
|
||||
RelatedView()
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if !pageAvailable(page) {
|
||||
page = .info
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(nil, value: player.currentItem)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
||||
#endif
|
||||
}
|
||||
|
||||
var pageView: some View {
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack {
|
||||
@@ -355,12 +296,69 @@ struct VideoDetails: View {
|
||||
|
||||
switch page {
|
||||
case .info:
|
||||
if let video = self.video {
|
||||
infoView(video: video)
|
||||
Group {
|
||||
if let video {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let description = video.description, !description.isEmpty {
|
||||
Section(header: descriptionHeader) {
|
||||
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if !video.isLocal {
|
||||
Text("No description")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if player.videoBeingOpened.isNil {
|
||||
if showChapters,
|
||||
!video.isLocal,
|
||||
!video.chapters.isEmpty
|
||||
{
|
||||
Section(header: chaptersHeader) {
|
||||
ChaptersView(expand: $chaptersExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
if showInspector == .always || video.isLocal {
|
||||
InspectorView(video: player.videoForDisplay)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
if showRelated,
|
||||
!sidebarQueue,
|
||||
!(player.videoForDisplay?.related.isEmpty ?? true)
|
||||
{
|
||||
RelatedView()
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if video != nil, !pageAvailable(page) {
|
||||
page = .info
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(nil, value: player.currentItem)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
||||
#endif
|
||||
|
||||
case .queue:
|
||||
PlayerQueueView(sidebarQueue: false)
|
||||
.padding(.horizontal)
|
||||
|
||||
case .comments:
|
||||
CommentsView()
|
||||
.onAppear {
|
||||
@@ -449,27 +447,9 @@ struct VideoDetails: View {
|
||||
player.videoForDisplay?.chapters.allSatisfy { $0.image != nil } ?? false
|
||||
}
|
||||
|
||||
var chapterImagesTheSame: Bool {
|
||||
guard let firstChapterURL = player.videoForDisplay?.chapters.first?.image else {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.videoForDisplay?.chapters.allSatisfy { $0.image == firstChapterURL } ?? false
|
||||
}
|
||||
|
||||
var showThumbnails: Bool {
|
||||
if !chaptersHaveImages || !showChapterThumbnails {
|
||||
return false
|
||||
}
|
||||
if showChapterThumbnailsOnlyWhenDifferent {
|
||||
return !chapterImagesTheSame
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var chaptersHeader: some View {
|
||||
Group {
|
||||
if !chaptersHaveImages || !showThumbnails {
|
||||
if !chaptersHaveImages {
|
||||
#if canImport(UIKit)
|
||||
Button(action: {
|
||||
chaptersExpanded.toggle()
|
||||
|
||||
@@ -153,7 +153,7 @@ struct AccountForm: View {
|
||||
return
|
||||
}
|
||||
|
||||
let account = AccountsModel.add(instance: instance, id: nil, name: name, username: username, password: password)
|
||||
let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
|
||||
selectedAccount?.wrappedValue = account
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
|
||||
@@ -5,7 +5,6 @@ struct AdvancedSettings: View {
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
@Default(.mpvCacheSecs) private var mpvCacheSecs
|
||||
@Default(.mpvCachePauseWait) private var mpvCachePauseWait
|
||||
@Default(.mpvDeinterlace) private var mpvDeinterlace
|
||||
@Default(.mpvEnableLogging) private var mpvEnableLogging
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
@Default(.feedCacheSize) private var feedCacheSize
|
||||
@@ -73,7 +72,7 @@ struct AdvancedSettings: View {
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
TextField("cache-secs", text: $mpvCacheSecs)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
@@ -83,13 +82,11 @@ struct AdvancedSettings: View {
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
TextField("cache-pause-wait", text: $mpvCachePauseWait)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
Toggle("deinterlace", isOn: $mpvDeinterlace)
|
||||
|
||||
if mpvEnableLogging {
|
||||
logButton
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ExportSettings: View {
|
||||
@ObservedObject private var model = ImportExportSettingsModel.shared
|
||||
@State private var presentingShareSheet = false
|
||||
@StateObject private var settings = SettingsModel.shared
|
||||
|
||||
private var filesToShare = [ImportExportSettingsModel.exportFile]
|
||||
@ObservedObject private var navigation = NavigationModel.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
VStack {
|
||||
list
|
||||
|
||||
importExportButtons
|
||||
}
|
||||
#else
|
||||
list
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
.sheet(
|
||||
isPresented: $presentingShareSheet,
|
||||
onDismiss: { self.model.isExportInProgress = false }
|
||||
) {
|
||||
ShareSheet(activityItems: filesToShare)
|
||||
.id("settings-share-\(filesToShare.count)")
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
exportButton
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.navigationTitle("Export Settings")
|
||||
}
|
||||
|
||||
var list: some View {
|
||||
List {
|
||||
exportView
|
||||
}
|
||||
.onAppear {
|
||||
model.reset()
|
||||
}
|
||||
}
|
||||
|
||||
var importExportButtons: some View {
|
||||
HStack {
|
||||
importButton
|
||||
|
||||
Spacer()
|
||||
|
||||
exportButton
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var importButton: some View {
|
||||
#if os(macOS)
|
||||
Button {
|
||||
navigation.presentingSettingsFileImporter = true
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct ExportGroupRow: View {
|
||||
let group: ImportExportSettingsModel.ExportGroup
|
||||
|
||||
@ObservedObject private var model = ImportExportSettingsModel.shared
|
||||
|
||||
var body: some View {
|
||||
Button(action: { model.toggleExportGroupSelection(group) }) {
|
||||
HStack {
|
||||
Text(group.label.localized())
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(isGroupInSelectedGroups ? 1 : 0)
|
||||
}
|
||||
.animation(nil, value: isGroupInSelectedGroups)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
var isGroupInSelectedGroups: Bool {
|
||||
model.selectedExportGroups.contains(group)
|
||||
}
|
||||
}
|
||||
|
||||
var exportView: some View {
|
||||
Group {
|
||||
Section(header: Text("Settings")) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.settingsGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Locations")) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.locationsGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
.disabled(!model.isGroupEnabled(group))
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Other"), footer: otherGroupsFooter) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.otherGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(model.isExportInProgress)
|
||||
}
|
||||
|
||||
var exportButton: some View {
|
||||
Button(action: exportSettings) {
|
||||
Text(model.isExportInProgress ? "In progress..." : "Export")
|
||||
.animation(nil, value: model.isExportInProgress)
|
||||
#if !os(macOS)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
}
|
||||
.disabled(!model.isExportAvailable)
|
||||
}
|
||||
|
||||
@ViewBuilder var otherGroupsFooter: some View {
|
||||
Text("Other data include last used playback preferences and listing options")
|
||||
}
|
||||
|
||||
func exportSettings() {
|
||||
let export = {
|
||||
model.isExportInProgress = true
|
||||
Delay.by(0.3) {
|
||||
model.exportAction()
|
||||
#if !os(macOS)
|
||||
self.presentingShareSheet = true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if model.isGroupSelected(.accountsUnencryptedPasswords) {
|
||||
settings.presentAlert(Alert(
|
||||
title: Text("Are you sure you want to export unencrypted passwords?"),
|
||||
message: Text("Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import"),
|
||||
primaryButton: .destructive(Text("Export"), action: export),
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
} else {
|
||||
export()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExportSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
ExportSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,6 @@ struct HistorySettings: View {
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveLastPlayed) private var saveLastPlayed
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showRecents) private var showRecents
|
||||
@Default(.limitRecents) private var limitRecents
|
||||
@Default(.limitRecentsAmount) private var limitRecentsAmount
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.watchedVideoStyle) private var watchedVideoStyle
|
||||
@@ -59,26 +56,6 @@ struct HistorySettings: View {
|
||||
Section(header: SettingsHeader(text: "History".localized())) {
|
||||
Toggle("Save history of searches, channels and playlists", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
Toggle("Show recents in sidebar", isOn: $showRecents)
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
.disabled(!showRecents)
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
#else
|
||||
Toggle("Limit recents shown", isOn: $limitRecents)
|
||||
.disabled(!showRecents)
|
||||
HStack {
|
||||
Text("Recents shown")
|
||||
Spacer()
|
||||
counterButtons(for: $limitRecentsAmount)
|
||||
.disabled(!limitRecents)
|
||||
}
|
||||
#endif
|
||||
Toggle("Show progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
Toggle("Keep last played video in the queue after restart", isOn: $saveLastPlayed)
|
||||
@@ -192,71 +169,6 @@ struct HistorySettings: View {
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func counterButtons(for _value: Binding<Int>) -> some View {
|
||||
var value: Binding<Int> {
|
||||
Binding(
|
||||
get: { return _value.wrappedValue },
|
||||
set: {
|
||||
if $0 < 1 {
|
||||
_value.wrappedValue = 1
|
||||
} else {
|
||||
_value.wrappedValue = $0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return HStack {
|
||||
#if !os(tvOS)
|
||||
Label("Minus", systemImage: "minus")
|
||||
.imageScale(.large)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(limitRecents ? .accentColor : .gray)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
#if os(iOS)
|
||||
.frame(minHeight: 35)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
value.wrappedValue -= 1
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
let textFieldWidth = 100.00
|
||||
#else
|
||||
let textFieldWidth = 30.00
|
||||
#endif
|
||||
|
||||
TextField("Duration", value: value, formatter: NumberFormatter())
|
||||
.frame(width: textFieldWidth, alignment: .trailing)
|
||||
.multilineTextAlignment(.center)
|
||||
.labelsHidden()
|
||||
.foregroundColor(limitRecents ? .accentColor : .gray)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
Label("Plus", systemImage: "plus")
|
||||
.imageScale(.large)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(limitRecents ? .accentColor : .gray)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
#if os(iOS)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
value.wrappedValue += 1
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HistorySettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -66,7 +66,6 @@ struct HomeSettings: View {
|
||||
.font(.system(size: 30))
|
||||
#endif
|
||||
}
|
||||
.help("Add to Favorites")
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettings: View {
|
||||
@State private var fileURL = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 100) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("1. Export settings from Yattee for iOS or macOS")
|
||||
Text("2. Upload it to a file hosting (e. g. Pastebin or GitHub Gist)")
|
||||
Text("3. Enter file URL in the field below. You can use iOS remote to paste.")
|
||||
}
|
||||
|
||||
TextField("URL", text: $fileURL)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: fileURL) {
|
||||
NavigationModel.shared.presentSettingsImportSheet(url)
|
||||
}
|
||||
} label: {
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.navigationTitle("Import Settings")
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImportSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettingsAccountRow: View {
|
||||
var account: Account
|
||||
var fileModel: ImportSettingsFileModel
|
||||
|
||||
@State private var password = ""
|
||||
|
||||
@State private var isValid = false
|
||||
@State private var isValidated = false
|
||||
@State private var isValidating = false
|
||||
@State private var validationError: String?
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@ObservedObject private var model = ImportSettingsSheetViewModel.shared
|
||||
|
||||
func afterValidation() {
|
||||
if isValid {
|
||||
model.importableAccounts.insert(account.id)
|
||||
model.selectedAccounts.insert(account.id)
|
||||
model.importableAccountsPasswords[account.id] = password
|
||||
} else {
|
||||
model.selectedAccounts.remove(account.id)
|
||||
model.importableAccounts.remove(account.id)
|
||||
model.importableAccountsPasswords.removeValue(forKey: account.id)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
row
|
||||
#else
|
||||
Button(action: { model.toggleAccount(account, accounts: accounts) }) {
|
||||
row
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
var row: some View {
|
||||
let accountExists = AccountsModel.shared.find(account.id) != nil
|
||||
|
||||
return VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(account.username)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(isChecked ? 1 : 0)
|
||||
}
|
||||
Text(account.instance?.description ?? "")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Group {
|
||||
if let instanceID = account.instanceID {
|
||||
if accountExists {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
Text("Account already exists")
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
if InstancesModel.shared.find(instanceID) != nil {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Custom Location already exists")
|
||||
}
|
||||
} else if model.selectedInstances.contains(instanceID) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Custom Location selected for import")
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Custom Location not selected for import")
|
||||
}
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 20)
|
||||
|
||||
if account.password.isNil || account.password!.isEmpty {
|
||||
Group {
|
||||
if password.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "key")
|
||||
Text("Password required to import")
|
||||
}
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
} else {
|
||||
AccountValidationStatus(
|
||||
app: .constant(instance.app),
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 20)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Password saved in import file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 2)
|
||||
|
||||
if !accountExists && (account.password.isNil || account.password!.isEmpty) {
|
||||
SecureField("Password", text: $password)
|
||||
.onChange(of: password) { _ in validate() }
|
||||
#if !os(tvOS)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onChange(of: isValid) { _ in afterValidation() }
|
||||
.animation(nil, value: isChecked)
|
||||
}
|
||||
|
||||
var isChecked: Bool {
|
||||
model.isSelectedForImport(account)
|
||||
}
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
fileModel.locationsSettingsGroupImporter
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
fileModel.locationsSettingsGroupImporter?.accounts ?? []
|
||||
}
|
||||
|
||||
private var instance: Instance! {
|
||||
(fileModel.locationsSettingsGroupImporter?.instances ?? []).first { $0.id == account.instanceID }
|
||||
}
|
||||
|
||||
private var validator: AccountValidator {
|
||||
AccountValidator(
|
||||
app: .constant(instance.app),
|
||||
url: instance.apiURLString,
|
||||
account: Account(instanceID: instance.id, urlString: instance.apiURLString, username: account.username, password: password),
|
||||
id: .constant(account.username),
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
|
||||
private func validate() {
|
||||
isValid = false
|
||||
validationDebounce.invalidate()
|
||||
|
||||
guard !account.username.isEmpty, !password.isEmpty else {
|
||||
validator.reset()
|
||||
return
|
||||
}
|
||||
|
||||
isValidating = true
|
||||
|
||||
validationDebounce.debouncing(1) {
|
||||
validator.validateAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportSettingsAccountRow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let fileModel = ImportSettingsFileModel()
|
||||
fileModel.loadData(URL(string: "https://gist.githubusercontent.com/arekf/578668969c9fdef1b3828bea864c3956/raw/f794a95a20261bcb1145e656c8dda00bea339e2a/yattee-recents.yatteesettings")!)
|
||||
|
||||
return List {
|
||||
ImportSettingsAccountRow(
|
||||
account: .init(name: "arekf", urlString: "https://instance.com", username: "arekf"),
|
||||
fileModel: fileModel
|
||||
)
|
||||
ImportSettingsAccountRow(
|
||||
account: .init(name: "arekf", urlString: "https://instance.com", username: "arekf", password: "a"),
|
||||
fileModel: fileModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettingsFileImporterViewModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.fileImporter(isPresented: $isPresented, allowedContentTypes: [.json]) { result in
|
||||
do {
|
||||
let selectedFile = try result.get()
|
||||
var urlToOpen: URL?
|
||||
|
||||
if let bookmarkURL = URLBookmarkModel.shared.loadBookmark(selectedFile) {
|
||||
urlToOpen = bookmarkURL
|
||||
}
|
||||
|
||||
if selectedFile.startAccessingSecurityScopedResource() {
|
||||
URLBookmarkModel.shared.saveBookmark(selectedFile)
|
||||
urlToOpen = selectedFile
|
||||
}
|
||||
|
||||
guard let urlToOpen else { return }
|
||||
NavigationModel.shared.presentSettingsImportSheet(urlToOpen, forceSettings: true)
|
||||
} catch {
|
||||
NavigationModel.shared.presentAlert(title: "Could not open Files")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettingsSheetView: View {
|
||||
@Binding var settingsFile: URL?
|
||||
@StateObject private var model = ImportSettingsSheetViewModel.shared
|
||||
@StateObject private var importExportModel = ImportExportSettingsModel.shared
|
||||
@StateObject private var fileModel = ImportSettingsFileModel.shared
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@State private var presentingCompletedAlert = false
|
||||
|
||||
private let accountsModel = AccountsModel.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
list
|
||||
.frame(width: 700, height: 800)
|
||||
#else
|
||||
NavigationView {
|
||||
list
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
guard let settingsFile else { return }
|
||||
fileModel.loadData(settingsFile)
|
||||
}
|
||||
.onChange(of: settingsFile) { _ in
|
||||
guard let settingsFile else { return }
|
||||
fileModel.loadData(settingsFile)
|
||||
}
|
||||
}
|
||||
|
||||
var list: some View {
|
||||
List {
|
||||
importGroupView
|
||||
|
||||
importOptions
|
||||
|
||||
metadata
|
||||
}
|
||||
.alert(isPresented: $presentingCompletedAlert) {
|
||||
completedAlert
|
||||
}
|
||||
#if os(iOS)
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
#endif
|
||||
.navigationTitle("Import Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: { presentationMode.wrappedValue.dismiss() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(action: {
|
||||
fileModel.performImport()
|
||||
presentingCompletedAlert = true
|
||||
ImportExportSettingsModel.shared.reset()
|
||||
}) {
|
||||
Text("Import")
|
||||
}
|
||||
.disabled(!canImport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var completedAlert: Alert {
|
||||
Alert(
|
||||
title: Text("Import Completed"),
|
||||
dismissButton: .default(Text("Close")) {
|
||||
if accountsModel.isEmpty,
|
||||
let account = InstancesModel.shared.all.first?.anonymousAccount
|
||||
{
|
||||
accountsModel.setCurrent(account)
|
||||
}
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var canImport: Bool {
|
||||
return !model.selectedAccounts.isEmpty || !model.selectedInstances.isEmpty || !importExportModel.selectedExportGroups.isEmpty
|
||||
}
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
fileModel.locationsSettingsGroupImporter
|
||||
}
|
||||
|
||||
struct ExportGroupRow: View {
|
||||
let group: ImportExportSettingsModel.ExportGroup
|
||||
|
||||
@ObservedObject private var model = ImportExportSettingsModel.shared
|
||||
|
||||
var body: some View {
|
||||
Button(action: { model.toggleExportGroupSelection(group) }) {
|
||||
HStack {
|
||||
Text(group.label.localized())
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(isChecked ? 1 : 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.foregroundColor(.primary)
|
||||
.animation(nil, value: isChecked)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var isChecked: Bool {
|
||||
model.selectedExportGroups.contains(group)
|
||||
}
|
||||
}
|
||||
|
||||
var importGroupView: some View {
|
||||
Group {
|
||||
Section(header: Text("Settings")) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.settingsGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
.disabled(!fileModel.isGroupIncludedInFile(group))
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Other")) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.otherGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
.disabled(!fileModel.isGroupIncludedInFile(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var metadata: some View {
|
||||
if let settingsFile {
|
||||
Section(header: Text("File information")) {
|
||||
MetadataRow(name: Text("Name"), value: Text(fileModel.filename(settingsFile)))
|
||||
|
||||
if let date = fileModel.metadataDate {
|
||||
MetadataRow(name: Text("Date"), value: Text(date))
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
}
|
||||
|
||||
if let build = fileModel.metadataBuild {
|
||||
MetadataRow(name: Text("Build"), value: Text(build))
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
}
|
||||
|
||||
if let platform = fileModel.metadataPlatform {
|
||||
MetadataRow(name: Text("Platform"), value: Text(platform))
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MetadataRow: View {
|
||||
let name: Text
|
||||
let value: Text
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
name
|
||||
.layoutPriority(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
value
|
||||
.layoutPriority(1)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var instances: [Instance] {
|
||||
locationsSettingsGroupImporter?.instances ?? []
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
locationsSettingsGroupImporter?.accounts ?? []
|
||||
}
|
||||
|
||||
struct ImportInstanceRow: View {
|
||||
var instance: Instance
|
||||
var accounts: [Account]
|
||||
|
||||
@ObservedObject private var model = ImportSettingsSheetViewModel.shared
|
||||
|
||||
var body: some View {
|
||||
Button(action: { model.toggleInstance(instance, accounts: accounts) }) {
|
||||
VStack {
|
||||
Group {
|
||||
HStack {
|
||||
Text(instance.description)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.opacity(isChecked ? 1 : 0)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
if model.isInstanceAlreadyAdded(instance) {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Custom Location already exists")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.foregroundColor(.primary)
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var isChecked: Bool {
|
||||
model.isImportable(instance) && model.selectedInstances.contains(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var importOptions: some View {
|
||||
if fileModel.isPublicInstancesSettingsGroupInFile || !instances.isEmpty {
|
||||
Section(header: Text("Locations")) {
|
||||
if fileModel.isPublicInstancesSettingsGroupInFile {
|
||||
ExportGroupRow(group: .locationsSettings)
|
||||
}
|
||||
|
||||
ForEach(instances) { instance in
|
||||
ImportInstanceRow(instance: instance, accounts: accounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !accounts.isEmpty {
|
||||
Section(header: Text("Accounts")) {
|
||||
ForEach(accounts) { account in
|
||||
ImportSettingsAccountRow(account: account, fileModel: fileModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportSettingsSheetView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImportSettingsSheetView(settingsFile: .constant(URL(string: "https://gist.githubusercontent.com/arekf/578668969c9fdef1b3828bea864c3956/raw/f794a95a20261bcb1145e656c8dda00bea339e2a/yattee-recents.yatteesettings")!))
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class ImportSettingsSheetViewModel: ObservableObject {
|
||||
static let shared = ImportSettingsSheetViewModel()
|
||||
|
||||
@Published var selectedInstances = Set<Instance.ID>()
|
||||
@Published var selectedAccounts = Set<Account.ID>()
|
||||
|
||||
@Published var importableAccounts = Set<Account.ID>()
|
||||
@Published var importableAccountsPasswords = [Account.ID: String]()
|
||||
|
||||
func toggleInstance(_ instance: Instance, accounts: [Account]) {
|
||||
if selectedInstances.contains(instance.id) {
|
||||
selectedInstances.remove(instance.id)
|
||||
} else {
|
||||
guard isImportable(instance) else { return }
|
||||
selectedInstances.insert(instance.id)
|
||||
}
|
||||
|
||||
removeNonImportableFromSelectedAccounts(accounts: accounts)
|
||||
}
|
||||
|
||||
func toggleAccount(_ account: Account, accounts: [Account]) {
|
||||
if selectedAccounts.contains(account.id) {
|
||||
selectedAccounts.remove(account.id)
|
||||
} else {
|
||||
guard isImportable(account.id, accounts: accounts) else { return }
|
||||
selectedAccounts.insert(account.id)
|
||||
}
|
||||
}
|
||||
|
||||
func isSelectedForImport(_ account: Account) -> Bool {
|
||||
importableAccounts.contains(account.id) && selectedAccounts.contains(account.id)
|
||||
}
|
||||
|
||||
func isImportable(_ accountID: Account.ID, accounts: [Account]) -> Bool {
|
||||
guard let account = accounts.first(where: { $0.id == accountID }),
|
||||
let instanceID = account.instanceID,
|
||||
AccountsModel.shared.find(accountID) == nil
|
||||
else { return false }
|
||||
|
||||
return ((account.password != nil && !account.password!.isEmpty) ||
|
||||
importableAccounts.contains(account.id)) && (
|
||||
(InstancesModel.shared.find(instanceID) != nil) ||
|
||||
selectedInstances.contains(instanceID)
|
||||
)
|
||||
}
|
||||
|
||||
func isImportable(_ instance: Instance) -> Bool {
|
||||
!isInstanceAlreadyAdded(instance)
|
||||
}
|
||||
|
||||
func isInstanceAlreadyAdded(_ instance: Instance) -> Bool {
|
||||
InstancesModel.shared.find(instance.id) != nil || InstancesModel.shared.findByURLString(instance.apiURLString) != nil
|
||||
}
|
||||
|
||||
func removeNonImportableFromSelectedAccounts(accounts: [Account]) {
|
||||
selectedAccounts = Set(selectedAccounts.filter { isImportable($0, accounts: accounts) })
|
||||
}
|
||||
|
||||
func reset() {
|
||||
selectedAccounts = []
|
||||
selectedInstances = []
|
||||
importableAccounts = []
|
||||
}
|
||||
|
||||
func reset(_ importer: LocationsSettingsGroupImporter? = nil) {
|
||||
reset()
|
||||
|
||||
guard let importer else { return }
|
||||
|
||||
selectedInstances = Set(importer.instances.filter { isImportable($0) }.map(\.id))
|
||||
importableAccounts = Set(importer.accounts.filter { isImportable($0.id, accounts: importer.accounts) }.map(\.id))
|
||||
selectedAccounts = importableAccounts
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
struct ImportSettingsSheetViewModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
@Binding var settingsFile: URL?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ImportSettingsSheetView(settingsFile: $settingsFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportSettingsSheetViewModifier_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Text("")
|
||||
.modifier(
|
||||
ImportSettingsSheetViewModifier(
|
||||
isPresented: .constant(true),
|
||||
settingsFile: .constant(URL(string: "https://gist.githubusercontent.com/arekf/87b4d6702755b01139431dcb809f9fdc/raw/7bb5cdba3ffc0c479f5260430ddc43c4a79a7a72/yattee-177-iPhone.yatteesettings")!)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,23 +9,9 @@ struct MultiselectRow: View {
|
||||
@State private var toggleChecked = false
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(disabled)
|
||||
#else
|
||||
#if os(macOS)
|
||||
Toggle(title, isOn: $toggleChecked)
|
||||
#if os(macOS)
|
||||
.toggleStyle(.checkbox)
|
||||
#endif
|
||||
.onAppear {
|
||||
guard !disabled else { return }
|
||||
toggleChecked = selected
|
||||
@@ -33,6 +19,24 @@ struct MultiselectRow: View {
|
||||
.onChange(of: toggleChecked) { new in
|
||||
action(new)
|
||||
}
|
||||
#else
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(disabled)
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ struct PlayerControlsSettings: View {
|
||||
@Default(.gestureBackwardSeekDuration) private var gestureBackwardSeekDuration
|
||||
@Default(.gestureForwardSeekDuration) private var gestureForwardSeekDuration
|
||||
@Default(.systemControlsSeekDuration) private var systemControlsSeekDuration
|
||||
@Default(.playerActionsButtonLabelStyle) private var playerActionsButtonLabelStyle
|
||||
@Default(.actionButtonShareEnabled) private var actionButtonShareEnabled
|
||||
@Default(.actionButtonSubscribeEnabled) private var actionButtonSubscribeEnabled
|
||||
@Default(.actionButtonCloseEnabled) private var actionButtonCloseEnabled
|
||||
@@ -118,15 +117,6 @@ struct PlayerControlsSettings: View {
|
||||
Section(header: SettingsHeader(text: "Actions Buttons".localized())) {
|
||||
actionButtonToggles
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Action button labels", selection: $playerActionsButtonLabelStyle) {
|
||||
ForEach(ButtonLabelStyle.allCases, id: \.rawValue) { style in
|
||||
Text(style.description).tag(style)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private var systemControlsCommandsPicker: some View {
|
||||
|
||||
@@ -32,8 +32,6 @@ struct PlayerSettings: View {
|
||||
|
||||
@Default(.showInspector) private var showInspector
|
||||
@Default(.showChapters) private var showChapters
|
||||
@Default(.showChapterThumbnails) private var showThumbnails
|
||||
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showThumbnailsOnlyWhenDifferent
|
||||
@Default(.expandChapters) private var expandChapters
|
||||
@Default(.showRelated) private var showRelated
|
||||
|
||||
@@ -82,6 +80,8 @@ struct PlayerSettings: View {
|
||||
Section(header: SettingsHeader(text: "Info".localized())) {
|
||||
expandVideoDescriptionToggle
|
||||
collapsedLineDescriptionStepper
|
||||
showChaptersToggle
|
||||
expandChaptersToggle
|
||||
showRelatedToggle
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
@@ -93,13 +93,6 @@ struct PlayerSettings: View {
|
||||
inspectorVisibilityPicker
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Chapters".localized())) {
|
||||
showChaptersToggle
|
||||
showThumbnailsToggle
|
||||
showThumbnailsWhenDifferentToggle
|
||||
expandChaptersToggle
|
||||
}
|
||||
#endif
|
||||
|
||||
let interface = Section(header: SettingsHeader(text: "Interface".localized())) {
|
||||
@@ -291,19 +284,7 @@ struct PlayerSettings: View {
|
||||
}
|
||||
|
||||
private var showChaptersToggle: some View {
|
||||
Toggle("Show chapters", isOn: $showChapters)
|
||||
}
|
||||
|
||||
private var showThumbnailsToggle: some View {
|
||||
Toggle("Show thumbnails", isOn: $showThumbnails)
|
||||
.disabled(!showChapters)
|
||||
.foregroundColor(showChapters ? .primary : .secondary)
|
||||
}
|
||||
|
||||
private var showThumbnailsWhenDifferentToggle: some View {
|
||||
Toggle("Show thumbnails only when unique", isOn: $showThumbnailsOnlyWhenDifferent)
|
||||
.disabled(!showChapters || !showThumbnails)
|
||||
.foregroundColor(showChapters && showThumbnails ? .primary : .secondary)
|
||||
Toggle("Chapters (if available)", isOn: $showChapters)
|
||||
}
|
||||
|
||||
private var expandChaptersToggle: some View {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct FormatState: Equatable {
|
||||
let format: QualityProfile.Format
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
struct QualityProfileForm: View {
|
||||
@Binding var qualityProfileID: QualityProfile.ID?
|
||||
|
||||
@@ -20,7 +15,6 @@ struct QualityProfileForm: View {
|
||||
@State private var backend = PlayerBackendType.mpv
|
||||
@State private var resolution = ResolutionSetting.hd1080p60
|
||||
@State private var formats = [QualityProfile.Format]()
|
||||
@State private var orderedFormats: [FormatState] = []
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfiles
|
||||
|
||||
@@ -32,7 +26,6 @@ struct QualityProfileForm: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
// swiftlint:disable trailing_closure
|
||||
var body: some View {
|
||||
VStack {
|
||||
Group {
|
||||
@@ -47,11 +40,8 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
|
||||
.onAppear(perform: initializeForm)
|
||||
.onChange(of: backend, perform: { _ in backendChanged(self.backend); updateActiveFormats(); validate() })
|
||||
|
||||
.onChange(of: name, perform: { _ in validate() })
|
||||
.onChange(of: resolution, perform: { _ in validate() })
|
||||
.onChange(of: orderedFormats, perform: { _ in validate() })
|
||||
.onChange(of: backend, perform: backendChanged)
|
||||
.onChange(of: formats) { _ in validate() }
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
@@ -63,8 +53,6 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// swiftlint:enable trailing_closure
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
|
||||
@@ -136,20 +124,9 @@ 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)
|
||||
Text("**Note:** HLS is an adaptive format where specific resolution settings don't apply.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top)
|
||||
Text("Yattee attempts to match the quality that is closest to the set resolution, but exact results cannot be guaranteed.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 0.1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
@ViewBuilder var qualityPicker: some View {
|
||||
@@ -222,25 +199,17 @@ struct QualityProfileForm: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var filteredFormatList: some View {
|
||||
ForEach(Array(orderedFormats.enumerated()), id: \.element.format) { idx, element in
|
||||
let format = element.format
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: element.isActive
|
||||
) { value in
|
||||
orderedFormats[idx].isActive = value
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
orderedFormats.move(fromOffsets: source, toOffset: destination)
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var formatsPicker: some View {
|
||||
#if os(macOS)
|
||||
let list = filteredFormatList
|
||||
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
@@ -253,19 +222,28 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
filteredFormatList
|
||||
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
|
||||
return orderedFormats.first { $0.format == format }?.isActive ?? false
|
||||
(initialized || qualityProfile.isNil ? formats : qualityProfile.formats).contains(format)
|
||||
}
|
||||
|
||||
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
|
||||
if let index = orderedFormats.firstIndex(where: { $0.format == format }) {
|
||||
orderedFormats[index].isActive = value
|
||||
if let index = formats.firstIndex(where: { $0 == format }), !value {
|
||||
formats.remove(at: index)
|
||||
} else if value {
|
||||
formats.append(format)
|
||||
}
|
||||
validate() // Check validity after a toggle operation
|
||||
}
|
||||
|
||||
var footer: some View {
|
||||
@@ -296,52 +274,34 @@ struct QualityProfileForm: View {
|
||||
return !avPlayerFormats.contains(format)
|
||||
}
|
||||
|
||||
func updateActiveFormats() {
|
||||
for (index, format) in orderedFormats.enumerated() where isFormatDisabled(format.format) {
|
||||
orderedFormats[index].isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
||||
guard backend == .appleAVPlayer else { return false }
|
||||
|
||||
return resolution.value > .hd1080p60
|
||||
return resolution.value > .hd720p30
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
if editing {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.orderedFormats = qualityProfile.order.map { order in
|
||||
let format = QualityProfile.Format.allCases[order]
|
||||
let isActive = qualityProfile.formats.contains(format)
|
||||
return FormatState(format: format, isActive: isActive)
|
||||
}
|
||||
self.initialized = true
|
||||
}
|
||||
} else {
|
||||
name = ""
|
||||
backend = .mpv
|
||||
resolution = .hd720p60
|
||||
orderedFormats = QualityProfile.Format.allCases.map {
|
||||
FormatState(format: $0, isActive: true)
|
||||
}
|
||||
initialized = true
|
||||
guard editing else {
|
||||
validate()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.formats = .init(qualityProfile.formats)
|
||||
self.initialized = true
|
||||
}
|
||||
|
||||
validate()
|
||||
}
|
||||
|
||||
func backendChanged(_: PlayerBackendType) {
|
||||
let defaultFormats = QualityProfile.Format.allCases.map {
|
||||
FormatState(format: $0, isActive: true)
|
||||
}
|
||||
|
||||
if backend == .appleAVPlayer {
|
||||
orderedFormats = orderedFormats.filter { !isFormatDisabled($0.format) }
|
||||
} else {
|
||||
orderedFormats = defaultFormats
|
||||
formats.filter { isFormatDisabled($0) }.forEach { format in
|
||||
if let index = formats.firstIndex(where: { $0 == format }) {
|
||||
formats.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
if isResolutionDisabled(resolution),
|
||||
@@ -352,33 +312,20 @@ struct QualityProfileForm: View {
|
||||
}
|
||||
|
||||
func validate() {
|
||||
if !initialized {
|
||||
valid = false
|
||||
} else if editing {
|
||||
let savedOrderFormats = qualityProfile.order.map { order in
|
||||
let format = QualityProfile.Format.allCases[order]
|
||||
let isActive = qualityProfile.formats.contains(format)
|
||||
return FormatState(format: format, isActive: isActive)
|
||||
}
|
||||
valid = name != qualityProfile.name
|
||||
|| backend != qualityProfile.backend
|
||||
|| resolution != qualityProfile.resolution
|
||||
|| orderedFormats != savedOrderFormats
|
||||
} else { valid = true }
|
||||
valid = !formats.isEmpty
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else { return }
|
||||
|
||||
let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format }
|
||||
formats = formats.unique()
|
||||
|
||||
let formProfile = QualityProfile(
|
||||
id: qualityProfile?.id ?? UUID().uuidString,
|
||||
name: name,
|
||||
backend: backend,
|
||||
resolution: resolution,
|
||||
formats: activeFormats,
|
||||
order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! }
|
||||
formats: Array(formats)
|
||||
)
|
||||
|
||||
if editing {
|
||||
|
||||
@@ -7,7 +7,7 @@ struct SettingsView: View {
|
||||
|
||||
#if os(macOS)
|
||||
private enum Tabs: Hashable {
|
||||
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, importExport, help
|
||||
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, help
|
||||
}
|
||||
|
||||
@State private var selection: Tabs = .browsing
|
||||
@@ -24,22 +24,13 @@ struct SettingsView: View {
|
||||
|
||||
@Default(.instances) private var instances
|
||||
|
||||
@State private var filesToShare = []
|
||||
|
||||
@ObservedObject private var navigation = NavigationModel.shared
|
||||
@ObservedObject private var settingsModel = SettingsModel.shared
|
||||
|
||||
var body: some View {
|
||||
settings
|
||||
.modifier(ImportSettingsSheetViewModifier(isPresented: $settingsModel.presentingSettingsImportSheet, settingsFile: $settingsModel.settingsImportURL))
|
||||
#if !os(tvOS)
|
||||
.modifier(ImportSettingsFileImporterViewModifier(isPresented: $navigation.presentingSettingsFileImporter))
|
||||
#endif
|
||||
.alert(isPresented: $model.presentingAlert) { model.alert }
|
||||
#if os(iOS)
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
#endif
|
||||
.alert(isPresented: $model.presentingAlert) { model.alert }
|
||||
}
|
||||
|
||||
var settings: some View {
|
||||
@@ -110,14 +101,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.tag(Tabs.advanced)
|
||||
|
||||
Group {
|
||||
ExportSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.tag(Tabs.importExport)
|
||||
|
||||
Form {
|
||||
Help()
|
||||
}
|
||||
@@ -127,7 +110,7 @@ struct SettingsView: View {
|
||||
.tag(Tabs.help)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 700, height: windowHeight)
|
||||
.frame(width: 650, height: windowHeight)
|
||||
#else
|
||||
NavigationView {
|
||||
settingsList
|
||||
@@ -223,8 +206,6 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 20)
|
||||
#endif
|
||||
|
||||
importView
|
||||
|
||||
Section(footer: helpFooter) {
|
||||
NavigationLink {
|
||||
Help()
|
||||
@@ -279,43 +260,13 @@ struct SettingsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
var importView: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
NavigationLink(destination: LazyView(ImportSettings())) {
|
||||
Label("Import Settings", systemImage: "square.and.arrow.down")
|
||||
.labelStyle(SettingsLabel())
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
#else
|
||||
Button(action: importSettings) {
|
||||
Label("Import Settings...", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink(destination: LazyView(ExportSettings())) {
|
||||
Label("Export Settings", systemImage: "square.and.arrow.up")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func importSettings() {
|
||||
navigation.presentingSettingsFileImporter = true
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var windowHeight: Double {
|
||||
switch selection {
|
||||
case .browsing:
|
||||
return 800
|
||||
case .player:
|
||||
return 550
|
||||
return 500
|
||||
case .controls:
|
||||
return 920
|
||||
case .quality:
|
||||
@@ -327,9 +278,7 @@ struct SettingsView: View {
|
||||
case .locations:
|
||||
return 600
|
||||
case .advanced:
|
||||
return 500
|
||||
case .importExport:
|
||||
return 580
|
||||
return 380
|
||||
case .help:
|
||||
return 650
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SponsorBlockSettings: View {
|
||||
@ObservedObject private var settings = SettingsModel.shared
|
||||
|
||||
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
|
||||
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
|
||||
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
||||
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
|
||||
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
|
||||
@Default(.sponsorBlockShowNoticeAfterSkip) private var showNoticeAfterSkip
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
@@ -41,70 +35,41 @@ struct SponsorBlockSettings: View {
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: Text("Playback")) {
|
||||
Toggle("Categories in timeline", isOn: $showCategoriesInTimeline)
|
||||
Toggle("Post-skip notice", isOn: $showNoticeAfterSkip)
|
||||
Toggle("Adjusted total time", isOn: $showTimeWithSkipsRemoved)
|
||||
}
|
||||
Section(header: SettingsHeader(text: "Categories to Skip".localized()), footer: categoriesDetails) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Categories to Skip".localized())) {
|
||||
categoryRows
|
||||
}
|
||||
colorSection
|
||||
|
||||
Button {
|
||||
settings.presentAlert(
|
||||
Alert(
|
||||
title: Text("Restore Default Colors?"),
|
||||
message: Text("This action will reset all custom colors back to their original defaults. " +
|
||||
"Any custom color changes you've made will be lost."),
|
||||
primaryButton: .destructive(Text("Restore")) {
|
||||
resetColors()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Restore Default Colors …")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section(footer: categoriesDetails) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var colorSection: some View {
|
||||
Section(header: SettingsHeader(text: "Colors for Categories")) {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
LazyVStack(alignment: .leading) {
|
||||
ColorPicker(
|
||||
SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selection: Binding(
|
||||
get: { getColor(for: category) },
|
||||
set: { setColor($0, for: category) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var categoryRows: some View {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
LazyVStack(alignment: .leading) {
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
MultiselectRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
) { value in
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,17 +79,17 @@ struct SponsorBlockSettings: View {
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
Text(SponsorBlockAPI.categoryDescription(category) ?? "Category")
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 0.5)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
Text(SponsorBlockAPI.categoryDetails(category) ?? "Details")
|
||||
.padding(.bottom, 10)
|
||||
.padding(.bottom, 3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
@@ -134,40 +99,6 @@ struct SponsorBlockSettings: View {
|
||||
sponsorBlockCategories.insert(category)
|
||||
}
|
||||
}
|
||||
|
||||
private func getColor(for category: String) -> Color {
|
||||
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
||||
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgbValue & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
return Color("AppRedColor") // Fallback color if no match found
|
||||
}
|
||||
|
||||
private func setColor(_ color: Color, for category: String) {
|
||||
let uiColor = UIColor(color)
|
||||
|
||||
// swiftlint:disable no_cgfloat
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
// swiftlint:enable no_cgfloat
|
||||
|
||||
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
let r = Int(red * 255.0)
|
||||
let g = Int(green * 255.0)
|
||||
let b = Int(blue * 255.0)
|
||||
|
||||
let rgbValue = (r << 16) | (g << 8) | b
|
||||
sponsorBlockColors[category] = String(format: "#%06x", rgbValue)
|
||||
}
|
||||
|
||||
private func resetColors() {
|
||||
sponsorBlockColors = SponsorBlockColors.dictionary
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -113,7 +113,6 @@ struct SubscriptionsView: View {
|
||||
} label: {
|
||||
Label("Play all unwatched", systemImage: "play")
|
||||
}
|
||||
.help("Play all unwatched")
|
||||
.disabled(!feed.canPlayUnwatchedFeed)
|
||||
}
|
||||
|
||||
@@ -131,7 +130,6 @@ struct SubscriptionsView: View {
|
||||
} label: {
|
||||
Label("Mark all as watched", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.help("Mark all as watched")
|
||||
.disabled(!feed.canMarkAllFeedAsWatched)
|
||||
}
|
||||
|
||||
@@ -141,7 +139,6 @@ struct SubscriptionsView: View {
|
||||
} label: {
|
||||
Label("Mark all as unwatched", systemImage: "checkmark.circle")
|
||||
}
|
||||
.help("Mark all as unwatched")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ struct ControlsBar: View {
|
||||
@State private var shareURL: URL?
|
||||
@Binding var expansionState: ExpansionState
|
||||
|
||||
@State var gestureThrottle = Throttle(interval: 0.25) // swiftlint:disable:this private_swiftui_state
|
||||
@State var gestureThrottle = Throttle(interval: 0.25) // swiftlint:disable:this swiftui_state_private
|
||||
|
||||
var presentingControls = true
|
||||
var backgroundEnabled = true
|
||||
|
||||
@@ -37,7 +37,6 @@ struct FavoriteButton: View {
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
}
|
||||
.help(isFavorite ? "Remove from Favorites" : "Add to Favorites")
|
||||
.disabled(item.isNil)
|
||||
.onAppear {
|
||||
isFavorite = item.isNil ? false : favorites.contains(item)
|
||||
|
||||
@@ -11,7 +11,6 @@ struct HomeSettingsButton: View {
|
||||
}
|
||||
.font(.caption)
|
||||
.imageScale(.small)
|
||||
.help("Home Settings")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ struct ListingStyleButtons: View {
|
||||
.imageScale(.small)
|
||||
#endif
|
||||
}
|
||||
.help(listingStyle == .cells ? "List" : "Cells")
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ struct ShareButton<LabelView: View>: View {
|
||||
label
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.help("Share")
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 60)
|
||||
#endif
|
||||
@@ -77,7 +76,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
|
||||
private var youtubeActions: some View {
|
||||
Group {
|
||||
if let url = accounts.api.shareURL(contentItem, frontendURL: "https://www.youtube.com") {
|
||||
if let url = accounts.api.shareURL(contentItem, frontendHost: "www.youtube.com") {
|
||||
Button(labelForShareURL("YouTube")) {
|
||||
shareAction(url)
|
||||
}
|
||||
@@ -87,7 +86,7 @@ struct ShareButton<LabelView: View>: View {
|
||||
shareAction(
|
||||
accounts.api.shareURL(
|
||||
contentItem,
|
||||
frontendURL: "https://www.youtube.com",
|
||||
frontendHost: "www.youtube.com",
|
||||
time: player.backend.currentTime
|
||||
)!
|
||||
)
|
||||
|
||||
@@ -21,14 +21,6 @@ struct YatteeApp: App {
|
||||
}
|
||||
|
||||
static var logsDirectory: URL {
|
||||
temporaryDirectory
|
||||
}
|
||||
|
||||
static var settingsExportDirectory: URL {
|
||||
temporaryDirectory
|
||||
}
|
||||
|
||||
private static var temporaryDirectory: URL {
|
||||
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
}
|
||||
|
||||
@@ -153,7 +145,7 @@ struct YatteeApp: App {
|
||||
#if DEBUG
|
||||
SiestaLog.Category.enabled = .common
|
||||
#endif
|
||||
SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared)
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
|
||||
if !Defaults[.lastAccountIsPublic] {
|
||||
@@ -204,7 +196,6 @@ struct YatteeApp: App {
|
||||
URLBookmarkModel.shared.refreshAll()
|
||||
|
||||
migrateHomeHistoryItems()
|
||||
migrateQualityProfiles()
|
||||
}
|
||||
|
||||
func migrateHomeHistoryItems() {
|
||||
@@ -222,16 +213,6 @@ struct YatteeApp: App {
|
||||
Defaults[.homeHistoryItems] = -1
|
||||
}
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfilesData
|
||||
|
||||
func migrateQualityProfiles() {
|
||||
for profile in qualityProfilesData where profile.order.isEmpty {
|
||||
var updatedProfile = profile
|
||||
updatedProfile.order = Array(QualityProfile.Format.allCases.indices)
|
||||
QualityProfilesModel.shared.update(profile, updatedProfile)
|
||||
}
|
||||
}
|
||||
|
||||
var navigationStyle: NavigationStyle {
|
||||
#if os(iOS)
|
||||
return horizontalSizeClass == .compact ? .tab : .sidebar
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"Add Account" = "إضافة حساب";
|
||||
"Add Account..." = "إضافة حساب…";
|
||||
"Add Location" = "إضافة موقع";
|
||||
"Add Location..." = "أضِف موقع..";
|
||||
"Add Location..." = "إضافة موقع...";
|
||||
"%@ Playlist" = "قائمة تشغيل %@";
|
||||
"%@ Channel" = "قناة %@";
|
||||
"%@ subscribers" = "مشتركين %@";
|
||||
@@ -307,7 +307,7 @@
|
||||
"Hardware decoder" = "وحدة فك ترميز الأجهزة";
|
||||
"Rate & Captions" = "معدل سرعة التشغيل و الترجمة";
|
||||
"Dropped frames" = "الإطارات المتساقطة";
|
||||
"Stream FPS" = "عدد الإطارات في الثانية في البث";
|
||||
"Stream FPS" = "عدد الإطارات فى الثانية فى البث";
|
||||
"Any format" = "أي شكل";
|
||||
"%@ formats" = "تنسيقات %@";
|
||||
"Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"" = "قائمة تشغيل فارغة\n\nالضغط مع الإستمرار على مقطع الفيديو ثم\n\"إضافة إلى قائمة تشغيل\"";
|
||||
@@ -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" = "حجم ملء الشاشة";
|
||||
@@ -435,7 +435,7 @@
|
||||
|
||||
/* Video date filter in search */
|
||||
"Today" = "اليوم";
|
||||
"Trending" = "المحتوى الرائج";
|
||||
"Trending" = "محتوى رائج";
|
||||
"Typically near or at the end of the video when the credits pop up and/or endcards are shown." = "عادةً ما تكون بالقرب من نهاية الفيديو عند ظهور قائمة الأسماء و / أو ظهور بطاقات النهاية.";
|
||||
"Unsubscribe" = "إلغاء الإشتراك";
|
||||
|
||||
@@ -604,28 +604,3 @@
|
||||
"Podcasts" = "بودكاست";
|
||||
"Releases" = "الإصدارات";
|
||||
"Add %@" = "إضافة %@";
|
||||
"Import Settings..." = "إستيراد الإعدادات...";
|
||||
"Accounts passwords (unencrypted)" = "كلمات مرور الحسابات (غير مشفرة)";
|
||||
"Other data" = "بيانات أخرى";
|
||||
"Export..." = "تصدير…";
|
||||
"Export" = "تصدير";
|
||||
"File information" = "معلومات الملف";
|
||||
"Build" = "بناء";
|
||||
"Platform" = "المنصة";
|
||||
"Import" = "إستيراد";
|
||||
"Action button labels" = "تسميات زر الإجراء";
|
||||
"Icon only" = "أيقونة فقط";
|
||||
"Icon and text" = "أيقونة و نص";
|
||||
"Custom Location not selected for import" = "لم يتم تحديد الموقع المخصّص للإستيراد";
|
||||
"Account already exists" = "الحساب موجود بالفعل";
|
||||
"Export Settings" = "تصدير الإعدادات";
|
||||
"Other" = "أخرى";
|
||||
"Other data include last used playback preferences and listing options" = "بيانات أخرى تتضمن آخر تفضيلات التشغيل المستخدمة وخيارات القائمة";
|
||||
"Are you sure you want to export unencrypted passwords?" = "هل أنت متأكد من أنك تريد تصدير كلمات المرور غير المشفرة؟";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "لا تشارك هذا الملف مع أي شخص وإلا قد تفقد إمكانية الوصول إلى حساباتك. إذا لم تحدّد تصدير كلمات المرور، فسوف يُطلب منك تقديمها أثناء الإستيراد";
|
||||
"Custom Location selected for import" = "حدّد الموقع المخصّص للإستيراد";
|
||||
"Custom Location already exists" = "الموقع المخصّص موجود بالفعل";
|
||||
"Password required to import" = "كلمة المرور مطلوبة للإستيراد";
|
||||
"Password saved in import file" = "كلمة المرور محفوظة في ملف الإستيراد";
|
||||
"Export in progress..." = "جارِ التصدير...";
|
||||
"In progress..." = "في تَقَدم…";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"Add Account" = "Add Account";
|
||||
"Add Account..." = "Add Account...";
|
||||
"Add Location" = "Add Location";
|
||||
"Add Location..." = "Add Location..";
|
||||
"Add Location..." = "Add Location...";
|
||||
"Add profile..." = "Add profile...";
|
||||
"Add Quality Profile" = "Add Quality Profile";
|
||||
"Add to %@" = "Add to %@";
|
||||
@@ -602,28 +602,3 @@
|
||||
"No preview" = "No preview";
|
||||
"Open vertical chapters expanded" = "Open vertical chapters expanded";
|
||||
"Chapters (if available)" = "Chapters (if available)";
|
||||
"Import Settings..." = "Import Settings...";
|
||||
"Export Settings" = "Export Settings";
|
||||
"Accounts passwords (unencrypted)" = "Accounts passwords (unencrypted)";
|
||||
"Other" = "Other";
|
||||
"Other data" = "Other data";
|
||||
"Export..." = "Export…";
|
||||
"Other data include last used playback preferences and listing options" = "Other data include last used playback preferences and listing options";
|
||||
"Are you sure you want to export unencrypted passwords?" = "Are you sure you want to export unencrypted passwords?";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import";
|
||||
"Icon only" = "Icon only";
|
||||
"Export" = "Export";
|
||||
"File information" = "File information";
|
||||
"Build" = "Build";
|
||||
"Import" = "Import";
|
||||
"Platform" = "Platform";
|
||||
"Action button labels" = "Action button labels";
|
||||
"Icon and text" = "Icon and text";
|
||||
"Password required to import" = "Password required to import";
|
||||
"Custom Location already exists" = "Custom Location already exists";
|
||||
"Custom Location selected for import" = "Custom Location selected for import";
|
||||
"Custom Location not selected for import" = "Custom Location not selected for import";
|
||||
"Account already exists" = "Account already exists";
|
||||
"Password saved in import file" = "Password saved in import file";
|
||||
"Export in progress..." = "Export in progress...";
|
||||
"In progress..." = "In progress…";
|
||||
|
||||
@@ -112,12 +112,12 @@
|
||||
"Help" = "Ayuda";
|
||||
"Hide sidebar" = "Ocultar barra lateral";
|
||||
"Add Location" = "Añadir ubicación";
|
||||
"Add Location..." = "Añadir ubicación..";
|
||||
"Add Location..." = "Añadir ubicación...";
|
||||
"Decrease rate" = "Tasa de disminución";
|
||||
"Decreased opacity" = "Opacidad disminuida";
|
||||
"High" = "Alto";
|
||||
"%lld videos" = "%lld videos";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Recordatorios explícitos para 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)." = "Recordatorios explícitos para marquen \"me gusta\", se suscriban o interactúen con ellos en cualquier plataforma de pago o gratuita (por ejemplo, haciendo clic en un vídeo).";
|
||||
"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";
|
||||
@@ -604,28 +604,3 @@
|
||||
"No preview" = "Sin vista previa";
|
||||
"Open vertical chapters expanded" = "Abrir capítulos verticales ampliados";
|
||||
"Chapters (if available)" = "Capítulos (si están disponibles)";
|
||||
"Password required to import" = "Se requiere contraseña para importar";
|
||||
"Export Settings" = "Ajustes de exportación";
|
||||
"Other" = "Otro";
|
||||
"Other data" = "Información adicional";
|
||||
"Export..." = "Exportar…";
|
||||
"Are you sure you want to export unencrypted passwords?" = "¿Estás seguro de que quieres exportar las contraseñas sin cifrar?";
|
||||
"Export" = "Exportar";
|
||||
"Build" = "Compilación";
|
||||
"Platform" = "Plataforma";
|
||||
"Import" = "Importar";
|
||||
"Action button labels" = "Etiquetas para los botones de acción";
|
||||
"Icon only" = "Solo icono";
|
||||
"Icon and text" = "Icono y texto";
|
||||
"Custom Location already exists" = "Ya existe una ubicación personalizada";
|
||||
"Custom Location selected for import" = "Ubicación personalizada seleccionada para la importación";
|
||||
"Custom Location not selected for import" = "Ubicación personalizada no seleccionada para la importación";
|
||||
"Password saved in import file" = "Contraseña guardada en el archivo de importación";
|
||||
"Export in progress..." = "Exportación en curso...";
|
||||
"In progress..." = "En proceso…";
|
||||
"Import Settings..." = "Importar configuración...";
|
||||
"Accounts passwords (unencrypted)" = "Contraseñas de las cuentas (no cifradas)";
|
||||
"Other data include last used playback preferences and listing options" = "Información adicional incluye las últimas preferencias de reproducción utilizadas y las opciones de listado";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "No compartas este archivo con nadie o puedes perder el acceso a tus cuentas. Si no selecciona exportar contraseñas se le pedirá que las proporcione durante la importación";
|
||||
"File information" = "Información del archivo";
|
||||
"Account already exists" = "La cuenta ya existe";
|
||||
|
||||
@@ -483,7 +483,3 @@
|
||||
"Not Playing" = "پخش نمیشود";
|
||||
"Video Details" = "جزییات ویدیو";
|
||||
"Live Streams" = "پخش زنده";
|
||||
"Backend" = "Backend";
|
||||
"Bugs and great feature ideas can be sent to the GitHub issues tracker. " = "ایرادها و پیشنهادی خوب برای امکانات را میتوانید به GitHub issues tracker بفرستید. ";
|
||||
"Copy %@ link" = "پیوند %@ را کپی کنید";
|
||||
"Copy %@ link with time" = "پیوند %@ با مهرزمان کپی کنید";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
" subscribers" = " abonnés";
|
||||
"Add Location..." = "Ajouter une instance..";
|
||||
"Add Location..." = "Ajouter une instance…";
|
||||
"Add profile..." = "Ajouter un profil…";
|
||||
"Add Quality Profile" = "Ajouter un profil de qualité";
|
||||
"Delete" = "Supprimer";
|
||||
@@ -264,7 +264,7 @@
|
||||
"Don't use public locations" = "Ne pas utiliser d'instances publiques";
|
||||
"Enable Return YouTube Dislike" = "Activer Return YouTube Dislike";
|
||||
"Enter fullscreen in landscape" = "Entrer en plein écran en mode paysage";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Rappels d'aimer la vidéo, de s'abonner ou d'interagir avec le créateur sur une plateforme gratuite ou payante.\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";
|
||||
@@ -604,28 +604,3 @@
|
||||
"No preview" = "Aucun aperçu";
|
||||
"Open vertical chapters expanded" = "Ouvrir les chapitres verticaux étendus";
|
||||
"Chapters (if available)" = "Chapitres (si disponibles)";
|
||||
"Accounts passwords (unencrypted)" = "Mots de passe des comptes (non chiffrés)";
|
||||
"Export..." = "Exporter…";
|
||||
"Export" = "Exporter";
|
||||
"Build" = "Build";
|
||||
"Import" = "Importer";
|
||||
"Action button labels" = "Textes des boutons d'action";
|
||||
"File information" = "Informations sur le fichier";
|
||||
"Export Settings" = "Paramètres d'exportation";
|
||||
"Import Settings..." = "Importer des paramètres...";
|
||||
"Other" = "Autres";
|
||||
"Other data" = "Autres données";
|
||||
"Other data include last used playback preferences and listing options" = "Les autres données incluent les dernières préférences de lecture et de liste utilisées";
|
||||
"Are you sure you want to export unencrypted passwords?" = "Êtes-vous sûr de vouloir exporter les mots de passe non chiffrés ?";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "Ne partagez pas ce fichier avec qui que ce soit, sinon vous risquez de perdre l'accès à vos comptes. Si vous ne choisissez pas d'exporter les mots de passe, il vous sera demandé de les fournir lors de l'importation";
|
||||
"Platform" = "Plateforme";
|
||||
"Icon only" = "Icône uniquement";
|
||||
"Icon and text" = "Icône et texte";
|
||||
"Custom Location already exists" = "L'emplacement personnalisé existe déjà";
|
||||
"Custom Location selected for import" = "Emplacement personnalisé sélectionné pour l'importation";
|
||||
"Custom Location not selected for import" = "Emplacement personnalisé non sélectionné pour l'importation";
|
||||
"Password required to import" = "Mot de passe requis pour l'importation";
|
||||
"Account already exists" = "Le compte existe déjà";
|
||||
"Password saved in import file" = "Mot de passe enregistré dans le fichier d'importation";
|
||||
"Export in progress..." = "Exportation en cours...";
|
||||
"In progress..." = "En cours…";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
" subscribers" = " 人の登録者";
|
||||
"%@ subscribers" = "%@ 人の登録者";
|
||||
"Accounts are not supported for the application of this instance" = "このインスタンスはアカウントに対応していません";
|
||||
"%lld videos" = "%lld本の動画";
|
||||
"%lld videos" = "本の動画";
|
||||
"%@ Channel" = "%@ チャンネル";
|
||||
"%@ Playlist" = "%@ 再生リスト";
|
||||
"Add Location" = "場所を追加";
|
||||
@@ -529,7 +529,7 @@
|
||||
"For custom locations you can configure Frontend URL in Locations settings" = "場所を指定するには、場所の設定からフロントエンドのURLを設定します";
|
||||
"Public Locations" = "公開された場所";
|
||||
"Switch to public locations" = "公開された場所に切り替え";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "有料/無料のプラットフォームかを問わず、いいね、登録などを明示的に操作を促す(例: 動画をクリック)。\n";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "有料/無料のプラットフォームかを問わず、いいね、登録などを明示的に操作を促す(例: 動画をクリック)。";
|
||||
"Proxy videos" = "動画閲覧にプロキシ使用";
|
||||
"Sections" = "表示するボタン";
|
||||
"System controls show buttons for %@" = "システム制御「%@」用のボタンを表示";
|
||||
@@ -604,28 +604,3 @@
|
||||
"No preview" = "プレビューなし";
|
||||
"Open vertical chapters expanded" = "チャプターを縦方向に開く";
|
||||
"Chapters (if available)" = "チャプター (あれば)";
|
||||
"Password required to import" = "取り込むにはパスワードが必要です";
|
||||
"Export..." = "出力…";
|
||||
"Other data include last used playback preferences and listing options" = "ほかのデータには、最後に使った再生設定と一覧オプションを含む";
|
||||
"File information" = "ファイル情報";
|
||||
"Platform" = "プラットフォーム";
|
||||
"Icon and text" = "アイコンと文字";
|
||||
"Custom Location not selected for import" = "指定の場所は取り込み用に選択されていません";
|
||||
"Import Settings..." = "設定の取り込み...";
|
||||
"Export Settings" = "設定を出力";
|
||||
"Accounts passwords (unencrypted)" = "アカウントのパスワード (暗号化なし)";
|
||||
"Other" = "ほか";
|
||||
"Other data" = "ほかのデータ";
|
||||
"Are you sure you want to export unencrypted passwords?" = "暗号化のないパスワードを本当に出力しますか?";
|
||||
"Custom Location selected for import" = "指定の場所は取り込み用に選択済み";
|
||||
"Export" = "出力";
|
||||
"Build" = "ビルド";
|
||||
"Import" = "取り込み";
|
||||
"Icon only" = "アイコンのみ";
|
||||
"Action button labels" = "操作ボタンの表示";
|
||||
"Export in progress..." = "エクスポート中...";
|
||||
"In progress..." = "実行中…";
|
||||
"Password saved in import file" = "取り込みファイルにパスワードを保存しました";
|
||||
"Account already exists" = "アカウントは既に存在します";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "このファイルを他の人と共有しないでください。パスワードを出力していなければ、取り込み時にパスワードが求められます";
|
||||
"Custom Location already exists" = "指定の場所は既に存在します";
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"I want to ask a question" = "Ik wil een vraag stellen";
|
||||
"If you are interested what's coming in future updates, you can track project Milestones." = "Als je geïnteresseerd bent in toekomstige updates, kan je Milestones van het project volgen.";
|
||||
"Increase rate" = "Verhoog tempo";
|
||||
"Info" = "Info";
|
||||
"Info" = "";
|
||||
"Instance of current account" = "Instantie van huidig account";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@@ -259,38 +259,3 @@
|
||||
"Save history of played videos" = "Sla geschiedenis van afgespeelde videos op";
|
||||
"Save history of searches, channels and playlists" = "Sla geschiedenis van zoekopdrachten, kanalen en afspeellijsten op";
|
||||
"Search" = "Zoeken";
|
||||
"Segments typically found at the start of a video that include an animation, still frame or clip which are also seen in other videos by the same creator." = "Stukken normaal in het begin van een video met een animatie, stil plaatje, of stukje van een andere video van dezelfde maker.";
|
||||
"Discord Server" = "Discord Server";
|
||||
"Enable logging" = "Loggen inschakelen";
|
||||
"Interface" = "Interface";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Intro" = "Intro";
|
||||
"LIVE" = "LIVE";
|
||||
"Matrix Chat" = "Matrix Chat";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "Outro";
|
||||
"Proxy videos" = "Video's door proxyserver leiden";
|
||||
"Reset" = "Herstellen";
|
||||
"Search history is empty" = "Zoekgeschiedenis is leeg";
|
||||
"Search..." = "Zoeken...";
|
||||
"Sections" = "Secties";
|
||||
"Seek gesture sensitivity" = "Zoek gebaar gevoeligheid";
|
||||
"Seek gesture speed" = "Zoek gebaar snelheid";
|
||||
"Seek with horizontal swipe on video" = "Scrollen met horizontale sleep op video";
|
||||
"Select location closest to you:" = "Selecteer de dichtstbijzijnde locatie:";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Self-promotion" = "Zelfpromotie";
|
||||
"Settings" = "Instellingen";
|
||||
"Share %@ link" = "%@ link delen";
|
||||
"Share %@ link with time" = "%@ link met tijd delen";
|
||||
"Share..." = "Delen...";
|
||||
|
||||
/* Video duration filter in search */
|
||||
"Short" = "Kort";
|
||||
"Show account username" = "Gebruikersnaam van account laten zien";
|
||||
"Show anonymous accounts" = "Anonieme accounts laten zien";
|
||||
"Show channel name" = "Naam van kanaal laten zien";
|
||||
"Show history" = "Geschiedenis laten zien";
|
||||
|
||||
@@ -605,28 +605,3 @@
|
||||
"No preview" = "Brak podglądu";
|
||||
"Open vertical chapters expanded" = "Otwórz pionowe rozdziały rozwinięte";
|
||||
"Chapters (if available)" = "Rozdziały (jeśli dostępne)";
|
||||
"Import Settings..." = "Importuj Ustawienia…";
|
||||
"Export Settings" = "Eksportuj Ustawienia";
|
||||
"Export" = "Eksportuj";
|
||||
"Accounts passwords (unencrypted)" = "Hasła kont (nieszyfrowane)";
|
||||
"Other" = "Inne";
|
||||
"Other data" = "Inne dane";
|
||||
"Export..." = "Eksportuj…";
|
||||
"Other data include last used playback preferences and listing options" = "Inne dane obejmują ostatnie preferencje odtwarzania i opcje listowania";
|
||||
"Are you sure you want to export unencrypted passwords?" = "Czy na pewno eksportować nieszyfrowane hasła?";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "Nie dziel się z nikim tym plikiem albo możesz stracić dostęp do swoich kont. Jeśli nie wybierzesz eksportu haseł, zostaniesz o nie zapytany podczas importu";
|
||||
"File information" = "Informacje o pliku";
|
||||
"Build" = "Wersja";
|
||||
"Platform" = "Platforma";
|
||||
"Import" = "Importuj";
|
||||
"Action button labels" = "Etykiety przycisków akcji";
|
||||
"Icon only" = "Tylko ikony";
|
||||
"Icon and text" = "Ikony i tekst";
|
||||
"Custom Location already exists" = "Własna Lokalizacja już istnieje";
|
||||
"Custom Location selected for import" = "Lokalizacja wybrana do zaimportowania";
|
||||
"Custom Location not selected for import" = "Lokalizacji nie wybrano do zaimportowania";
|
||||
"Password required to import" = "Hasło wymagane do zaimportowania";
|
||||
"Password saved in import file" = "Hasło zapisane w importowanym pliku";
|
||||
"Account already exists" = "Konto już istnieje";
|
||||
"Export in progress..." = "Eksport w toku…";
|
||||
"In progress..." = "W trakcie…";
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
"Could not extract channel information" = "Não pôde extrair informação do canal";
|
||||
"For custom locations you can configure Frontend URL in Locations settings" = "Para localizações personalizadas você pode configurar URL do frontend nas configurações de localização";
|
||||
"Add Location" = "Adicionar Localização";
|
||||
"Add Location..." = "Adicionar Localização..";
|
||||
"Add Location..." = "Adicionar Localização…";
|
||||
"For videos which feature music as the primary content." = "Para vídeos que têm música como conteúdo principal.";
|
||||
"Close video after playing last in the queue" = "Fechar vídeo depois de tocar o último na fila";
|
||||
"Clear Search History" = "Limpar Histórico de Busca";
|
||||
@@ -406,7 +406,7 @@
|
||||
"Country" = "País";
|
||||
"Clear All" = "Limpar Tudo";
|
||||
"Clear All Recents" = "Limpar Todos os Recentes";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. 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. clique 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.";
|
||||
@@ -604,28 +604,3 @@
|
||||
"No preview" = "Sem prévia";
|
||||
"Open vertical chapters expanded" = "Abrir capítulos verticais expandidos";
|
||||
"Chapters (if available)" = "Capítulos (se disponível)";
|
||||
"Password required to import" = "Senha necessária para importar";
|
||||
"Export Settings" = "Exportar Ajustes";
|
||||
"Accounts passwords (unencrypted)" = "Senhas das contas (não encriptadas)";
|
||||
"Other" = "Outro";
|
||||
"Export" = "Exportar";
|
||||
"Build" = "Compilação";
|
||||
"Action button labels" = "Rótulos dos botões de ação";
|
||||
"Icon and text" = "Ícone e texto";
|
||||
"Password saved in import file" = "Senha salva em arquivo de importação";
|
||||
"Export in progress..." = "Exportação em progresso…";
|
||||
"In progress..." = "Em progresso…";
|
||||
"Import Settings..." = "Importar Ajustes…";
|
||||
"Other data" = "Outros dados";
|
||||
"Other data include last used playback preferences and listing options" = "Outros dados incluem as preferências de playback usadas pela última vez e opções de listagem";
|
||||
"Export..." = "Exportar…";
|
||||
"Platform" = "Plataforma";
|
||||
"Are you sure you want to export unencrypted passwords?" = "Tem certeza que deseja exportar senhas sem criptografia?";
|
||||
"Icon only" = "Apenas ícone";
|
||||
"Custom Location already exists" = "Localização Personalizada já existe";
|
||||
"Custom Location selected for import" = "Localização Personalizada selecionada para importação";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "Não compartilhe este arquivo com ninguém, ou você poderá perder acesso às suas contas. Se você não selecionar a exportação de senhas, será perguntado por elas durante a importação";
|
||||
"File information" = "Informação do arquivo";
|
||||
"Import" = "Importar";
|
||||
"Custom Location not selected for import" = "Localização Personalizada não selecionada para importação";
|
||||
"Account already exists" = "Conta já existe";
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"Add Account" = "Adicionar Conta";
|
||||
"Add Account..." = "Adicionar Conta…";
|
||||
"Add Location" = "Adicionar Localização";
|
||||
"Add Location..." = "Adicionar Localização...";
|
||||
"Add Location..." = "Adicionar Localização…";
|
||||
"Add profile..." = "Adicionar perfil…";
|
||||
"Add Quality Profile" = "Adicionar Perfil de Qualidade";
|
||||
"Add to %@" = "Adicionar a %@";
|
||||
@@ -148,7 +148,7 @@
|
||||
"Enter fullscreen in landscape" = "Entrar no ecrã inteiro em modo paisagem";
|
||||
"Error" = "Erro";
|
||||
"Error when accessing playlist" = "Erro ao acessar playlist";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos para dar gosto, subscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar num 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. clique num vídeo).";
|
||||
"Favorites" = "Favoritos";
|
||||
"Filter" = "Filtro";
|
||||
"Filter: active" = "Filtro: ativo";
|
||||
@@ -604,9 +604,3 @@
|
||||
"Add %@" = "Adicionar %@";
|
||||
"No preview" = "Sem prévia";
|
||||
"Chapters (if available)" = "Capítulos (se disponível)";
|
||||
"Import Settings..." = "Definições de Importação...";
|
||||
"Export Settings" = "Definições de Exportação";
|
||||
"Accounts passwords (unencrypted)" = "Palavras-passe das contas (não encriptadas)";
|
||||
"Other" = "Outro";
|
||||
"Other data" = "Outros dados";
|
||||
"Export..." = "Exportar…";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"Accounts are not supported for the application of this instance" = "Conturile nu sunt acceptate pentru aplicaţia acestei instanțe";
|
||||
"%lld videos" = "%lld videoclipuri";
|
||||
"Add Location" = "Adaugă locație";
|
||||
"Add Location..." = "Adaugă locație..";
|
||||
"Add Location..." = "Adaugă locație...";
|
||||
"Add profile..." = "Adaugă profil...";
|
||||
"Add to %@" = "Adaugă la %@";
|
||||
"Add to Playlist" = "Adaugă la playlist";
|
||||
@@ -62,7 +62,7 @@
|
||||
"Edit" = "Editați";
|
||||
"Edit Playlist" = "Editați Playlist";
|
||||
"Enter fullscreen in landscape" = "Introduceți ecranul complet în peisaj";
|
||||
"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Mementouri explicite de a aprecia, de a vă abona sau de a interacționa cu ele pe orice platformă (platforme) plătite sau gratuite (de exemplu, faceți clic pe un videoclip).\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.";
|
||||
@@ -604,28 +604,3 @@
|
||||
"Description preview" = "Descriere preview";
|
||||
"No preview" = "Fără previzualizare";
|
||||
"Chapters (if available)" = "Capitole (dacă există)";
|
||||
"Password required to import" = "Parolă necesară pentru a importa";
|
||||
"Import Settings..." = "Importă Setări...";
|
||||
"Export Settings" = "Exportă Setări";
|
||||
"Other" = "Alte";
|
||||
"Other data" = "Alte date";
|
||||
"Export..." = "Exportă…";
|
||||
"Other data include last used playback preferences and listing options" = "Alte date includ ultimele preferințe de redare utilizate și opțiunile de listare";
|
||||
"Are you sure you want to export unencrypted passwords?" = "Sigur doriți să exportați parole necriptate?";
|
||||
"Export" = "Exportă";
|
||||
"File information" = "Informații despre fișier";
|
||||
"Build" = "Build";
|
||||
"Platform" = "Platformă";
|
||||
"Import" = "Importă";
|
||||
"Action button labels" = "Etichete pentru butoanele de acțiune";
|
||||
"Icon only" = "Doar pictogramă";
|
||||
"Icon and text" = "Pictogramă și text";
|
||||
"Custom Location already exists" = "Locația customizată există deja";
|
||||
"Custom Location not selected for import" = "Locația customizată nu este selectată pentru importare";
|
||||
"Account already exists" = "Există deja un cont";
|
||||
"Password saved in import file" = "Parolă salvată în fișierul de import";
|
||||
"Export in progress..." = "Export în curs...";
|
||||
"In progress..." = "În curs…";
|
||||
"Custom Location selected for import" = "Locație customizată selectată pentru importare";
|
||||
"Accounts passwords (unencrypted)" = "Parolele conturilor (necriptate)";
|
||||
"Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import" = "Nu partajați acest fișier cu nimeni, altfel puteți pierde accesul la conturile tale. Dacă nu selectați să exportați parolele, vi se va cere să le furnizați în timpul importului";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user