mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
1421 lines
53 KiB
Swift
1421 lines
53 KiB
Swift
//
|
|
// UnifiedPlaylistDetailView.swift
|
|
// Yattee
|
|
//
|
|
// Unified view for displaying both local and remote playlists.
|
|
//
|
|
|
|
import SwiftUI
|
|
import NukeUI
|
|
|
|
/// Cached playlist data for showing header immediately while loading.
|
|
private struct CachedPlaylistHeader {
|
|
let title: String
|
|
let thumbnailURL: URL?
|
|
let videoCount: Int
|
|
|
|
init(from recentPlaylist: RecentPlaylist) {
|
|
title = recentPlaylist.title
|
|
thumbnailURL = recentPlaylist.thumbnailURLString.flatMap { URL(string: $0) }
|
|
videoCount = recentPlaylist.videoCount
|
|
}
|
|
}
|
|
|
|
/// Source type for playlist data - either local (SwiftData) or remote (API).
|
|
enum PlaylistSource: Hashable {
|
|
case local(UUID, 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
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// Returns a unique identifier for zoom transitions.
|
|
var transitionID: AnyHashable {
|
|
switch self {
|
|
case .local(let uuid, _):
|
|
return uuid
|
|
case .remote(let playlistID, _, _, _):
|
|
return playlistID
|
|
}
|
|
}
|
|
}
|
|
|
|
struct UnifiedPlaylistDetailView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
let source: PlaylistSource
|
|
|
|
// MARK: - Shared State
|
|
|
|
@State private var title: String
|
|
|
|
init(source: PlaylistSource) {
|
|
self.source = source
|
|
self._title = State(initialValue: source.initialTitle ?? "")
|
|
}
|
|
@State private var descriptionText: String?
|
|
@State private var thumbnailURL: URL?
|
|
@State private var videos: [Video] = []
|
|
@State private var videoCount: Int = 0
|
|
|
|
// MARK: - Remote-only State
|
|
|
|
@State private var isLoading = true
|
|
@State private var cachedHeader: CachedPlaylistHeader?
|
|
@State private var errorMessage: String?
|
|
@State private var isImporting = false
|
|
@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?
|
|
|
|
/// Whether the server lists this playlist publicly, as of the last snapshot.
|
|
@State private var remoteIsListed: Bool?
|
|
|
|
/// Whether the refetch that has to land before the edit sheet opens is out.
|
|
@State private var isPreparingEdit = false
|
|
|
|
/// Which load the state on screen belongs to.
|
|
///
|
|
/// Credential notifications arrive repeatedly, and each one discards and
|
|
/// reloads. The reload awaits a server, so a second notification finds the
|
|
/// discard already done and starts another one — leaving the first still in
|
|
/// flight, free to write its error or drop the spinner over a load that has
|
|
/// superseded it. Only the newest generation is allowed to land.
|
|
@State private var loadGeneration = 0
|
|
|
|
// MARK: - Local-only State
|
|
|
|
@State private var localPlaylist: LocalPlaylist?
|
|
@State private var showingEditSheet = false
|
|
@State private var showingDeleteConfirmation = false
|
|
@State private var isDescriptionExpanded = false
|
|
|
|
#if os(tvOS)
|
|
@State private var isDescriptionScrollLocked = false
|
|
#endif
|
|
|
|
#if !os(tvOS)
|
|
@State private var downloadCoordinator = BatchDownloadCoordinator()
|
|
// Cache download state to avoid triggering @Observable tracking on every render.
|
|
// Prevents entire playlist view re-rendering when download progress updates.
|
|
@State private var cachedAllVideosDownloaded = false
|
|
@State private var hasLoadedDownloadState = false
|
|
#endif
|
|
|
|
@State private var watchEntriesMap: [String: WatchEntry] = [:]
|
|
|
|
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
|
private var isQueueEnabled: Bool { appEnvironment?.settingsManager.queueEnabled ?? true }
|
|
|
|
private var isLocal: Bool {
|
|
if case .local = source { return true }
|
|
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
|
|
}
|
|
|
|
/// The playlist's visibility, as far as the server said it.
|
|
///
|
|
/// Invidious answers with a single "listed" flag, so `true` is exactly
|
|
/// public and `false` is unlisted *or* private with no way to tell which.
|
|
/// The form starts the picker at the conservative reading of `false` and
|
|
/// only sends a visibility the user actually chose, so the ambiguity never
|
|
/// turns into a change nobody asked for.
|
|
private var knownRemotePrivacy: PlaylistPrivacy? {
|
|
guard let remoteIsListed else { return nil }
|
|
return remoteIsListed ? .public : .private
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
private var navigationThumbnailURL: URL? {
|
|
// Rendered as a single URL without fallback, so rewrite to the
|
|
// always-available hqdefault variant.
|
|
Thumbnail.reliableURL(for: videos.first?.bestThumbnail?.url ?? thumbnailURL ?? cachedHeader?.thumbnailURL)
|
|
}
|
|
|
|
/// Summary text for the playlist (e.g., "5 videos · 1h 23m").
|
|
private var playlistSummaryText: String? {
|
|
var parts: [String] = []
|
|
|
|
if videoCount > 0 || !isLoading {
|
|
parts.append(String(localized: "playlist.videoCount \(videoCount)"))
|
|
}
|
|
|
|
if let localPlaylist {
|
|
parts.append(localPlaylist.formattedTotalDuration)
|
|
}
|
|
|
|
// Return placeholder while loading to prevent navigation subtitle jump
|
|
if parts.isEmpty && isLoading {
|
|
return String(localized: "common.loading")
|
|
}
|
|
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
Group {
|
|
if !title.isEmpty || localPlaylist != nil {
|
|
playlistContent
|
|
} else if case .remote = source, isLoading, let cachedHeader {
|
|
// Show header with cached data + spinner for video list
|
|
loadingContent(cachedHeader)
|
|
} else if case .remote = source, isLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = errorMessage {
|
|
errorView(error)
|
|
} else {
|
|
ContentUnavailableView(
|
|
String(localized: "playlist.notFound"),
|
|
systemImage: "list.bullet.rectangle",
|
|
description: Text(String(localized: "playlist.notFound.description"))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.navigationTitle(navigationTitleText)
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
.navigationSubtitleIfAvailable(playlistSummaryText)
|
|
#endif
|
|
#if os(macOS)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
macOSNavigationTitle
|
|
}
|
|
}
|
|
#endif
|
|
.sheet(isPresented: $showingEditSheet) {
|
|
if let localPlaylist {
|
|
PlaylistFormSheet(mode: .edit(localPlaylist)) { newTitle, newDescription in
|
|
dataManager?.updatePlaylist(localPlaylist, title: newTitle, description: newDescription)
|
|
loadLocalPlaylist()
|
|
}
|
|
} else if canEditRemotePlaylist {
|
|
PlaylistFormSheet(
|
|
mode: .editRemote(
|
|
title: title,
|
|
description: descriptionText,
|
|
privacy: knownRemotePrivacy
|
|
),
|
|
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(
|
|
deleteConfirmationTitle,
|
|
isPresented: $showingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
if let localPlaylist {
|
|
dataManager?.deletePlaylist(localPlaylist)
|
|
dismiss()
|
|
} else {
|
|
Task { await deleteRemotePlaylist() }
|
|
}
|
|
}
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
.task {
|
|
await loadPlaylist()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
|
loadWatchEntries()
|
|
}
|
|
// Signing in again keeps the account, the source and usually the status,
|
|
// so nothing else this view watches would move — but the login behind an
|
|
// account-loaded playlist may now be somebody else.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
|
discardAccountLoadedContentAndReload()
|
|
}
|
|
// The same, for a change of Apple account: every credential on the
|
|
// device belongs to an identity, and this one's are no longer readable.
|
|
.onReceive(NotificationCenter.default.publisher(for: .credentialIdentityScopeDidChange)) { _ in
|
|
discardAccountLoadedContentAndReload()
|
|
}
|
|
#if !os(tvOS)
|
|
.batchDownload(coordinator: downloadCoordinator)
|
|
.onAppear {
|
|
downloadCoordinator.setEnvironment(appEnvironment)
|
|
loadDownloadStateIfNeeded()
|
|
}
|
|
.onChange(of: videos) { _, _ in
|
|
// Update cache when videos change (e.g., after loading)
|
|
updateAllVideosDownloadedCache()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
#if os(macOS)
|
|
private var macOSNavigationTitle: some View {
|
|
HStack(spacing: 8) {
|
|
LazyImage(url: navigationThumbnailURL) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(.quaternary)
|
|
.overlay {
|
|
Image(systemName: "list.bullet.rectangle")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 26, height: 15)
|
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
|
|
|
Text(navigationTitleText)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
}
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
private var playlistContent: some View {
|
|
#if os(tvOS)
|
|
tvOSTwoColumnContent
|
|
#else
|
|
scrollablePlaylistContent()
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func scrollablePlaylistContent() -> some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
playlistHeader()
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
playlistBody
|
|
}
|
|
}
|
|
.refreshable {
|
|
if case .remote = source {
|
|
await loadPlaylist()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var playlistBody: some View {
|
|
if videos.isEmpty {
|
|
if isLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 40)
|
|
} else {
|
|
emptyPlaylistView
|
|
}
|
|
} else {
|
|
videoList
|
|
}
|
|
}
|
|
|
|
/// Shows cached header with a spinner below while loading full playlist data.
|
|
private func loadingContent(_ cached: CachedPlaylistHeader) -> some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
playlistHeader(
|
|
thumbnailURL: cached.thumbnailURL,
|
|
videoCount: cached.videoCount
|
|
)
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
// Centered spinner for content area
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func playlistHeader() -> some View {
|
|
playlistHeader(
|
|
thumbnailURL: thumbnailURL,
|
|
videoCount: videoCount,
|
|
localPlaylist: localPlaylist,
|
|
descriptionText: descriptionText
|
|
)
|
|
}
|
|
|
|
private func playlistHeader(
|
|
thumbnailURL: URL?,
|
|
videoCount: Int,
|
|
localPlaylist: LocalPlaylist? = nil,
|
|
descriptionText: String? = nil
|
|
) -> some View {
|
|
// Build summary text for this header instance
|
|
let summaryText: String? = {
|
|
var parts: [String] = []
|
|
if videoCount > 0 || !isLoading {
|
|
parts.append(String(localized: "playlist.videoCount \(videoCount)"))
|
|
}
|
|
if let localPlaylist {
|
|
parts.append(localPlaylist.formattedTotalDuration)
|
|
}
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}()
|
|
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
// Summary info (only shown on pre-iOS 26, or non-iOS platforms)
|
|
#if os(iOS)
|
|
if #unavailable(iOS 26) {
|
|
if let summaryText {
|
|
Text(summaryText)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
if let summaryText {
|
|
Text(summaryText)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
#endif
|
|
|
|
// Description (full width, expandable)
|
|
if let descriptionText, !descriptionText.isEmpty {
|
|
ExpandableText(text: descriptionText, lineLimit: 2, isExpanded: $isDescriptionExpanded)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// 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 || canEditRemotePlaylist {
|
|
playlistActionButtons
|
|
}
|
|
#endif
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
// MARK: - Action Buttons Row
|
|
|
|
#if !os(tvOS)
|
|
@ViewBuilder
|
|
private var playlistActionButtons: some View {
|
|
HStack(spacing: 12) {
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Menu button (circular with glass background)
|
|
Menu {
|
|
// Remote-only: Save to Library, Share
|
|
if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal {
|
|
// 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)
|
|
}
|
|
|
|
ShareLink(item: playlistShareURL()) {
|
|
Label(String(localized: "common.share"), systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
|
|
// Edit and Delete: local playlists, and server-side ones this
|
|
// account owns.
|
|
if (isLocal && localPlaylist != nil) || canEditRemotePlaylist {
|
|
Button {
|
|
beginEdit()
|
|
} label: {
|
|
Label(String(localized: "playlist.edit"), systemImage: "pencil")
|
|
}
|
|
.disabled(isPreparingEdit)
|
|
|
|
Button(role: .destructive) {
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "playlist.delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.font(.body.weight(.medium))
|
|
.frame(width: 44, height: 44)
|
|
.background(.regularMaterial, in: Circle())
|
|
}
|
|
.menuIndicator(.hidden)
|
|
#if os(macOS)
|
|
// The default macOS menu style renders as a bordered pull-down that
|
|
// stretches to fill available width on macOS 15 (Sequoia).
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
#endif
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
#endif
|
|
|
|
// MARK: - tvOS Two-Column Layout
|
|
|
|
#if os(tvOS)
|
|
@ViewBuilder
|
|
private var tvOSTwoColumnContent: some View {
|
|
GeometryReader { geometry in
|
|
let leftWidth = geometry.size.width * 0.30
|
|
HStack(alignment: .top, spacing: 40) {
|
|
tvOSLeftColumn
|
|
.frame(width: leftWidth, alignment: .leading)
|
|
.focusSection()
|
|
|
|
tvOSRightColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.focusSection()
|
|
}
|
|
.padding(.horizontal, 60)
|
|
.padding(.vertical, 40)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tvOSLeftColumn: some View {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
tvOSPlaylistThumbnail
|
|
.frame(maxWidth: .infinity)
|
|
|
|
if !title.isEmpty {
|
|
Text(title)
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
.foregroundStyle(.white)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let summaryText = playlistSummaryText {
|
|
Text(summaryText)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
if let descriptionText, !descriptionText.isEmpty {
|
|
TVScrollableDescription(
|
|
description: descriptionText,
|
|
isScrollLocked: $isDescriptionScrollLocked,
|
|
showsHeader: false
|
|
)
|
|
.frame(maxHeight: .infinity)
|
|
} else {
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tvOSPlaylistThumbnail: some View {
|
|
let urls = (videos.first?.thumbnailURLsByQuality ?? [])
|
|
+ [Thumbnail.reliableURL(for: thumbnailURL)].compactMap { $0 }
|
|
FallbackLazyImage(urls: urls) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.overlay {
|
|
Image(systemName: "music.note.list")
|
|
.font(.system(size: 56, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.aspectRatio(16.0 / 9.0, contentMode: .fit)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: .black.opacity(0.4), radius: 10, y: 5)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tvOSRightColumn: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
playlistBody
|
|
}
|
|
.padding(.vertical, 20)
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tvOSPlaylistActionButtons: some View {
|
|
VStack(spacing: 12) {
|
|
// Play (only when queue is enabled, and only with something to play)
|
|
if isQueueEnabled, !videos.isEmpty {
|
|
Button {
|
|
playAll()
|
|
} label: {
|
|
Label(String(localized: "playlist.play"), systemImage: "play.fill")
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.font(.headline)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
|
|
// Remote-only: Save to Library
|
|
if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal, !videos.isEmpty {
|
|
Button {
|
|
Task { await importToLocal() }
|
|
} label: {
|
|
Group {
|
|
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")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.font(.headline)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isImporting)
|
|
}
|
|
|
|
// Edit and Delete: local playlists, and server-side ones this
|
|
// account owns.
|
|
if (isLocal && localPlaylist != nil) || canEditRemotePlaylist {
|
|
Button {
|
|
beginEdit()
|
|
} label: {
|
|
Group {
|
|
if isPreparingEdit {
|
|
ProgressView()
|
|
} else {
|
|
Label(String(localized: "playlist.edit"), systemImage: "pencil")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.font(.headline)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isPreparingEdit)
|
|
|
|
Button {
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "playlist.delete"), systemImage: "trash")
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.font(.headline)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if !os(tvOS)
|
|
/// Loads download state once on appear to avoid continuous re-renders from @Observable.
|
|
private func loadDownloadStateIfNeeded() {
|
|
guard !hasLoadedDownloadState else { return }
|
|
hasLoadedDownloadState = true
|
|
updateAllVideosDownloadedCache()
|
|
}
|
|
|
|
/// Updates the cached allVideosDownloaded state.
|
|
/// Called on appear and when videos array changes.
|
|
private func updateAllVideosDownloadedCache() {
|
|
guard let downloadManager = appEnvironment?.downloadManager else {
|
|
cachedAllVideosDownloaded = false
|
|
return
|
|
}
|
|
cachedAllVideosDownloaded = videos.allSatisfy { video in
|
|
downloadManager.downloadedVideoIDs.contains(video.id) ||
|
|
downloadManager.downloadingVideoIDs.contains(video.id)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private var emptyPlaylistView: some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "playlist.empty"), systemImage: "music.note.list")
|
|
} description: {
|
|
Text(String(localized: "playlist.empty.description"))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(.top, 40)
|
|
}
|
|
|
|
private var videoList: some View {
|
|
#if os(tvOS)
|
|
let indexColumnWidth: CGFloat = 60
|
|
#else
|
|
let indexColumnWidth: CGFloat = 32
|
|
#endif
|
|
return LazyVStack(spacing: 0) {
|
|
// Keyed by row rather than by video: a playlist may hold the same
|
|
// video twice, and two rows sharing an identity leave SwiftUI free
|
|
// to pair a row's view with the other one's index — which is what
|
|
// the removal closure is addressed by.
|
|
ForEach(Array(videos.enumerated()), id: \.offset) { index, video in
|
|
VideoListRow(
|
|
isLast: index == videos.count - 1,
|
|
rowStyle: .regular,
|
|
listStyle: .plain,
|
|
indexWidth: indexColumnWidth // Index column width in VideoRowView
|
|
) {
|
|
Button {
|
|
playFromIndex(index)
|
|
} label: {
|
|
PlaylistVideoRowView(
|
|
index: index + 1,
|
|
video: video,
|
|
watchProgress: watchProgress(for: video),
|
|
onRemove: removeAction(forRow: index)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error View
|
|
|
|
private func errorView(_ error: String) -> some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(error)
|
|
} actions: {
|
|
Button(String(localized: "common.retry")) {
|
|
Task { await loadPlaylist() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
private func loadPlaylist() async {
|
|
switch source {
|
|
case .local:
|
|
loadLocalPlaylist()
|
|
case .remote(let playlistID, let instance, _, _):
|
|
await loadRemotePlaylist(playlistID: playlistID, instance: instance)
|
|
}
|
|
loadWatchEntries()
|
|
}
|
|
|
|
/// Throws away everything an account fetched here, and loads again.
|
|
///
|
|
/// Cleared first and synchronously, because the reload awaits a server:
|
|
/// until it answers, a playlist that may be private would otherwise still
|
|
/// be on screen — with its rows, its title and its destructive affordances
|
|
/// — under a credential that no longer belongs to the person who fetched
|
|
/// it. The reload resolves ownership again from scratch, and falls back to
|
|
/// the public endpoint when the account can no longer read the playlist.
|
|
///
|
|
/// Only for a playlist an account loaded: a public one shows nothing that
|
|
/// a login could make private, and reloading it would be a flash for
|
|
/// nothing.
|
|
private func discardAccountLoadedContentAndReload() {
|
|
guard case .remote = source, entries != nil || owningAccount != nil else { return }
|
|
|
|
// Bumped here rather than only in the reload it starts: the load being
|
|
// superseded has to stop counting at the moment its content is thrown
|
|
// away, not whenever the replacement gets around to running.
|
|
loadGeneration += 1
|
|
|
|
entries = nil
|
|
entriesByRow = [:]
|
|
owningAccount = nil
|
|
remoteIsListed = nil
|
|
remotePlaylist = nil
|
|
title = ""
|
|
descriptionText = nil
|
|
thumbnailURL = nil
|
|
videos = []
|
|
videoCount = 0
|
|
errorMessage = nil
|
|
isLoading = true
|
|
|
|
Task { await loadPlaylist() }
|
|
}
|
|
|
|
private func loadWatchEntries() {
|
|
watchEntriesMap = dataManager?.watchEntriesMap() ?? [:]
|
|
}
|
|
|
|
private func watchProgress(for video: Video) -> Double? {
|
|
guard let entry = watchEntriesMap[video.id.videoID] else { return nil }
|
|
let progress = entry.progress
|
|
return progress > 0 && progress < 1 ? progress : nil
|
|
}
|
|
|
|
private func loadLocalPlaylist() {
|
|
guard case .local(let uuid, _) = source else { return }
|
|
|
|
localPlaylist = dataManager?.playlists().first { $0.id == uuid }
|
|
|
|
if let playlist = localPlaylist {
|
|
title = playlist.title
|
|
descriptionText = playlist.playlistDescription
|
|
thumbnailURL = playlist.thumbnailURL
|
|
videos = playlist.sortedItems.map { $0.toVideo() }
|
|
videoCount = playlist.videoCount
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
private func loadRemotePlaylist(playlistID: PlaylistID, instance: Instance?) async {
|
|
// Every load supersedes the ones before it — pull to refresh and retry
|
|
// as much as a credential change. Taken before the first suspension, so
|
|
// an older task can never claim it.
|
|
loadGeneration += 1
|
|
let generation = loadGeneration
|
|
|
|
guard let appEnvironment else {
|
|
errorMessage = "App not initialized"
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
// Use passed instance, or find appropriate one based on source
|
|
let targetInstance: Instance?
|
|
if let instance {
|
|
targetInstance = instance
|
|
} else if let accountInstance = instanceForOpeningAccount() {
|
|
// The account the playlist was opened as names the server it lives
|
|
// on, and it says so with more authority than any guess from the
|
|
// id: a Piped playlist is `.global` like any other, so the guess
|
|
// below would land on an Invidious instance and the ownership check
|
|
// would then throw away the very account that can read it.
|
|
targetInstance = accountInstance
|
|
} else if let playlistSource = playlistID.source {
|
|
targetInstance = instanceForSource(playlistSource, instancesManager: appEnvironment.instancesManager)
|
|
} else {
|
|
targetInstance = appEnvironment.instancesManager.activeInstance
|
|
}
|
|
|
|
guard let resolvedInstance = targetInstance else {
|
|
errorMessage = "No instance available"
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
// Load cached header data for immediate display
|
|
if let recentPlaylist = appEnvironment.dataManager.recentPlaylistEntry(forPlaylistID: playlistID.playlistID) {
|
|
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
|
|
|
|
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,
|
|
for: account
|
|
)
|
|
|
|
guard generation == loadGeneration else { return }
|
|
|
|
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 {
|
|
// Public endpoint: no account, no row identities, no editing.
|
|
entries = nil
|
|
entriesByRow = [:]
|
|
|
|
fetchedPlaylist = try await appEnvironment.contentService.playlist(
|
|
id: playlistID.playlistID,
|
|
instance: resolvedInstance
|
|
)
|
|
|
|
guard generation == loadGeneration else { return }
|
|
|
|
apply(fetchedPlaylist)
|
|
}
|
|
|
|
// Save to recent playlists (only remote playlists, unless incognito mode is enabled or recent playlists disabled)
|
|
if appEnvironment.settingsManager.incognitoModeEnabled != true,
|
|
appEnvironment.settingsManager.saveRecentPlaylists {
|
|
appEnvironment.dataManager.addRecentPlaylist(fetchedPlaylist)
|
|
}
|
|
} catch {
|
|
guard generation == loadGeneration else { return }
|
|
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
|
|
guard generation == loadGeneration else { return }
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
/// The instance of the account the playlist was opened as, when the place
|
|
/// it was opened from named one and this device still has it.
|
|
private func instanceForOpeningAccount() -> Instance? {
|
|
guard let appEnvironment,
|
|
let accountID = source.accountID,
|
|
let account = appEnvironment.accountsManager.account(id: accountID)
|
|
else { return nil }
|
|
|
|
return appEnvironment.accountsManager.instance(for: account)
|
|
}
|
|
|
|
/// 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 }
|
|
|
|
// Only if that account is on *this* instance. An id that came in over
|
|
// Handoff was minted on another device, where it may name an account
|
|
// this one has on a different server entirely — and resolving it would
|
|
// send the authenticated fetch somewhere the playlist does not live,
|
|
// with a credential that has nothing to do with it.
|
|
if let accountID = source.accountID,
|
|
let account = appEnvironment.accountsManager.account(id: accountID),
|
|
appEnvironment.accountsManager.instance(for: account)?.id == instance.id {
|
|
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)
|
|
remoteIsListed = snapshot.isListed
|
|
}
|
|
|
|
/// Pairs each displayed row with the entry that can name it to the server.
|
|
///
|
|
/// The correspondence is the one the snapshot builder recorded, not one
|
|
/// recovered here: `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 — and matching the two lists back up by video id
|
|
/// cannot tell two rows of the same video apart, so a duplicate row without
|
|
/// an identity would steal its twin's entry and remove the wrong one.
|
|
private static func rowEntries(for snapshot: RemotePlaylistSnapshot) -> [Int: RemotePlaylistEntry] {
|
|
let rowCount = snapshot.playlist.videos.count
|
|
var map: [Int: RemotePlaylistEntry] = [:]
|
|
|
|
for entry in snapshot.entries where entry.displayRow >= 0 && entry.displayRow < rowCount {
|
|
map[entry.displayRow] = entry
|
|
}
|
|
|
|
return map
|
|
}
|
|
|
|
/// Returns the appropriate instance for the playlist's content source.
|
|
private func instanceForSource(_ source: ContentSource, instancesManager: InstancesManager) -> Instance? {
|
|
switch source {
|
|
case .global:
|
|
// For global content (YouTube), prefer Invidious instances over Yattee Server
|
|
// since Invidious playlists (IVPL*) are Invidious-specific
|
|
if let invidiousInstance = instancesManager.enabledInstances.first(where: { $0.type == .invidious }) {
|
|
return invidiousInstance
|
|
}
|
|
// Fall back to any YouTube-compatible instance
|
|
return instancesManager.enabledInstances.first(where: { $0.isYouTubeInstance })
|
|
case .federated(let provider, let instanceURL):
|
|
// For federated content, find matching instance by URL
|
|
if let existingInstance = instancesManager.instances.first(where: { $0.url == instanceURL }) {
|
|
return existingInstance
|
|
}
|
|
// If no configured instance matches, create a temporary one for PeerTube
|
|
if provider == ContentSource.peertubeProvider {
|
|
return Instance(type: .peertube, url: instanceURL)
|
|
}
|
|
return nil
|
|
case .extracted:
|
|
// For extracted content, use Yattee Server
|
|
return instancesManager.yatteeServerInstances.first
|
|
}
|
|
}
|
|
|
|
// MARK: - Playback Actions
|
|
|
|
private func playFromIndex(_ index: Int) {
|
|
guard let appEnvironment else { return }
|
|
|
|
appEnvironment.queueManager.playFromList(
|
|
videos: videos,
|
|
index: index,
|
|
queueSource: queueSource,
|
|
sourceLabel: title
|
|
)
|
|
}
|
|
|
|
private func playAll() {
|
|
guard !videos.isEmpty, let appEnvironment else { return }
|
|
|
|
appEnvironment.queueManager.playFromList(
|
|
videos: videos,
|
|
index: 0,
|
|
queueSource: queueSource,
|
|
sourceLabel: title
|
|
)
|
|
}
|
|
|
|
private var queueSource: QueueSource {
|
|
switch source {
|
|
case .local:
|
|
return .manual
|
|
case .remote(let playlistID, _, _, _):
|
|
return .playlist(playlistID: playlistID.playlistID, continuation: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Local Playlist Actions
|
|
|
|
private func removeVideo(at index: Int) {
|
|
guard let localPlaylist else { return }
|
|
dataManager?.removeVideoFromPlaylist(at: index, playlist: localPlaylist)
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Opens the edit form — over the playlist as the server has it *now*, when
|
|
/// the playlist is one.
|
|
///
|
|
/// The form saves every field at once, so an edit started from a snapshot
|
|
/// taken minutes ago would put that snapshot's title and description back
|
|
/// over anything another client changed since. A local playlist is the only
|
|
/// copy there is and needs none of this.
|
|
private func beginEdit() {
|
|
guard !isLocal else {
|
|
showingEditSheet = true
|
|
return
|
|
}
|
|
Task { await beginRemoteEdit() }
|
|
}
|
|
|
|
private func beginRemoteEdit() async {
|
|
guard let appEnvironment,
|
|
let account = owningAccount,
|
|
let playlistID = remotePlaylistID
|
|
else { return }
|
|
|
|
isPreparingEdit = true
|
|
defer { isPreparingEdit = false }
|
|
|
|
do {
|
|
try await appEnvironment.remotePlaylistsCache.refreshSnapshot(id: playlistID, for: account)
|
|
|
|
// Nothing came back to edit: the account was cleared while this was
|
|
// in flight and its data went with it, exactly as on load.
|
|
guard let snapshot = appEnvironment.remotePlaylistsCache.snapshot(
|
|
id: playlistID,
|
|
for: account.id
|
|
) else { return }
|
|
|
|
apply(snapshot)
|
|
showingEditSheet = true
|
|
} catch {
|
|
// No sheet: editing from data known to be stale is the thing this
|
|
// refetch exists to prevent.
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "playlist.remote.refresh.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.
|
|
///
|
|
/// No snapshot left is an answer too: the refetch found the playlist gone
|
|
/// (a delete that reported a failure but landed), or the account it belonged
|
|
/// to was cleared. Either way there is nothing left to show or to edit, so
|
|
/// the view leaves exactly as it does after a delete that reported success.
|
|
private func applyRefetchedSnapshot() {
|
|
guard let appEnvironment,
|
|
let account = owningAccount,
|
|
let playlistID = remotePlaylistID
|
|
else { return }
|
|
|
|
guard let snapshot = appEnvironment.remotePlaylistsCache.snapshot(id: playlistID, for: account.id) else {
|
|
dismiss()
|
|
return
|
|
}
|
|
|
|
apply(snapshot)
|
|
}
|
|
|
|
// MARK: - Remote Playlist Actions
|
|
|
|
private func playlistShareURL() -> URL {
|
|
guard let remotePlaylist else {
|
|
return URL(string: "yattee://playlist")!
|
|
}
|
|
|
|
switch remotePlaylist.id.source {
|
|
case .global:
|
|
return URL(string: "https://youtube.com/playlist?list=\(remotePlaylist.id.playlistID)")!
|
|
case .federated(_, let instance):
|
|
return instance.appendingPathComponent("video-playlists/\(remotePlaylist.id.playlistID)")
|
|
case .extracted(_, let originalURL):
|
|
return originalURL
|
|
case nil:
|
|
return URL(string: "yattee://playlist/\(remotePlaylist.id.playlistID)")!
|
|
}
|
|
}
|
|
|
|
/// Generates a unique playlist name by appending (2), (3), etc. if needed.
|
|
private func generateUniqueName(_ baseName: String, existingTitles: Set<String>) -> String {
|
|
if !existingTitles.contains(baseName) {
|
|
return baseName
|
|
}
|
|
|
|
var counter = 2
|
|
while true {
|
|
let candidateName = "\(baseName) (\(counter))"
|
|
if !existingTitles.contains(candidateName) {
|
|
return candidateName
|
|
}
|
|
counter += 1
|
|
}
|
|
}
|
|
|
|
/// Imports the remote playlist to local storage.
|
|
private func importToLocal() async {
|
|
guard let appEnvironment, let remotePlaylist else { return }
|
|
|
|
isImporting = true
|
|
importProgress = nil
|
|
|
|
// Generate unique name
|
|
let existingTitles = Set(appEnvironment.dataManager.playlists().map(\.title))
|
|
let uniqueName = generateUniqueName(remotePlaylist.title, existingTitles: existingTitles)
|
|
|
|
// Create local playlist
|
|
let newLocalPlaylist = appEnvironment.dataManager.createPlaylist(
|
|
title: uniqueName,
|
|
description: remotePlaylist.description
|
|
)
|
|
|
|
// Add videos
|
|
let total = videos.count
|
|
|
|
for (index, video) in videos.enumerated() {
|
|
importProgress = (current: index + 1, total: total)
|
|
appEnvironment.dataManager.addToPlaylist(video, playlist: newLocalPlaylist)
|
|
|
|
// Small delay to allow UI to update for larger playlists
|
|
if total > 10 {
|
|
try? await Task.sleep(for: .milliseconds(30))
|
|
}
|
|
}
|
|
|
|
isImporting = false
|
|
importProgress = nil
|
|
|
|
appEnvironment.toastManager.showSuccess(String(localized: "playlist.imported.title"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Local Playlist") {
|
|
NavigationStack {
|
|
UnifiedPlaylistDetailView(source: .local(UUID()))
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|
|
|
|
#Preview("Remote Playlist") {
|
|
NavigationStack {
|
|
UnifiedPlaylistDetailView(source: .remote(.global("PLtest"), instance: nil))
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|