mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
257
Yattee/Views/Playlist/BatchDownloadCoordinator.swift
Normal file
257
Yattee/Views/Playlist/BatchDownloadCoordinator.swift
Normal 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
|
||||
98
Yattee/Views/Playlist/BatchDownloadQualitySheet.swift
Normal file
98
Yattee/Views/Playlist/BatchDownloadQualitySheet.swift
Normal 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
|
||||
210
Yattee/Views/Playlist/PlaylistFormSheet.swift
Normal file
210
Yattee/Views/Playlist/PlaylistFormSheet.swift
Normal 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 }
|
||||
}
|
||||
64
Yattee/Views/Playlist/PlaylistVideoRowView.swift
Normal file
64
Yattee/Views/Playlist/PlaylistVideoRowView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
52
Yattee/Views/Playlist/TappablePlaylistVideoRow.swift
Normal file
52
Yattee/Views/Playlist/TappablePlaylistVideoRow.swift
Normal 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)
|
||||
}
|
||||
807
Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift
Normal file
807
Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user