mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
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
362 lines
12 KiB
Swift
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 }
|
|
}
|