mirror of
https://github.com/yattee/yattee.git
synced 2026-11-02 09:57:20 +00:00
The app could show a server-side playlist and nothing else. Editing was never the missing part on its own: nothing on screen knew whose playlist it was looking at, and without an owner there is no credential to fetch it with, no row identity to remove by, and nobody to send a change to. So the destination carries the account. Only Invidious ids hint at ownership — the IVPL prefix — and that heuristic stays for links that arrive from elsewhere; a Piped playlist looks exactly like any other, so the places that already know whose list a row came out of now say so. An owned playlist is then fetched authenticated, which is both the only way to see a private one and the only way to learn what the server calls its rows. A playlist read from the public endpoint has no row identities at all, and that is precisely why nothing about it may be changed: the edit affordances appear over the authenticated snapshot or not at all. Rows are paired with entries by walking the two lists together rather than by index. Entries are a subsequence — a row the server declined to name is still displayed, it just can never be a target — and matching on the video alone would be wrong too, since a playlist may hold the same video twice and each copy is its own row. Every change goes through the cache, so the refetch that reconciles it cannot be skipped, and what the view shows afterwards is only ever what came back. That includes the failures: a removal aimed at a row that has since moved says so specifically, over a list that has already been refreshed. Adding a video now offers every account that can be written to, on every instance rather than the selected one alone — this is not a browsing decision. Those rows never claim the video is already there: the list fetch does not carry a playlist's videos, so membership is unknowable from here, and a duplicate is the server's business. Capabilities decide throughout, never the instance type, which is what finally lets a Piped login list and edit its playlists in the browse tab beside an Invidious one — and what keeps the visibility picker away from a backend that has no such setting. Handoff leaves the account out of its payload deliberately. It assembles its userInfo key by key, so the added value changes nothing about the coding either way, but an account is only usable on a device holding its credential — the receiving device resolves ownership for itself.
312 lines
10 KiB
Swift
312 lines
10 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 does
|
|
/// not always say what it is — Invidious returns no visibility with the
|
|
/// playlist, so the picker falls back to its conservative default.
|
|
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
|
|
|
|
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
|
|
|
|
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 }
|
|
}
|
|
}
|
|
#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(String(localized: "playlist.privacy.footer"))
|
|
}
|
|
}
|
|
}
|
|
#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(String(localized: "playlist.privacy.footer"))
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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, trimmedDescription.isEmpty ? nil : trimmedDescription, privacy)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Create") {
|
|
PlaylistFormSheet(mode: .create) { _, _ in }
|
|
}
|