Files
yattee/Yattee/Views/Settings/PlayerControls/PresetEditorView.swift
Arkadiusz Fal 011ffee53c Let the words fit the things they describe
tvOS UX campaign polish (TV1, TV3, TV5, TV6):
- Invidious sign-in asks for a Username, which is what it verifies;
  the wire field is still called email, noted where it is sent
- Video counts pluralize ("1 video"), via catalog plural variations;
  the two-argument count-and-duration key uses fully positional
  substitutions, since a mixed format made CFRetain fault on the
  integer argument (TV10)
- Character counters render their limits verbatim ("0/1000"), here
  and in the preset-name and bookmark-note counters with the same bug
- Deleting a playlist, local or remote, names the playlist the way
  removing an account already named the account
2026-09-10 20:34:46 +02:00

233 lines
6.2 KiB
Swift

//
// PresetEditorView.swift
// Yattee
//
// Sheet view for creating, renaming, or duplicating a preset.
//
import SwiftUI
/// Mode for the preset editor.
enum PresetEditorMode {
case create(baseLayouts: [LayoutPreset], activePreset: LayoutPreset?)
case rename(currentName: String)
var title: String {
switch self {
case .create:
return String(localized: "settings.playerControls.newPreset")
case .rename:
return String(localized: "settings.playerControls.renamePreset")
}
}
var placeholder: String {
switch self {
case .create, .rename:
return String(localized: "settings.playerControls.presetNamePlaceholder")
}
}
var initialValue: String {
switch self {
case .create:
return ""
case .rename(let currentName):
return currentName
}
}
var saveButtonTitle: String {
switch self {
case .create:
return String(localized: "settings.playerControls.create")
case .rename:
return String(localized: "settings.playerControls.save")
}
}
}
/// Sheet view for creating or renaming a preset.
struct PresetEditorView: View {
@Environment(\.dismiss) private var dismiss
let mode: PresetEditorMode
let onSave: (String, UUID?) -> Void
@State private var name: String
@State private var selectedBaseLayoutID: UUID?
@FocusState private var isNameFocused: Bool
private var baseLayouts: [LayoutPreset] {
if case .create(let layouts, _) = mode {
return layouts
}
return []
}
init(mode: PresetEditorMode, onSave: @escaping (String, UUID?) -> Void) {
self.mode = mode
self.onSave = onSave
_name = State(initialValue: mode.initialValue)
// Set default selection to active preset for create mode
if case .create(_, let activePreset) = mode {
_selectedBaseLayoutID = State(initialValue: activePreset?.id)
}
}
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var isValid: Bool {
!trimmedName.isEmpty && trimmedName.count <= LayoutPreset.maxNameLength
}
var body: some View {
#if os(macOS)
macOSDialog
#else
NavigationStack {
Form {
// Base Layout Picker (only for create mode)
if case .create(_, _) = mode {
Section {
basePicker
}
}
Section {
nameField
} footer: {
nameFooter
}
}
.navigationTitle(mode.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "settings.playerControls.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(mode.saveButtonTitle) {
saveIfValid()
}
.disabled(!isValid)
}
}
.onAppear {
isNameFocused = true
}
}
#if os(iOS)
.presentationDetents([.medium])
#endif
#endif
}
// MARK: - Shared Controls
private var basePicker: some View {
Picker(
String(localized: "settings.playerControls.baseLayout"),
selection: $selectedBaseLayoutID
) {
ForEach(baseLayouts) { preset in
Text(preset.name).tag(preset.id as UUID?)
}
}
}
private var nameField: some View {
TextField(mode.placeholder, text: $name)
.focused($isNameFocused)
.submitLabel(.done)
.onSubmit(saveIfValid)
}
private var nameFooter: some View {
HStack {
if trimmedName.count > LayoutPreset.maxNameLength {
Text(String(localized: "settings.playerControls.nameTooLong"))
.foregroundStyle(.red)
}
Spacer()
Text(verbatim: "\(trimmedName.count)/\(LayoutPreset.maxNameLength)")
.foregroundStyle(
trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary
)
}
}
// MARK: - macOS Dialog
#if os(macOS)
/// Compact dialog layout matching native macOS sheets (title, fields,
/// trailing action buttons) instead of the iOS navigation-bar form.
private var macOSDialog: some View {
VStack(alignment: .leading, spacing: 16) {
Text(mode.title)
.font(.headline)
if case .create(_, _) = mode {
basePicker
}
VStack(alignment: .leading, spacing: 4) {
nameField
.textFieldStyle(.roundedBorder)
nameFooter
.font(.caption)
}
HStack {
Spacer()
Button(String(localized: "settings.playerControls.cancel")) {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button(mode.saveButtonTitle) {
saveIfValid()
}
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
}
}
.padding(20)
.frame(width: 400)
.onAppear {
isNameFocused = true
}
}
#endif
private func saveIfValid() {
guard isValid else { return }
onSave(trimmedName, selectedBaseLayoutID)
dismiss()
}
}
// MARK: - Preview
#Preview("Create") {
PresetEditorView(
mode: .create(
baseLayouts: LayoutPreset.allBuiltIn(),
activePreset: LayoutPreset.defaultPreset()
)
) { _, _ in }
}
#Preview("Rename") {
PresetEditorView(mode: .rename(currentName: "My Custom Preset")) { _, _ in }
}