Files
yattee/Yattee/Views/Home/OpenLinkSheet.swift
Arkadiusz Fal ac9fbec255 Move Open Link's video resolving and auto-download into LinkVideoResolver
No behaviour change; URL scheme actions will reuse it.
2026-10-05 23:54:44 +02:00

886 lines
32 KiB
Swift

//
// OpenLinkSheet.swift
// Yattee
//
// Sheet for entering URLs to play or download via Yattee Server's yt-dlp extraction.
// 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 prefilledURL: URL?
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.prefilledURL = prefilledURL
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 {
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"
)
}
.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 {
// Open/Play button
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 button
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
}
return true
}
}
// 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: - 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
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) {
// 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))
appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil)
}
// 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"))
}