Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,257 @@
//
// BatchDownloadCoordinator.swift
// Yattee
//
// Shared coordinator for batch downloading videos from playlists.
// Used by UnifiedPlaylistDetailView for batch downloading playlist videos.
//
import SwiftUI
#if !os(tvOS)
/// Observable state for batch download operations.
@Observable
@MainActor
final class BatchDownloadCoordinator {
// MARK: - State
/// Whether a batch download is in progress.
var isDownloading = false
/// Current progress (current, total).
var progress: (current: Int, total: Int)?
/// Whether to show the quality picker sheet.
var showingQualitySheet = false
/// Whether to show the error alert.
var showingErrorAlert = false
/// Error info for the alert.
var errorInfo: (videoTitle: String, errorMessage: String)?
/// Whether to continue downloading after an error.
private var shouldContinue = true
/// Videos to download (set when quality sheet is shown).
private var pendingVideos: [Video] = []
// MARK: - Dependencies
private weak var appEnvironment: AppEnvironment?
// MARK: - Initialization
init() {}
func setEnvironment(_ environment: AppEnvironment?) {
self.appEnvironment = environment
}
// MARK: - Public API
/// Starts the download process for the given videos.
/// Shows quality picker if needed, otherwise starts immediately.
func startDownload(videos: [Video]) {
guard let appEnvironment, !videos.isEmpty else { return }
pendingVideos = videos
let downloadSettings = appEnvironment.downloadSettings
if downloadSettings.preferredDownloadQuality != .ask {
// Use saved preference - start immediately
Task {
await performBatchDownload(
videos: videos,
quality: downloadSettings.preferredDownloadQuality,
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload
)
}
} else {
// Show quality picker
showingQualitySheet = true
}
}
/// Called when user confirms quality selection from the sheet.
func confirmDownload(quality: DownloadQuality, includeSubtitles: Bool) {
let videos = pendingVideos
pendingVideos = []
Task {
await performBatchDownload(
videos: videos,
quality: quality,
includeSubtitles: includeSubtitles
)
}
}
/// Called when user chooses to continue after an error.
func continueAfterError() {
shouldContinue = true
}
/// Called when user chooses to stop after an error.
func stopAfterError() {
shouldContinue = false
}
/// The number of videos pending download (for sheet display).
var pendingVideoCount: Int {
pendingVideos.count
}
// MARK: - Private Implementation
private func performBatchDownload(
videos: [Video],
quality: DownloadQuality,
includeSubtitles: Bool
) async {
guard let appEnvironment,
let firstVideo = videos.first,
let instance = appEnvironment.instancesManager.instance(for: firstVideo) else {
appEnvironment?.toastManager.show(
category: .error,
title: String(localized: "batchDownload.error.title"),
subtitle: String(localized: "batchDownload.error.noInstance.subtitle")
)
return
}
isDownloading = true
shouldContinue = true
progress = nil
// Show batch start toast
appEnvironment.toastManager.show(
category: .download,
title: String(localized: "batchDownload.starting.title"),
subtitle: String(localized: "batchDownload.starting.subtitle \(videos.count)"),
icon: "arrow.down.circle",
iconColor: .blue,
autoDismissDelay: 3.0
)
// Capture downloadManager before closure to avoid Swift 6 concurrency warning
let downloadManager = appEnvironment.downloadManager
let result = await downloadManager.batchAutoEnqueue(
videos: videos,
preferredQuality: quality,
preferredAudioLanguage: appEnvironment.settingsManager.preferredAudioLanguage,
preferredSubtitlesLanguage: appEnvironment.settingsManager.preferredSubtitlesLanguage,
includeSubtitles: includeSubtitles,
contentService: appEnvironment.contentService,
instance: instance,
onProgress: { @Sendable [weak self] current, total in
guard let self else { return }
await MainActor.run {
self.progress = (current: current, total: total)
}
},
onError: { [weak self] video, error in
guard let self else { return false }
await MainActor.run {
self.errorInfo = (videoTitle: video.title, errorMessage: error.localizedDescription)
self.showingErrorAlert = true
}
// Wait for user to dismiss the alert
while await MainActor.run(body: { self.showingErrorAlert }) {
try? await Task.sleep(for: .milliseconds(100))
}
return await MainActor.run { self.shouldContinue }
},
onEnqueued: { @Sendable downloadID in
// Register each download as part of the batch to suppress individual toasts.
// Downloads are removed from batchDownloadIDs when they complete, fail, or are cancelled.
await MainActor.run {
_ = downloadManager.batchDownloadIDs.insert(downloadID)
}
}
)
// Note: Don't clear batchDownloadIDs here - downloads are removed individually
// when they complete (completeMultiFileDownload), fail (handleDownloadError),
// or are cancelled (cancel).
isDownloading = false
progress = nil
showCompletionToast(result)
}
private func showCompletionToast(_ result: DownloadManager.BatchDownloadResult) {
guard let appEnvironment else { return }
if result.failedVideos.isEmpty {
if result.skippedCount > 0 {
appEnvironment.toastManager.showSuccess(
String(localized: "batchDownload.complete.title"),
subtitle: String(localized: "batchDownload.complete.withSkipped.subtitle \(result.successCount) \(result.skippedCount)")
)
} else if result.successCount > 0 {
appEnvironment.toastManager.showSuccess(
String(localized: "batchDownload.complete.title"),
subtitle: String(localized: "batchDownload.complete.success.subtitle \(result.successCount)")
)
} else {
// All skipped
appEnvironment.toastManager.show(
category: .info,
title: String(localized: "batchDownload.complete.skipped.title"),
subtitle: String(localized: "batchDownload.complete.allSkipped.subtitle")
)
}
} else {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "batchDownload.complete.partial.title"),
subtitle: String(localized: "batchDownload.complete.partial.subtitle \(result.successCount) \(result.failedVideos.count)")
)
}
}
}
// MARK: - View Modifier
/// View modifier that adds batch download sheet and error alert.
struct BatchDownloadModifier: ViewModifier {
@Bindable var coordinator: BatchDownloadCoordinator
func body(content: Content) -> some View {
content
.sheet(isPresented: $coordinator.showingQualitySheet) {
BatchDownloadQualitySheet(videoCount: coordinator.pendingVideoCount) { quality, includeSubtitles in
coordinator.confirmDownload(quality: quality, includeSubtitles: includeSubtitles)
}
}
.alert(
String(localized: "batchDownload.error.title"),
isPresented: $coordinator.showingErrorAlert,
presenting: coordinator.errorInfo
) { _ in
Button(String(localized: "batchDownload.error.continue")) {
coordinator.continueAfterError()
}
Button(String(localized: "batchDownload.error.stop"), role: .destructive) {
coordinator.stopAfterError()
}
} message: { info in
Text("batchDownload.error.message \(info.videoTitle) \(info.errorMessage)")
}
}
}
extension View {
/// Adds batch download capability to a view.
func batchDownload(coordinator: BatchDownloadCoordinator) -> some View {
modifier(BatchDownloadModifier(coordinator: coordinator))
}
}
#endif

