mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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.
604 lines
22 KiB
Swift
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)
|
|
}
|