mirror of
https://github.com/yattee/yattee.git
synced 2026-08-18 13:11:33 +00:00
Yattee v2 rewrite
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AccountForm: View {
|
||||
let instance: Instance
|
||||
var selectedAccount: Binding<Account?>?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var username = ""
|
||||
@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()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Group {
|
||||
header
|
||||
form
|
||||
#if os(macOS)
|
||||
VStack {
|
||||
validationStatus
|
||||
}
|
||||
.frame(minHeight: 60, alignment: .topLeading)
|
||||
.padding(.horizontal, 15)
|
||||
#endif
|
||||
footer
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.frame(width: 400, height: 180)
|
||||
.padding(.vertical)
|
||||
#endif
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Text("Add Account")
|
||||
.font(.title2.bold())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
Group {
|
||||
#if !os(tvOS)
|
||||
Form {
|
||||
formFields
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
formFields
|
||||
#endif
|
||||
}
|
||||
.onChange(of: username) { _ in validate() }
|
||||
.onChange(of: password) { _ in validate() }
|
||||
}
|
||||
|
||||
@ViewBuilder var formFields: some View {
|
||||
TextField("Username", text: $username)
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
.disableAutocorrection(true)
|
||||
SecureField("Password", text: $password)
|
||||
|
||||
#if os(tvOS)
|
||||
VStack {
|
||||
validationStatus
|
||||
}
|
||||
.frame(minHeight: 100)
|
||||
#elseif os(iOS)
|
||||
validationStatus
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var validationStatus: some View {
|
||||
Section {
|
||||
if username.isEmpty || password.isEmpty {
|
||||
Text("Enter account credentials to connect...")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
AccountValidationStatus(
|
||||
app: .constant(instance.app),
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!isValid)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
#if os(tvOS)
|
||||
.padding(.top, 30)
|
||||
#endif
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func validate() {
|
||||
isValid = false
|
||||
validationDebounce.invalidate()
|
||||
|
||||
guard !username.isEmpty, !password.isEmpty else {
|
||||
validator.reset()
|
||||
return
|
||||
}
|
||||
|
||||
isValidating = true
|
||||
|
||||
validationDebounce.debouncing(1) {
|
||||
validator.validateAccount()
|
||||
}
|
||||
}
|
||||
|
||||
private func submitForm() {
|
||||
guard isValid else {
|
||||
return
|
||||
}
|
||||
|
||||
let account = AccountsModel.add(instance: instance, id: nil, name: name, username: username, password: password)
|
||||
selectedAccount?.wrappedValue = account
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
|
||||
private var validator: AccountValidator {
|
||||
AccountValidator(
|
||||
app: .constant(instance.app),
|
||||
url: instance.apiURLString,
|
||||
account: Account(instanceID: instance.id, urlString: instance.apiURLString, username: username, password: password),
|
||||
id: $username,
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountFormView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AccountForm(instance: Instance.fixture)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct AccountValidationStatus: View {
|
||||
@Binding var app: VideosApp?
|
||||
@Binding var isValid: Bool
|
||||
@Binding var isValidated: Bool
|
||||
@Binding var isValidating: Bool
|
||||
@Binding var error: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Image(systemName: validationStatusSystemImage)
|
||||
.foregroundColor(validationStatusColor)
|
||||
.imageScale(.medium)
|
||||
.opacity(isValidating ? 1 : (isValidated ? 1 : 0))
|
||||
|
||||
Text(isValid ? "Connected successfully (\(app?.name ?? "Unknown"))" : "Connection failed")
|
||||
.opacity(isValidated && !isValidating ? 1 : 0)
|
||||
}
|
||||
if errorVisible {
|
||||
Text(error ?? "")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(nil)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 5)
|
||||
.opacity(errorTextVisible ? 1 : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errorVisible: Bool {
|
||||
#if !os(iOS)
|
||||
true
|
||||
#else
|
||||
errorTextVisible
|
||||
#endif
|
||||
}
|
||||
|
||||
var errorTextVisible: Bool {
|
||||
error != nil && isValidated && !isValid && !isValidating
|
||||
}
|
||||
|
||||
var validationStatusSystemImage: String {
|
||||
if isValidating {
|
||||
return "bolt.horizontal.fill"
|
||||
}
|
||||
|
||||
return isValid ? "checkmark.circle.fill" : "xmark.circle.fill"
|
||||
}
|
||||
|
||||
var validationStatusColor: Color {
|
||||
if isValidating {
|
||||
return .accentColor
|
||||
}
|
||||
|
||||
return isValid ? .green : .red
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountsNavigationLink: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
var instance: Instance
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(instance.longDescription) {
|
||||
InstanceSettings(instance: instance)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
removeInstanceButton(instance)
|
||||
|
||||
#if os(tvOS)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInstanceButton(_ instance: Instance) -> some View {
|
||||
Button {
|
||||
removeAction(instance)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAction(_ instance: Instance) {
|
||||
if accounts.current?.instance == instance {
|
||||
accounts.setCurrent(nil)
|
||||
}
|
||||
InstancesModel.shared.remove(instance)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddPublicInstanceButton: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
@State private var id = UUID().uuidString
|
||||
|
||||
var body: some View {
|
||||
if let account = accounts.current, let app = account.app, account.isPublic, !account.isPublicAddedToCustom {
|
||||
Button {
|
||||
_ = InstancesModel.shared.add(app: app, name: "", url: account.urlString)
|
||||
regenerateID()
|
||||
} label: {
|
||||
Label(String(format: "Add %@", account.urlString), systemImage: "plus")
|
||||
}
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func regenerateID() {
|
||||
id = UUID().uuidString
|
||||
}
|
||||
}
|
||||
|
||||
struct AddPublicInstanceButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddPublicInstanceButton()
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AdvancedSettings: View {
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
@Default(.mpvCacheSecs) private var mpvCacheSecs
|
||||
@Default(.mpvCachePauseWait) private var mpvCachePauseWait
|
||||
@Default(.mpvCachePauseInital) private var mpvCachePauseInital
|
||||
@Default(.mpvDeinterlace) private var mpvDeinterlace
|
||||
@Default(.mpvEnableLogging) private var mpvEnableLogging
|
||||
@Default(.mpvHWdec) private var mpvHWdec
|
||||
@Default(.mpvDemuxerLavfProbeInfo) private var mpvDemuxerLavfProbeInfo
|
||||
@Default(.mpvInitialAudioSync) private var mpvInitialAudioSync
|
||||
@Default(.mpvSetRefreshToContentFPS) private var mpvSetRefreshToContentFPS
|
||||
@Default(.showCacheStatus) private var showCacheStatus
|
||||
@Default(.feedCacheSize) private var feedCacheSize
|
||||
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
|
||||
@Default(.videoLoadingRetryCount) private var videoLoadingRetryCount
|
||||
@Default(.avPlayerAllowsNonStreamableFormats) private var avPlayerAllowsNonStreamableFormats
|
||||
|
||||
@State private var filesToShare = [MPVClient.logFile]
|
||||
@State private var presentingShareSheet = false
|
||||
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(macOS)
|
||||
advancedSettings
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
advancedSettings
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#elseif os(iOS)
|
||||
.sheet(isPresented: $presentingShareSheet) {
|
||||
ShareSheet(activityItems: filesToShare)
|
||||
.id("logs-\(filesToShare.count)")
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#endif
|
||||
.navigationTitle("Advanced")
|
||||
}
|
||||
|
||||
var logButton: some View {
|
||||
Button {
|
||||
#if os(macOS)
|
||||
NSWorkspace.shared.selectFile(MPVClient.logFile.path, inFileViewerRootedAtPath: YatteeApp.logsDirectory.path)
|
||||
#else
|
||||
presentingShareSheet = true
|
||||
#endif
|
||||
} label: {
|
||||
#if os(macOS)
|
||||
let labelText = "Open logs in Finder".localized()
|
||||
#else
|
||||
let labelText = "Share Logs...".localized()
|
||||
#endif
|
||||
Text(labelText)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var advancedSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Advanced")) {
|
||||
showPlayNowInBackendButtonsToggle
|
||||
videoLoadingRetryCountField
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "AVPlayer"), footer: avPlayerNonStreamableFormatsFooter) {
|
||||
avPlayerAllowsNonStreamableFormatsToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
|
||||
showMPVPlaybackStatsToggle
|
||||
#if !os(tvOS)
|
||||
mpvEnableLoggingToggle
|
||||
#endif
|
||||
|
||||
Toggle(isOn: $mpvCachePauseInital) {
|
||||
HStack {
|
||||
Text("cache-pause-initial")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-initial")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-initial")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("cache-secs")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-secs")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-secs")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
|
||||
#endif
|
||||
TextField("cache-secs", text: $mpvCacheSecs)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
HStack {
|
||||
Group {
|
||||
Text("cache-pause-wait")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-wait")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-cache-pause-wait")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}.frame(minWidth: 140, alignment: .leading)
|
||||
|
||||
TextField("cache-pause-wait", text: $mpvCachePauseWait)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
Toggle(isOn: $mpvDeinterlace) {
|
||||
HStack {
|
||||
Text("deinterlace")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-deinterlace")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-deinterlace")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Toggle(isOn: $mpvInitialAudioSync) {
|
||||
HStack {
|
||||
Text("initial-audio-sync")
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-initial-audio-sync")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-initial-audio-sync")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("hwdec")
|
||||
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-hwdec")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-hwdec")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
|
||||
Picker("", selection: $mpvHWdec) {
|
||||
ForEach(["auto", "auto-safe", "auto-copy"], id: \.self) {
|
||||
Text($0)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("demuxer-lavf-probe-info")
|
||||
|
||||
#if !os(tvOS)
|
||||
Image(systemName: "link")
|
||||
.font(.footnote)
|
||||
#if os(iOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(URL(string: "https://mpv.io/manual/stable/#options-demuxer-lavf-probe-info")!)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.accessibilityAddTraits([.isButton, .isLink])
|
||||
.onTapGesture {
|
||||
NSWorkspace.shared.open(URL(string: "https://mpv.io/manual/stable/#options-demuxer-lavf-probe-info")!)
|
||||
}
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
#endif
|
||||
|
||||
Picker("", selection: $mpvDemuxerLavfProbeInfo) {
|
||||
ForEach(["yes", "no", "auto", "nostreams"], id: \.self) {
|
||||
Text($0)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
Toggle(isOn: $mpvSetRefreshToContentFPS) {
|
||||
HStack {
|
||||
Text("Sync refresh rate with content FPS – EXPERIMENTAL")
|
||||
}
|
||||
}
|
||||
|
||||
if mpvEnableLogging {
|
||||
logButton
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Cache"), footer: cacheSize) {
|
||||
showCacheStatusToggle
|
||||
feedCacheSizeTextField
|
||||
clearCacheButton
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var mpvFooter: some View {
|
||||
let url = "https://mpv.io/manual/stable/"
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Restart the app to apply the settings above.")
|
||||
.padding(.bottom, 1)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
#if os(tvOS)
|
||||
Text("More info can be found in MPV reference manual:")
|
||||
Text(url)
|
||||
#else
|
||||
Text("Further information can be found in the ")
|
||||
+ Text("MPV reference manual").underline().bold()
|
||||
+ Text(" by clicking on the link icon next to the option.")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
var showPlayNowInBackendButtonsToggle: some View {
|
||||
Toggle("Show video context menu options to force selected backend", isOn: $showPlayNowInBackendContextMenu)
|
||||
}
|
||||
|
||||
private var videoLoadingRetryCountField: some View {
|
||||
HStack {
|
||||
Text("Maximum retries for video loading")
|
||||
.frame(minWidth: 200, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
TextField("Limit", value: $videoLoadingRetryCount, formatter: NumberFormatter())
|
||||
.multilineTextAlignment(.trailing)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var showMPVPlaybackStatsToggle: some View {
|
||||
Toggle("Show playback statistics", isOn: $showMPVPlaybackStats)
|
||||
}
|
||||
|
||||
var mpvEnableLoggingToggle: some View {
|
||||
Toggle("Enable logging", isOn: $mpvEnableLogging)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func onHover(_ inside: Bool) {
|
||||
if inside {
|
||||
NSCursor.pointingHand.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var feedCacheSizeTextField: some View {
|
||||
HStack {
|
||||
Text("Maximum feed items")
|
||||
.frame(minWidth: 200, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
TextField("Limit", text: $feedCacheSize)
|
||||
.multilineTextAlignment(.trailing)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var showCacheStatusToggle: some View {
|
||||
Toggle("Show cache status", isOn: $showCacheStatus)
|
||||
}
|
||||
|
||||
private var clearCacheButton: some View {
|
||||
Button {
|
||||
settings.presentAlert(
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to clear cache?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Clear"), action: BaseCacheModel.shared.clear),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Clear all")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
var cacheSize: some View {
|
||||
Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
var avPlayerAllowsNonStreamableFormatsToggle: some View {
|
||||
Toggle("Enable non-streamable formats (MP4/AVC1)", isOn: $avPlayerAllowsNonStreamableFormats)
|
||||
.onChange(of: avPlayerAllowsNonStreamableFormats) { _ in
|
||||
// Trigger refresh of available streams when setting changes
|
||||
if let video = player.currentVideo {
|
||||
player.loadAvailableStreams(video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var avPlayerNonStreamableFormatsFooter: some View {
|
||||
Text("Non-streamable video formats (MP4/AVC1) may take a long time to start playback with AVPlayer. These formats require downloading metadata before playback can begin. Limited to 1080p maximum. For better performance with these formats, use MPV backend instead.")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct AdvancedSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AdvancedSettings()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct BrowsingSettings: View {
|
||||
#if !os(tvOS)
|
||||
@Default(.accountPickerDisplaysUsername) private var accountPickerDisplaysUsername
|
||||
@Default(.roundedThumbnails) private var roundedThumbnails
|
||||
#endif
|
||||
@Default(.accountPickerDisplaysAnonymousAccounts) private var accountPickerDisplaysAnonymousAccounts
|
||||
@Default(.showUnwatchedFeedBadges) private var showUnwatchedFeedBadges
|
||||
@Default(.keepChannelsWithUnwatchedFeedOnTop) private var keepChannelsWithUnwatchedFeedOnTop
|
||||
#if os(iOS)
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||
@Default(.showDocuments) private var showDocuments
|
||||
#endif
|
||||
@Default(.thumbnailsQuality) private var thumbnailsQuality
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
@Default(.startupSection) private var startupSection
|
||||
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||
@Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture
|
||||
@Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture
|
||||
@Default(.playerButtonShowsControlButtonsWhenMinimized) private var playerButtonShowsControlButtonsWhenMinimized
|
||||
@Default(.playerButtonIsExpanded) private var playerButtonIsExpanded
|
||||
@Default(.playerBarMaxWidth) private var playerBarMaxWidth
|
||||
@Default(.expandChannelDescription) private var expandChannelDescription
|
||||
@Default(.showChannelAvatarInChannelsLists) private var showChannelAvatarInChannelsLists
|
||||
@Default(.showChannelAvatarInVideosListing) private var showChannelAvatarInVideosListing
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
#if os(iOS)
|
||||
@State private var homeRecentDocumentsItemsText = ""
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@State private var presentingHomeSettingsSheet = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
VStack(alignment: .leading) {
|
||||
sections
|
||||
Spacer()
|
||||
}
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#elseif os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1200)
|
||||
#else
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.navigationTitle("Browsing")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
homeSettings
|
||||
if !accounts.isEmpty {
|
||||
startupSectionPicker
|
||||
showSearchSuggestionsToggle
|
||||
visibleSectionsSettings
|
||||
}
|
||||
let interface = interfaceSettings
|
||||
#if os(tvOS)
|
||||
if !accounts.isEmpty {
|
||||
interface
|
||||
}
|
||||
#else
|
||||
interface
|
||||
playerBarSettings
|
||||
#endif
|
||||
if !accounts.isEmpty {
|
||||
thumbnailsSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var homeSettings: some View {
|
||||
if !accounts.isEmpty {
|
||||
Section {
|
||||
#if os(macOS)
|
||||
Button {
|
||||
presentingHomeSettingsSheet = true
|
||||
} label: {
|
||||
Text("Home Settings")
|
||||
}
|
||||
.sheet(isPresented: $presentingHomeSettingsSheet) {
|
||||
VStack(alignment: .leading) {
|
||||
Button("Done") {
|
||||
presentingHomeSettingsSheet = false
|
||||
}
|
||||
.padding()
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
HomeSettings()
|
||||
}
|
||||
.frame(width: 500, height: 800)
|
||||
}
|
||||
#else
|
||||
NavigationLink(destination: LazyView(HomeSettings())) {
|
||||
Text("Home Settings")
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var playerBarSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Player Bar".localized()), footer: playerBarFooter) {
|
||||
Toggle("Open expanded", isOn: $playerButtonIsExpanded)
|
||||
Toggle("Always show controls buttons", isOn: $playerButtonShowsControlButtonsWhenMinimized)
|
||||
playerBarGesturePicker("Single tap gesture".localized(), selection: $playerButtonSingleTapGesture)
|
||||
playerBarGesturePicker("Double tap gesture".localized(), selection: $playerButtonDoubleTapGesture)
|
||||
HStack {
|
||||
Text("Maximum width expanded")
|
||||
Spacer()
|
||||
TextField("Maximum width expanded", text: $playerBarMaxWidth)
|
||||
.frame(maxWidth: 100, alignment: .trailing)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerBarGesturePicker(_ label: String, selection: Binding<PlayerTapGestureAction>) -> some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Picker(label, selection: selection) {
|
||||
ForEach(PlayerTapGestureAction.allCases, id: \.rawValue) { action in
|
||||
Text(action.label.localized()).tag(action)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#else
|
||||
Picker(label, selection: selection) {
|
||||
ForEach(PlayerTapGestureAction.allCases, id: \.rawValue) { action in
|
||||
Text(action.label.localized()).tag(action)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
|
||||
var playerBarFooter: some View {
|
||||
#if os(iOS)
|
||||
Text("Tap and hold channel thumbnail to open context menu with more actions")
|
||||
#elseif os(macOS)
|
||||
Text("Right click channel thumbnail to open context menu with more actions")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 10)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
private var interfaceSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Interface".localized())) {
|
||||
#if !os(tvOS)
|
||||
Toggle("Show Open Videos toolbar button", isOn: $showOpenActionsToolbarItem)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
Toggle("Show Documents", isOn: $showDocuments)
|
||||
|
||||
if Constants.isIPhone {
|
||||
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
|
||||
.onChange(of: lockPortraitWhenBrowsing) { lock in
|
||||
if lock {
|
||||
enterFullscreenInLandscape = true
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
enterFullscreenInLandscape = false
|
||||
Orientation.lockOrientation(.all)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if !accounts.isEmpty {
|
||||
#if !os(tvOS)
|
||||
Toggle("Show account username", isOn: $accountPickerDisplaysUsername)
|
||||
#endif
|
||||
|
||||
Toggle("Show anonymous accounts", isOn: $accountPickerDisplaysAnonymousAccounts)
|
||||
Toggle("Show unwatched feed badges", isOn: $showUnwatchedFeedBadges)
|
||||
.onChange(of: showUnwatchedFeedBadges) { newValue in
|
||||
if newValue {
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Open channels with description expanded", isOn: $expandChannelDescription)
|
||||
}
|
||||
|
||||
Toggle("Keep channels with unwatched videos on top of subscriptions list", isOn: $keepChannelsWithUnwatchedFeedOnTop)
|
||||
|
||||
Toggle("Show channel avatars in channels lists", isOn: $showChannelAvatarInChannelsLists)
|
||||
Toggle("Show channel avatars in videos lists", isOn: $showChannelAvatarInVideosListing)
|
||||
}
|
||||
}
|
||||
|
||||
private var thumbnailsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Thumbnails".localized())) {
|
||||
thumbnailsQualityPicker
|
||||
#if !os(tvOS)
|
||||
Toggle("Round corners", isOn: $roundedThumbnails)
|
||||
#endif
|
||||
Toggle("Show channel name", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length", isOn: $timeOnThumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
private var thumbnailsQualityPicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Quality")
|
||||
Spacer()
|
||||
Picker("Quality", selection: $thumbnailsQuality) {
|
||||
ForEach(ThumbnailsQuality.allCases, id: \.self) { quality in
|
||||
Text(quality.description)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#else
|
||||
Picker("Quality", selection: $thumbnailsQuality) {
|
||||
ForEach(ThumbnailsQuality.allCases, id: \.self) { quality in
|
||||
Text(quality.description)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var visibleSectionsSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Sections".localized())) {
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
if section != .trending || FeatureFlags.trendingEnabled {
|
||||
MultiselectRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var startupSectionPicker: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
SettingsHeader(text: "Startup section".localized())
|
||||
#endif
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Startup section")
|
||||
Spacer()
|
||||
Picker("Startup section", selection: $startupSection) {
|
||||
ForEach(StartupSection.allCases, id: \.rawValue) { section in
|
||||
if section != .trending || FeatureFlags.trendingEnabled {
|
||||
Text(section.label).tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#else
|
||||
Picker("Startup section", selection: $startupSection) {
|
||||
ForEach(StartupSection.allCases, id: \.rawValue) { section in
|
||||
if section != .trending || FeatureFlags.trendingEnabled {
|
||||
Text(section.label).tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var showSearchSuggestionsToggle: some View {
|
||||
Toggle("Show search suggestions", isOn: $showSearchSuggestions)
|
||||
}
|
||||
|
||||
private func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||
if value {
|
||||
visibleSections.insert(section)
|
||||
} else {
|
||||
visibleSections.remove(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowsingSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
BrowsingSettings()
|
||||
}
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Help: View {
|
||||
static let wikiURL = URL(string: "https://github.com/yattee/yattee/wiki")!
|
||||
static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")!
|
||||
static let discordURL = URL(string: "https://yattee.stream/discord")!
|
||||
static let issuesURL = URL(string: "https://github.com/yattee/yattee/issues")!
|
||||
static let milestonesURL = URL(string: "https://github.com/yattee/yattee/milestones")!
|
||||
static let contributingURL = URL(string: "https://github.com/yattee/yattee/wiki/Contributing")!
|
||||
static let translationsURL = URL(string: "https://hosted.weblate.org/engage/yattee/")!
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Section {
|
||||
header("I am lost".localized())
|
||||
|
||||
Text("You can find information about using Yattee in the Wiki pages.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
helpItemLink("Wiki".localized(), url: Self.wikiURL, systemImage: "questionmark.circle")
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
header("I want to ask a question".localized())
|
||||
|
||||
Text("Discussions take place in Discord and Matrix. It's a good spot for general questions.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
helpItemLink("Discord Server".localized(), url: Self.discordURL, systemImage: "message")
|
||||
.padding(.bottom, 8)
|
||||
helpItemLink("Matrix Channel".localized(), url: Self.matrixURL, systemImage: "message")
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
header("I found a bug /".localized())
|
||||
header("I have a feature request".localized())
|
||||
|
||||
Text("Bugs and great feature ideas can be sent to the GitHub issues tracker. ")
|
||||
Text("If you are reporting a bug, include all relevant details (especially: app\u{00a0}version, used device and system version, steps to reproduce).")
|
||||
Text("If you are interested what's coming in future updates, you can track project Milestones.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
helpItemLink("Issues Tracker".localized(), url: Self.issuesURL, systemImage: "ladybug")
|
||||
helpItemLink("Milestones".localized(), url: Self.milestonesURL, systemImage: "list.star")
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
header("I like this app!".localized())
|
||||
|
||||
Text("That's nice to hear. It is fun to deliver apps other people want to use. You can consider donating to the project or help by contributing to new features development.")
|
||||
Text("If you want this app to be available in your language, join translation project.")
|
||||
.padding(.bottom, 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
helpItemLink("Contributing".localized(), url: Self.contributingURL, systemImage: "hammer")
|
||||
helpItemLink("Translations".localized(), url: Self.translationsURL, systemImage: "flag")
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#else
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.navigationTitle("Help")
|
||||
}
|
||||
|
||||
func header(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.fontWeight(.bold)
|
||||
.font(.title3)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
func helpItemLink(_ label: String, url: URL, systemImage: String) -> some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
VStack {
|
||||
Button {} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
Text(label)
|
||||
}
|
||||
.font(.system(size: 25).bold())
|
||||
}
|
||||
|
||||
Text(url.absoluteString)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
#else
|
||||
Button {
|
||||
openURL(url)
|
||||
} label: {
|
||||
Label(label, systemImage: systemImage)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Help_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Help()
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct HistorySettings: View {
|
||||
static let watchedThresholds = [50, 60, 70, 80, 90, 95, 100]
|
||||
|
||||
private var player = PlayerModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
@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
|
||||
@Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor
|
||||
@Default(.watchedVideoPlayNowBehavior) private var watchedVideoPlayNowBehavior
|
||||
@Default(.resetWatchedStatusOnPlaying) private var resetWatchedStatusOnPlaying
|
||||
@Default(.showToggleWatchedStatusButton) private var showToggleWatchedStatusButton
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("History")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
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 progress of watching on thumbnails", isOn: $showWatchingProgress)
|
||||
.disabled(!saveHistory)
|
||||
|
||||
watchedVideoPlayNowBehaviorPicker
|
||||
|
||||
watchedThresholdPicker
|
||||
resetWatchedStatusOnPlayingToggle
|
||||
watchedVideoStylePicker
|
||||
watchedVideoBadgeColorPicker
|
||||
}
|
||||
#else
|
||||
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)
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Watched".localized())) {
|
||||
watchedVideoPlayNowBehaviorPicker
|
||||
#if os(macOS)
|
||||
.padding(.top, 1)
|
||||
#endif
|
||||
watchedThresholdPicker
|
||||
resetWatchedStatusOnPlayingToggle
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Interface".localized())) {
|
||||
watchedVideoStylePicker
|
||||
#if os(macOS)
|
||||
.padding(.top, 1)
|
||||
#endif
|
||||
watchedVideoBadgeColorPicker
|
||||
showToggleWatchedStatusButtonToggle
|
||||
.disabled(watchedVideoStyle != .badge)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
clearHistoryButton
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedThresholdPicker: some View {
|
||||
Section(header: SettingsHeader(text: "Mark video as watched after playing".localized(), secondary: true)) {
|
||||
Picker("Mark video as watched after playing", selection: $watchedThreshold) {
|
||||
ForEach(Self.watchedThresholds, id: \.self) { threshold in
|
||||
Text("\(threshold)%").tag(threshold)
|
||||
}
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoStylePicker: some View {
|
||||
Section(header: SettingsHeader(text: "Mark watched videos with".localized(), secondary: true)) {
|
||||
Picker("Mark watched videos with", selection: $watchedVideoStyle) {
|
||||
Text("Nothing").tag(WatchedVideoStyle.nothing)
|
||||
Text("Badge").tag(WatchedVideoStyle.badge)
|
||||
Text("Decreased opacity").tag(WatchedVideoStyle.decreasedOpacity)
|
||||
Text("Badge & Decreased opacity").tag(WatchedVideoStyle.both)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var showToggleWatchedStatusButtonToggle: some View {
|
||||
#if !os(tvOS)
|
||||
Toggle("Show toggle watch status button", isOn: $showToggleWatchedStatusButton)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var watchedVideoBadgeColorPicker: some View {
|
||||
Section(header: SettingsHeader(text: "Badge color".localized(), secondary: true)) {
|
||||
Picker("Badge color", selection: $watchedVideoBadgeColor) {
|
||||
Text("Based on system color scheme").tag(WatchedVideoBadgeColor.colorSchemeBased)
|
||||
Text("Blue").tag(WatchedVideoBadgeColor.blue)
|
||||
Text("Red").tag(WatchedVideoBadgeColor.red)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.disabled(watchedVideoStyle == .decreasedOpacity)
|
||||
.disabled(watchedVideoStyle == .nothing)
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private var watchedVideoPlayNowBehaviorPicker: some View {
|
||||
Section(header: SettingsHeader(text: "When partially watched video is played".localized(), secondary: true)) {
|
||||
Picker("When partially watched video is played", selection: $watchedVideoPlayNowBehavior) {
|
||||
Text("Continue").tag(WatchedVideoPlayNowBehavior.continue)
|
||||
Text("Restart").tag(WatchedVideoPlayNowBehavior.restart)
|
||||
}
|
||||
.disabled(!saveHistory)
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private var resetWatchedStatusOnPlayingToggle: some View {
|
||||
Toggle("Reset watched status when playing again", isOn: $resetWatchedStatusOnPlaying)
|
||||
.disabled(!saveHistory)
|
||||
}
|
||||
|
||||
private var clearHistoryButton: some View {
|
||||
Button {
|
||||
settings.presentAlert(
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to clear history of watched videos?"
|
||||
),
|
||||
message: Text(
|
||||
"This cannot be reverted. You might need to switch between views or restart the app to see changes."
|
||||
),
|
||||
primaryButton: .destructive(Text("Clear All"), action: player.removeHistory),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Clear History")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func counterButtons(for _value: Binding<Int>) -> some View {
|
||||
var value: Binding<Int> {
|
||||
Binding(
|
||||
get: { _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)
|
||||
#if os(iOS)
|
||||
.frame(minHeight: 35)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.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)
|
||||
#if os(iOS)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.onTapGesture {
|
||||
value.wrappedValue += 1
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HistorySettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HistorySettings()
|
||||
}
|
||||
.frame(minHeight: 500)
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct HomeSettings: View {
|
||||
private var model = FavoritesModel.shared
|
||||
|
||||
@Default(.favorites) private var favorites
|
||||
@Default(.showHome) private var showHome
|
||||
@Default(.showFavoritesInHome) private var showFavoritesInHome
|
||||
@Default(.showQueueInHome) private var showQueueInHome
|
||||
@Default(.showOpenActionsInHome) private var showOpenActionsInHome
|
||||
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
homeSettings
|
||||
.padding(.horizontal)
|
||||
editor
|
||||
}
|
||||
}
|
||||
.frame(width: 1000)
|
||||
#else
|
||||
List {
|
||||
homeSettings
|
||||
editor
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("Home Settings")
|
||||
}
|
||||
|
||||
var editor: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Favorites")) {
|
||||
if favorites.isEmpty {
|
||||
Text("Favorites is empty")
|
||||
.padding(.vertical)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
ForEach(favorites) { item in
|
||||
FavoriteItemEditor(item: item)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.trailing, 40)
|
||||
#endif
|
||||
|
||||
if !model.addableItems().isEmpty {
|
||||
Section(header: SettingsHeader(text: "Available")) {
|
||||
ForEach(model.addableItems()) { item in
|
||||
HStack {
|
||||
FavoriteItemLabel(item: item)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.add(item)
|
||||
} label: {
|
||||
Label("Add to Favorites", systemImage: "heart")
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 30))
|
||||
#endif
|
||||
}
|
||||
.help("Add to Favorites")
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.trailing, 40)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
|
||||
private var homeSettings: some View {
|
||||
Section(header: SettingsHeader(text: "Home".localized())) {
|
||||
#if !os(tvOS)
|
||||
if !accounts.isEmpty {
|
||||
Toggle("Show Home", isOn: $showHome)
|
||||
}
|
||||
#endif
|
||||
Toggle("Show Open Videos quick actions", isOn: $showOpenActionsInHome)
|
||||
Toggle("Show Next in Queue", isOn: $showQueueInHome)
|
||||
|
||||
if !accounts.isEmpty {
|
||||
Toggle("Show Favorites", isOn: $showFavoritesInHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoriteItemLabel: View {
|
||||
var item: FavoriteItem
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch item.section {
|
||||
case let .playlist(_, id):
|
||||
return PlaylistsModel.shared.find(id: id)?.title ?? "Playlist".localized()
|
||||
default:
|
||||
return item.section.label.localized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoriteItemEditor: View {
|
||||
var item: FavoriteItem
|
||||
|
||||
private var model: FavoritesModel { .shared }
|
||||
|
||||
@State private var listingStyle = WidgetListingStyle.horizontalCells
|
||||
@State private var limit = 3
|
||||
|
||||
@State private var presentingRemoveAlert = false
|
||||
|
||||
@Default(.favorites) private var favorites
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
FavoriteItemLabel(item: item)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
FavoriteItemEditorButton(color: model.canMoveUp(item) ? .accentColor : .secondary) {
|
||||
Label("Move Up", systemImage: "arrow.up")
|
||||
} onTapGesture: {
|
||||
model.moveUp(item)
|
||||
}
|
||||
|
||||
FavoriteItemEditorButton(color: model.canMoveDown(item) ? .accentColor : .secondary) {
|
||||
Label("Move Down", systemImage: "arrow.down")
|
||||
} onTapGesture: {
|
||||
model.moveDown(item)
|
||||
}
|
||||
|
||||
FavoriteItemEditorButton(color: .init("AppRedColor")) {
|
||||
Label("Remove", systemImage: "trash")
|
||||
} onTapGesture: {
|
||||
presentingRemoveAlert = true
|
||||
}
|
||||
.alert(isPresented: $presentingRemoveAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
String(
|
||||
format: "Are you sure you want to remove %@ from Favorites?".localized(),
|
||||
item.section.label.localized()
|
||||
)
|
||||
),
|
||||
message: Text("This cannot be reverted"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
model.remove(item)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listingStylePicker
|
||||
.padding(.vertical, 5)
|
||||
|
||||
limitInput
|
||||
|
||||
#if !os(iOS)
|
||||
Divider()
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: setupEditor)
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
}
|
||||
|
||||
var listingStylePicker: some View {
|
||||
Picker("Listing Style", selection: $listingStyle) {
|
||||
Text("Cells").tag(WidgetListingStyle.horizontalCells)
|
||||
Text("List").tag(WidgetListingStyle.list)
|
||||
}
|
||||
.onChange(of: listingStyle) { newValue in
|
||||
model.setListingStyle(newValue, item)
|
||||
limit = min(limit, WidgetSettings.maxLimit(newValue))
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
var limitInput: some View {
|
||||
HStack {
|
||||
Text("Limit")
|
||||
Spacer()
|
||||
|
||||
#if !os(tvOS)
|
||||
limitMinusButton
|
||||
.disabled(limit == 1)
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
let textFieldWidth = 100.00
|
||||
#else
|
||||
let textFieldWidth = 30.00
|
||||
#endif
|
||||
|
||||
TextField("Limit", value: $limit, formatter: NumberFormatter())
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
.labelsHidden()
|
||||
.frame(width: textFieldWidth, alignment: .trailing)
|
||||
.multilineTextAlignment(.center)
|
||||
.onChange(of: limit) { newValue in
|
||||
let value = min(limit, WidgetSettings.maxLimit(listingStyle))
|
||||
if newValue <= 0 || newValue != value {
|
||||
limit = value
|
||||
} else {
|
||||
model.setLimit(value, item)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
limitPlusButton
|
||||
.disabled(limit == WidgetSettings.maxLimit(listingStyle))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
var limitMinusButton: some View {
|
||||
FavoriteItemEditorButton {
|
||||
Label("Minus", systemImage: "minus")
|
||||
} onTapGesture: {
|
||||
limit = max(1, limit - 1)
|
||||
}
|
||||
}
|
||||
|
||||
var limitPlusButton: some View {
|
||||
FavoriteItemEditorButton {
|
||||
Label("Plus", systemImage: "plus")
|
||||
} onTapGesture: {
|
||||
limit = max(1, limit + 1)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func setupEditor() {
|
||||
listingStyle = model.listingStyle(item)
|
||||
limit = model.limit(item)
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoriteItemEditorButton<LabelView: View>: View {
|
||||
var color = Color.accentColor
|
||||
var label: LabelView
|
||||
var onTapGesture: () -> Void = {}
|
||||
|
||||
init(
|
||||
color: Color = .accentColor,
|
||||
@ViewBuilder label: () -> LabelView,
|
||||
onTapGesture: @escaping () -> Void = {}
|
||||
) {
|
||||
self.color = color
|
||||
self.label = label()
|
||||
self.onTapGesture = onTapGesture
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
Button(action: onTapGesture) {
|
||||
label
|
||||
}
|
||||
#else
|
||||
label
|
||||
.imageScale(.medium)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.frame(minWidth: 40, minHeight: 40)
|
||||
.foregroundColor(color)
|
||||
#if os(iOS)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(color))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.onTapGesture(perform: onTapGesture)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HomeSettings()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -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 || InstancesModel.shared.findByURLString(account.urlString) != 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 || InstancesModel.shared.findByURLString(account.urlString) != 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")!)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceForm: View {
|
||||
@Binding var savedInstanceID: Instance.ID?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var url = ""
|
||||
|
||||
@State private var app: VideosApp?
|
||||
@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()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
header
|
||||
form
|
||||
#if os(macOS)
|
||||
VStack {
|
||||
validationStatus
|
||||
}
|
||||
.frame(minHeight: 60, alignment: .topLeading)
|
||||
.padding(.horizontal, 15)
|
||||
#endif
|
||||
footer
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
}
|
||||
.onChange(of: url) { _ in validate() }
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.frame(width: 400, height: 180)
|
||||
.padding(.vertical)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("Add Location")
|
||||
.font(.title2.bold())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
#if !os(tvOS)
|
||||
Form {
|
||||
formFields
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
formFields
|
||||
#endif
|
||||
}
|
||||
|
||||
private var formFields: some View {
|
||||
Group {
|
||||
TextField("Name", text: $name)
|
||||
|
||||
TextField("Address", text: $url)
|
||||
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.disableAutocorrection(true)
|
||||
|
||||
#if os(tvOS)
|
||||
VStack {
|
||||
validationStatus
|
||||
}
|
||||
.frame(minHeight: 100)
|
||||
#elseif os(iOS)
|
||||
validationStatus
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var validationStatus: some View {
|
||||
Section {
|
||||
if url.isEmpty {
|
||||
Text("Enter location address to connect...")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
AccountValidationStatus(
|
||||
app: $app,
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!isValid)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.top, 30)
|
||||
#endif
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
var validator: AccountValidator {
|
||||
AccountValidator(
|
||||
app: $app,
|
||||
url: url,
|
||||
id: $url,
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
|
||||
func validate() {
|
||||
isValid = false
|
||||
validationDebounce.invalidate()
|
||||
|
||||
guard !url.isEmpty else {
|
||||
validator.reset()
|
||||
return
|
||||
}
|
||||
|
||||
isValidating = true
|
||||
|
||||
validationDebounce.debouncing(2) {
|
||||
validator.validateInstance()
|
||||
}
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard isValid, let app else {
|
||||
return
|
||||
}
|
||||
|
||||
let savedInstance = InstancesModel.shared.add(app: app, name: name, url: url)
|
||||
savedInstanceID = savedInstance.id
|
||||
|
||||
if accounts.isEmpty {
|
||||
accounts.setCurrent(savedInstance.anonymousAccount)
|
||||
}
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct InstanceFormView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InstanceForm(savedInstanceID: .constant(nil))
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceSettings: View {
|
||||
let instance: Instance
|
||||
|
||||
@State private var accountsChanged = false
|
||||
@State private var presentingAccountForm = false
|
||||
|
||||
@State private var frontendURL = ""
|
||||
@State private var proxiesVideos = false
|
||||
@State private var invidiousCompanion = false
|
||||
@State private var hideVideosWithoutDuration = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: Text("Accounts".localized())) {
|
||||
if instance.app.supportsAccounts {
|
||||
ForEach(InstancesModel.shared.accounts(instance.id), id: \.self) { account in
|
||||
#if os(tvOS)
|
||||
Button(account.description) {}
|
||||
.contextMenu {
|
||||
Button("Remove") { removeAccount(account) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#else
|
||||
ZStack {
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
EmptyView()
|
||||
}
|
||||
.disabled(true)
|
||||
.hidden()
|
||||
|
||||
HStack {
|
||||
Text(account.description)
|
||||
Spacer()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
removeAccount(account)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.redrawOn(change: accountsChanged)
|
||||
|
||||
Button {
|
||||
presentingAccountForm = true
|
||||
} label: {
|
||||
Label("Add Account...", systemImage: "plus")
|
||||
}
|
||||
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
||||
AccountForm(instance: instance)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
} else {
|
||||
Text("Accounts are not supported for the application of this instance")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if instance.app.hasFrontendURL {
|
||||
Section(header: Text("Frontend URL".localized())) {
|
||||
TextField(
|
||||
"Frontend URL",
|
||||
text: $frontendURL
|
||||
)
|
||||
.onAppear {
|
||||
frontendURL = instance.frontendURL ?? ""
|
||||
}
|
||||
.onChange(of: frontendURL) { newValue in
|
||||
InstancesModel.shared.setFrontendURL(instance, newValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
}
|
||||
|
||||
if instance.app.allowsDisablingVidoesProxying {
|
||||
proxiesVideosToggle
|
||||
.onAppear {
|
||||
proxiesVideos = instance.proxiesVideos
|
||||
}
|
||||
.onChange(of: proxiesVideos) { newValue in
|
||||
InstancesModel.shared.setProxiesVideos(instance, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
if instance.app == .invidious {
|
||||
invidiousCompanionToggle
|
||||
.onAppear {
|
||||
invidiousCompanion = instance.invidiousCompanion
|
||||
}
|
||||
.onChange(of: invidiousCompanion) { newValue in
|
||||
InstancesModel.shared.setInvidiousCompanion(instance, newValue)
|
||||
}
|
||||
|
||||
Section(footer: Text("This can be used to hide shorts".localized())) {
|
||||
hideVideosWithoutDurationToggle
|
||||
.onAppear {
|
||||
hideVideosWithoutDuration = instance.hideVideosWithoutDuration
|
||||
}
|
||||
.onChange(of: hideVideosWithoutDuration) { newValue in
|
||||
InstancesModel.shared.setHideVideosWithoutDuration(instance, newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
|
||||
.navigationTitle(instance.description)
|
||||
}
|
||||
|
||||
private var proxiesVideosToggle: some View {
|
||||
Toggle("Proxy videos", isOn: $proxiesVideos)
|
||||
}
|
||||
|
||||
private var invidiousCompanionToggle: some View {
|
||||
Toggle("Invidious companion", isOn: $invidiousCompanion)
|
||||
}
|
||||
|
||||
private var hideVideosWithoutDurationToggle: some View {
|
||||
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
|
||||
}
|
||||
|
||||
private func removeAccount(_ account: Account) {
|
||||
AccountsModel.remove(account)
|
||||
accountsChanged.toggle()
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct LocationsSettings: View {
|
||||
@State private var countries = [String]()
|
||||
@State private var presentingInstanceForm = false
|
||||
@State private var savedFormInstanceID: Instance.ID?
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
private var model = SettingsModel.shared
|
||||
|
||||
@Default(.countryOfPublicInstances) private var countryOfPublicInstances
|
||||
@Default(.instances) private var instances
|
||||
@Default(.instancesManifest) private var instancesManifest
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(macOS)
|
||||
settings
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
settings
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: loadCountries)
|
||||
.onChange(of: countryOfPublicInstances) { newCountry in
|
||||
InstancesManifest.shared.setPublicAccount(newCountry, asCurrent: accounts.current?.isPublic ?? true)
|
||||
}
|
||||
.onChange(of: instancesManifest) { _ in
|
||||
countryOfPublicInstances = nil
|
||||
if let account = accounts.current, account.isPublic {
|
||||
accounts.setCurrent(nil)
|
||||
}
|
||||
countries.removeAll()
|
||||
}
|
||||
.sheet(isPresented: $presentingInstanceForm) {
|
||||
InstanceForm(savedInstanceID: $savedFormInstanceID)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#endif
|
||||
.navigationTitle("Locations")
|
||||
}
|
||||
|
||||
@ViewBuilder var settings: some View {
|
||||
Section(header: SettingsHeader(text: "Locations Manifest".localized())) {
|
||||
TextField("URL", text: $instancesManifest)
|
||||
#if !os(macOS)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
.disableAutocorrection(true)
|
||||
Button("Reload manifest", action: loadCountries)
|
||||
.disabled(instancesManifest.isEmpty)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if !InstancesManifest.shared.manifestURL.isNil {
|
||||
Section(header: SettingsHeader(text: "Public Locations".localized()), footer: countryFooter) {
|
||||
Picker("Country", selection: $countryOfPublicInstances) {
|
||||
Text("Don't use public locations").tag(String?.none)
|
||||
ForEach(countries, id: \.self) { country in
|
||||
Text(country).tag(Optional(country))
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
.disabled(countries.isEmpty)
|
||||
|
||||
Button {
|
||||
InstancesManifest.shared.changePublicAccount()
|
||||
} label: {
|
||||
if let account = accounts.current, account.isPublic {
|
||||
Text("Switch to other public location")
|
||||
} else {
|
||||
Text("Switch to public locations")
|
||||
}
|
||||
}
|
||||
.disabled(countryOfPublicInstances.isNil)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "Custom Locations".localized())) {
|
||||
#if os(macOS)
|
||||
InstancesSettings()
|
||||
#else
|
||||
ForEach(instances) { instance in
|
||||
AccountsNavigationLink(instance: instance)
|
||||
}
|
||||
AddPublicInstanceButton()
|
||||
addInstanceButton
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var countryFooter: some View {
|
||||
if let account = accounts.current {
|
||||
let locationType = account.isPublic ? (account.country ?? "Unknown") : "Custom".localized()
|
||||
let description = account.isPublic ? account.urlString : account.instance?.description ?? "unknown".localized()
|
||||
|
||||
Text("Current: \(locationType)\n\(description)")
|
||||
.foregroundColor(.secondary)
|
||||
#if os(macOS)
|
||||
.padding(.bottom, 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func loadCountries() {
|
||||
InstancesManifest.shared.configure()
|
||||
InstancesManifest.shared.instancesList?.load()
|
||||
.onSuccess { response in
|
||||
if let instances: [ManifestedInstance] = response.typedContent() {
|
||||
self.countries = instances.map(\.country).unique().sorted()
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
model.presentAlert(title: "Could not load locations manifest".localized())
|
||||
}
|
||||
}
|
||||
|
||||
private var addInstanceButton: some View {
|
||||
Button {
|
||||
presentingInstanceForm = true
|
||||
} label: {
|
||||
Label("Add Location...", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocationsSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
LocationsSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MultiselectRow: View {
|
||||
let title: String
|
||||
var selected: Bool
|
||||
var disabled = false
|
||||
var action: (Bool) -> Void
|
||||
|
||||
@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
|
||||
Toggle(title, isOn: $toggleChecked)
|
||||
#if os(macOS)
|
||||
.toggleStyle(.checkbox)
|
||||
#endif
|
||||
.onAppear {
|
||||
guard !disabled else { return }
|
||||
toggleChecked = selected
|
||||
}
|
||||
.onChange(of: toggleChecked) { new in
|
||||
action(new)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiselectRow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MultiselectRow(title: "Title", selected: false) { _ in }
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerControlsSettings: View {
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
@Default(.systemControlsCommands) private var systemControlsCommands
|
||||
@Default(.playerControlsLayout) private var playerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@Default(.horizontalPlayerGestureEnabled) private var horizontalPlayerGestureEnabled
|
||||
@Default(.fullscreenPlayerGestureEnabled) private var fullscreenPlayerGestureEnabled
|
||||
@Default(.seekGestureSpeed) private var seekGestureSpeed
|
||||
@Default(.seekGestureSensitivity) private var seekGestureSensitivity
|
||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
||||
@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
|
||||
@Default(.actionButtonAddToPlaylistEnabled) private var actionButtonAddToPlaylistEnabled
|
||||
@Default(.actionButtonSettingsEnabled) private var actionButtonSettingsEnabled
|
||||
@Default(.actionButtonFullScreenEnabled) private var actionButtonFullScreenEnabled
|
||||
@Default(.actionButtonPipEnabled) private var actionButtonPipEnabled
|
||||
@Default(.actionButtonLockOrientationEnabled) private var actionButtonLockOrientationEnabled
|
||||
@Default(.actionButtonRestartEnabled) private var actionButtonRestartEnabled
|
||||
@Default(.actionButtonAdvanceToNextItemEnabled) private var actionButtonAdvanceToNextItemEnabled
|
||||
@Default(.actionButtonMusicModeEnabled) private var actionButtonMusicModeEnabled
|
||||
@Default(.actionButtonHideEnabled) private var actionButtonHideEnabled
|
||||
|
||||
#if os(iOS)
|
||||
@Default(.playerControlsLockOrientationEnabled) private var playerControlsLockOrientationEnabled
|
||||
#endif
|
||||
@Default(.playerControlsSettingsEnabled) private var playerControlsSettingsEnabled
|
||||
@Default(.playerControlsCloseEnabled) private var playerControlsCloseEnabled
|
||||
@Default(.playerControlsRestartEnabled) private var playerControlsRestartEnabled
|
||||
@Default(.playerControlsAdvanceToNextEnabled) private var playerControlsAdvanceToNextEnabled
|
||||
@Default(.playerControlsPlaybackModeEnabled) private var playerControlsPlaybackModeEnabled
|
||||
@Default(.playerControlsMusicModeEnabled) private var playerControlsMusicModeEnabled
|
||||
@Default(.playerControlsBackgroundOpacity) private var playerControlsBackgroundOpacity
|
||||
|
||||
private var player = PlayerModel.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("Controls")
|
||||
}
|
||||
|
||||
@ViewBuilder var sections: some View {
|
||||
#if !os(tvOS)
|
||||
Section(header: SettingsHeader(text: "Player Controls".localized()), footer: controlsLayoutFooter) {
|
||||
avPlayerUsesSystemControlsToggle
|
||||
#if os(iOS)
|
||||
fullscreenPlayerGestureEnabledToggle
|
||||
#endif
|
||||
horizontalPlayerGestureEnabledToggle
|
||||
SettingsHeader(text: "Seek gesture sensitivity".localized(), secondary: true)
|
||||
seekGestureSensitivityPicker
|
||||
SettingsHeader(text: "Seek gesture speed".localized(), secondary: true)
|
||||
seekGestureSpeedPicker
|
||||
SettingsHeader(text: "Regular size".localized(), secondary: true)
|
||||
playerControlsLayoutPicker
|
||||
SettingsHeader(text: "Fullscreen size".localized(), secondary: true)
|
||||
fullScreenPlayerControlsLayoutPicker
|
||||
SettingsHeader(text: "Background opacity".localized(), secondary: true)
|
||||
playerControlsBackgroundOpacityPicker
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(header: SettingsHeader(text: "Seeking".localized()), footer: seekingGestureSection) {
|
||||
systemControlsCommandsPicker
|
||||
|
||||
seekingSection
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading) {
|
||||
controlsButtonsSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
actionsButtonsSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
#else
|
||||
|
||||
controlsButtonsSection
|
||||
|
||||
#if !os(tvOS)
|
||||
actionsButtonsSection
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
var controlsButtonsSection: some View {
|
||||
Section(header: SettingsHeader(text: "Player Control Buttons".localized())) {
|
||||
controlButtonToggles
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var actionsButtonsSection: some View {
|
||||
Section(header: SettingsHeader(text: "Actions Buttons".localized())) {
|
||||
actionButtonToggles
|
||||
}
|
||||
|
||||
Section {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Action button labels")
|
||||
Spacer()
|
||||
Picker("Action button labels", selection: $playerActionsButtonLabelStyle) {
|
||||
ForEach(ButtonLabelStyle.allCases, id: \.rawValue) { style in
|
||||
Text(style.description).tag(style)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#else
|
||||
Picker("Action button labels", selection: $playerActionsButtonLabelStyle) {
|
||||
ForEach(ButtonLabelStyle.allCases, id: \.rawValue) { style in
|
||||
Text(style.description).tag(style)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var systemControlsCommandsPicker: some View {
|
||||
func labelText(_ label: String) -> String {
|
||||
#if os(macOS)
|
||||
String(format: "System controls show buttons for %@".localized(), label)
|
||||
#else
|
||||
label
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
// Custom implementation for tvOS to avoid focus overlay
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
Text("System controls buttons")
|
||||
.font(.headline)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Button(action: { systemControlsCommands = .seek }) {
|
||||
HStack {
|
||||
Text(labelText("Seek".localized()))
|
||||
Spacer()
|
||||
if systemControlsCommands == .seek {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button(action: {
|
||||
systemControlsCommands = .restartAndAdvanceToNext
|
||||
player.updateRemoteCommandCenter()
|
||||
}) {
|
||||
HStack {
|
||||
Text(labelText("Restart/Play next".localized()))
|
||||
Spacer()
|
||||
if systemControlsCommands == .restartAndAdvanceToNext {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
#else
|
||||
return Picker("System controls buttons", selection: $systemControlsCommands) {
|
||||
Text(labelText("Seek".localized())).tag(SystemControlsCommands.seek)
|
||||
Text(labelText("Restart/Play next".localized())).tag(SystemControlsCommands.restartAndAdvanceToNext)
|
||||
}
|
||||
.onChange(of: systemControlsCommands) { _ in
|
||||
player.updateRemoteCommandCenter()
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var controlsLayoutFooter: some View {
|
||||
#if os(iOS)
|
||||
Text("Large layout is not suitable for all devices and using it may cause controls not to fit on the screen.")
|
||||
#endif
|
||||
}
|
||||
|
||||
private var fullscreenPlayerGestureEnabledToggle: some View {
|
||||
Toggle("Swipe up toggles fullscreen", isOn: $fullscreenPlayerGestureEnabled)
|
||||
}
|
||||
|
||||
private var horizontalPlayerGestureEnabledToggle: some View {
|
||||
Toggle("Seek with horizontal swipe", isOn: $horizontalPlayerGestureEnabled)
|
||||
}
|
||||
|
||||
private var avPlayerUsesSystemControlsToggle: some View {
|
||||
Toggle("Use system controls with AVPlayer", isOn: $avPlayerUsesSystemControls)
|
||||
}
|
||||
|
||||
private var seekGestureSensitivityPicker: some View {
|
||||
Picker("Seek gesture sensitivity", selection: $seekGestureSensitivity) {
|
||||
Text("Highest").tag(1.0)
|
||||
Text("High").tag(10.0)
|
||||
Text("Normal").tag(30.0)
|
||||
Text("Low").tag(50.0)
|
||||
Text("Lowest").tag(100.0)
|
||||
}
|
||||
.disabled(!horizontalPlayerGestureEnabled)
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var seekGestureSpeedPicker: some View {
|
||||
Picker("Seek gesture speed", selection: $seekGestureSpeed) {
|
||||
ForEach([1, 0.75, 0.66, 0.5, 0.33, 0.25, 0.1], id: \.self) { value in
|
||||
Text(String(format: "%.0f%%", value * 100)).tag(value)
|
||||
}
|
||||
}
|
||||
.disabled(!horizontalPlayerGestureEnabled)
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var playerControlsLayoutPicker: some View {
|
||||
Picker("Regular Size", selection: $playerControlsLayout) {
|
||||
ForEach(PlayerControlsLayout.allCases.filter(\.available), id: \.self) { layout in
|
||||
Text(layout.description).tag(layout.rawValue)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var fullScreenPlayerControlsLayoutPicker: some View {
|
||||
Picker("Fullscreen size", selection: $fullScreenPlayerControlsLayout) {
|
||||
ForEach(PlayerControlsLayout.allCases.filter(\.available), id: \.self) { layout in
|
||||
Text(layout.description).tag(layout.rawValue)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var playerControlsBackgroundOpacityPicker: some View {
|
||||
Picker("Background opacity", selection: $playerControlsBackgroundOpacity) {
|
||||
ForEach(Array(stride(from: 0.0, through: 1.0, by: 0.1)), id: \.self) { value in
|
||||
Text("\(Int(value * 100))%").tag(value)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
@ViewBuilder private var seekingSection: some View {
|
||||
seekingDurationSetting("System controls", $systemControlsSeekDuration)
|
||||
.foregroundColor(systemControlsCommands == .restartAndAdvanceToNext ? .secondary : .primary)
|
||||
.disabled(systemControlsCommands == .restartAndAdvanceToNext)
|
||||
seekingDurationSetting("Controls button: backwards", $buttonBackwardSeekDuration)
|
||||
seekingDurationSetting("Controls button: forwards", $buttonForwardSeekDuration)
|
||||
seekingDurationSetting("Gesture: backwards", $gestureBackwardSeekDuration)
|
||||
seekingDurationSetting("Gesture: fowards", $gestureForwardSeekDuration)
|
||||
}
|
||||
|
||||
private var seekingGestureSection: some View {
|
||||
#if os(iOS)
|
||||
Text("Gesture settings control skipping interval for double tap gesture on left/right side of the player. Changing system controls settings requires restart.")
|
||||
#elseif os(macOS)
|
||||
Text("Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart.")
|
||||
.foregroundColor(.secondary)
|
||||
#else
|
||||
Text("Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart.")
|
||||
#endif
|
||||
}
|
||||
|
||||
private func seekingDurationSetting(_ name: String, _ value: Binding<String>) -> some View {
|
||||
HStack {
|
||||
Text(name.localized())
|
||||
.frame(minWidth: 140, alignment: .leading)
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
#if !os(tvOS)
|
||||
Label("Minus", systemImage: "minus")
|
||||
.imageScale(.large)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(.accentColor)
|
||||
#if os(iOS)
|
||||
.frame(minHeight: 35)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.onTapGesture {
|
||||
var intValue = Int(value.wrappedValue) ?? 10
|
||||
intValue -= 5
|
||||
if intValue <= 0 {
|
||||
intValue = 5
|
||||
}
|
||||
value.wrappedValue = String(intValue)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
let textFieldWidth = 100.00
|
||||
#else
|
||||
let textFieldWidth = 30.00
|
||||
#endif
|
||||
|
||||
TextField("Duration", text: value)
|
||||
.frame(width: textFieldWidth, alignment: .trailing)
|
||||
.multilineTextAlignment(.center)
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
Label("Plus", systemImage: "plus")
|
||||
.imageScale(.large)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(7)
|
||||
.foregroundColor(.accentColor)
|
||||
#if os(iOS)
|
||||
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(lineWidth: 1).foregroundColor(.accentColor))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.onTapGesture {
|
||||
var intValue = Int(value.wrappedValue) ?? 10
|
||||
intValue += 5
|
||||
if intValue <= 0 {
|
||||
intValue = 5
|
||||
}
|
||||
value.wrappedValue = String(intValue)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var actionButtonToggles: some View {
|
||||
Group {
|
||||
Toggle("Share", isOn: $actionButtonShareEnabled)
|
||||
Toggle("Add to Playlist", isOn: $actionButtonAddToPlaylistEnabled)
|
||||
Toggle("Subscribe/Unsubscribe", isOn: $actionButtonSubscribeEnabled)
|
||||
Toggle("Settings", isOn: $actionButtonSettingsEnabled)
|
||||
Toggle("Fullscreen", isOn: $actionButtonFullScreenEnabled)
|
||||
Toggle("Picture in Picture", isOn: $actionButtonPipEnabled)
|
||||
}
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if !Constants.isIPad {
|
||||
Toggle("Lock orientation", isOn: $actionButtonLockOrientationEnabled)
|
||||
}
|
||||
#endif
|
||||
Toggle("Restart", isOn: $actionButtonRestartEnabled)
|
||||
Toggle("Play next item", isOn: $actionButtonAdvanceToNextItemEnabled)
|
||||
Toggle("Music Mode", isOn: $actionButtonMusicModeEnabled)
|
||||
Toggle("Hide player", isOn: $actionButtonHideEnabled)
|
||||
Toggle("Close video", isOn: $actionButtonCloseEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var controlButtonToggles: some View {
|
||||
#if os(iOS)
|
||||
if !Constants.isIPad {
|
||||
Toggle("Lock orientation", isOn: $playerControlsLockOrientationEnabled)
|
||||
}
|
||||
#endif
|
||||
Toggle("Settings", isOn: $playerControlsSettingsEnabled)
|
||||
#if !os(tvOS)
|
||||
Toggle("Close", isOn: $playerControlsCloseEnabled)
|
||||
#endif
|
||||
Toggle("Restart", isOn: $playerControlsRestartEnabled)
|
||||
Toggle("Play next item", isOn: $playerControlsAdvanceToNextEnabled)
|
||||
Toggle("Playback Mode", isOn: $playerControlsPlaybackModeEnabled)
|
||||
#if !os(tvOS)
|
||||
Toggle("Music Mode", isOn: $playerControlsMusicModeEnabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControlsSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading) {
|
||||
PlayerControlsSettings()
|
||||
}
|
||||
.frame(minHeight: 800)
|
||||
}
|
||||
}
|
||||
@@ -1,576 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerSettings: View {
|
||||
@Default(.instances) private var instances
|
||||
@Default(.playerInstanceID) private var playerInstanceID
|
||||
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.showComments) private var showComments
|
||||
#if !os(tvOS)
|
||||
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
|
||||
@Default(.collapsedLinesDescription) private var collapsedLinesDescription
|
||||
@Default(.exitFullscreenOnEOF) private var exitFullscreenOnEOF
|
||||
#endif
|
||||
@Default(.expandVideoDescription) private var expandVideoDescription
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closeVideoOnEOF) private var closeVideoOnEOF
|
||||
#if os(iOS)
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.lockPortraitWhenBrowsing) private var lockPortraitWhenBrowsing
|
||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
||||
#endif
|
||||
@Default(.closePiPOnNavigation) private var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
|
||||
@Default(.closePlayerOnOpeningPiP) private var closePlayerOnOpeningPiP
|
||||
#if !os(macOS)
|
||||
@Default(.pauseOnEnteringBackground) private var pauseOnEnteringBackground
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) private var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
||||
|
||||
@Default(.showRelated) private var showRelated
|
||||
@Default(.showInspector) private var showInspector
|
||||
|
||||
@Default(.showChapters) private var showChapters
|
||||
@Default(.showChapterThumbnails) private var showThumbnails
|
||||
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showThumbnailsOnlyWhenDifferent
|
||||
@Default(.expandChapters) private var expandChapters
|
||||
|
||||
@Default(.captionsAutoShow) private var captionsAutoShow
|
||||
@Default(.captionsDefaultLanguageCode) private var captionsDefaultLanguageCode
|
||||
@Default(.captionsFallbackLanguageCode) private var captionsFallbackLanguageCode
|
||||
@Default(.captionsFontScaleSize) private var captionsFontScaleSize
|
||||
@Default(.captionsFontColor) private var captionsFontColor
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
|
||||
#if os(iOS)
|
||||
private var idiom: UIUserInterfaceIdiom {
|
||||
UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@State private var isShowingDefaultLanguagePicker = false
|
||||
@State private var isShowingFallbackLanguagePicker = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("Player")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Playback".localized())) {
|
||||
if !accounts.isEmpty {
|
||||
sourcePicker
|
||||
}
|
||||
pauseOnHidingPlayerToggle
|
||||
closeVideoOnEOFToggle
|
||||
#if os(macOS)
|
||||
exitFullscreenOnEOFToggle
|
||||
#endif
|
||||
#if !os(macOS)
|
||||
pauseOnEnteringBackgroundToogle
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
Section(header: SettingsHeader(text: "Info".localized())) {
|
||||
expandVideoDescriptionToggle
|
||||
collapsedLineDescriptionStepper
|
||||
showRelatedToggle
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Inspector")
|
||||
Spacer()
|
||||
inspectorVisibilityPicker
|
||||
}
|
||||
#else
|
||||
inspectorVisibilityPicker
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(header: SettingsHeader(text: "Captions".localized())) {
|
||||
#if os(tvOS)
|
||||
Text("Size").font(.subheadline)
|
||||
#endif
|
||||
captionsFontScaleSizePicker
|
||||
#if os(tvOS)
|
||||
Text("Color").font(.subheadline)
|
||||
#endif
|
||||
captionsFontColorPicker
|
||||
showCaptionsAutoShowToggle
|
||||
|
||||
#if !os(tvOS)
|
||||
captionDefaultLanguagePicker
|
||||
captionFallbackLanguagePicker
|
||||
#else
|
||||
Button(action: { isShowingDefaultLanguagePicker = true }) {
|
||||
HStack {
|
||||
Text("Default language")
|
||||
Spacer()
|
||||
Text("\(LanguageCodes(rawValue: captionsDefaultLanguageCode)!.description.capitalized) (\(captionsDefaultLanguageCode))").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) {
|
||||
defaultLanguagePickerTVOS(
|
||||
selectedLanguage: $captionsDefaultLanguageCode,
|
||||
isShowing: $isShowingDefaultLanguagePicker
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { isShowingFallbackLanguagePicker = true }) {
|
||||
HStack {
|
||||
Text("Fallback language")
|
||||
Spacer()
|
||||
Text("\(LanguageCodes(rawValue: captionsFallbackLanguageCode)!.description.capitalized) (\(captionsFallbackLanguageCode))").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) {
|
||||
fallbackLanguagePickerTVOS(
|
||||
selectedLanguage: $captionsFallbackLanguageCode,
|
||||
isShowing: $isShowingFallbackLanguagePicker
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
Section(header: SettingsHeader(text: "Chapters".localized())) {
|
||||
showChaptersToggle
|
||||
showThumbnailsToggle
|
||||
showThumbnailsWhenDifferentToggle
|
||||
expandChaptersToggle
|
||||
}
|
||||
#endif
|
||||
|
||||
let interface = Section(header: SettingsHeader(text: "Interface".localized())) {
|
||||
#if os(iOS)
|
||||
if idiom == .pad {
|
||||
sidebarPicker
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
sidebarPicker
|
||||
#endif
|
||||
|
||||
if !accounts.isEmpty {
|
||||
keywordsToggle
|
||||
|
||||
commentsToggle
|
||||
#if !os(tvOS)
|
||||
showScrollToTopInCommentsToggle
|
||||
#endif
|
||||
|
||||
returnYouTubeDislikeToggle
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
if !accounts.isEmpty {
|
||||
interface
|
||||
}
|
||||
#elseif os(macOS)
|
||||
interface
|
||||
#elseif os(iOS)
|
||||
if idiom == .pad || !accounts.isEmpty {
|
||||
interface
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
Section(header: SettingsHeader(text: "Fullscreen".localized())) {
|
||||
if Constants.isIPad {
|
||||
enterFullscreenInLandscapeToggle
|
||||
}
|
||||
|
||||
exitFullscreenOnEOFToggle
|
||||
rotateToLandscapeOnEnterFullScreenPicker
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(header: SettingsHeader(text: "Picture in Picture".localized())) {
|
||||
closePiPOnNavigationToggle
|
||||
closePiPOnOpeningPlayerToggle
|
||||
closePlayerOnOpeningPiPToggle
|
||||
#if !os(macOS)
|
||||
closePiPAndOpenPlayerOnEnteringForegroundToggle
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var videoDetailsHeaderPadding: Double {
|
||||
#if os(macOS)
|
||||
5.0
|
||||
#else
|
||||
0.0
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sourcePicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Source")
|
||||
Spacer()
|
||||
Picker("Source", selection: $playerInstanceID) {
|
||||
Text("Instance of current account").tag(String?.none)
|
||||
|
||||
ForEach(instances) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#else
|
||||
Picker("Source", selection: $playerInstanceID) {
|
||||
Text("Instance of current account").tag(String?.none)
|
||||
|
||||
ForEach(instances) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sidebarPicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Sidebar")
|
||||
Spacer()
|
||||
Picker("Sidebar", selection: $playerSidebar) {
|
||||
Text("Show sidebar").tag(PlayerSidebarSetting.always)
|
||||
Text("Hide sidebar").tag(PlayerSidebarSetting.never)
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#else
|
||||
Picker("Sidebar", selection: $playerSidebar) {
|
||||
#if os(iOS)
|
||||
Text("Show sidebar when space permits").tag(PlayerSidebarSetting.whenFits)
|
||||
#endif
|
||||
Text("Hide sidebar").tag(PlayerSidebarSetting.never)
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var commentsToggle: some View {
|
||||
Toggle("Show comments", isOn: $showComments)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var showScrollToTopInCommentsToggle: some View {
|
||||
Toggle("Show scroll to top button in comments", isOn: $showScrollToTopInComments).disabled(!showComments)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var keywordsToggle: some View {
|
||||
Toggle("Show keywords", isOn: $showKeywords)
|
||||
}
|
||||
|
||||
private var expandVideoDescriptionToggle: some View {
|
||||
Toggle("Open video description expanded", isOn: $expandVideoDescription)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var collapsedLineDescriptionStepper: some View {
|
||||
LazyVStack {
|
||||
Stepper(value: $collapsedLinesDescription, in: 0 ... 10) {
|
||||
Text("Description preview")
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
if collapsedLinesDescription == 0 {
|
||||
Text("No preview")
|
||||
} else {
|
||||
Text("\(collapsedLinesDescription) lines")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var returnYouTubeDislikeToggle: some View {
|
||||
Toggle("Enable Return YouTube Dislike", isOn: $enableReturnYouTubeDislike)
|
||||
}
|
||||
|
||||
private var pauseOnHidingPlayerToggle: some View {
|
||||
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer)
|
||||
}
|
||||
|
||||
private var closeVideoOnEOFToggle: some View {
|
||||
Toggle("Close video and player on end", isOn: $closeVideoOnEOF)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var exitFullscreenOnEOFToggle: some View {
|
||||
Toggle("Exit fullscreen on end", isOn: $exitFullscreenOnEOF)
|
||||
.disabled(closeVideoOnEOF)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
private var pauseOnEnteringBackgroundToogle: some View {
|
||||
Toggle("Pause when entering background", isOn: $pauseOnEnteringBackground)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private var enterFullscreenInLandscapeToggle: some View {
|
||||
Toggle("Enter fullscreen in landscape orientation", isOn: $enterFullscreenInLandscape)
|
||||
.disabled(lockPortraitWhenBrowsing)
|
||||
}
|
||||
|
||||
private var rotateToLandscapeOnEnterFullScreenPicker: some View {
|
||||
Picker("Default orientation", selection: $rotateToLandscapeOnEnterFullScreen) {
|
||||
Text("Landscape left").tag(FullScreenRotationSetting.landscapeLeft)
|
||||
Text("Landscape right").tag(FullScreenRotationSetting.landscapeRight)
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
#endif
|
||||
|
||||
private var closePiPOnNavigationToggle: some View {
|
||||
Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation)
|
||||
}
|
||||
|
||||
private var closePiPOnOpeningPlayerToggle: some View {
|
||||
Toggle("Close PiP when player is opened", isOn: $closePiPOnOpeningPlayer)
|
||||
}
|
||||
|
||||
private var closePlayerOnOpeningPiPToggle: some View {
|
||||
Toggle("Close player when starting PiP", isOn: $closePlayerOnOpeningPiP)
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private var closePiPAndOpenPlayerOnEnteringForegroundToggle: some View {
|
||||
Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var showCaptionsAutoShowToggle: some View {
|
||||
Toggle("Always show captions", isOn: $captionsAutoShow)
|
||||
}
|
||||
|
||||
private var captionsFontScaleSizePicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Size")
|
||||
Spacer()
|
||||
Picker("Size", selection: $captionsFontScaleSize) {
|
||||
Text("Small").tag(String("0.725"))
|
||||
Text("Medium").tag(String("1.0"))
|
||||
Text("Large").tag(String("1.5"))
|
||||
}
|
||||
.onChange(of: captionsFontScaleSize) { _ in
|
||||
PlayerModel.shared.mpvBackend.client.setSubFontSize(scaleSize: captionsFontScaleSize)
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
#else
|
||||
Picker("Size", selection: $captionsFontScaleSize) {
|
||||
Text("Small").tag(String("0.725"))
|
||||
Text("Medium").tag(String("1.0"))
|
||||
Text("Large").tag(String("1.5"))
|
||||
}
|
||||
.onChange(of: captionsFontScaleSize) { _ in
|
||||
PlayerModel.shared.mpvBackend.client.setSubFontSize(scaleSize: captionsFontScaleSize)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var captionsFontColorPicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Color")
|
||||
Spacer()
|
||||
Picker("Color", selection: $captionsFontColor) {
|
||||
Text("White").tag(String("#FFFFFF"))
|
||||
Text("Yellow").tag(String("#FFFF00"))
|
||||
Text("Red").tag(String("#FF0000"))
|
||||
Text("Orange").tag(String("#FFA500"))
|
||||
Text("Green").tag(String("#008000"))
|
||||
Text("Blue").tag(String("#0000FF"))
|
||||
}
|
||||
.onChange(of: captionsFontColor) { _ in
|
||||
PlayerModel.shared.mpvBackend.client.setSubFontColor(color: captionsFontColor)
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
#else
|
||||
Picker("Color", selection: $captionsFontColor) {
|
||||
Text("White").tag(String("#FFFFFF"))
|
||||
Text("Yellow").tag(String("#FFFF00"))
|
||||
Text("Red").tag(String("#FF0000"))
|
||||
Text("Orange").tag(String("#FFA500"))
|
||||
Text("Green").tag(String("#008000"))
|
||||
Text("Blue").tag(String("#0000FF"))
|
||||
}
|
||||
.onChange(of: captionsFontColor) { _ in
|
||||
PlayerModel.shared.mpvBackend.client.setSubFontColor(color: captionsFontColor)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var captionDefaultLanguagePicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Default language")
|
||||
Spacer()
|
||||
Picker("Default language", selection: $captionsDefaultLanguageCode) {
|
||||
ForEach(LanguageCodes.allCases, id: \.self) { language in
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
#else
|
||||
Picker("Default language", selection: $captionsDefaultLanguageCode) {
|
||||
ForEach(LanguageCodes.allCases, id: \.self) { language in
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var captionFallbackLanguagePicker: some View {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Fallback language")
|
||||
Spacer()
|
||||
Picker("Fallback language", selection: $captionsFallbackLanguageCode) {
|
||||
ForEach(LanguageCodes.allCases, id: \.self) { language in
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
#else
|
||||
Picker("Fallback language", selection: $captionsFallbackLanguageCode) {
|
||||
ForEach(LanguageCodes.allCases, id: \.self) { language in
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
struct defaultLanguagePickerTVOS: View {
|
||||
@Binding var selectedLanguage: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List(LanguageCodes.allCases, id: \.self) { language in
|
||||
Button(action: {
|
||||
selectedLanguage = language.rawValue
|
||||
isShowing = false
|
||||
}) {
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Default Language")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct fallbackLanguagePickerTVOS: View {
|
||||
@Binding var selectedLanguage: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List(LanguageCodes.allCases, id: \.self) { language in
|
||||
Button(action: {
|
||||
selectedLanguage = language.rawValue
|
||||
isShowing = false
|
||||
}) {
|
||||
Text("\(language.description.capitalized) (\(language.rawValue))")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Fallback Language")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
private var inspectorVisibilityPicker: some View {
|
||||
Picker("Inspector", selection: $showInspector) {
|
||||
Text("Always").tag(ShowInspectorSetting.always)
|
||||
Text("Only for local files and URLs").tag(ShowInspectorSetting.onlyLocal)
|
||||
}
|
||||
#if os(macOS)
|
||||
.labelsHidden()
|
||||
#endif
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private var expandChaptersToggle: some View {
|
||||
Toggle("Open vertical chapters expanded", isOn: $expandChapters)
|
||||
.disabled(!showChapters)
|
||||
.foregroundColor(showChapters ? .primary : .secondary)
|
||||
}
|
||||
|
||||
private var showRelatedToggle: some View {
|
||||
Toggle("Related", isOn: $showRelated)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct PlayerSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading) {
|
||||
PlayerSettings()
|
||||
}
|
||||
.frame(minHeight: 800)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct FormatState: Equatable {
|
||||
let format: QualityProfile.Format
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
struct QualityProfileForm: View {
|
||||
@Binding var qualityProfileID: QualityProfile.ID?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@State private var valid = false
|
||||
|
||||
@State private var initialized = false
|
||||
@State private var name = ""
|
||||
@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
|
||||
|
||||
var qualityProfile: QualityProfile! {
|
||||
if let id = qualityProfileID {
|
||||
return QualityProfilesModel.shared.find(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// swiftlint:disable trailing_closure
|
||||
var body: some View {
|
||||
VStack {
|
||||
Group {
|
||||
header
|
||||
form
|
||||
footer
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(20)
|
||||
#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() })
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.frame(width: 400, height: 450)
|
||||
.padding(.vertical, 10)
|
||||
#endif
|
||||
}
|
||||
|
||||
// swiftlint:enable trailing_closure
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
|
||||
.font(.title2.bold())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
var form: some View {
|
||||
#if os(tvOS)
|
||||
ScrollView {
|
||||
VStack {
|
||||
formFields
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
#else
|
||||
Form {
|
||||
formFields
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var formFields: some View {
|
||||
Group {
|
||||
Section {
|
||||
HStack {
|
||||
nameHeader
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.labelsHidden()
|
||||
}
|
||||
#if os(tvOS)
|
||||
Section(header: Text("Resolution")) {
|
||||
qualityButton
|
||||
}
|
||||
Section(header: Text("Backend")) {
|
||||
backendPicker
|
||||
}
|
||||
#else
|
||||
backendPicker
|
||||
qualityPicker
|
||||
#endif
|
||||
}
|
||||
Section(header: Text("Preferred Formats"), footer: formatsFooter) {
|
||||
formatsPicker
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var nameHeader: some View {
|
||||
#if os(macOS)
|
||||
Text("Name")
|
||||
#endif
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ViewBuilder var qualityPicker: some View {
|
||||
let picker = Picker("Resolution", selection: $resolution) {
|
||||
ForEach(availableResolutions, id: \.self) { resolution in
|
||||
Text(resolution.description).tag(resolution)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
Text("Resolution")
|
||||
Spacer()
|
||||
Menu {
|
||||
picker
|
||||
} label: {
|
||||
Text(resolution.description)
|
||||
.frame(minWidth: 120, alignment: .trailing)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
#else
|
||||
picker
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
var qualityButton: some View {
|
||||
Button(resolution.description) {
|
||||
resolution = resolution.next()
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(availableResolutions, id: \.self) { resolution in
|
||||
Button(resolution.description) {
|
||||
self.resolution = resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var availableResolutions: [ResolutionSetting] {
|
||||
ResolutionSetting.allCases.filter { !isResolutionDisabled($0) }
|
||||
}
|
||||
|
||||
@ViewBuilder var backendPicker: some View {
|
||||
let picker = Picker("Backend", selection: $backend) {
|
||||
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
|
||||
Text(backend.label).tag(backend)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
Text("Backend")
|
||||
Spacer()
|
||||
Menu {
|
||||
picker
|
||||
} label: {
|
||||
Text(backend.label)
|
||||
.frame(minWidth: 120, alignment: .trailing)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
#else
|
||||
picker
|
||||
#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
|
||||
|
||||
list.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
Spacer()
|
||||
#else
|
||||
filteredFormatList
|
||||
#endif
|
||||
}
|
||||
|
||||
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
|
||||
return orderedFormats.first { $0.format == format }?.isActive ?? false
|
||||
}
|
||||
|
||||
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
|
||||
if let index = orderedFormats.firstIndex(where: { $0.format == format }) {
|
||||
orderedFormats[index].isActive = value
|
||||
}
|
||||
validate() // Check validity after a toggle operation
|
||||
}
|
||||
|
||||
var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
#if os(tvOS)
|
||||
.padding(.top, 30)
|
||||
#endif
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
var editing: Bool {
|
||||
!qualityProfile.isNil
|
||||
}
|
||||
|
||||
func isFormatDisabled(_ format: QualityProfile.Format) -> Bool {
|
||||
guard backend == .appleAVPlayer else { return false }
|
||||
|
||||
let avPlayerFormats = [.stream, QualityProfile.Format.hls]
|
||||
|
||||
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 }
|
||||
|
||||
let hd720p30 = Stream.Resolution.predefined(.hd720p30)
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if isResolutionDisabled(resolution),
|
||||
let newResolution = availableResolutions.first
|
||||
{
|
||||
resolution = newResolution
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else { return }
|
||||
|
||||
let activeFormats = orderedFormats.filter(\.isActive).map(\.format)
|
||||
|
||||
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)! }
|
||||
)
|
||||
|
||||
if editing {
|
||||
QualityProfilesModel.shared.update(qualityProfile, formProfile)
|
||||
} else {
|
||||
let wasEmpty = qualityProfiles.isEmpty
|
||||
QualityProfilesModel.shared.add(formProfile)
|
||||
|
||||
if wasEmpty {
|
||||
QualityProfilesModel.shared.applyToAll(formProfile)
|
||||
}
|
||||
}
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityProfileForm_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
QualityProfileForm(qualityProfileID: .constant(QualityProfile.defaultProfile.id))
|
||||
.environment(\.navigationStyle, .tab)
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct QualitySettings: View {
|
||||
@State private var presentingProfileForm = false
|
||||
@State private var editedProfileID: QualityProfile.ID?
|
||||
|
||||
@ObservedObject private var settings = SettingsModel.shared
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfiles
|
||||
@Default(.batteryCellularProfile) private var batteryCellularProfile
|
||||
@Default(.batteryNonCellularProfile) private var batteryNonCellularProfile
|
||||
@Default(.chargingCellularProfile) private var chargingCellularProfile
|
||||
@Default(.chargingNonCellularProfile) private var chargingNonCellularProfile
|
||||
@Default(.forceAVPlayerForLiveStreams) private var forceAVPlayerForLiveStreams
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $presentingProfileForm) {
|
||||
QualityProfileForm(qualityProfileID: $editedProfileID)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("Quality")
|
||||
}
|
||||
|
||||
var sections: some View {
|
||||
Group {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
Section(header: Text("Default Profile")) {
|
||||
Text("\(QualityProfilesModel.shared.tvOSProfile?.description ?? "None")")
|
||||
}
|
||||
#elseif os(iOS)
|
||||
if UIDevice.current.hasCellularCapabilites {
|
||||
Section(header: Text("Battery")) {
|
||||
Picker("Wi-Fi", selection: $batteryNonCellularProfile) { profilePickerOptions }
|
||||
Picker("Cellular", selection: $batteryCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
Section(header: Text("Charging")) {
|
||||
Picker("Wi-Fi", selection: $chargingNonCellularProfile) { profilePickerOptions }
|
||||
Picker("Cellular", selection: $chargingCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
} else {
|
||||
nonCellularBatteryDevicesProfilesPickers
|
||||
}
|
||||
#else
|
||||
if Power.hasInternalBattery {
|
||||
nonCellularBatteryDevicesProfilesPickers
|
||||
} else {
|
||||
Picker("Default", selection: $chargingNonCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
#endif
|
||||
|
||||
forceAVPlayerForLiveStreamsToggle
|
||||
}
|
||||
.disabled(qualityProfiles.isEmpty)
|
||||
Section(header: SettingsHeader(text: "Profiles".localized()), footer: profilesFooter) {
|
||||
profilesList
|
||||
|
||||
Button {
|
||||
editedProfileID = nil
|
||||
presentingProfileForm = true
|
||||
} label: {
|
||||
Label("Add profile...", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
settings.presentAlert(
|
||||
Alert(
|
||||
title: Text("Are you sure you want to restore default quality profiles?"),
|
||||
message: Text("This will remove all your custom profiles and return their default values. This cannot be reverted."),
|
||||
primaryButton: .destructive(Text("Reset")) {
|
||||
QualityProfilesModel.shared.reset()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Text("Restore default profiles...")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var nonCellularBatteryDevicesProfilesPickers: some View {
|
||||
Picker("Battery", selection: $batteryNonCellularProfile) { profilePickerOptions }
|
||||
Picker("Charging", selection: $chargingNonCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
|
||||
@ViewBuilder var forceAVPlayerForLiveStreamsToggle: some View {
|
||||
Toggle("Always use AVPlayer for live videos", isOn: $forceAVPlayerForLiveStreams)
|
||||
}
|
||||
|
||||
@ViewBuilder func profileControl(_ qualityProfile: QualityProfile) -> some View {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
QualityProfilesModel.shared.applyToAll(qualityProfile)
|
||||
} label: {
|
||||
Text(qualityProfile.description)
|
||||
}
|
||||
#else
|
||||
Text(qualityProfile.description)
|
||||
#endif
|
||||
}
|
||||
|
||||
var profilePickerOptions: some View {
|
||||
ForEach(qualityProfiles) { qualityProfile in
|
||||
Text(qualityProfile.description).tag(qualityProfile.id)
|
||||
}
|
||||
}
|
||||
|
||||
var profilesFooter: some View {
|
||||
#if os(tvOS)
|
||||
Text("You can switch between profiles in playback settings controls.")
|
||||
#else
|
||||
Text("You can use automatic profile selection based on current device status or switch it in video playback settings controls.")
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var profilesList: some View {
|
||||
let list = ForEach(qualityProfiles) { qualityProfile in
|
||||
profileControl(qualityProfile)
|
||||
.contextMenu {
|
||||
Button {
|
||||
QualityProfilesModel.shared.applyToAll(qualityProfile)
|
||||
} label: {
|
||||
#if os(tvOS)
|
||||
Text("Make default")
|
||||
#elseif os(iOS)
|
||||
Label("Apply to all", systemImage: "wand.and.stars")
|
||||
#else
|
||||
if Power.hasInternalBattery {
|
||||
Text("Apply to all")
|
||||
} else {
|
||||
Text("Make default")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Button {
|
||||
editedProfileID = qualityProfile.id
|
||||
presentingProfileForm = true
|
||||
} label: {
|
||||
Label("Edit...", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
QualityProfilesModel.shared.remove(qualityProfile)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
List {
|
||||
list
|
||||
}
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
#else
|
||||
list
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct QualitySettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
#if os(macOS)
|
||||
QualitySettings()
|
||||
#else
|
||||
NavigationView {
|
||||
EmptyView()
|
||||
QualitySettings()
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsHeader: View {
|
||||
var text: String
|
||||
var secondary = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if secondary {
|
||||
EmptyView()
|
||||
} else {
|
||||
Text(text.localized())
|
||||
}
|
||||
#else
|
||||
Text(text.localized())
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.font(secondary ? .footnote : .title3)
|
||||
.foregroundColor(.secondary)
|
||||
.focusable(false)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.font(secondary ? .system(size: 13) : .system(size: 15))
|
||||
.foregroundColor(secondary ? Color.primary : .secondary)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsHeader_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsHeader(text: "Header")
|
||||
}
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")!
|
||||
static let discordURL = URL(string: "https://yattee.stream/discord")!
|
||||
|
||||
#if os(macOS)
|
||||
private enum Tabs: Hashable {
|
||||
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, importExport, help
|
||||
}
|
||||
|
||||
@State private var selection: Tabs = .browsing
|
||||
#endif
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
#endif
|
||||
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var model = SettingsModel.shared
|
||||
|
||||
@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
|
||||
#if os(iOS)
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
#endif
|
||||
.alert(isPresented: $model.presentingAlert) { model.alert }
|
||||
}
|
||||
|
||||
var settings: some View {
|
||||
#if os(macOS)
|
||||
TabView(selection: $selection) {
|
||||
Form {
|
||||
BrowsingSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Browsing", systemImage: "list.and.film")
|
||||
}
|
||||
.tag(Tabs.browsing)
|
||||
|
||||
Form {
|
||||
PlayerSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Player", systemImage: "play.rectangle")
|
||||
}
|
||||
.tag(Tabs.player)
|
||||
|
||||
Form {
|
||||
PlayerControlsSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Controls", systemImage: "hand.tap")
|
||||
}
|
||||
.tag(Tabs.controls)
|
||||
|
||||
Form {
|
||||
QualitySettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Quality", systemImage: "4k.tv")
|
||||
}
|
||||
.tag(Tabs.quality)
|
||||
|
||||
Form {
|
||||
HistorySettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.tag(Tabs.history)
|
||||
|
||||
if !accounts.isEmpty {
|
||||
Form {
|
||||
SponsorBlockSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("SponsorBlock", systemImage: "dollarsign.circle")
|
||||
}
|
||||
.tag(Tabs.sponsorBlock)
|
||||
}
|
||||
Form {
|
||||
LocationsSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Locations", systemImage: "globe")
|
||||
}
|
||||
.tag(Tabs.locations)
|
||||
|
||||
Group {
|
||||
AdvancedSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Advanced", systemImage: "wrench.and.screwdriver")
|
||||
}
|
||||
.tag(Tabs.advanced)
|
||||
|
||||
Group {
|
||||
ExportSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.tag(Tabs.importExport)
|
||||
|
||||
Form {
|
||||
Help()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Help", systemImage: "questionmark.circle")
|
||||
}
|
||||
.tag(Tabs.help)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 700, height: windowHeight)
|
||||
#else
|
||||
NavigationView {
|
||||
settingsList
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme).ignoresSafeArea())
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
struct SettingsLabel: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
#if os(tvOS)
|
||||
Label {
|
||||
configuration.title.padding(.leading, 10)
|
||||
} icon: {
|
||||
configuration.icon
|
||||
}
|
||||
#else
|
||||
Label(configuration)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
var settingsList: some View {
|
||||
List {
|
||||
#if os(tvOS)
|
||||
if !accounts.isEmpty {
|
||||
Section(header: Text("Current Location")) {
|
||||
NavigationLink(destination: AccountsView()) {
|
||||
if let account = accounts.current {
|
||||
Text(account.isPublic ? account.description : "\(account.description) — \(account.instance.shortDescription)")
|
||||
} else {
|
||||
Text("Not Selected")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
#endif
|
||||
|
||||
Section {
|
||||
NavigationLink {
|
||||
BrowsingSettings()
|
||||
} label: {
|
||||
Label("Browsing", systemImage: "list.and.film").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
NavigationLink {
|
||||
PlayerSettings()
|
||||
} label: {
|
||||
Label("Player", systemImage: "play.rectangle").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
NavigationLink {
|
||||
PlayerControlsSettings()
|
||||
} label: {
|
||||
Label("Controls", systemImage: "hand.tap").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
NavigationLink {
|
||||
QualitySettings()
|
||||
} label: {
|
||||
Label("Quality", systemImage: "4k.tv").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
NavigationLink {
|
||||
HistorySettings()
|
||||
} label: {
|
||||
Label("History", systemImage: "clock.arrow.circlepath").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
if !accounts.isEmpty {
|
||||
NavigationLink {
|
||||
SponsorBlockSettings()
|
||||
} label: {
|
||||
Label("SponsorBlock", systemImage: "dollarsign.circle").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
LocationsSettings()
|
||||
} label: {
|
||||
Label("Locations", systemImage: "globe").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
NavigationLink {
|
||||
AdvancedSettings()
|
||||
} label: {
|
||||
Label("Advanced", systemImage: "wrench.and.screwdriver").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 20)
|
||||
#endif
|
||||
|
||||
importView
|
||||
|
||||
Section(footer: helpFooter) {
|
||||
NavigationLink {
|
||||
Help()
|
||||
} label: {
|
||||
Label("Help", systemImage: "questionmark.circle").labelStyle(SettingsLabel())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 20)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
Section(header: Text("Contact"), footer: versionString) {
|
||||
Link(destination: Self.discordURL) {
|
||||
HStack {
|
||||
Image("Discord")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.frame(maxWidth: 30, maxHeight: 30)
|
||||
.padding(.trailing, 6)
|
||||
Text("Discord Server")
|
||||
}
|
||||
}
|
||||
|
||||
Link(destination: Self.matrixURL) {
|
||||
HStack {
|
||||
Image("Matrix")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.frame(maxWidth: 30, maxHeight: 30)
|
||||
.padding(.trailing, 6)
|
||||
Text("Matrix Chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
#if !os(tvOS)
|
||||
Button("Done") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
var importView: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
NavigationLink(destination: LazyView(ImportSettings())) {
|
||||
Label("Import Settings", systemImage: "square.and.arrow.down")
|
||||
.labelStyle(SettingsLabel())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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 850
|
||||
case .controls:
|
||||
return 970
|
||||
case .quality:
|
||||
return 450
|
||||
case .history:
|
||||
return 600
|
||||
case .sponsorBlock:
|
||||
return 980
|
||||
case .locations:
|
||||
return 600
|
||||
case .advanced:
|
||||
return 700
|
||||
case .importExport:
|
||||
return 580
|
||||
case .help:
|
||||
return 650
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var helpFooter: some View {
|
||||
#if os(tvOS)
|
||||
versionString
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var versionString: some View {
|
||||
Text("Yattee \(YatteeApp.version) (build \(YatteeApp.build))")
|
||||
#if os(tvOS)
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
#if os(macOS)
|
||||
.frame(width: 600, height: 300)
|
||||
#else
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
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 {
|
||||
sections
|
||||
}
|
||||
#if os(tvOS)
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
.toggleStyle(TVOSPlainToggleStyle())
|
||||
.frame(maxWidth: 1000)
|
||||
#else
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.navigationTitle("SponsorBlock")
|
||||
}
|
||||
|
||||
private var sections: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "SponsorBlock API")) {
|
||||
TextField(
|
||||
"SponsorBlock API Instance",
|
||||
text: $sponsorBlockInstance
|
||||
)
|
||||
.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())) {
|
||||
categoryRows
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
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)
|
||||
}
|
||||
#endif
|
||||
|
||||
Section(footer: categoriesDetails) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
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) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var categoriesDetails: some View {
|
||||
VStack(alignment: .leading) {
|
||||
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)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
if let index = sponsorBlockCategories.firstIndex(where: { $0 == category }), !value {
|
||||
sponsorBlockCategories.remove(at: index)
|
||||
} else if value {
|
||||
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
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
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)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func resetColors() {
|
||||
sponsorBlockColors = SponsorBlockColors.dictionary
|
||||
}
|
||||
}
|
||||
|
||||
struct SponsorBlockSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
SponsorBlockSettings()
|
||||
}
|
||||
.frame(maxHeight: 600)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(tvOS)
|
||||
struct TVOSPlainToggleStyle: ToggleStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: { configuration.isOn.toggle() }) {
|
||||
HStack {
|
||||
configuration.label
|
||||
Spacer()
|
||||
Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(configuration.isOn ? .accentColor : .secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user