Files
yattee/Yattee/Views/Settings/PlayerControls/PresetSelectorView.swift
Arkadiusz Fal 9e2b0fa095 Extend opaque background to settings and other pushed pages on macOS
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.

Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-07 22:16:09 +02:00

506 lines
16 KiB
Swift

//
// PresetSelectorView.swift
// Yattee
//
// View for selecting and managing player controls presets.
//
import SwiftUI
#if !os(tvOS)
import UniformTypeIdentifiers
#endif
// MARK: - Export File Wrapper
#if !os(tvOS)
private struct ExportFile: Identifiable {
let id = UUID()
let url: URL
}
#endif
/// Pending preset creation request
private struct PendingPresetCreation: Equatable {
let name: String
let basePresetID: UUID?
}
/// Pending preset rename request
private struct PendingPresetRename: Equatable {
let presetID: UUID
let newName: String
}
/// Notification posted when a preset is selected in PresetSelectorView
extension Notification.Name {
static let presetSelectionDidChange = Notification.Name("presetSelectionDidChange")
}
/// View for selecting and managing player controls layout presets.
struct PresetSelectorView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
var onPresetSelected: ((String) -> Void)?
@State private var showCreateSheet = false
@State private var presetToRename: LayoutPreset?
@State private var pendingCreation: PendingPresetCreation?
@State private var pendingRename: PendingPresetRename?
@State private var listRefreshID = UUID()
// Track active preset ID locally to force view updates
@State private var trackedActivePresetID: UUID?
// Import/Export state
#if !os(tvOS)
@State private var showingImportPicker = false
@State private var isImporting = false
@State private var importError: String?
@State private var showingImportError = false
@State private var importedPresetName: String?
@State private var showingImportSuccess = false
@State private var exportFile: ExportFile?
#endif
var body: some View {
presetList
.opaqueSettingsFormBackground()
.navigationTitle(String(localized: "settings.playerControls.presets"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { toolbarContent }
.sheet(isPresented: $showCreateSheet) { createPresetSheet }
.sheet(item: $presetToRename) { preset in renamePresetSheet(preset) }
#if os(iOS)
.sheet(isPresented: $showingImportPicker) { importPickerSheet }
.sheet(item: $exportFile) { file in ShareSheet(items: [file.url]) }
#endif
#if !os(tvOS)
.alert(
String(localized: "settings.playerControls.import.error.title"),
isPresented: $showingImportError
) {
Button(String(localized: "settings.playerControls.ok")) {}
} message: {
if let error = importError {
Text(error)
}
}
.alert(
String(localized: "settings.playerControls.import.success.title"),
isPresented: $showingImportSuccess
) {
Button(String(localized: "settings.playerControls.ok")) {}
} message: {
if let name = importedPresetName {
Text(String(localized: "settings.playerControls.import.success.message \(name)"))
}
}
#endif
.task(id: pendingCreation) {
guard let creation = pendingCreation else { return }
let basePreset = creation.basePresetID.flatMap { id in
viewModel.presets.first { $0.id == id }
}
await viewModel.createPreset(name: creation.name, basedOn: basePreset)
pendingCreation = nil
// Force view update by updating local tracked state
trackedActivePresetID = viewModel.activePreset?.id
listRefreshID = UUID()
// Notify parent of selection change
if let name = viewModel.activePreset?.name {
onPresetSelected?(name)
NotificationCenter.default.post(name: .presetSelectionDidChange, object: name)
}
}
.task(id: pendingRename) {
guard let rename = pendingRename else { return }
if let preset = viewModel.presets.first(where: { $0.id == rename.presetID }) {
await viewModel.renamePreset(preset, to: rename.newName)
}
pendingRename = nil
listRefreshID = UUID()
}
.onAppear {
trackedActivePresetID = viewModel.activePreset?.id
}
}
// MARK: - View Components
private var presetList: some View {
List {
builtInPresetsSection
customPresetsSection
}
.id(listRefreshID)
}
private var builtInPresetsSection: some View {
Section {
ForEach(viewModel.builtInPresets) { preset in
PresetRow(
preset: preset,
isActive: preset.id == trackedActivePresetID,
onSelect: {
viewModel.selectPreset(preset)
trackedActivePresetID = preset.id
onPresetSelected?(preset.name)
NotificationCenter.default.post(name: .presetSelectionDidChange, object: preset.name)
}
)
}
} header: {
Text(String(localized: "settings.playerControls.builtInPresets"))
}
}
@ViewBuilder
private var customPresetsSection: some View {
if !viewModel.customPresets.isEmpty {
Section {
ForEach(viewModel.customPresets) { preset in
customPresetRow(preset)
}
} header: {
Text(String(localized: "settings.playerControls.customPresets"))
}
}
}
private func customPresetRow(_ preset: LayoutPreset) -> some View {
PresetRow(
preset: preset,
isActive: preset.id == trackedActivePresetID,
onSelect: {
viewModel.selectPreset(preset)
trackedActivePresetID = preset.id
onPresetSelected?(preset.name)
NotificationCenter.default.post(name: .presetSelectionDidChange, object: preset.name)
},
onRename: { presetToRename = preset },
onExport: {
#if !os(tvOS)
exportPreset(preset)
#endif
},
onDelete: {
Task { await viewModel.deletePreset(preset) }
},
canDelete: preset.id != trackedActivePresetID
)
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
#if !os(tvOS)
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showCreateSheet = true
} label: {
Label(
String(localized: "settings.playerControls.newPreset"),
systemImage: "plus"
)
}
Divider()
Button {
showImportPicker()
} label: {
Label(
String(localized: "settings.playerControls.importPreset"),
systemImage: "square.and.arrow.down"
)
}
.disabled(isImporting)
} label: {
Label(
String(localized: "settings.playerControls.newPreset"),
systemImage: "plus"
)
}
}
#else
ToolbarItem(placement: .primaryAction) {
Button {
showCreateSheet = true
} label: {
Label(
String(localized: "settings.playerControls.newPreset"),
systemImage: "plus"
)
}
}
#endif
}
private var createPresetSheet: some View {
PresetEditorView(
mode: .create(
baseLayouts: viewModel.presets,
activePreset: viewModel.activePreset
),
onSave: { name, basePresetID in
pendingCreation = PendingPresetCreation(name: name, basePresetID: basePresetID)
}
)
}
private func renamePresetSheet(_ preset: LayoutPreset) -> some View {
let presetID = preset.id
return PresetEditorView(
mode: .rename(currentName: preset.name),
onSave: { name, _ in
pendingRename = PendingPresetRename(presetID: presetID, newName: name)
}
)
}
#if os(iOS)
private var importPickerSheet: some View {
PresetFilePickerView { url in
handleImportedFile(url)
}
}
#endif
// MARK: - Import/Export Actions
#if !os(tvOS)
private func showImportPicker() {
#if os(iOS)
showingImportPicker = true
#elseif os(macOS)
showMacOSImportPanel()
#endif
}
#if os(macOS)
private func showMacOSImportPanel() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = String(localized: "settings.playerControls.import.panel.message")
if panel.runModal() == .OK, let url = panel.url {
handleImportedFile(url)
}
}
#endif
private func handleImportedFile(_ url: URL) {
isImporting = true
Task {
do {
let presetName = try await viewModel.importPreset(from: url)
await MainActor.run {
isImporting = false
importedPresetName = presetName
showingImportSuccess = true
}
} catch {
await MainActor.run {
isImporting = false
importError = error.localizedDescription
showingImportError = true
}
}
}
}
private func exportPreset(_ preset: LayoutPreset) {
guard let url = viewModel.exportPreset(preset) else { return }
#if os(iOS)
exportFile = ExportFile(url: url)
#elseif os(macOS)
showMacOSSavePanel(url: url, preset: preset)
#endif
}
#if os(macOS)
private func showMacOSSavePanel(url: URL, preset: LayoutPreset) {
let panel = NSSavePanel()
panel.nameFieldStringValue = PlayerControlsPresetExportImport.generateExportFilename(for: preset)
panel.allowedContentTypes = [.json]
if panel.runModal() == .OK, let saveURL = panel.url {
do {
// Remove existing file if it exists (NSSavePanel asks for confirmation)
if FileManager.default.fileExists(atPath: saveURL.path) {
try FileManager.default.removeItem(at: saveURL)
}
try FileManager.default.copyItem(at: url, to: saveURL)
} catch {
LoggingService.shared.error("Failed to save preset file: \(error.localizedDescription)")
}
}
}
#endif
#endif
}
// MARK: - Preset Row
private struct PresetRow: View {
let preset: LayoutPreset
let isActive: Bool
let onSelect: () -> Void
var onRename: (() -> Void)? = nil
var onExport: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil
var canDelete: Bool = true
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(preset.name)
.foregroundStyle(.primary)
if preset.isBuiltIn {
Text(String(localized: "settings.playerControls.builtIn"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if isActive {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if let onDelete, !preset.isBuiltIn, canDelete {
Button(role: .destructive) {
onDelete()
} label: {
Label(
String(localized: "settings.playerControls.delete"),
systemImage: "trash"
)
}
}
if let onRename, !preset.isBuiltIn {
Button {
onRename()
} label: {
Label(
String(localized: "settings.playerControls.rename"),
systemImage: "pencil"
)
}
.tint(.orange)
}
if let onExport, !preset.isBuiltIn {
Button {
onExport()
} label: {
Label(
String(localized: "settings.playerControls.exportPreset"),
systemImage: "square.and.arrow.up"
)
}
.tint(.blue)
}
}
#endif
.contextMenu {
if let onRename, !preset.isBuiltIn {
Button {
onRename()
} label: {
Label(
String(localized: "settings.playerControls.rename"),
systemImage: "pencil"
)
}
}
if let onExport, !preset.isBuiltIn {
Button {
onExport()
} label: {
Label(
String(localized: "settings.playerControls.exportPreset"),
systemImage: "square.and.arrow.up"
)
}
}
if let onDelete, !preset.isBuiltIn, canDelete {
Divider()
Button(role: .destructive) {
onDelete()
} label: {
Label(
String(localized: "settings.playerControls.delete"),
systemImage: "trash"
)
}
}
}
}
}
// MARK: - File Picker (iOS)
#if os(iOS)
private struct PresetFilePickerView: UIViewControllerRepresentable {
let onSelect: (URL) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.json])
picker.delegate = context.coordinator
picker.allowsMultipleSelection = false
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let onSelect: (URL) -> Void
init(onSelect: @escaping (URL) -> Void) {
self.onSelect = onSelect
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onSelect(url)
}
}
}
#endif
// MARK: - Preview
#Preview {
NavigationStack {
PresetSelectorView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}