View File

@@ -0,0 +1,98 @@
//
// BatchDownloadQualitySheet.swift
// Yattee
//
// Sheet for selecting download quality when batch downloading multiple videos.
//
import SwiftUI
#if !os(tvOS)
struct BatchDownloadQualitySheet: View {
let videoCount: Int
let onConfirm: (DownloadQuality, Bool) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@State private var selectedQuality: DownloadQuality = .best
@State private var includeSubtitles = false
/// Quality options excluding "Ask" since we're already in the ask flow
private var qualityOptions: [DownloadQuality] {
DownloadQuality.allCases.filter { $0 != .ask }
}
var body: some View {
NavigationStack {
Form {
Section {
Picker(String(localized: "batchDownload.quality"), selection: $selectedQuality) {
ForEach(qualityOptions, id: \.self) { quality in
Text(quality.displayName).tag(quality)
}
}
} header: {
Text("batchDownload.subtitle \(videoCount)")
}
Section {
Toggle(String(localized: "batchDownload.includeSubtitles"), isOn: $includeSubtitles)
}
}
#if os(macOS)
.background(Color(nsColor: .windowBackgroundColor))
#else
.background(Color(.systemGroupedBackground))
#endif
.navigationTitle(String(localized: "batchDownload.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "batchDownload.start")) {
onConfirm(selectedQuality, includeSubtitles)
dismiss()
}
}
}
.onAppear {
// Pre-select quality based on settings if available
if let settingsManager = appEnvironment?.settingsManager {
// Use preferred video quality as a hint for download quality
if let maxRes = settingsManager.preferredQuality.maxResolution {
// Find matching download quality
for quality in qualityOptions {
if quality.maxResolution == maxRes {
selectedQuality = quality
break
}
}
}
}
// Pre-check subtitles if user has a preferred subtitle language
if let preferredSubtitles = appEnvironment?.settingsManager.preferredSubtitlesLanguage,
!preferredSubtitles.isEmpty {
includeSubtitles = true
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - Preview
#Preview {
BatchDownloadQualitySheet(videoCount: 15) { _, _ in }
.appEnvironment(.preview)
}
#endif

View File

@@ -0,0 +1,210 @@
//
// PlaylistFormSheet.swift
// Yattee
//
// Reusable form sheet for creating and editing playlists.
//
import SwiftUI
struct PlaylistFormSheet: View {
enum Mode: Equatable {
case create
case edit(LocalPlaylist)
static func == (lhs: Mode, rhs: Mode) -> Bool {
switch (lhs, rhs) {
case (.create, .create):
return true
case (.edit(let lhsPlaylist), .edit(let rhsPlaylist)):
return lhsPlaylist.id == rhsPlaylist.id
default:
return false
}
}
}
let mode: Mode
let onSave: (String, String?) -> Void
@Environment(\.dismiss) private var dismiss
@State private var title: String = ""
@State private var descriptionText: String = ""
private let maxDescriptionLength = 1000
private var isEditing: Bool {
if case .edit = mode { return true }
return false
}
private var navigationTitle: String {
isEditing
? String(localized: "playlist.edit")
: String(localized: "playlist.new")
}
private var saveButtonTitle: String {
isEditing
? String(localized: "common.save")
: String(localized: "common.create")
}
private var canSave: Bool {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
#if os(tvOS)
tvOSContent
#else
formContent
.navigationTitle(navigationTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(saveButtonTitle) {
save()
}
.disabled(!canSave)
}
}
#endif
}
.onAppear {
if case .edit(let playlist) = mode {
title = playlist.title
descriptionText = playlist.playlistDescription ?? ""
}
}
#if os(iOS)
.presentationDetents([.medium])
#endif
}
// MARK: - Form Content
#if !os(tvOS)
private var formContent: some View {
Form {
Section {
TextField(String(localized: "playlist.name"), text: $title)
#if os(iOS)
.textInputAutocapitalization(.sentences)
#endif
} header: {
Text(String(localized: "playlist.name"))
}
Section {
TextEditor(text: $descriptionText)
.frame(minHeight: 100)
.onChange(of: descriptionText) { _, newValue in
if newValue.count > maxDescriptionLength {
descriptionText = String(newValue.prefix(maxDescriptionLength))
}
}
} header: {
Text(String(localized: "playlist.description"))
} footer: {
HStack {
Text(String(localized: "playlist.description.optional"))
Spacer()
Text("\(descriptionText.count)/\(maxDescriptionLength)")
.monospacedDigit()
}
.foregroundStyle(.secondary)
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
#endif
// MARK: - tvOS Content
#if os(tvOS)
private var tvOSContent: some View {
VStack(spacing: 0) {
// Header
HStack {
Button(String(localized: "common.cancel")) {
dismiss()
}
.buttonStyle(TVToolbarButtonStyle())
Spacer()
Text(navigationTitle)
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(saveButtonTitle) {
save()
}
.buttonStyle(TVToolbarButtonStyle())
.disabled(!canSave)
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
// Form
Form {
Section {
TVSettingsTextField(
title: String(localized: "playlist.name"),
text: $title
)
} header: {
Text(String(localized: "playlist.name"))
}
Section {
TVSettingsTextField(
title: String(localized: "playlist.description.placeholder"),
text: $descriptionText
)
} header: {
Text(String(localized: "playlist.description"))
} footer: {
HStack {
Text(String(localized: "playlist.description.optional"))
Spacer()
Text("\(descriptionText.count)/\(maxDescriptionLength)")
.monospacedDigit()
}
.foregroundStyle(.secondary)
}
}
}
}
#endif
// MARK: - Actions
private func save() {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(trimmedTitle, trimmedDescription.isEmpty ? nil : trimmedDescription)
dismiss()
}
}
// MARK: - Preview
#Preview("Create") {
PlaylistFormSheet(mode: .create) { _, _ in }
}

View File

@@ -0,0 +1,64 @@
//
// PlaylistVideoRowView.swift
// Yattee
//
// Unified row view for playlist videos (both remote and local playlists).
//
import SwiftUI
/// Unified row view for displaying videos in playlists.
/// Used by UnifiedPlaylistDetailView for both remote and local playlists.
/// Uses VideoRowView for consistent presentation with automatic DeArrow integration.
struct PlaylistVideoRowView: View {
let index: Int
let video: Video
var onRemove: (() -> Void)? = nil
var body: some View {
VideoRowView(
video: video,
style: .regular,
index: index
)
.videoContextMenu(
video: video,
customActions: onRemove.map { removeAction in
[VideoContextAction(
String(localized: "playlist.removeVideo"),
systemImage: "trash",
role: .destructive,
action: removeAction
)]
} ?? [],
context: .playlist
)
}
}
// MARK: - Convenience Initializers
extension PlaylistVideoRowView {
/// Initialize from a LocalPlaylistItem model.
init(item: LocalPlaylistItem, index: Int, onRemove: @escaping () -> Void) {
self.index = index
self.video = item.toVideo()
self.onRemove = onRemove
}
}
// MARK: - Preview
#Preview {
List {
PlaylistVideoRowView(
index: 1,
video: .preview
)
PlaylistVideoRowView(
index: 2,
video: .preview,
onRemove: {}
)
}
}

View File

@@ -0,0 +1,52 @@
//
// TappablePlaylistVideoRow.swift
// Yattee
//
// Tappable row view for playlist videos.
//
import SwiftUI
/// A playlist video row that plays the video when tapped.
///
/// Note: For new code, consider using `PlaylistVideoRowView(...).tappableVideo(video, includeContextMenu: false)` directly.
struct TappablePlaylistVideoRow: View {
let video: Video
let index: Int
var body: some View {
PlaylistVideoRowView(
index: index,
video: video
)
.padding(.horizontal)
.padding(.vertical, 8)
.tappableVideo(video, includeContextMenu: false)
}
}
// MARK: - Preview
#Preview {
List {
TappablePlaylistVideoRow(
video: Video(
id: .global("test"),
title: "Test Video",
description: nil,
author: Author(id: "author", name: "Test Channel"),
duration: 360,
publishedAt: nil,
publishedText: "2 days ago",
viewCount: 10000,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
),
index: 1
)
}
.appEnvironment(.preview)
}

View File

@@ -0,0 +1,807 @@
//
// 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 {
let source: PlaylistSource
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
// 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)
}