Files
yattee/Yattee/Views/Home/OpenLinkSheet.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

972 lines
36 KiB
Swift

//
// OpenLinkSheet.swift
// Yattee
//
// Sheet for entering links: Load shows a single link's video, playlist or
// channel screen; Play and Download act on video links directly (YouTube,
// PeerTube, direct media, or anything Yattee Server's yt-dlp extracts).
// Supports multiple URLs (one per line) with batch processing.
//
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// MARK: - Extraction Item Model
/// Tracks extraction status for each URL in batch processing.
private struct ExtractedItem: Identifiable {
let id = UUID()
let url: URL
var displayHost: String { url.host ?? url.absoluteString }
var status: ExtractionStatus = .pending
var video: Video?
var streams: [Stream] = []
var captions: [Caption] = []
var storyboards: [Storyboard] = []
}
private enum ExtractionStatus {
case pending
case extracting
case success
case failed(String)
}
// MARK: - OpenLinkSheet
/// Sheet for entering URLs to play or download from external sites.
/// Supports multiple URLs (one per line, max 20).
struct OpenLinkSheet: View {
@Environment(\.dismiss) private var dismiss
let prefilledURL: URL?
init(prefilledURL: URL? = nil) {
self.prefilledURL = prefilledURL
}
var body: some View {
#if os(macOS)
OpenLinkFormView(prefilledURL: prefilledURL, onRequestDismiss: { dismiss() })
.frame(minWidth: 520, minHeight: 440)
#else
NavigationStack {
OpenLinkFormView(prefilledURL: prefilledURL, onRequestDismiss: { dismiss() })
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
}
}
#endif
}
}
// MARK: - OpenLinkView
/// Standalone view for entering URLs — used as a tab root (e.g. tvOS sidebar).
/// Not wrapped in a NavigationStack; the containing tab provides one.
struct OpenLinkView: View {
var body: some View {
OpenLinkFormView(prefilledURL: nil, onRequestDismiss: nil)
}
}
// MARK: - OpenLinkFormView
/// Shared form body used by both OpenLinkSheet (sheet) and OpenLinkView (tab root).
struct OpenLinkFormView: View {
@Environment(\.appEnvironment) private var appEnvironment
let onRequestDismiss: (() -> Void)?
@State private var urlText: String
@State private var clipboardURLs: [URL] = []
@FocusState private var isTextEditorFocused: Bool
// Extraction state
@State private var isExtracting = false
@State private var extractedItems: [ExtractedItem] = []
@State private var hasErrors = false
// Download flow states
@State private var showingDownloadSheet = false
@State private var pendingDownloadItems: [ExtractedItem] = []
/// Maximum number of URLs allowed.
fileprivate static let maxURLs = 20
init(prefilledURL: URL?, onRequestDismiss: (() -> Void)?) {
self.onRequestDismiss = onRequestDismiss
_urlText = State(initialValue: prefilledURL?.absoluteString ?? "")
}
private func dismissIfRequested() {
onRequestDismiss?()
}
var body: some View {
platformBody
.onAppear {
checkClipboard()
if urlText.isEmpty {
isTextEditorFocused = true
}
}
#if !os(tvOS)
.sheet(isPresented: $showingDownloadSheet, onDismiss: {
// Close sheet when download sheet is dismissed (if no errors and we're in sheet mode)
if !hasErrors {
dismissIfRequested()
}
}) {
BatchDownloadQualitySheet(videoCount: pendingDownloadItems.count) { quality, includeSubtitles in
Task {
await downloadPendingItems(quality: quality, includeSubtitles: includeSubtitles)
}
}
}
#endif
}
@ViewBuilder
private var platformBody: some View {
#if os(macOS)
macOSBody
#else
Form {
urlInputSection
extractionResultsSection
actionButtonsSection
yatteeServerWarningSection
}
#if !os(tvOS)
.navigationTitle(String(localized: "openLink.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.immediately)
#endif
#endif
}
#if os(macOS)
@ViewBuilder
private var macOSBody: some View {
VStack(alignment: .leading, spacing: 14) {
Text(String(localized: "openLink.title"))
.font(.title3)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 6) {
TextEditor(text: $urlText)
.font(.system(.body, design: .monospaced))
.focused($isTextEditorFocused)
.disabled(isExtracting)
.scrollContentBackground(.hidden)
.padding(6)
.frame(minHeight: 90, maxHeight: 140)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .textBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
)
HStack {
Text(supportedSitesHint)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if isTooManyURLs {
Label(
String(localized: "openLink.tooManyUrls \(Self.maxURLs)"),
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.orange)
.font(.caption)
} else if urlCount > 0 {
Text(String(localized: "openLink.urlCount \(urlCount)"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
if !clipboardURLs.isEmpty, !isExtracting {
let clipboardText = clipboardURLs.map(\.absoluteString).joined(separator: "\n")
if clipboardText != urlText {
Button {
urlText = clipboardText
} label: {
HStack(spacing: 8) {
Image(systemName: "doc.on.clipboard")
if clipboardURLs.count > 1 {
Text(String(localized: "openLink.pasteMultiple \(clipboardURLs.count)"))
} else {
Text(String(localized: "openLink.pasteClipboard"))
}
Text("·")
.foregroundStyle(.secondary)
Text(clipboardURLs.first?.host ?? "")
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.font(.subheadline)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
}
if !extractedItems.isEmpty {
Divider()
macOSResultsList
}
if !hasYatteeServer, hasExternalURLs, !isExtracting {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "openLink.yatteeServerNotConfigured"))
.font(.subheadline)
Text(String(localized: "openLink.yatteeServerMessage"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Spacer(minLength: 0)
HStack(spacing: 8) {
if onRequestDismiss != nil {
Button(String(localized: "common.cancel")) {
onRequestDismiss?()
}
.keyboardShortcut(.cancelAction)
}
Spacer()
if isExtracting {
ProgressView()
.controlSize(.small)
Text(String(localized: "openLink.extracting"))
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
if allLinksAreVideos {
Button {
isTextEditorFocused = false
Task { await downloadAllURLs() }
} label: {
Label(
isMultipleURLs
? String(localized: "openLink.downloadAll")
: String(localized: "openLink.download"),
systemImage: "arrow.down.circle"
)
}
.disabled(!isValidInput)
Button {
isTextEditorFocused = false
Task { await openAllURLs() }
} label: {
Label(
isMultipleURLs
? String(localized: "openLink.openAll")
: String(localized: "openLink.open"),
systemImage: "play.fill"
)
}
.disabled(!isValidInput)
}
Button {
isTextEditorFocused = false
Task { await loadURLs() }
} label: {
Label(loadButtonTitle, systemImage: "arrow.right.circle")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: .command)
.disabled(!isValidInput)
}
}
}
.padding(20)
}
@ViewBuilder
private var macOSResultsList: some View {
VStack(alignment: .leading, spacing: 6) {
if isExtracting {
let processed = extractedItems.filter { item in
if case .pending = item.status { return false }
return true
}.count
Text(String(localized: "openLink.extractingProgress \(processed) \(extractedItems.count)"))
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(String(localized: "openLink.results"))
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(extractedItems) { item in
HStack(spacing: 10) {
Group {
switch item.status {
case .pending:
Image(systemName: "circle").foregroundStyle(.secondary)
case .extracting:
ProgressView().controlSize(.small)
case .success:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
case .failed:
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
}
}
.frame(width: 18)
VStack(alignment: .leading, spacing: 1) {
if let video = item.video {
Text(video.title).lineLimit(1)
Text(item.displayHost)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else {
Text(item.url.absoluteString)
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
if case .failed(let error) = item.status {
Text(error).font(.caption2).foregroundStyle(.red)
}
}
Spacer()
}
}
}
}
.frame(maxHeight: 120)
}
}
#endif
// MARK: - URL Input Section
@ViewBuilder
private var urlInputSection: some View {
Section {
#if os(tvOS)
// tvOS doesn't have TextEditor, use TextField for single URL
TextField(String(localized: "openLink.urlPlaceholder"), text: $urlText)
.textContentType(.URL)
.focused($isTextEditorFocused)
.disabled(isExtracting)
#else
TextEditor(text: $urlText)
.frame(minHeight: 100, maxHeight: 200)
.font(.system(.body, design: .monospaced))
#if os(iOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
.focused($isTextEditorFocused)
.disabled(isExtracting)
#endif
#if !os(tvOS)
// URL count indicator (not shown on tvOS since it only supports single URL)
HStack {
if isTooManyURLs {
Label(
String(localized: "openLink.tooManyUrls \(Self.maxURLs)"),
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.orange)
.font(.caption)
} else if urlCount > 0 {
Text(String(localized: "openLink.urlCount \(urlCount)"))
.foregroundStyle(.secondary)
.font(.caption)
}
Spacer()
}
// Clipboard paste button (not available on tvOS)
if !clipboardURLs.isEmpty, !isExtracting {
let clipboardText = clipboardURLs.map(\.absoluteString).joined(separator: "\n")
if clipboardText != urlText {
Button {
urlText = clipboardText
} label: {
HStack {
Image(systemName: "doc.on.clipboard")
VStack(alignment: .leading) {
if clipboardURLs.count > 1 {
Text(String(localized: "openLink.pasteMultiple \(clipboardURLs.count)"))
.font(.subheadline)
} else {
Text(String(localized: "openLink.pasteClipboard"))
.font(.subheadline)
}
Text(clipboardURLs.first?.host ?? "")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
}
}
}
#endif
} footer: {
Text(supportedSitesHint)
}
}
/// Dynamic hint text based on enabled backend instances.
private var supportedSitesHint: String {
guard let instancesManager = appEnvironment?.instancesManager else {
return String(localized: "openLink.hint.noInstances")
}
let hasEnabledYatteeServer = !instancesManager.enabledYatteeServerInstances.isEmpty
let hasEnabledInvidiousPiped = instancesManager.enabledInstances.contains {
$0.type == .invidious || $0.type == .piped
}
if hasEnabledYatteeServer {
return String(localized: "openLink.hint.yatteeServer")
} else if hasEnabledInvidiousPiped {
return String(localized: "openLink.hint.youtubeOnly")
} else {
return String(localized: "openLink.hint.noInstances")
}
}
// MARK: - Extraction Results Section
@ViewBuilder
private var extractionResultsSection: some View {
if !extractedItems.isEmpty {
Section {
ForEach(extractedItems) { item in
HStack(spacing: 12) {
// Status indicator
Group {
switch item.status {
case .pending:
Image(systemName: "circle")
.foregroundStyle(.secondary)
case .extracting:
ProgressView()
.controlSize(.small)
case .success:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failed:
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
}
}
.frame(width: 20)
// URL info
VStack(alignment: .leading, spacing: 2) {
if let video = item.video {
Text(video.title)
.lineLimit(1)
Text(item.displayHost)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
// No video extracted - show full URL (useful for failures)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
if case .failed(let error) = item.status {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
}
}
Spacer()
}
}
} header: {
if isExtracting {
let processed = extractedItems.filter { item in
switch item.status {
case .pending: return false
default: return true
}
}.count
Text(String(localized: "openLink.extractingProgress \(processed) \(extractedItems.count)"))
} else {
Text(String(localized: "openLink.results"))
}
}
}
}
// MARK: - Action Buttons Section
@ViewBuilder
private var actionButtonsSection: some View {
Section {
if isExtracting {
HStack {
ProgressView()
Text(String(localized: "openLink.extracting"))
.foregroundStyle(.secondary)
}
} else {
// Load: show the link's video, playlist or channel screen,
// or several links as mixed results
Button {
isTextEditorFocused = false
Task { await loadURLs() }
} label: {
Label(loadButtonTitle, systemImage: "arrow.right.circle")
.fontWeight(.semibold)
}
.disabled(!isValidInput)
if allLinksAreVideos {
// Play shortcut
Button {
isTextEditorFocused = false
Task { await openAllURLs() }
} label: {
Label(
isMultipleURLs
? String(localized: "openLink.openAll")
: String(localized: "openLink.open"),
systemImage: "play.fill"
)
}
.disabled(!isValidInput)
#if !os(tvOS)
// Download shortcut
Button {
isTextEditorFocused = false
Task { await downloadAllURLs() }
} label: {
Label(
isMultipleURLs
? String(localized: "openLink.downloadAll")
: String(localized: "openLink.download"),
systemImage: "arrow.down.circle"
)
}
.disabled(!isValidInput)
#endif
}
}
}
}
// MARK: - Yattee Server Warning Section
@ViewBuilder
private var yatteeServerWarningSection: some View {
if !hasYatteeServer && hasExternalURLs && !isExtracting {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(String(localized: "openLink.yatteeServerNotConfigured"))
.font(.subheadline)
}
Text(String(localized: "openLink.yatteeServerMessage"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Computed Properties
private var hasYatteeServer: Bool {
appEnvironment?.instancesManager.yatteeServerInstance != nil
}
/// Parse URLs from input text, one per line.
private var parsedURLs: [URL] {
urlText
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.compactMap { URL(string: $0) }
.filter { url in
guard let scheme = url.scheme?.lowercased() else { return false }
return (scheme == "http" || scheme == "https") && url.host != nil
}
.prefix(Self.maxURLs)
.map { $0 }
}
private var urlCount: Int { parsedURLs.count }
private var isValidInput: Bool { !parsedURLs.isEmpty }
private var isMultipleURLs: Bool { urlCount > 1 }
private var isTooManyURLs: Bool {
urlText
.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.count > Self.maxURLs
}
/// Whether any of the parsed URLs are external (non-YouTube/PeerTube).
private var hasExternalURLs: Bool {
let router = URLRouter()
return parsedURLs.contains { url in
if let destination = router.route(url) {
if case .externalVideo = destination { return true }
return false
}
// YouTube pages that route nowhere are unsupported, not external
return !router.isYouTubeHost(url.host)
}
}
/// Whether every parsed link is a video, so Play and Download apply.
/// Playlists and channels can only be loaded.
private var allLinksAreVideos: Bool {
let router = URLRouter()
return parsedURLs.allSatisfy { url in
switch router.route(url) {
case .video, .externalVideo, .directMedia: return true
case nil: return !router.isYouTubeHost(url.host)
default: return false
}
}
}
// MARK: - Clipboard
private func checkClipboard() {
clipboardURLs = []
#if os(iOS)
if let string = UIPasteboard.general.string {
clipboardURLs = parseURLsFromString(string)
}
#elseif os(macOS)
if let string = NSPasteboard.general.string(forType: .string) {
clipboardURLs = parseURLsFromString(string)
}
#endif
}
private func parseURLsFromString(_ string: String) -> [URL] {
string
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.compactMap { URL(string: $0) }
.filter { url in
guard let scheme = url.scheme?.lowercased() else { return false }
return (scheme == "http" || scheme == "https") && url.host != nil
}
.prefix(Self.maxURLs)
.map { $0 }
}
// MARK: - Load Action
private var loadButtonTitle: String {
isMultipleURLs ? String(localized: "openLink.loadAll") : String(localized: "openLink.load")
}
/// Shows a single link's screen, or several links on the Opened Links
/// page, which resolves them itself.
private func loadURLs() async {
let urls = parsedURLs
guard let appEnvironment, !urls.isEmpty else { return }
// A watch link inside a playlist shows both, like several links
if urls.count > 1 || URLRouter().youTubePlaylistContext(urls[0]) != nil {
extractedItems = []
dismissIfRequested()
appEnvironment.navigationCoordinator.navigateToOpenedLink(.openedLinks(urls))
} else {
await loadURL()
}
}
/// Resolves the single link and shows its screen: a video's details page,
/// or the playlist / channel screen. Errors stay in the form.
private func loadURL() async {
guard let url = parsedURLs.first, !isMultipleURLs, let appEnvironment else { return }
isExtracting = true
hasErrors = false
extractedItems = [ExtractedItem(url: url, status: .extracting)]
do {
let destination = try await LinkVideoResolver(appEnvironment: appEnvironment).resolveDestination(url)
if case .video(.loaded(let video), _) = destination {
extractedItems[0].video = video
}
extractedItems[0].status = .success
isExtracting = false
dismissIfRequested()
appEnvironment.navigationCoordinator.navigateToOpenedLink(destination)
} catch {
extractedItems[0].status = .failed(error.localizedDescription)
hasErrors = true
isExtracting = false
}
}
// MARK: - Open (Play) Action
private func openAllURLs() async {
let urls = parsedURLs
guard !urls.isEmpty, let appEnvironment else { return }
isExtracting = true
hasErrors = false
extractedItems = urls.map { ExtractedItem(url: $0) }
var successCount = 0
var failedCount = 0
var firstVideoPlayed = false
for (index, url) in urls.enumerated() {
extractedItems[index].status = .extracting
do {
let (video, streams, _, _) = try await LinkVideoResolver(appEnvironment: appEnvironment).resolve(url)
extractedItems[index].status = .success
extractedItems[index].video = video
extractedItems[index].streams = streams
successCount += 1
if !firstVideoPlayed {
// Play first video - this expands player
await playVideo(video, sourceURL: url, appEnvironment: appEnvironment)
firstVideoPlayed = true
} else {
// Add to queue
appEnvironment.queueManager.addToQueue(video, queueSource: .manual)
}
} catch {
extractedItems[index].status = .failed(error.localizedDescription)
failedCount += 1
hasErrors = true
}
}
isExtracting = false
// Show completion toast and handle dismissal
if failedCount == 0 {
if successCount > 1 {
appEnvironment.toastManager.showSuccess(
String(localized: "openLink.queuedSuccess.title"),
subtitle: String(localized: "openLink.queuedSuccess.subtitle \(successCount)")
)
}
dismissIfRequested()
} else if successCount > 0 {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.queuedPartial.title"),
subtitle: String(localized: "openLink.queuedPartial.subtitle \(successCount) \(failedCount)")
)
// Keep sheet open so user can see errors
} else {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.allFailed.title"),
subtitle: String(localized: "openLink.allFailed.subtitle \(failedCount)")
)
// Keep sheet open
}
}
private func playVideo(_ video: Video, sourceURL: URL, appEnvironment: AppEnvironment) async {
// Don't pass a specific stream - let the player's selectStreamAndBackend
// choose the best video+audio combination. Using streams.first would
// incorrectly select audio-only streams for sites like Bilibili.
let router = URLRouter()
let startTime = router.parseTimestamp(router.unwrapped(sourceURL))
// A watch link inside a playlist queues the playlist around it.
// Awaited, so later links queue after the playlist instead of being
// cleared by it.
await LinkVideoResolver(appEnvironment: appEnvironment).play(video, from: sourceURL, startTime: startTime)
}
// MARK: - Download Action
#if !os(tvOS)
private func downloadAllURLs() async {
let urls = parsedURLs
guard !urls.isEmpty, let appEnvironment else { return }
isExtracting = true
hasErrors = false
extractedItems = urls.map { ExtractedItem(url: $0) }
let downloadSettings = appEnvironment.downloadSettings
var successCount = 0
var failedCount = 0
for (index, url) in urls.enumerated() {
extractedItems[index].status = .extracting
do {
let (video, streams, captions, storyboards) = try await LinkVideoResolver(appEnvironment: appEnvironment).resolve(url)
extractedItems[index].status = .success
extractedItems[index].video = video
extractedItems[index].streams = streams
extractedItems[index].captions = captions
extractedItems[index].storyboards = storyboards
successCount += 1
// If auto-download is configured, enqueue immediately
if downloadSettings.preferredDownloadQuality != .ask {
try await LinkVideoResolver(appEnvironment: appEnvironment).enqueueDownload(
video: video,
streams: streams,
captions: captions,
storyboards: storyboards,
quality: downloadSettings.preferredDownloadQuality,
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload
)
}
} catch {
extractedItems[index].status = .failed(error.localizedDescription)
failedCount += 1
hasErrors = true
}
}
isExtracting = false
// Handle completion based on download mode
if downloadSettings.preferredDownloadQuality == .ask {
// Show quality picker for all extracted videos
pendingDownloadItems = extractedItems.filter { $0.video != nil }
if !pendingDownloadItems.isEmpty {
showingDownloadSheet = true
} else if failedCount > 0 {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.allFailed.title"),
subtitle: String(localized: "openLink.allFailed.subtitle \(failedCount)")
)
}
} else {
// Auto-download mode - show completion toast
if failedCount == 0 {
if successCount >= 2 {
appEnvironment.toastManager.showSuccess(
String(localized: "openLink.downloadQueued.title"),
subtitle: String(localized: "openLink.downloadQueued.subtitle \(successCount)")
)
}
dismissIfRequested()
} else if successCount > 0 {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.downloadPartial.title"),
subtitle: String(localized: "openLink.downloadPartial.subtitle \(successCount) \(failedCount)")
)
// Keep sheet open
} else {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.allFailed.title"),
subtitle: String(localized: "openLink.allFailed.subtitle \(failedCount)")
)
// Keep sheet open
}
}
}
/// Downloads pending items after user selects quality.
private func downloadPendingItems(quality: DownloadQuality, includeSubtitles: Bool) async {
guard let appEnvironment else { return }
var successCount = 0
var failedCount = 0
for item in pendingDownloadItems {
guard let video = item.video else { continue }
do {
try await LinkVideoResolver(appEnvironment: appEnvironment).enqueueDownload(
video: video,
streams: item.streams,
captions: item.captions,
storyboards: item.storyboards,
quality: quality,
includeSubtitles: includeSubtitles
)
successCount += 1
} catch {
failedCount += 1
}
}
pendingDownloadItems = []
// Show completion toast
if failedCount == 0 {
if successCount >= 2 {
appEnvironment.toastManager.showSuccess(
String(localized: "openLink.downloadQueued.title"),
subtitle: String(localized: "openLink.downloadQueued.subtitle \(successCount)")
)
}
if !hasErrors {
dismissIfRequested()
}
} else {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.downloadPartial.title"),
subtitle: String(localized: "openLink.downloadPartial.subtitle \(successCount) \(failedCount)")
)
}
}
#endif
}
// MARK: - Previews
#Preview {
OpenLinkSheet()
}
#Preview("With URL") {
OpenLinkSheet(prefilledURL: URL(string: "https://vimeo.com/123456789"))
}
#Preview("Multiple URLs") {
OpenLinkSheet(prefilledURL: URL(string: "https://youtube.com/watch?v=abc123"))
}