mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Ten review findings in the remote-playlist work, all of them the same shape: something acted on what it remembered instead of what the server says now. - A confirmed-gone playlist is forgotten whole — snapshot, list entry, and both ordering fences — so a refresh that started before the delete can no longer republish it, and a list refetch that fails offline no longer leaves it on screen. - `.accountsDidChange` discards account-scoped content when the account it belonged to is no longer the effective one, as the status notification already did. - A tab that stops being offered no longer stays selected: the browse view moves to the first available one, which reloads by itself. - A Handoff account id is accepted only when that account is on the instance the playlist lives on. - Composite writes moved up into `AccountService`, over a transport whose every method is exactly one request: create-with-privacy, rename, and set-description. The credential is rechecked between each pair, so a sign-out between the two halves of a create or an edit can no longer send the second half under the previous user's secret. - Editing a playlist no longer forces it private. Invidious serialises visibility as `isListed` — `privacy.public?`, so unlisted and private are indistinguishable — which the snapshot now carries; the picker starts there, and sends nothing at all unless it was moved. Invidious keeps the stored visibility when the PATCH omits it (measured), so an untouched picker leaves it exactly as it was. - Tapping Edit on a server-side playlist awaits an authenticated refetch first and populates the form from it; a failed refetch shows an error and no sheet. - Playlist rows are keyed by row rather than by video id, which two rows of the same video share. - The add-to-playlist sheet offers only accounts whose instance is enabled and that hold a credential, alongside the write capability.
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("\(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("\(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 }
|
|
}
|