mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Reach a playlist through the account that owns it
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.
This commit is contained in:
@@ -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" : {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UUID> = []
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user