diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index e9ce693d..4fd4aee7 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -8262,16 +8262,6 @@ } } }, - "playlist.empty.title" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Empty Playlist" - } - } - } - }, "playlist.imported.title" : { "comment" : "Toast title when playlist is imported to library", "localizations" : { @@ -8618,6 +8608,28 @@ } } }, + "playlists.selector.empty.account" : { + "comment" : "Shown in the playlist picker when an account has no playlists", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No playlists on this account yet." + } + } + } + }, + "playlists.selector.empty.local" : { + "comment" : "Shown in the playlist picker when there are no playlists on this device", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No playlists on this device yet." + } + } + } + }, "playlists.title" : { "comment" : "Title for playlists tab in instance browse", "localizations" : { diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index 5e83baac..aec80a97 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -34,9 +34,7 @@ struct InstanceBrowseView: View { @State private var accountContentAccountID: UUID? @State private var feedVideos: [Video] = [] @State private var feedSubscriptions: [Channel] = [] - - // Playlists state (for Invidious login) - @State private var userPlaylists: [Playlist] = [] + @State private var selectedFeedChannelID: String? @State private var isLoggedIn = false @State private var feedPage = 1 @@ -602,6 +600,19 @@ struct InstanceBrowseView: View { appEnvironment?.account(forInstance: instance) } + /// The account's playlists, read from the shared cache rather than fetched + /// into this view. + /// + /// Everything that changes a server-side playlist goes through that cache — + /// the picker that creates one while adding a video, the detail view that + /// renames or deletes one — so reading it is what makes those changes show + /// up here. It is keyed by account, which is also the discard on an account + /// change: whose playlists these are moves with `playlistsAccount`. + private var userPlaylists: [Playlist] { + guard let appEnvironment, let account = playlistsAccount else { return [] } + return appEnvironment.remotePlaylistsCache.playlists(for: account.id) ?? [] + } + private var availableTabs: [BrowseTab] { if instance.supportsFeed && isLoggedIn { // Capabilities decide, never the instance type: a Piped account @@ -1226,11 +1237,12 @@ struct InstanceBrowseView: View { /// Drops what belonged to the previously selected login. /// /// Public browsing — popular and trending — is unauthenticated and stays. + /// The playlists are not listed here either: they are read from the shared + /// cache under the account's own id, so they leave with the account. private func discardAccountScopedContent() { accountContentGeneration += 1 feedVideos = [] feedSubscriptions = [] - userPlaylists = [] feedPage = 1 hasMoreFeedResults = false isLoadingMoreFeed = false @@ -1319,7 +1331,9 @@ struct InstanceBrowseView: View { case .popular: hasData = !popularVideos.isEmpty case .trending: hasData = !trendingVideos.isEmpty case .feed: hasData = !feedVideos.isEmpty - case .playlists: hasData = !userPlaylists.isEmpty + // Never skipped: the list is a cache other screens change, and what was + // already fetched stays on screen while it is refetched. + case .playlists: hasData = false } if hasData && !forceRefresh { @@ -1419,19 +1433,17 @@ struct InstanceBrowseView: View { prefetchBranding(for: videos) case .playlists: // Addressed by account rather than by instance and secret: the - // service resolves the credential, and refuses backends that - // cannot list playlists at all. + // cache resolves the credential through the service, and refuses + // backends that cannot list playlists at all. Nothing is stored + // here — the rows are read back out of the cache, which is what + // lets a playlist created or deleted elsewhere show up. guard let account = playlistsAccount else { errorMessage = String(localized: "feed.error.notLoggedIn") isLoading = false return } - let playlists = try await appEnvironment.accountService.userPlaylists(for: account) - - guard generation == accountContentGeneration else { return } - - userPlaylists = playlists + try await appEnvironment.remotePlaylistsCache.refreshList(for: account) } } catch is CancellationError { // Task was cancelled — another load is taking over, don't touch state diff --git a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift index 04e992e0..1948b4cc 100644 --- a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift +++ b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift @@ -466,9 +466,13 @@ struct UnifiedPlaylistDetailView: View { .foregroundStyle(.secondary) } - // Action buttons row (iOS/macOS only) + // Action buttons row (iOS/macOS only). + // + // An owned playlist keeps its row after the last video is removed: + // renaming and deleting it are the only things left to do with it, + // and the row is where both live. #if !os(tvOS) - if !videos.isEmpty || localPlaylist != nil { + if !videos.isEmpty || localPlaylist != nil || canEditRemotePlaylist { playlistActionButtons } #endif @@ -482,51 +486,54 @@ struct UnifiedPlaylistDetailView: View { @ViewBuilder private var playlistActionButtons: some View { HStack(spacing: 12) { - // Play button (only when queue is enabled) - if isQueueEnabled { - Button { - playAll() - } label: { - Label(String(localized: "playlist.play"), systemImage: "play.fill") - .fontWeight(.semibold) + // Playing and downloading need videos to act on; the menu does not. + if !videos.isEmpty { + // Play button (only when queue is enabled) + if isQueueEnabled { + Button { + playAll() + } label: { + Label(String(localized: "playlist.play"), systemImage: "play.fill") + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) - } - // Download button with three states - if downloadCoordinator.isDownloading { - Button { - // No action while downloading - } label: { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - if let progress = downloadCoordinator.progress { - Text("\(progress.current)/\(progress.total)") - .monospacedDigit() + // Download button with three states + if downloadCoordinator.isDownloading { + Button { + // No action while downloading + } label: { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + if let progress = downloadCoordinator.progress { + Text("\(progress.current)/\(progress.total)") + .monospacedDigit() + } } } + .fontWeight(.semibold) + .buttonStyle(.bordered) + .disabled(true) + } else if cachedAllVideosDownloaded { + Button { + // No action - already downloaded + } label: { + Label(String(localized: "playlist.downloaded"), systemImage: "checkmark.circle.fill") + } + .fontWeight(.semibold) + .buttonStyle(.bordered) + .disabled(true) + } else { + Button { + downloadCoordinator.startDownload(videos: videos) + } label: { + Label(String(localized: "playlist.downloadAll"), systemImage: "arrow.down.circle") + } + .fontWeight(.semibold) + .buttonStyle(.bordered) } - .fontWeight(.semibold) - .buttonStyle(.bordered) - .disabled(true) - } else if cachedAllVideosDownloaded { - Button { - // No action - already downloaded - } label: { - Label(String(localized: "playlist.downloaded"), systemImage: "checkmark.circle.fill") - } - .fontWeight(.semibold) - .buttonStyle(.bordered) - .disabled(true) - } else { - Button { - downloadCoordinator.startDownload(videos: videos) - } label: { - Label(String(localized: "playlist.downloadAll"), systemImage: "arrow.down.circle") - } - .fontWeight(.semibold) - .buttonStyle(.bordered) } Spacer() @@ -535,16 +542,19 @@ struct UnifiedPlaylistDetailView: View { Menu { // Remote-only: Save to Library, Share if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal { - Button { - Task { await importToLocal() } - } label: { - if isImporting, let progress = importProgress { - Label(String(localized: "playlist.savingToLibrary \(progress.current) \(progress.total)"), systemImage: "plus.rectangle.on.folder") - } else { - Label(String(localized: "playlist.saveToLibrary"), systemImage: "plus.rectangle.on.folder") + // Nothing to copy out of an empty playlist. + if !videos.isEmpty { + Button { + Task { await importToLocal() } + } label: { + if isImporting, let progress = importProgress { + Label(String(localized: "playlist.savingToLibrary \(progress.current) \(progress.total)"), systemImage: "plus.rectangle.on.folder") + } else { + Label(String(localized: "playlist.saveToLibrary"), systemImage: "plus.rectangle.on.folder") + } } + .disabled(isImporting) } - .disabled(isImporting) ShareLink(item: playlistShareURL()) { Label(String(localized: "common.share"), systemImage: "square.and.arrow.up") @@ -627,7 +637,9 @@ struct UnifiedPlaylistDetailView: View { .foregroundStyle(.secondary) } - if !isDescriptionScrollLocked, !videos.isEmpty || localPlaylist != nil { + // An owned playlist keeps its buttons after the last video is + // removed: editing and deleting are all that is left to do with it. + if !isDescriptionScrollLocked, !videos.isEmpty || localPlaylist != nil || canEditRemotePlaylist { tvOSPlaylistActionButtons } @@ -685,8 +697,8 @@ struct UnifiedPlaylistDetailView: View { @ViewBuilder private var tvOSPlaylistActionButtons: some View { VStack(spacing: 12) { - // Play (only when queue is enabled) - if isQueueEnabled { + // Play (only when queue is enabled, and only with something to play) + if isQueueEnabled, !videos.isEmpty { Button { playAll() } label: { @@ -698,7 +710,7 @@ struct UnifiedPlaylistDetailView: View { } // Remote-only: Save to Library - if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal { + if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal, !videos.isEmpty { Button { Task { await importToLocal() } } label: { diff --git a/Yattee/Views/Video/PlaylistSelectorSheet.swift b/Yattee/Views/Video/PlaylistSelectorSheet.swift index 41cf78a6..da6f9e77 100644 --- a/Yattee/Views/Video/PlaylistSelectorSheet.swift +++ b/Yattee/Views/Video/PlaylistSelectorSheet.swift @@ -90,8 +90,12 @@ struct PlaylistSelectorSheet: View { } // Existing playlists - if !playlists.isEmpty { - Section { + Section { + if playlists.isEmpty { + emptyDestinationsRow( + String(localized: "playlists.selector.empty.local") + ) + } else { ForEach(playlists, id: \.id) { playlist in PlaylistSelectionRow( playlist: playlist, @@ -101,24 +105,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 { - ContentUnavailableView { - Label( - String(localized: "playlist.empty.title"), - systemImage: "music.note.list" - ) - } description: { - Text(String(localized: "playlist.empty.description")) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } header: { + // Unlabelled while local playlists are the only + // destinations there are. + if !remoteDestinations.isEmpty { + Text(String(localized: "playlist.section.local")) } } @@ -199,15 +191,24 @@ struct PlaylistSelectorSheet: View { } 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) } + if remotePlaylists.isEmpty { + // The fetch landed and the account simply has none, which is + // worth saying — the "New Playlist" row above it is the only + // other thing in the section. + emptyDestinationsRow( + String(localized: "playlists.selector.empty.account") + ) + } else { + 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) { @@ -222,6 +223,14 @@ struct PlaylistSelectorSheet: View { } } + /// Says that a section has no playlists to offer — not that some playlist is + /// empty, which is a different sentence entirely. + private func emptyDestinationsRow(_ text: String) -> some View { + Text(text) + .font(.subheadline) + .foregroundStyle(.secondary) + } + /// 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 {