Files
yattee/Yattee/Views/Video/PlaylistSelectorSheet.swift
Arkadiusz Fal e28ca98831 Give the tvOS New Playlist row air between icon and title
A tvOS Label sets its plus glyph nearly against the text at this size;
the row spells the gap out with an HStack there, shared by the local and
remote sections. Other platforms keep the plain Label.
2026-09-15 23:23:47 +02:00

604 lines
22 KiB
Swift

//
// PlaylistSelectorSheet.swift
// Yattee
//
// Sheet for selecting a playlist to add a video to.
//
import SwiftUI
import NukeUI
struct PlaylistSelectorSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
let video: Video
@State private var playlists: [LocalPlaylist] = []
@State private var showingNewPlaylist = false
@State private var pendingPlaylistTitle: String?
@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?
/// Which destination's playlists the sheet is showing.
///
/// Seeded from the app's selected data context, but a private copy from
/// then on: picking a different account here changes what this sheet
/// offers and nothing else — adding a video somewhere is not a decision
/// to browse there.
@State private var selectedDestination: DataContext = .local
/// Seed once per presentation; `onAppear` must not stomp a pick the user
/// already made if the view reappears.
@State private var destinationSeeded = false
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.
///
/// The same three things the write layer insists on, asked here so an
/// account that could only ever fail is not offered as somewhere to put a
/// video: the backend must do playlist writes, the instance must be one
/// this app still talks to, and there must be a credential to talk with.
/// Account *status* deliberately is not among them — a stale "rejected"
/// would hide a login that works, and a failed add says so with a toast.
private var remoteDestinations: [Account] {
guard let appEnvironment else { return [] }
return appEnvironment.accountsManager.accounts.filter { account in
appEnvironment.accountsManager.capabilities(for: account)?.supportsPlaylistWrites == true
&& appEnvironment.accountsManager.instance(for: account)?.isEnabled == true
&& appEnvironment.accountCredentialsManager(for: account)?.isLoggedIn(for: account) == true
}
}
var body: some View {
NavigationStack {
List {
// Warning for local folder videos
if video.isFromLocalFolder {
Section {
Label {
Text(String(localized: "playlist.localFileWarning"))
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
}
// Only show playlist options if not from local folder
if !video.isFromLocalFolder {
// One destination shown at a time, picked at the top. No
// picker while local playlists are the only destination
// there is.
if !remoteDestinations.isEmpty {
Section {
destinationPicker
}
}
if let account = selectedAccount {
remoteSection(for: account)
} else {
localSection
}
}
}
#if os(tvOS)
.scrollClipDisabled()
.padding(.horizontal, 40)
.padding(.vertical, 24)
#else
.navigationTitle(String(localized: "playlist.addTo"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
sheetCloseToolbarItem { dismiss() }
}
#endif
.sheet(isPresented: $showingNewPlaylist) {
PlaylistFormSheet(mode: .create) { title, description in
pendingPlaylistTitle = title
pendingPlaylistDescription = description
}
}
.onChange(of: pendingPlaylistTitle) { _, newValue in
if newValue != nil {
createAndAddToPlaylist()
}
}
.onAppear {
loadPlaylists()
seedDestination()
}
.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])
#endif
#if os(macOS)
.frame(minWidth: 450, minHeight: 500)
#endif
}
// MARK: - Destination Picker
/// The account whose playlists are on show, when the pick is not local.
///
/// Resolved against the *eligible* accounts, so a login that stops
/// qualifying mid-presentation quietly falls back to the local list
/// instead of a section that could only fail.
private var selectedAccount: Account? {
guard case let .account(id) = selectedDestination else { return nil }
return remoteDestinations.first { $0.id == id }
}
private var destinationPicker: some View {
#if os(tvOS)
// A tvOS menu picker shows only its value; the name of the control has
// to be the row's own, so the menu's label is the whole labelled row —
// the same shape as the settings switcher row.
Menu {
destinationOptions
} label: {
HStack {
Text(String(localized: "accounts.switcher.title"))
Spacer(minLength: 8)
Text(DataContextSwitcherLabels.title(for: selectedDestination, in: appEnvironment))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
#else
Picker(String(localized: "accounts.switcher.title"), selection: $selectedDestination) {
destinationOptions
}
.pickerStyle(.menu)
#endif
}
#if os(tvOS)
/// The same choices, hosted by the labelled menu: a picker inside a menu
/// renders as checkmarked items.
private var destinationOptions: some View {
Picker(selection: $selectedDestination) {
destinationOptionRows
} label: {}
}
#else
private var destinationOptions: some View { destinationOptionRows }
#endif
@ViewBuilder private var destinationOptionRows: some View {
Text(DataContextSwitcherLabels.title(for: .local, in: appEnvironment))
.tag(DataContext.local)
ForEach(remoteDestinations) { account in
Text(DataContextSwitcherLabels.title(for: .account(account.id), in: appEnvironment))
.tag(DataContext.account(account.id))
}
}
/// The pick the sheet opens on: the account the app is browsing as, when
/// that account is somewhere a video can be put — otherwise local, which
/// always is.
private func seedDestination() {
guard !destinationSeeded else { return }
destinationSeeded = true
guard let current = appEnvironment?.accountsManager.currentContext,
case let .account(id) = current,
remoteDestinations.contains(where: { $0.id == id })
else { return }
selectedDestination = current
}
// MARK: - Sections
/// The "New Playlist" row's label. tvOS lays a `Label`'s icon nearly
/// against its title at this size, so the gap is spelled out there.
private var newPlaylistLabel: some View {
#if os(tvOS)
HStack(spacing: 20) {
Image(systemName: "plus.circle")
Text(String(localized: "playlist.new"))
}
#else
Label(
String(localized: "playlist.new"),
systemImage: "plus.circle"
)
#endif
}
private var localSection: some View {
Section {
Button {
showingNewPlaylist = true
} label: {
newPlaylistLabel
}
if playlists.isEmpty {
emptyDestinationsRow(
String(localized: "playlists.selector.empty.local")
)
} else {
ForEach(playlists, id: \.id) { playlist in
PlaylistSelectionRow(
playlist: playlist,
video: video,
wasAdded: addedToPlaylist?.id == playlist.id
) {
addVideoToPlaylist(playlist)
}
}
}
}
}
@ViewBuilder
private func remoteSection(for account: Account) -> some View {
Section {
Button {
newRemotePlaylistAccount = account
} label: {
newPlaylistLabel
}
if let remotePlaylists = appEnvironment?.remotePlaylistsCache.playlists(for: account.id) {
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
let selection = RemoteSelection(
accountID: account.id,
playlistID: playlist.id.playlistID
)
RemotePlaylistSelectionRow(
playlist: playlist,
wasAdded: addedRemotePlaylist == selection,
isAdding: remoteAddInFlight == selection
) {
Task { await addVideo(to: playlist, on: account) }
}
}
}
} else if loadingAccountIDs.contains(account.id) {
ProgressView()
#if !os(tvOS)
.controlSize(.small)
#endif
.frame(maxWidth: .infinity)
}
}
}
/// 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)
}
private func loadPlaylists() {
playlists = dataManager?.playlists() ?? []
}
/// Whether a tap on a playlist may still start an add.
///
/// One add per presentation: after a tap lands, the sheet is only showing
/// its checkmark until the auto-dismiss — the same row is still on screen
/// and still tappable, so without this a quick double-tap put the video in
/// twice. A remote add holds the gate for the whole round-trip; only a
/// failure reopens it, since failing is the one way the sheet stays up.
private var canStartAdd: Bool {
remoteAddInFlight == nil && addedToPlaylist == nil && addedRemotePlaylist == nil
}
/// The row whose add is on the wire, so that row — and only that row — can
/// show a spinner where its plus was.
@State private var remoteAddInFlight: RemoteSelection?
private func addVideoToPlaylist(_ playlist: LocalPlaylist) {
guard let dataManager, canStartAdd else { return }
dataManager.addToPlaylist(video, playlist: playlist)
addedToPlaylist = playlist
// Auto-dismiss after a short delay to show the checkmark
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismiss()
}
}
// 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, canStartAdd else { return }
remoteAddInFlight = RemoteSelection(
accountID: account.id,
playlistID: playlist.id.playlistID
)
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. Retrying means the gate reopens.
remoteAddInFlight = nil
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 }
let newPlaylist = dataManager.createPlaylist(title: title, description: pendingPlaylistDescription)
dataManager.addToPlaylist(video, playlist: newPlaylist)
pendingPlaylistTitle = nil
pendingPlaylistDescription = nil
addedToPlaylist = newPlaylist
// Refresh list and auto-dismiss
loadPlaylists()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismiss()
}
}
}
// MARK: - Playlist Selection Row
private struct PlaylistSelectionRow: View {
let playlist: LocalPlaylist
let video: Video
let wasAdded: Bool
let onAdd: () -> Void
var body: some View {
Button(action: onAdd) {
HStack(spacing: 12) {
// Thumbnail
LazyImage(url: 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))
// Info
VStack(alignment: .leading, spacing: 2) {
Text(playlist.title)
.font(.body)
.foregroundStyle(.primary)
Text(String(localized: "playlist.videoCount \(playlist.videoCount)"))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
// Status
if wasAdded {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if playlist.contains(videoID: video.id.videoID) {
Text(String(localized: "playlist.alreadyAdded"))
.font(.caption)
.foregroundStyle(.secondary)
} else {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(playlist.contains(videoID: video.id.videoID) && !wasAdded)
}
}
// 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 isAdding: 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()
// The checkmark wins: success replaces the spinner, not the
// other way round.
if wasAdded {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if isAdding {
ProgressView()
#if !os(tvOS)
.controlSize(.small)
#endif
} else {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
PlaylistSelectorSheet(video: .preview)
.appEnvironment(.preview)
}