mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Keep playlist affordances where an empty list took them
Three things live testing found, all of them where a playlist list or a playlist is empty. The picker said "This playlist has no videos" when there were no playlists to pick — copy about a playlist's contents standing in for copy about the list of them. Each section now says it has none, scoped to where it is: the device, or that account. An account whose fetch came back empty said nothing at all and left a bare gap. The browse view's Playlists tab fetched into its own state, so a playlist created through the picker — which goes through the shared cache — never appeared, and nothing refetched on coming back to the tab. It reads the cache now, and selecting the tab always refetches with the rows already there kept on screen. The cache is keyed by account, which is the discard on an account change the private copy needed a generation for. Removing the last video from a playlist took away the whole action row, so an owned server-side playlist could no longer be renamed or deleted from the app at all. The menu stays; only what needs videos to act on — play, download, save to library — goes.
This commit is contained in:
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user