Files
yattee/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift
Arkadiusz Fal 88551a1366 Offer a delayed Cancel while a tvOS remote playlist loads
A remote playlist this account does not own showed only a spinner while
loading, with or without a cached header, so the tvOS remote had nothing
to focus. Cancel stops at the error view and its Retry, since a sidebar
playlist is a tab root that cannot be popped.
2026-10-18 19:51:09 +02:00

1478 lines
56 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 an account ever read this playlist here, and so whether it is one
/// the public endpoint is entitled to describe.
///
/// Sticky on purpose, where `owningAccount` is not: a discard clears the
/// account, and the notification that says the credentials are readable
/// again would then find nothing left to identify this as an account's
/// playlist — leaving whatever the discard put on screen there for good.
@State private var wasAccountLoaded = false
/// 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.
///
/// Both name the playlist, and both fall back to the unnamed wording while
/// there is no name yet to say.
private var deleteConfirmationTitle: String {
let name = title.isEmpty ? cachedHeader?.title ?? "" : title
guard !name.isEmpty else {
return isLocal
? String(localized: "playlist.delete.confirm")
: String(localized: "playlist.remote.delete.confirm")
}
return isLocal
? String(localized: "playlist.delete.confirm.named \(name)")
: String(localized: "playlist.remote.delete.confirm.named \(name)")
}
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 {
loadingSpinner
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage {
errorView(error)
} else {
notFoundView
}
}
#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 {
// Let go of the model *before* deleting it: dismissal is
// animated, so this view renders at least once more, and
// reading a deleted model — the duration in the subtitle is
// enough — traps in SwiftData with "context is missing".
self.localPlaylist = nil
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))
.accessibilityHidden(true)
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 {
loadingSpinner
.frame(maxWidth: .infinity)
.padding(.top, 40)
} else if let errorMessage {
// A remote playlist arrives with its title, so a failed load
// lands in playlistContent rather than the body's error branch.
errorView(errorMessage)
.padding(.top, 40)
} else {
emptyPlaylistView
}
} else {
videoList
}
}
/// On tvOS a remote playlist this account does not own has no other
/// focusable element while it loads, and as a sidebar playlist it is a tab
/// root that cannot be popped — so Cancel lands on the error and its Retry.
private var loadingSpinner: some View {
ProgressView()
#if os(tvOS)
.tvStalledLoadCancel {
isLoading = false
errorMessage = String(localized: "common.loadingStopped")
}
#endif
}
/// 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
loadingSpinner
.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"))
} actions: {
#if os(tvOS)
// The left column has no buttons for an empty remote playlist this
// account does not own; without one the remote has nowhere to go.
if case .remote = source {
Button(String(localized: "common.refresh")) {
Task { await loadPlaylist() }
}
}
#endif
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 40)
}
private var notFoundView: some View {
ContentUnavailableView {
Label(String(localized: "playlist.notFound"), systemImage: "list.bullet.rectangle")
} description: {
Text(String(localized: "playlist.notFound.description"))
} actions: {
#if os(tvOS)
// Nothing else on this screen is focusable, and as a sidebar
// playlist it is a tab root that Menu cannot pop.
if case .remote = source {
Button(String(localized: "common.retry")) {
Task { await loadPlaylist() }
}
} else {
Button(String(localized: "playlist.notFound.openPlaylists")) {
appEnvironment?.navigationCoordinator.router.select(.playlistsList)
}
}
#endif
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
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.
///
/// `wasAccountLoaded` rather than only what is on screen now: these
/// notifications come in pairs — the credentials become unreadable, then
/// readable again — and the first one is what clears `entries` and
/// `owningAccount`. Asking only about those would take the discard and skip
/// the recovery, leaving the view on whatever the unreadable window
/// produced until the user reloads it by hand.
private func discardAccountLoadedContentAndReload() {
guard case .remote = source,
entries != nil || owningAccount != nil || wasAccountLoaded
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
// "Nobody can be named right now" is not the answer "nobody owns it".
// While the identity scope is unresolved the account list is empty —
// and it drops to unresolved and comes back on its own, on every
// activation with iCloud Drive off. Reading an account's playlist from
// the public endpoint in that window is not a fallback but a wrong
// answer: Invidious replies 404 "Playlist does not exist." to an
// unauthenticated read of a *private* playlist, so a playlist that was
// on screen a moment ago is reported as gone. Nothing is read until the
// scope comes back, which reloads this view.
if account == nil, wasAccountLoaded, !appEnvironment.credentialIdentityScope.isResolved {
return
}
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.
wasAccountLoaded = true
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? {
instancesManager.playlistInstance(for: source)
}
// 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)")!
}
}
/// 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 uniqueName = appEnvironment.dataManager.uniquePlaylistTitle(remotePlaylist.title)
// 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)
}