diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 261dad22..025706a0 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -8346,6 +8346,149 @@ } } }, + "playlist.privacy" : { + "comment" : "Playlist visibility picker label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visibility" + } + } + } + }, + "playlist.privacy.footer" : { + "comment" : "Explains what the playlist visibility picker does", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Who can see this playlist on the server. The selection shown here is applied when you save." + } + } + } + }, + "playlist.privacy.private" : { + "comment" : "Playlist visibility: only the account owner", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Private" + } + } + } + }, + "playlist.privacy.public" : { + "comment" : "Playlist visibility: anyone", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Public" + } + } + } + }, + "playlist.privacy.unlisted" : { + "comment" : "Playlist visibility: anyone with the link", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlisted" + } + } + } + }, + "playlist.remote.add.error" : { + "comment" : "Adding a video to a server-side playlist failed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not add the video" + } + } + } + }, + "playlist.remote.create.error" : { + "comment" : "Creating a playlist on the server failed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not create the playlist" + } + } + } + }, + "playlist.remote.delete.confirm" : { + "comment" : "Delete server-side playlist confirmation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete this playlist from your account?" + } + } + } + }, + "playlist.remote.delete.error" : { + "comment" : "Deleting a playlist from the server failed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not delete the playlist" + } + } + } + }, + "playlist.remote.edit.error" : { + "comment" : "Saving changes to a server-side playlist failed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not save the playlist" + } + } + } + }, + "playlist.remote.entryMismatch.message" : { + "comment" : "Body of the toast shown when a playlist row moved before it could be removed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The list was refreshed — try removing the video again." + } + } + } + }, + "playlist.remote.entryMismatch.title" : { + "comment" : "Title of the toast shown when a playlist row moved before it could be removed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlist changed on the server" + } + } + } + }, + "playlist.remote.remove.error" : { + "comment" : "Removing a video from a server-side playlist failed", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not remove the video" + } + } + } + }, "playlist.removeVideo" : { "comment" : "Remove video from playlist menu item", "localizations" : { @@ -8379,6 +8522,17 @@ } } }, + "playlist.section.local" : { + "comment" : "Header for the on-device playlists, shown when account playlists are listed too", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local Playlists" + } + } + } + }, "playlist.title" : { "comment" : "Generic playlist title", "localizations" : { diff --git a/Yattee/Services/Handoff/HandoffManager.swift b/Yattee/Services/Handoff/HandoffManager.swift index 4b8859f4..4c871b91 100644 --- a/Yattee/Services/Handoff/HandoffManager.swift +++ b/Yattee/Services/Handoff/HandoffManager.swift @@ -164,7 +164,13 @@ final class HandoffManager { userInfo[UserInfoKey.destinationType] = DestinationType.localPlaylist.rawValue userInfo[UserInfoKey.localPlaylistUUID] = uuid.uuidString activity.title = "Playlist" - case .remote(let playlistID, _, _): + // The owning account is deliberately left out of the payload. The + // userInfo is assembled key by key rather than encoded from the + // enum, so an extra associated value changes nothing about the + // coding shape either way — but an account is only usable on a + // device that holds its credential, and the receiving device + // resolves ownership for itself from the instance it lands on. + case .remote(let playlistID, _, _, _): userInfo[UserInfoKey.destinationType] = DestinationType.playlist.rawValue userInfo[UserInfoKey.playlistID] = playlistID.playlistID if let contentSource = playlistID.source { diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index e326b5b4..78c7c713 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -586,10 +586,18 @@ struct InstanceBrowseView: View { .pickerStyle(.segmented) } + /// The account whose playlists the Playlists tab shows. + private var playlistsAccount: Account? { + appEnvironment?.account(forInstance: instance) + } + private var availableTabs: [BrowseTab] { if instance.supportsFeed && isLoggedIn { - // Playlists tab only available for Invidious (Piped playlists to be added in future) - if instance.type == .invidious { + // Capabilities decide, never the instance type: a Piped account + // reads its playlists through exactly the same service, so the tab + // is offered wherever an account can actually fill it. + if let account = playlistsAccount, + appEnvironment?.accountsManager.capabilities(for: account)?.supportsPlaylistReads == true { return [.feed, .popular, .trending, .playlists] } return [.feed, .popular, .trending] @@ -918,7 +926,12 @@ struct InstanceBrowseView: View { rowStyle: rowStyle, listStyle: listStyle ) { - NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: instance, title: playlist.title))) { + // These are the account's own playlists, so the detail view + // is told whose they are — that is what lets it fetch them + // authenticated and offer to change them. + NavigationLink(value: NavigationDestination.playlist( + .remote(playlist.id, instance: instance, title: playlist.title, accountID: playlistsAccount?.id) + )) { SearchPlaylistRowView(playlist: playlist, style: rowStyle) .contentShape(Rectangle()) } @@ -933,7 +946,9 @@ struct InstanceBrowseView: View { private var playlistsGridContent: some View { VideoGridContent(columns: gridConfig.effectiveColumns) { ForEach(userPlaylists) { playlist in - NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: instance, title: playlist.title))) { + NavigationLink(value: NavigationDestination.playlist( + .remote(playlist.id, instance: instance, title: playlist.title, accountID: playlistsAccount?.id) + )) { PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards) .contentShape(Rectangle()) } @@ -1344,21 +1359,16 @@ struct InstanceBrowseView: View { feedVideos = videos prefetchBranding(for: videos) case .playlists: - // Playlists are currently only supported for Invidious - guard instance.type == .invidious else { - errorMessage = String(localized: "feed.error.notSupported") - isLoading = false - return - } - - guard let credential = appEnvironment.credential(forInstance: instance) else { + // Addressed by account rather than by instance and secret: the + // service resolves the credential, and refuses backends that + // cannot list playlists at all. + guard let account = playlistsAccount else { errorMessage = String(localized: "feed.error.notLoggedIn") isLoading = false return } - let api = InvidiousAPI(httpClient: appEnvironment.httpClient) - let playlists = try await api.userPlaylists(instance: instance, sid: credential) + let playlists = try await appEnvironment.accountService.userPlaylists(for: account) guard generation == accountContentGeneration else { return } diff --git a/Yattee/Views/Playlist/PlaylistFormSheet.swift b/Yattee/Views/Playlist/PlaylistFormSheet.swift index 523823c7..fddecba1 100644 --- a/Yattee/Views/Playlist/PlaylistFormSheet.swift +++ b/Yattee/Views/Playlist/PlaylistFormSheet.swift @@ -11,6 +11,13 @@ 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) { @@ -18,6 +25,9 @@ struct PlaylistFormSheet: View { 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 } @@ -27,16 +37,49 @@ struct PlaylistFormSheet: View { @Environment(\.dismiss) private var dismiss let mode: Mode - let onSave: (String, String?) -> Void + + /// 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 { - if case .edit = mode { return true } - return false + switch mode { + case .create: return false + case .edit, .editRemote: return true + } } private var navigationTitle: String { @@ -81,9 +124,18 @@ struct PlaylistFormSheet: View { #endif } .onAppear { - if case .edit(let playlist) = mode { + 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) @@ -134,6 +186,16 @@ struct PlaylistFormSheet: View { } .foregroundStyle(.secondary) } + + if showsPrivacy { + Section { + privacyPicker + } header: { + Text(String(localized: "playlist.privacy")) + } footer: { + Text(String(localized: "playlist.privacy.footer")) + } + } } #if os(iOS) .scrollDismissesKeyboard(.interactively) @@ -182,6 +244,16 @@ struct PlaylistFormSheet: View { .foregroundStyle(.secondary) } + if showsPrivacy { + Section { + privacyPicker + } header: { + Text(String(localized: "playlist.privacy")) + } footer: { + Text(String(localized: "playlist.privacy.footer")) + } + } + Section { Button(saveButtonTitle) { save() @@ -200,13 +272,34 @@ struct PlaylistFormSheet: View { } #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) + onSave(trimmedTitle, trimmedDescription.isEmpty ? nil : trimmedDescription, privacy) dismiss() } } diff --git a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift index f11e5be8..6f001f00 100644 --- a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift +++ b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift @@ -24,13 +24,25 @@ private struct CachedPlaylistHeader { /// Source type for playlist data - either local (SwiftData) or remote (API). enum PlaylistSource: Hashable { case local(UUID, title: String? = nil) - case remote(PlaylistID, instance: Instance?, title: String? = nil) + /// - Parameter accountID: the account this playlist was opened as, when the + /// place it was opened from knows. Ownership is what makes a playlist + /// editable, and only Invidious ids carry a hint of it (the `IVPL` + /// prefix) — a Piped playlist looks exactly like any other. + case remote(PlaylistID, instance: Instance?, title: String? = nil, accountID: UUID? = nil) /// Initial title to show while loading (if provided). var initialTitle: String? { switch self { case .local(_, let title): return title - case .remote(_, _, let title): return title + case .remote(_, _, let title, _): return title + } + } + + /// The account whose playlist this is, when it was opened as one. + var accountID: UUID? { + switch self { + case .local: return nil + case .remote(_, _, _, let accountID): return accountID } } @@ -39,7 +51,7 @@ enum PlaylistSource: Hashable { switch self { case .local(let uuid, _): return uuid - case .remote(let playlistID, _, _): + case .remote(let playlistID, _, _, _): return playlistID } } @@ -73,6 +85,20 @@ struct UnifiedPlaylistDetailView: View { @State private var importProgress: (current: Int, total: Int)? @State private var remotePlaylist: Playlist? + // MARK: - Owned Remote Playlist State + + /// The rows the server named in the authenticated fetch. + /// + /// `nil` means this playlist came from the public endpoint: no row here can + /// be named to the server, so nothing about it may be changed. + @State private var entries: [RemotePlaylistEntry]? + + /// Which displayed row each addressable entry belongs to. + @State private var entriesByRow: [Int: RemotePlaylistEntry] = [:] + + /// The account this playlist belongs to, when it belongs to one. + @State private var owningAccount: Account? + // MARK: - Local-only State @State private var localPlaylist: LocalPlaylist? @@ -102,6 +128,38 @@ struct UnifiedPlaylistDetailView: View { return false } + /// The server-side id of the playlist on screen, if it has one. + private var remotePlaylistID: String? { + if case .remote(let playlistID, _, _, _) = source { return playlistID.playlistID } + return nil + } + + /// Whether this playlist is one the resolved account may change. + /// + /// `entries != nil` is part of the answer on purpose: the edit affordances + /// are only ever offered over the authenticated snapshot, never over a + /// public fetch that happens to be of an owned playlist. Account *status* + /// deliberately is not — a stale "rejected" would hide the affordances from + /// an account that works, and a failed write says so with a toast. + private var canEditRemotePlaylist: Bool { + guard case .remote = source, entries != nil, let owningAccount else { return false } + return appEnvironment?.accountsManager.capabilities(for: owningAccount)?.supportsPlaylistWrites == true + } + + /// Whether the owning account's backend has a visibility setting at all. + private var remoteSupportsPrivacy: Bool { + guard let owningAccount else { return false } + return appEnvironment?.accountsManager.capabilities(for: owningAccount)?.supportsPlaylistPrivacy == true + } + + /// Deleting a server-side playlist takes it off the server, which is a very + /// different sentence from deleting a local one. + private var deleteConfirmationTitle: String { + isLocal + ? String(localized: "playlist.delete.confirm") + : String(localized: "playlist.remote.delete.confirm") + } + private var navigationTitleText: String { title.isEmpty ? cachedHeader?.title ?? String(localized: "playlist.title") : title } @@ -173,10 +231,23 @@ struct UnifiedPlaylistDetailView: View { dataManager?.updatePlaylist(localPlaylist, title: newTitle, description: newDescription) loadLocalPlaylist() } + } else if canEditRemotePlaylist { + // The fetch carries no visibility, so the picker starts at the + // conservative default rather than pretending to know. + PlaylistFormSheet( + mode: .editRemote(title: title, description: descriptionText, privacy: nil), + showsPrivacy: remoteSupportsPrivacy + ) { newTitle, newDescription, newPrivacy in + Task { + await saveRemoteEdit(title: newTitle, description: newDescription, privacy: newPrivacy) + } + } } } + // One dialog for both kinds of playlist: two of them attached to the + // same view compete over which is presented. .confirmationDialog( - String(localized: "playlist.delete.confirm"), + deleteConfirmationTitle, isPresented: $showingDeleteConfirmation, titleVisibility: .visible ) { @@ -184,6 +255,8 @@ struct UnifiedPlaylistDetailView: View { if let localPlaylist { dataManager?.deletePlaylist(localPlaylist) dismiss() + } else { + Task { await deleteRemotePlaylist() } } } } @@ -438,8 +511,9 @@ struct UnifiedPlaylistDetailView: View { } } - // Local-only: Edit, Delete - if isLocal, localPlaylist != nil { + // Edit and Delete: local playlists, and server-side ones this + // account owns. + if (isLocal && localPlaylist != nil) || canEditRemotePlaylist { Button { showingEditSheet = true } label: { @@ -601,8 +675,9 @@ struct UnifiedPlaylistDetailView: View { .disabled(isImporting) } - // Local-only: Edit, Delete - if isLocal, localPlaylist != nil { + // Edit and Delete: local playlists, and server-side ones this + // account owns. + if (isLocal && localPlaylist != nil) || canEditRemotePlaylist { Button { showingEditSheet = true } label: { @@ -679,7 +754,7 @@ struct UnifiedPlaylistDetailView: View { index: index + 1, video: video, watchProgress: watchProgress(for: video), - onRemove: isLocal ? { removeVideo(at: index) } : nil + onRemove: removeAction(forRow: index) ) } .buttonStyle(.plain) @@ -710,7 +785,7 @@ struct UnifiedPlaylistDetailView: View { switch source { case .local: loadLocalPlaylist() - case .remote(let playlistID, let instance, _): + case .remote(let playlistID, let instance, _, _): await loadRemotePlaylist(playlistID: playlistID, instance: instance) } loadWatchEntries() @@ -773,40 +848,51 @@ struct UnifiedPlaylistDetailView: View { cachedHeader = CachedPlaylistHeader(from: recentPlaylist) } + // Whose playlist this is decides which endpoint can see it: the account + // reads its own, everybody else reads the public one. Resolved every + // load rather than once, because signing in or out changes the answer + // without the source ever changing. + let account = resolveOwningAccount(playlistID: playlistID, instance: resolvedInstance) + owningAccount = account + do { let fetchedPlaylist: Playlist - // Check if this is an Invidious user playlist (IVPL prefix) that requires authentication - let isInvidiousUserPlaylist = playlistID.playlistID.hasPrefix("IVPL") - let isInvidiousInstance = resolvedInstance.type == .invidious - // Resolved through the account, not the instance: a login created - // after the revamp only ever writes an account-scoped credential, - // so the instance-keyed lookup would miss it and fall through to - // the public endpoint — which cannot see a private playlist. - let sid = appEnvironment.credential(forInstance: resolvedInstance) - - if isInvidiousUserPlaylist && isInvidiousInstance, let sid { - // Use authenticated endpoint for Invidious user playlists (including private ones) - let api = InvidiousAPI(httpClient: appEnvironment.httpClient) - fetchedPlaylist = try await api.userPlaylist( + if let account, + appEnvironment.accountsManager.capabilities(for: account)?.supportsPlaylistReads == true { + // The authenticated fetch is the only one that can see a + // private playlist — and the only one that names the rows, so + // it is also what makes the playlist editable at all. + try await appEnvironment.remotePlaylistsCache.refreshSnapshot( id: playlistID.playlistID, - instance: resolvedInstance, - sid: sid + for: account ) + + guard let snapshot = appEnvironment.remotePlaylistsCache.snapshot( + id: playlistID.playlistID, + for: account.id + ) else { + // The account was cleared while this was in flight and its + // data went with it. Nothing to show, and nothing to say + // that whatever cleared it is not already saying. + isLoading = false + return + } + + apply(snapshot) + fetchedPlaylist = snapshot.playlist } else { - // Use public endpoint for regular playlists + // Public endpoint: no account, no row identities, no editing. + entries = nil + entriesByRow = [:] + fetchedPlaylist = try await appEnvironment.contentService.playlist( id: playlistID.playlistID, instance: resolvedInstance ) - } - remotePlaylist = fetchedPlaylist - title = fetchedPlaylist.title - descriptionText = fetchedPlaylist.description - thumbnailURL = fetchedPlaylist.thumbnailURL - videos = fetchedPlaylist.videos - videoCount = fetchedPlaylist.videoCount + apply(fetchedPlaylist) + } // Save to recent playlists (only remote playlists, unless incognito mode is enabled or recent playlists disabled) if appEnvironment.settingsManager.incognitoModeEnabled != true, @@ -820,6 +906,67 @@ struct UnifiedPlaylistDetailView: View { isLoading = false } + /// The account whose playlist this is, or `nil` when it is nobody's. + /// + /// The id passed in with the source is the reliable answer — the place the + /// playlist was opened from knew whose list it came out of. Failing that, + /// only Invidious leaves a trace: `IVPL` ids are user playlists, so a + /// signed-in account on that instance is very probably the owner. Piped ids + /// say nothing at all, which is why the accountID exists. + private func resolveOwningAccount(playlistID: PlaylistID, instance: Instance) -> Account? { + guard let appEnvironment else { return nil } + + if let accountID = source.accountID, + let account = appEnvironment.accountsManager.account(id: accountID) { + return account + } + + guard instance.type == .invidious, playlistID.playlistID.hasPrefix("IVPL") else { return nil } + return appEnvironment.account(forInstance: instance) + } + + /// Shows a playlist that came from the public endpoint. + private func apply(_ playlist: Playlist) { + remotePlaylist = playlist + title = playlist.title + descriptionText = playlist.description + thumbnailURL = playlist.thumbnailURL + videos = playlist.videos + videoCount = playlist.videoCount + } + + /// Shows a playlist the account fetched, together with the rows it may + /// address. + private func apply(_ snapshot: RemotePlaylistSnapshot) { + apply(snapshot.playlist) + entries = snapshot.entries + entriesByRow = Self.rowEntries(for: snapshot) + } + + /// Pairs each displayed row with the entry that can name it to the server. + /// + /// `entries` is a *subsequence* of the playlist's videos — a row the server + /// refused to hand an identity to is still displayed, it just cannot be a + /// target — so the two lists are walked together rather than indexed + /// against each other. Matching on the video id alone would not do either: + /// a playlist may hold the same video twice, and each copy is its own row. + private static func rowEntries(for snapshot: RemotePlaylistSnapshot) -> [Int: RemotePlaylistEntry] { + let videos = snapshot.playlist.videos + var map: [Int: RemotePlaylistEntry] = [:] + var row = 0 + + for entry in snapshot.entries { + while row < videos.count, videos[row].id != entry.video.id { + row += 1 + } + guard row < videos.count else { break } + map[row] = entry + row += 1 + } + + return map + } + /// Returns the appropriate instance for the playlist's content source. private func instanceForSource(_ source: ContentSource, instancesManager: InstancesManager) -> Instance? { switch source { @@ -875,7 +1022,7 @@ struct UnifiedPlaylistDetailView: View { switch source { case .local: return .manual - case .remote(let playlistID, _, _): + case .remote(let playlistID, _, _, _): return .playlist(playlistID: playlistID.playlistID, continuation: nil) } } @@ -888,6 +1035,113 @@ struct UnifiedPlaylistDetailView: View { loadLocalPlaylist() } + // MARK: - Owned Remote Playlist Actions + + /// The removal a row offers, or `nil` when it has none. + /// + /// Local rows always have one. A server-side row needs the account to own + /// the playlist *and* the server to have named the row in the snapshot — + /// a row it refused to name can never be the target of a removal. + private func removeAction(forRow index: Int) -> (() -> Void)? { + if isLocal { + return { removeVideo(at: index) } + } + + guard canEditRemotePlaylist, let entry = entriesByRow[index] else { return nil } + return { Task { await removeRemoteEntry(entry) } } + } + + private func removeRemoteEntry(_ entry: RemotePlaylistEntry) async { + guard let appEnvironment, + let account = owningAccount, + let playlistID = remotePlaylistID + else { return } + + do { + try await appEnvironment.remotePlaylistsCache.removeEntry( + entry, + fromPlaylist: playlistID, + for: account + ) + applyRefetchedSnapshot() + } catch AccountServiceError.entryMismatch { + // The row moved out from under the snapshot that named it. The + // cache has already refetched, so what is on screen after this is + // the playlist as it now stands. + applyRefetchedSnapshot() + appEnvironment.toastManager.showError( + String(localized: "playlist.remote.entryMismatch.title"), + subtitle: String(localized: "playlist.remote.entryMismatch.message") + ) + } catch { + applyRefetchedSnapshot() + appEnvironment.toastManager.showError( + String(localized: "playlist.remote.remove.error"), + subtitle: error.localizedDescription + ) + } + } + + private func saveRemoteEdit(title newTitle: String, description: String?, privacy: PlaylistPrivacy) async { + guard let appEnvironment, + let account = owningAccount, + let playlistID = remotePlaylistID + else { return } + + do { + try await appEnvironment.remotePlaylistsCache.updatePlaylist( + id: playlistID, + title: newTitle, + description: description, + privacy: privacy, + for: account + ) + applyRefetchedSnapshot() + } catch { + // A half-applied edit is a real outcome on Piped, where the name and + // the description are two calls — the refetch is what says which + // half landed. + applyRefetchedSnapshot() + appEnvironment.toastManager.showError( + String(localized: "playlist.remote.edit.error"), + subtitle: error.localizedDescription + ) + } + } + + private func deleteRemotePlaylist() async { + guard let appEnvironment, + let account = owningAccount, + let playlistID = remotePlaylistID + else { return } + + do { + try await appEnvironment.remotePlaylistsCache.deletePlaylist(id: playlistID, for: account) + dismiss() + } catch { + applyRefetchedSnapshot() + appEnvironment.toastManager.showError( + String(localized: "playlist.remote.delete.error"), + subtitle: error.localizedDescription + ) + } + } + + /// Re-reads whatever the cache's reconciling refetch left behind. + /// + /// Nothing here is edited optimistically: what the view shows after a change + /// is only ever what the server answered with — including after a failure, + /// because a write that reported one may still have landed. + private func applyRefetchedSnapshot() { + guard let appEnvironment, + let account = owningAccount, + let playlistID = remotePlaylistID, + let snapshot = appEnvironment.remotePlaylistsCache.snapshot(id: playlistID, for: account.id) + else { return } + + apply(snapshot) + } + // MARK: - Remote Playlist Actions private func playlistShareURL() -> URL { diff --git a/Yattee/Views/Video/PlaylistSelectorSheet.swift b/Yattee/Views/Video/PlaylistSelectorSheet.swift index fc5eca44..de69b7e6 100644 --- a/Yattee/Views/Video/PlaylistSelectorSheet.swift +++ b/Yattee/Views/Video/PlaylistSelectorSheet.swift @@ -20,8 +20,37 @@ struct PlaylistSelectorSheet: View { @State private var pendingPlaylistDescription: String? @State private var addedToPlaylist: LocalPlaylist? + // MARK: - Remote Destinations + + /// One account's playlist. Two accounts can hold ids that look alike, so the + /// account is part of naming one. + private struct RemoteSelection: Equatable { + let accountID: UUID + let playlistID: String + } + + /// The server-side playlist the video was just added to, so its row can show + /// the same check the local ones do. + @State private var addedRemotePlaylist: RemoteSelection? + + /// Accounts whose list is being fetched with nothing cached to show yet. + @State private var loadingAccountIDs: Set = [] + + /// The account a new server-side playlist is being created on. + @State private var newRemotePlaylistAccount: Account? + private var dataManager: DataManager? { appEnvironment?.dataManager } + /// Every account that can be added to, on every instance — not just the one + /// currently selected as the data context. Adding a video to a playlist is + /// not a browsing decision, and the user may well mean the other login. + private var remoteDestinations: [Account] { + guard let appEnvironment else { return [] } + return appEnvironment.accountsManager.accounts.filter { + appEnvironment.accountsManager.capabilities(for: $0)?.supportsPlaylistWrites == true + } + } + var body: some View { NavigationStack { List { @@ -63,6 +92,12 @@ struct PlaylistSelectorSheet: View { addVideoToPlaylist(playlist) } } + } header: { + // Unlabelled while local playlists are the only + // destinations there are. + if !remoteDestinations.isEmpty { + Text(String(localized: "playlist.section.local")) + } } } else { Section { @@ -77,6 +112,10 @@ struct PlaylistSelectorSheet: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + ForEach(remoteDestinations) { account in + remoteSection(for: account) + } } } #if os(tvOS) @@ -106,6 +145,27 @@ struct PlaylistSelectorSheet: View { .onAppear { loadPlaylists() } + .task { + await refreshRemotePlaylists() + } + } + // Attached outside the stack, so it is never the second sheet on the + // same view as the local "New Playlist" one. + .sheet(item: $newRemotePlaylistAccount) { account in + PlaylistFormSheet( + mode: .create, + showsPrivacy: appEnvironment?.accountsManager + .capabilities(for: account)?.supportsPlaylistPrivacy == true + ) { title, description, privacy in + Task { + await createRemotePlaylist( + on: account, + title: title, + description: description, + privacy: privacy + ) + } + } } #if os(iOS) .presentationDetents([.medium, .large]) @@ -115,6 +175,57 @@ struct PlaylistSelectorSheet: View { #endif } + // MARK: - Remote Sections + + @ViewBuilder + private func remoteSection(for account: Account) -> some View { + Section { + Button { + newRemotePlaylistAccount = account + } label: { + Label( + String(localized: "playlist.new"), + systemImage: "plus.circle" + ) + } + + if let remotePlaylists = appEnvironment?.remotePlaylistsCache.playlists(for: account.id) { + ForEach(remotePlaylists) { playlist in + RemotePlaylistSelectionRow( + playlist: playlist, + wasAdded: addedRemotePlaylist == RemoteSelection( + accountID: account.id, + playlistID: playlist.id.playlistID + ) + ) { + Task { await addVideo(to: playlist, on: account) } + } + } + } else if loadingAccountIDs.contains(account.id) { + ProgressView() + #if !os(tvOS) + .controlSize(.small) + #endif + .frame(maxWidth: .infinity) + } + } header: { + Text(sectionTitle(for: account)) + } + } + + /// The account's name, and the host it lives on when the name does not + /// already carry it (a login shows up as `user@host`). + private func sectionTitle(for account: Account) -> String { + guard let appEnvironment else { return "" } + + let name = appEnvironment.dataContextValidator.displayName(for: account) + guard let host = appEnvironment.accountsManager.instance(for: account)?.url.host, + !name.localizedCaseInsensitiveContains(host) + else { return name } + + return "\(name) · \(host)" + } + private func loadPlaylists() { playlists = dataManager?.playlists() ?? [] } @@ -131,6 +242,86 @@ struct PlaylistSelectorSheet: View { } } + // MARK: - Remote Actions + + /// Refetches every eligible account's list, all at once. + /// + /// Failures are left silent: a section that cannot be filled simply offers + /// its "New Playlist" row, and the error the user needs to see is the one + /// from the add they actually tried. + private func refreshRemotePlaylists() async { + guard let appEnvironment else { return } + + let accounts = remoteDestinations + loadingAccountIDs.formUnion( + accounts.filter { appEnvironment.remotePlaylistsCache.playlists(for: $0.id) == nil } + .map(\.id) + ) + + await withTaskGroup(of: Void.self) { group in + for account in accounts { + group.addTask { @MainActor in + try? await appEnvironment.remotePlaylistsCache.refreshList(for: account) + loadingAccountIDs.remove(account.id) + } + } + } + } + + private func addVideo(to playlist: Playlist, on account: Account) async { + guard let appEnvironment else { return } + + do { + try await appEnvironment.remotePlaylistsCache.addVideo( + videoID: video.id.videoID, + toPlaylist: playlist.id.playlistID, + for: account + ) + + addedRemotePlaylist = RemoteSelection( + accountID: account.id, + playlistID: playlist.id.playlistID + ) + + // Same beat as the local path: the check is shown, then the sheet + // goes away. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismiss() + } + } catch { + // No dismissal on failure — the sheet is where the user retries, or + // picks somewhere else. + appEnvironment.toastManager.showError( + String(localized: "playlist.remote.add.error"), + subtitle: error.localizedDescription + ) + } + } + + private func createRemotePlaylist( + on account: Account, + title: String, + description: String?, + privacy: PlaylistPrivacy + ) async { + guard let appEnvironment else { return } + + do { + let playlist = try await appEnvironment.remotePlaylistsCache.createPlaylist( + title: title, + description: description, + privacy: privacy, + for: account + ) + await addVideo(to: playlist, on: account) + } catch { + appEnvironment.toastManager.showError( + String(localized: "playlist.remote.create.error"), + subtitle: error.localizedDescription + ) + } + } + private func createAndAddToPlaylist() { guard let dataManager, let title = pendingPlaylistTitle else { return } @@ -210,6 +401,62 @@ private struct PlaylistSelectionRow: View { } } +// MARK: - Remote Playlist Selection Row + +/// A destination that lives on a server. +/// +/// It never says "already added": the list fetch does not carry the playlist's +/// videos (Piped's rows are not in it at all), so membership is unknowable from +/// here. Guessing would be worse than the duplicate — which is the server's +/// business anyway. +private struct RemotePlaylistSelectionRow: View { + let playlist: Playlist + let wasAdded: Bool + let onAdd: () -> Void + + var body: some View { + Button(action: onAdd) { + HStack(spacing: 12) { + // Single URL with no fallback chain, so the always-available + // variant is the one that gets asked for. + LazyImage(url: Thumbnail.reliableURL(for: playlist.thumbnailURL)) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Rectangle() + .fill(.quaternary) + .overlay { + Image(systemName: "music.note.list") + .foregroundStyle(.secondary) + } + } + } + .frame(width: 60, height: 34) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + VStack(alignment: .leading, spacing: 2) { + Text(playlist.title) + .font(.body) + .foregroundStyle(.primary) + + Text(String(localized: "playlist.videoCount \(playlist.videoCount)")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: wasAdded ? "checkmark.circle.fill" : "plus.circle") + .foregroundStyle(wasAdded ? .green : .secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + // MARK: - Preview #Preview { diff --git a/YatteeTests/NavigationTests.swift b/YatteeTests/NavigationTests.swift index c0bcedb5..85e3edae 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -351,7 +351,7 @@ struct URLRouterTests { let url = URL(string: "https://www.youtube.com/playlist?list=PLtest123")! let destination = router.route(url) - if case .playlist(.remote(let playlistID, _, _)) = destination { + if case .playlist(.remote(let playlistID, _, _, _)) = destination { #expect(playlistID.playlistID == "PLtest123") if case .global = playlistID.source { // Expected @@ -434,7 +434,7 @@ struct URLRouterTests { let url = URL(string: "yattee://playlist/PLtest123")! let destination = router.route(url) - if case .playlist(.remote(let playlistID, _, _)) = destination { + if case .playlist(.remote(let playlistID, _, _, _)) = destination { #expect(playlistID.playlistID == "PLtest123") } else { Issue.record("Expected playlist destination")