Files
yattee/Yattee/Views/Playlist/PlaylistFormSheet.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

362 lines
12 KiB
Swift

//
// PlaylistFormSheet.swift
// Yattee
//
// Reusable form sheet for creating and editing playlists.
//
import SwiftUI
struct PlaylistFormSheet: View {
enum Mode: Equatable {
case create
case edit(LocalPlaylist)
/// Editing a playlist that lives on a server.
///
/// Server-side playlists are plain values rather than models, so what to
/// pre-fill is passed in. `privacy` is optional because the fetch cannot
/// always say what it is: Invidious reports visibility as a single
/// "listed" flag, which is exact for a public playlist and ambiguous
/// between unlisted and private for every other one. The picker then
/// starts at the conservative end — and an untouched picker sends
/// nothing at all, so a visibility that could not be read back is left
/// alone rather than overwritten with a guess.
case editRemote(title: String, description: String?, privacy: PlaylistPrivacy?)
static func == (lhs: Mode, rhs: Mode) -> Bool {
switch (lhs, rhs) {
case (.create, .create):
return true
case (.edit(let lhsPlaylist), .edit(let rhsPlaylist)):
return lhsPlaylist.id == rhsPlaylist.id
case (.editRemote(let lhsTitle, let lhsDescription, let lhsPrivacy),
.editRemote(let rhsTitle, let rhsDescription, let rhsPrivacy)):
return lhsTitle == rhsTitle && lhsDescription == rhsDescription && lhsPrivacy == rhsPrivacy
default:
return false
}
}
}
@Environment(\.dismiss) private var dismiss
let mode: Mode
/// Whether the destination has a visibility setting to pick at all.
///
/// Follows the destination account's capabilities: a backend without a
/// visibility concept is never asked about one, and the write layer drops
/// the value rather than inventing it.
let showsPrivacy: Bool
/// - Parameter privacy: `nil` means "leave whatever the playlist has",
/// which only an edit can ask for.
private let onSave: (String, String?, PlaylistPrivacy?) -> Void
/// Local playlists have no visibility, so their callers never see one.
init(mode: Mode, onSave: @escaping (String, String?) -> Void) {
self.mode = mode
self.showsPrivacy = false
self.onSave = { title, description, _ in onSave(title, description) }
}
/// Server-side playlists, where the caller is the one that talks to the
/// account — this sheet still never touches a service.
init(
mode: Mode,
showsPrivacy: Bool,
privacy: PlaylistPrivacy = .private,
onSaveWithPrivacy: @escaping (String, String?, PlaylistPrivacy?) -> Void
) {
self.mode = mode
self.showsPrivacy = showsPrivacy
self.onSave = onSaveWithPrivacy
self._privacy = State(initialValue: privacy)
}
@State private var title: String = ""
@State private var descriptionText: String = ""
/// Private by default: the conservative choice, and the one Invidious makes.
@State private var privacy: PlaylistPrivacy = .private
/// What the picker was pre-filled with when editing something that already
/// exists, so a selection left untouched can be told from one that was made.
@State private var prefilledPrivacy: PlaylistPrivacy?
private let maxDescriptionLength = 1000
private var isEditing: Bool {
switch mode {
case .create: return false
case .edit, .editRemote: return true
}
}
private var navigationTitle: String {
isEditing
? String(localized: "playlist.edit")
: String(localized: "playlist.new")
}
private var saveButtonTitle: String {
isEditing
? String(localized: "common.save")
: String(localized: "common.create")
}
private var canSave: Bool {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
#if os(tvOS)
tvOSContent
#else
formContent
.navigationTitle(navigationTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(saveButtonTitle) {
save()
}
.disabled(!canSave)
}
}
#endif
}
.onAppear {
switch mode {
case .create:
break
case .edit(let playlist):
title = playlist.title
descriptionText = playlist.playlistDescription ?? ""
case .editRemote(let remoteTitle, let remoteDescription, let remotePrivacy):
title = remoteTitle
descriptionText = remoteDescription ?? ""
// Only when the fetch actually said so — otherwise whatever the
// caller preselected stands.
if let remotePrivacy { privacy = remotePrivacy }
prefilledPrivacy = privacy
}
}
#if os(iOS)
.presentationDetents([.medium])
#endif
#if os(macOS)
.frame(minWidth: 500, minHeight: 400)
#endif
}
// MARK: - Form Content
#if !os(tvOS)
private var formContent: some View {
Form {
Section {
TextField(
String(localized: "playlist.name"),
text: $title,
prompt: Text(String(localized: "playlist.name"))
)
#if os(iOS)
.textInputAutocapitalization(.sentences)
#endif
#if os(macOS)
.labelsHidden()
#endif
} header: {
Text(String(localized: "playlist.name"))
}
Section {
TextEditor(text: $descriptionText)
.frame(minHeight: 100)
.onChange(of: descriptionText) { _, newValue in
if newValue.count > maxDescriptionLength {
descriptionText = String(newValue.prefix(maxDescriptionLength))
}
}
} header: {
Text(String(localized: "playlist.description"))
} footer: {
HStack {
Text(String(localized: "playlist.description.optional"))
Spacer()
Text(verbatim: "\(descriptionText.count)/\(maxDescriptionLength)")
.monospacedDigit()
}
.foregroundStyle(.secondary)
}
if showsPrivacy {
Section {
privacyPicker
} header: {
Text(String(localized: "playlist.privacy"))
} footer: {
Text(privacyFooter)
}
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
#if os(macOS)
.formStyle(.grouped)
#endif
}
#endif
// MARK: - tvOS Content
#if os(tvOS)
private var tvOSContent: some View {
VStack(spacing: 0) {
Text(navigationTitle)
.font(.title3)
.fontWeight(.semibold)
.lineLimit(1)
.padding(.top, 16)
Form {
Section {
TVSettingsTextField(
title: String(localized: "playlist.name"),
text: $title
)
} header: {
Text(String(localized: "playlist.name"))
}
Section {
TVSettingsTextField(
title: String(localized: "playlist.description.placeholder"),
text: $descriptionText
)
} header: {
Text(String(localized: "playlist.description"))
} footer: {
HStack {
Text(String(localized: "playlist.description.optional"))
Spacer()
Text(verbatim: "\(descriptionText.count)/\(maxDescriptionLength)")
.monospacedDigit()
}
.foregroundStyle(.secondary)
}
if showsPrivacy {
Section {
privacyPicker
} header: {
Text(String(localized: "playlist.privacy"))
} footer: {
Text(privacyFooter)
}
}
Section {
Button(saveButtonTitle) {
save()
}
.buttonStyle(TVToolbarButtonStyle())
.lineLimit(1)
.disabled(!canSave)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
}
}
.scrollClipDisabled()
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
#endif
// MARK: - Privacy
/// What the picker means here — which is not the same sentence when editing.
///
/// Creating applies the selection outright. Editing applies it only if it
/// was changed, because the value it starts on is the server's answer as
/// far as the server gives one.
private var privacyFooter: String {
if case .editRemote = mode {
return String(localized: "playlist.privacy.footer.edit")
}
return String(localized: "playlist.privacy.footer")
}
private var privacyPicker: some View {
Picker(String(localized: "playlist.privacy"), selection: $privacy) {
ForEach(PlaylistPrivacy.allCases, id: \.self) { option in
Text(Self.label(for: option)).tag(option)
}
}
#if os(macOS)
.labelsHidden()
#endif
}
private static func label(for privacy: PlaylistPrivacy) -> String {
switch privacy {
case .public: String(localized: "playlist.privacy.public")
case .unlisted: String(localized: "playlist.privacy.unlisted")
case .private: String(localized: "playlist.privacy.private")
}
}
// MARK: - Actions
private func save() {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(trimmedTitle, savedDescription(trimmedDescription), savedPrivacy)
dismiss()
}
/// The visibility to send, or `nil` to leave the playlist's alone.
///
/// An existing playlist keeps whatever it has unless the picker was
/// actually moved: the value it was pre-filled with is a best guess — the
/// server can say a playlist is not listed without saying whether it is
/// unlisted or private — and sending a guess back would change a setting
/// nobody asked to change. Creating is the opposite: there is nothing to
/// preserve, and Invidious refuses a creation without a visibility.
private var savedPrivacy: PlaylistPrivacy? {
privacy == prefilledPrivacy ? nil : privacy
}
/// What an emptied description field means to the caller.
///
/// Editing a server-side playlist is the one case where the difference
/// matters: `nil` means "leave whatever is there" all the way down to the
/// write layer, so an emptied field has to travel as `""` or clearing a
/// description would silently keep it. Everywhere else — creating anything,
/// editing a local playlist — an empty description *is* no description.
private func savedDescription(_ trimmed: String) -> String? {
guard !trimmed.isEmpty else {
if case .editRemote = mode { return "" }
return nil
}
return trimmed
}
}
// MARK: - Preview
#Preview("Create") {
PlaylistFormSheet(mode: .create) { _, _ in }
}