mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
485
Yattee/Views/Settings/AdvancedSettingsView.swift
Normal file
485
Yattee/Views/Settings/AdvancedSettingsView.swift
Normal file
@@ -0,0 +1,485 @@
|
||||
//
|
||||
// AdvancedSettingsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Advanced settings including debugging and logging options.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Nuke
|
||||
|
||||
struct AdvancedSettingsView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@State private var showingClearDataConfirmation = false
|
||||
@State private var userAgentText: String = ""
|
||||
|
||||
// Orphaned files state
|
||||
@State private var orphanedFilesCount: Int = 0
|
||||
@State private var orphanedFilesSize: Int64 = 0
|
||||
@State private var isScanning = false
|
||||
@State private var showingOrphanCleanupConfirmation = false
|
||||
@State private var showingOrphanCleanupResult = false
|
||||
@State private var orphanCleanupResult: (deleted: Int, freed: Int64)?
|
||||
|
||||
// Storage diagnostics state
|
||||
@State private var storageDiagnostics: StorageDiagnostics?
|
||||
@State private var isScanningStorage = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
streamDetailsSection
|
||||
mpvSection
|
||||
settingsSection
|
||||
#if !os(tvOS)
|
||||
downloadsStorageSection
|
||||
#endif
|
||||
developerSection
|
||||
}
|
||||
.navigationTitle(String(localized: "settings.advanced.title"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.confirmationDialog(
|
||||
String(localized: "settings.advanced.data.clearCache.confirmation"),
|
||||
isPresented: $showingClearDataConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "settings.advanced.data.clearCache"), role: .destructive) {
|
||||
clearCache()
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
userAgentText = settingsManager.customUserAgent
|
||||
#if !os(tvOS)
|
||||
scanForOrphanedFiles()
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.confirmationDialog(
|
||||
String(localized: "settings.advanced.storage.deleteOrphaned.title"),
|
||||
isPresented: $showingOrphanCleanupConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "settings.advanced.storage.deleteOrphaned.action \(orphanedFilesCount)"), role: .destructive) {
|
||||
deleteOrphanedFiles()
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "settings.advanced.storage.deleteOrphaned.message \(orphanedFilesCount) \(formatBytes(orphanedFilesSize))"))
|
||||
}
|
||||
.alert(
|
||||
String(localized: "settings.advanced.storage.cleanupComplete"),
|
||||
isPresented: $showingOrphanCleanupResult
|
||||
) {
|
||||
Button(String(localized: "common.ok"), role: .cancel) {}
|
||||
} message: {
|
||||
if let result = orphanCleanupResult {
|
||||
Text(String(localized: "settings.advanced.storage.cleanupResult \(result.deleted) \(formatBytes(result.freed))"))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@ViewBuilder
|
||||
private var settingsSection: some View {
|
||||
if let settingsManager = appEnvironment?.settingsManager {
|
||||
userAgentSection(settingsManager: settingsManager)
|
||||
deviceNameSection
|
||||
feedSection(settingsManager: settingsManager)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func feedSection(settingsManager: SettingsManager) -> some View {
|
||||
Section {
|
||||
Picker(selection: Binding(
|
||||
get: { settingsManager.feedCacheValidityMinutes },
|
||||
set: { settingsManager.feedCacheValidityMinutes = $0 }
|
||||
)) {
|
||||
ForEach(Self.feedCacheValidityOptions, id: \.minutes) { option in
|
||||
Text(option.label).tag(option.minutes)
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "settings.advanced.feed.cacheValidity"), systemImage: "clock")
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "settings.advanced.feed.sectionTitle"))
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(String(localized: "settings.advanced.feed.footer"))
|
||||
if let lastCheck = settingsManager.lastBackgroundCheck {
|
||||
Text(String(localized: "settings.advanced.feed.lastBackgroundRefresh \(lastCheck.formatted(date: .abbreviated, time: .shortened))"))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(String(localized: "settings.advanced.feed.lastBackgroundRefresh.never"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func userAgentSection(settingsManager: SettingsManager) -> some View {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { settingsManager.randomizeUserAgentPerRequest },
|
||||
set: {
|
||||
settingsManager.randomizeUserAgentPerRequest = $0
|
||||
appEnvironment?.updateUserAgent()
|
||||
}
|
||||
)) {
|
||||
Label(String(localized: "settings.advanced.userAgent.randomizePerRequest"), systemImage: "shuffle")
|
||||
}
|
||||
|
||||
if !settingsManager.randomizeUserAgentPerRequest {
|
||||
TextField(
|
||||
String(localized: "settings.advanced.userAgent.placeholder"),
|
||||
text: $userAgentText,
|
||||
axis: .vertical
|
||||
)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.lineLimit(3...6)
|
||||
.onChange(of: userAgentText) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
settingsManager.customUserAgent = trimmed
|
||||
appEnvironment?.updateUserAgent()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
settingsManager.randomizeUserAgent()
|
||||
userAgentText = settingsManager.customUserAgent
|
||||
appEnvironment?.updateUserAgent()
|
||||
} label: {
|
||||
Label(String(localized: "settings.advanced.userAgent.randomize"), systemImage: "arrow.trianglehead.2.clockwise")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "settings.advanced.userAgent.sectionTitle"))
|
||||
} footer: {
|
||||
Text(String(localized: "settings.advanced.userAgent.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var streamDetailsSection: some View {
|
||||
if let settingsManager = appEnvironment?.settingsManager {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { settingsManager.showAdvancedStreamDetails },
|
||||
set: { settingsManager.showAdvancedStreamDetails = $0 }
|
||||
)) {
|
||||
Label(String(localized: "settings.advanced.stream.showDetails"), systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "settings.advanced.stream.showDetails.footer"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mpvSection: some View {
|
||||
if let settingsManager = appEnvironment?.settingsManager {
|
||||
Section {
|
||||
Picker(selection: Binding(
|
||||
get: { settingsManager.mpvBufferSeconds },
|
||||
set: { settingsManager.mpvBufferSeconds = $0 }
|
||||
)) {
|
||||
ForEach(Self.mpvBufferOptions, id: \.self) { seconds in
|
||||
Text(formatBufferOption(seconds)).tag(seconds)
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "settings.advanced.mpv.buffer"), systemImage: "hourglass")
|
||||
}
|
||||
|
||||
Toggle(isOn: Binding(
|
||||
get: { settingsManager.mpvUseEDLStreams },
|
||||
set: { settingsManager.mpvUseEDLStreams = $0 }
|
||||
)) {
|
||||
Label(String(localized: "settings.advanced.mpv.edl"), systemImage: "arrow.trianglehead.merge")
|
||||
}
|
||||
|
||||
Toggle(isOn: Binding(
|
||||
get: { settingsManager.dashEnabled },
|
||||
set: { settingsManager.dashEnabled = $0 }
|
||||
)) {
|
||||
Label(String(localized: "settings.playback.dash"), systemImage: "bolt.horizontal")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MPVOptionsSettingsView()
|
||||
} label: {
|
||||
Label(String(localized: "settings.advanced.mpv.options"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "settings.advanced.mpv.title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let mpvBufferOptions: [Double] = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||
|
||||
private func formatBufferOption(_ seconds: Double) -> String {
|
||||
if seconds == 1.0 {
|
||||
return String(localized: "settings.advanced.mpv.bufferSecond")
|
||||
} else {
|
||||
return String(localized: "settings.advanced.mpv.bufferSeconds \(Int(seconds))")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
@ViewBuilder
|
||||
private var downloadsStorageSection: some View {
|
||||
Section {
|
||||
// Storage diagnostic button
|
||||
Button {
|
||||
runStorageDiagnostics()
|
||||
} label: {
|
||||
HStack {
|
||||
Label(String(localized: "settings.advanced.storage.scan"), systemImage: "internaldrive")
|
||||
Spacer()
|
||||
if isScanningStorage {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if let diagnostics = storageDiagnostics {
|
||||
Text(diagnostics.formattedTotal)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isScanningStorage)
|
||||
|
||||
// Show storage breakdown if scanned
|
||||
if let diagnostics = storageDiagnostics {
|
||||
ForEach(diagnostics.items.sorted(by: { $0.size > $1.size }).prefix(10)) { item in
|
||||
HStack {
|
||||
Text(item.name)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(formatBytes(item.size))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache button
|
||||
Button(role: .destructive) {
|
||||
showingClearDataConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "settings.advanced.data.clearCache"), systemImage: "trash")
|
||||
}
|
||||
|
||||
// Delete orphaned files button
|
||||
Button(role: .destructive) {
|
||||
showingOrphanCleanupConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label(String(localized: "settings.advanced.storage.deleteOrphaned"), systemImage: "trash")
|
||||
Spacer()
|
||||
if isScanning {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(orphanedFilesCount == 0 || isScanning)
|
||||
} header: {
|
||||
Text(String(localized: "settings.advanced.storage.title"))
|
||||
} footer: {
|
||||
if isScanning {
|
||||
Text(String(localized: "settings.advanced.storage.scanning"))
|
||||
} else if orphanedFilesCount > 0 {
|
||||
Text(String(localized: "settings.advanced.storage.foundOrphaned \(orphanedFilesCount) \(formatBytes(orphanedFilesSize))"))
|
||||
} else if storageDiagnostics != nil {
|
||||
Text(String(localized: "settings.advanced.storage.noOrphaned"))
|
||||
} else {
|
||||
Text(String(localized: "settings.advanced.storage.tapToScan"))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private var deviceNameSection: some View {
|
||||
if let settingsManager = appEnvironment?.settingsManager {
|
||||
Section {
|
||||
TextField(
|
||||
LocalNetworkService.systemDeviceName,
|
||||
text: Binding(
|
||||
get: { settingsManager.remoteControlCustomDeviceName },
|
||||
set: { newValue in
|
||||
settingsManager.remoteControlCustomDeviceName = newValue
|
||||
appEnvironment?.localNetworkService.updateDeviceName()
|
||||
}
|
||||
)
|
||||
)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.words)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
Toggle(isOn: Binding(
|
||||
get: { settingsManager.remoteControlHideWhenBackgrounded },
|
||||
set: { settingsManager.remoteControlHideWhenBackgrounded = $0 }
|
||||
)) {
|
||||
Label(String(localized: "remoteControl.hideWhenBackgrounded"), systemImage: "moon.fill")
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "settings.advanced.remoteControl.sectionTitle"))
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(String(localized: "settings.advanced.remoteControl.footer"))
|
||||
#if os(iOS) || os(tvOS)
|
||||
Text(String(localized: "settings.advanced.remoteControl.hideWhenBackgrounded.footer"))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var developerSection: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
DeveloperSettingsView()
|
||||
} label: {
|
||||
Label(String(localized: "settings.developer.title"), systemImage: "hammer")
|
||||
}
|
||||
|
||||
if appEnvironment?.legacyMigrationService.hasLegacyData() == true {
|
||||
NavigationLink {
|
||||
LegacyDataImportView()
|
||||
} label: {
|
||||
Label(String(localized: "settings.advanced.data.importLegacy"), systemImage: "arrow.up.doc")
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "settings.developer.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var settingsManager: SettingsManager {
|
||||
appEnvironment?.settingsManager ?? SettingsManager()
|
||||
}
|
||||
|
||||
// MARK: - Feed Cache Options
|
||||
|
||||
private static let feedCacheValidityOptions: [(minutes: Int, label: String)] = [
|
||||
(5, String(localized: "settings.advanced.feed.cacheValidity.5min")),
|
||||
(15, String(localized: "settings.advanced.feed.cacheValidity.15min")),
|
||||
(30, String(localized: "settings.advanced.feed.cacheValidity.30min")),
|
||||
(60, String(localized: "settings.advanced.feed.cacheValidity.1hour")),
|
||||
(120, String(localized: "settings.advanced.feed.cacheValidity.2hours")),
|
||||
(360, String(localized: "settings.advanced.feed.cacheValidity.6hours")),
|
||||
(720, String(localized: "settings.advanced.feed.cacheValidity.12hours")),
|
||||
(1440, String(localized: "settings.advanced.feed.cacheValidity.24hours")),
|
||||
]
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func clearCache() {
|
||||
Task {
|
||||
// Clear Nuke image cache
|
||||
ImageLoadingService.shared.clearCache()
|
||||
|
||||
// Clear feed cache
|
||||
await FeedCache.shared.clear()
|
||||
await MainActor.run {
|
||||
SubscriptionFeedCache.shared.clear()
|
||||
}
|
||||
|
||||
// Clear DeArrow cache
|
||||
await appEnvironment?.deArrowBrandingProvider.clearCache()
|
||||
|
||||
// Clear URL cache
|
||||
URLCache.shared.removeAllCachedResponses()
|
||||
|
||||
// Clear temp directory (can contain large leftover files from downloads/playback)
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
if let contents = try? FileManager.default.contentsOfDirectory(at: tempURL, includingPropertiesForKeys: nil, options: []) {
|
||||
for item in contents {
|
||||
try? FileManager.default.removeItem(at: item)
|
||||
}
|
||||
LoggingService.shared.info("Cleared \(contents.count) temp files", category: .general)
|
||||
}
|
||||
|
||||
// Clear system cache directories
|
||||
if let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
let systemCacheDirs = [
|
||||
"com.apple.nsurlsessiond",
|
||||
"com.apple.nsurlcache",
|
||||
"fsCachedData"
|
||||
]
|
||||
for dirName in systemCacheDirs {
|
||||
let dirURL = cachesURL.appendingPathComponent(dirName)
|
||||
if FileManager.default.fileExists(atPath: dirURL.path) {
|
||||
try? FileManager.default.removeItem(at: dirURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log the action
|
||||
LoggingService.shared.info("All caches and temp files cleared by user", category: .general)
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private func scanForOrphanedFiles() {
|
||||
guard let downloadManager = appEnvironment?.downloadManager else { return }
|
||||
isScanning = true
|
||||
let info = downloadManager.findOrphanedFiles()
|
||||
orphanedFilesCount = info.orphanedFiles.count
|
||||
orphanedFilesSize = info.totalOrphanedSize
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
private func deleteOrphanedFiles() {
|
||||
guard let downloadManager = appEnvironment?.downloadManager else { return }
|
||||
Task {
|
||||
let result = await downloadManager.deleteOrphanedFiles()
|
||||
orphanCleanupResult = (result.deletedCount, result.bytesFreed)
|
||||
showingOrphanCleanupResult = true
|
||||
// Rescan after cleanup
|
||||
scanForOrphanedFiles()
|
||||
// Also refresh storage diagnostics
|
||||
runStorageDiagnostics()
|
||||
}
|
||||
}
|
||||
|
||||
private func runStorageDiagnostics() {
|
||||
isScanningStorage = true
|
||||
let diagnostics = scanAppStorage()
|
||||
diagnostics.logDiagnostics()
|
||||
storageDiagnostics = diagnostics
|
||||
isScanningStorage = false
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AdvancedSettingsView()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user