Files
yattee/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift
Arkadiusz Fal 612dce6b9f Refactor views
2026-02-09 01:13:02 +01:00

808 lines
28 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)
case remote(PlaylistID, instance: Instance?, title: String? = 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
}
}
/// 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: - 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 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
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
}
/// 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"))
)
}
}
.navigationTitle(title.isEmpty ? String(localized: "playlist.title") : title)
#if !os(tvOS)
.toolbarTitleDisplayMode(.inlineLarge)
#endif
.navigationSubtitleIfAvailable(playlistSummaryText)
#if os(tvOS)
.toolbar {
if !videos.isEmpty || localPlaylist != nil {
ToolbarItem(placement: .primaryAction) {
toolbarMenu
}
}
}
#endif
.sheet(isPresented: $showingEditSheet) {
if let localPlaylist {
PlaylistFormSheet(mode: .edit(localPlaylist)) { newTitle, newDescription in
dataManager?.updatePlaylist(localPlaylist, title: newTitle, description: newDescription)
loadLocalPlaylist()
}
}
}
.confirmationDialog(
String(localized: "playlist.delete.confirm"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
if let localPlaylist {
dataManager?.deletePlaylist(localPlaylist)
dismiss()
}
}
}
.task {
await loadPlaylist()
}
#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
@ViewBuilder
private var playlistContent: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
playlistHeader
Divider()
.padding(.horizontal)
if videos.isEmpty {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 40)
} else {
emptyPlaylistView
}
} else {
videoList
}
}
}
.refreshable {
if case .remote = source {
await loadPlaylist()
}
}
}
/// 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 var 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)
}
}
#else
// Show on non-iOS platforms (macOS, tvOS)
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)
}
#if !os(tvOS)
// Action buttons row (iOS/macOS only)
if !videos.isEmpty || localPlaylist != nil {
playlistActionButtons
}
#endif
}
.padding()
}
// MARK: - Action Buttons Row
#if !os(tvOS)
@ViewBuilder
private var playlistActionButtons: some View {
HStack(spacing: 12) {
// Play button (only when queue is enabled)
if isQueueEnabled {
Button {
playAll()
} label: {
Label(String(localized: "playlist.play"), systemImage: "play.fill")
.fontWeight(.semibold)
}
.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 {
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")
}
}
// Local-only: Edit, Delete
if isLocal, localPlaylist != nil {
Button {
showingEditSheet = true
} label: {
Label(String(localized: "playlist.edit"), systemImage: "pencil")
}
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())
}
}
.padding(.top, 8)
}
#endif
// MARK: - Toolbar Menu
@ViewBuilder
private var toolbarMenu: some View {
Menu {
// Play (only when queue is enabled)
if isQueueEnabled {
Button {
playAll()
} label: {
Label(String(localized: "playlist.play"), systemImage: "play.fill")
}
}
#if !os(tvOS)
// Download All
if downloadCoordinator.isDownloading {
Label {
if let progress = downloadCoordinator.progress {
Text("\(progress.current)/\(progress.total)")
} else {
Text(String(localized: "common.downloading"))
}
} icon: {
Image(systemName: "arrow.down.circle")
}
} else {
Button {
downloadCoordinator.startDownload(videos: videos)
} label: {
Label(String(localized: "playlist.downloadAll"), systemImage: "arrow.down.circle")
}
.disabled(cachedAllVideosDownloaded)
}
#endif
// Remote-only: Save to Library, Share
if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal {
Button {
Task { await importToLocal() }
} label: {
if isImporting, let progress = importProgress {
Label(String(localized: "playlist.savingToLibrary \(progress.current) \(progress.total)"), systemImage: "plus.rectangle.on.folder")
} else {
Label(String(localized: "playlist.saveToLibrary"), systemImage: "plus.rectangle.on.folder")
}
}
.disabled(isImporting)
#if !os(tvOS)
ShareLink(item: playlistShareURL()) {
Label(String(localized: "common.share"), systemImage: "square.and.arrow.up")
}
#endif
}
// Local-only: Edit, Delete
if isLocal, localPlaylist != nil {
Divider()
Button {
showingEditSheet = true
} label: {
Label(String(localized: "playlist.edit"), systemImage: "pencil")
}
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "playlist.delete"), systemImage: "trash")
}
}
} label: {
Image(systemName: "ellipsis")
}
}
#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"))
}
.padding(.top, 40)
}
private var videoList: some View {
LazyVStack(spacing: 0) {
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == videos.count - 1,
rowStyle: .regular,
listStyle: .plain,
indexWidth: 32 // Index column width in VideoRowView
) {
Button {
playFromIndex(index)
} label: {
PlaylistVideoRowView(
index: index + 1,
video: video,
onRemove: isLocal ? { removeVideo(at: index) } : nil
)
}
.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)
}
}
// MARK: - Data Loading
private func loadPlaylist() async {
switch source {
case .local:
loadLocalPlaylist()
case .remote(let playlistID, let instance, _):
await loadRemotePlaylist(playlistID: playlistID, instance: instance)
}
}
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 {
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 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)
}
do {
let fetchedPlaylist: Playlist
// Check if this is an Invidious user playlist (IVPL prefix) that requires authentication
let isInvidiousUserPlaylist = playlistID.playlistID.hasPrefix("IVPL")
let isInvidiousInstance = resolvedInstance.type == .invidious
let sid = appEnvironment.invidiousCredentialsManager.sid(for: resolvedInstance)
if isInvidiousUserPlaylist && isInvidiousInstance, let sid {
// Use authenticated endpoint for Invidious user playlists (including private ones)
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
fetchedPlaylist = try await api.userPlaylist(
id: playlistID.playlistID,
instance: resolvedInstance,
sid: sid
)
} else {
// Use public endpoint for regular playlists
fetchedPlaylist = try await appEnvironment.contentService.playlist(
id: playlistID.playlistID,
instance: resolvedInstance
)
}
remotePlaylist = fetchedPlaylist
title = fetchedPlaylist.title
descriptionText = fetchedPlaylist.description
thumbnailURL = fetchedPlaylist.thumbnailURL
videos = fetchedPlaylist.videos
videoCount = fetchedPlaylist.videoCount
// 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 {
errorMessage = error.localizedDescription
}
isLoading = false
}
/// 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: - 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)
}