Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent d94a50f8c3
commit 100df744d9
1043 changed files with 163886 additions and 68471 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")!))
}
}

View File

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

View File

@@ -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")!)
)
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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