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:
854
Yattee/Views/Home/OpenLinkSheet.swift
Normal file
854
Yattee/Views/Home/OpenLinkSheet.swift
Normal file
@@ -0,0 +1,854 @@
|
||||
//
|
||||
// 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
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@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.
|
||||
private static let maxURLs = 20
|
||||
|
||||
/// Initialize with optional pre-filled URL.
|
||||
init(prefilledURL: URL? = nil) {
|
||||
_urlText = State(initialValue: prefilledURL?.absoluteString ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
urlInputSection
|
||||
extractionResultsSection
|
||||
actionButtonsSection
|
||||
yatteeServerWarningSection
|
||||
}
|
||||
.navigationTitle(String(localized: "openLink.title"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "common.cancel")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
checkClipboard()
|
||||
if urlText.isEmpty {
|
||||
isTextEditorFocused = true
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $showingDownloadSheet, onDismiss: {
|
||||
// Close OpenLinkSheet when download sheet is dismissed (if no errors)
|
||||
if !hasErrors {
|
||||
dismiss()
|
||||
}
|
||||
}) {
|
||||
BatchDownloadQualitySheet(videoCount: pendingDownloadItems.count) { quality, includeSubtitles in
|
||||
Task {
|
||||
await downloadPendingItems(quality: quality, includeSubtitles: includeSubtitles)
|
||||
}
|
||||
}
|
||||
}
|
||||
#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 extractVideo(from: url, appEnvironment: appEnvironment)
|
||||
extractedItems[index].status = .success
|
||||
extractedItems[index].video = video
|
||||
extractedItems[index].streams = streams
|
||||
successCount += 1
|
||||
|
||||
if !firstVideoPlayed {
|
||||
// Play first video - this expands player
|
||||
playVideo(video, 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)")
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts video from URL, routing through appropriate API.
|
||||
private func extractVideo(
|
||||
from url: URL,
|
||||
appEnvironment: AppEnvironment
|
||||
) async throws -> (Video, [Stream]) {
|
||||
let router = URLRouter()
|
||||
let destination = router.route(url)
|
||||
|
||||
switch destination {
|
||||
case .video(let source, _):
|
||||
guard case .id(let videoID) = source else {
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
// YouTube/PeerTube - use content-aware instance selection
|
||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
||||
throw OpenLinkError.noInstanceAvailable
|
||||
}
|
||||
let (video, streams, _, _) = try await appEnvironment.contentService
|
||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
||||
id: videoID.videoID,
|
||||
instance: instance
|
||||
)
|
||||
return (video, streams)
|
||||
|
||||
case .directMedia(let mediaURL):
|
||||
// Direct media URL - no extraction needed
|
||||
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
||||
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
||||
return (video, [stream])
|
||||
|
||||
case .externalVideo, nil:
|
||||
// External URL - use Yattee Server
|
||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
||||
throw OpenLinkError.noYatteeServer
|
||||
}
|
||||
let (video, streams, _) = try await appEnvironment.contentService
|
||||
.extractURL(url, instance: instance)
|
||||
return (video, streams)
|
||||
|
||||
default:
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
}
|
||||
|
||||
private func playVideo(_ video: Video, 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.
|
||||
appEnvironment.playerService.openVideo(video)
|
||||
}
|
||||
|
||||
// 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 extractVideoFull(from: url, appEnvironment: appEnvironment)
|
||||
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 enqueueDownload(
|
||||
video: video,
|
||||
streams: streams,
|
||||
captions: captions,
|
||||
storyboards: storyboards,
|
||||
quality: downloadSettings.preferredDownloadQuality,
|
||||
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload,
|
||||
appEnvironment: appEnvironment
|
||||
)
|
||||
}
|
||||
} 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)")
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts video with full details (including captions and storyboards) for download.
|
||||
private func extractVideoFull(
|
||||
from url: URL,
|
||||
appEnvironment: AppEnvironment
|
||||
) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
|
||||
let router = URLRouter()
|
||||
let destination = router.route(url)
|
||||
|
||||
switch destination {
|
||||
case .video(let source, _):
|
||||
guard case .id(let videoID) = source else {
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
// YouTube/PeerTube - use content-aware instance selection
|
||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
||||
throw OpenLinkError.noInstanceAvailable
|
||||
}
|
||||
let (video, streams, captions, storyboards) = try await appEnvironment.contentService
|
||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
||||
id: videoID.videoID,
|
||||
instance: instance
|
||||
)
|
||||
return (video, streams, captions, storyboards)
|
||||
|
||||
case .directMedia(let mediaURL):
|
||||
// Direct media URL - no extraction needed, no captions/storyboards
|
||||
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
||||
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
||||
return (video, [stream], [], [])
|
||||
|
||||
case .externalVideo, nil:
|
||||
// External URL - use Yattee Server (doesn't support storyboards)
|
||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
||||
throw OpenLinkError.noYatteeServer
|
||||
}
|
||||
let (video, streams, captions) = try await appEnvironment.contentService
|
||||
.extractURL(url, instance: instance)
|
||||
return (video, streams, captions, [])
|
||||
|
||||
default:
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 enqueueDownload(
|
||||
video: video,
|
||||
streams: item.streams,
|
||||
captions: item.captions,
|
||||
storyboards: item.storyboards,
|
||||
quality: quality,
|
||||
includeSubtitles: includeSubtitles,
|
||||
appEnvironment: appEnvironment
|
||||
)
|
||||
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 {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.downloadPartial.title"),
|
||||
subtitle: String(localized: "openLink.downloadPartial.subtitle \(successCount) \(failedCount)")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues a single video for download with already-fetched streams.
|
||||
private func enqueueDownload(
|
||||
video: Video,
|
||||
streams: [Stream],
|
||||
captions: [Caption],
|
||||
storyboards: [Storyboard],
|
||||
quality: DownloadQuality,
|
||||
includeSubtitles: Bool,
|
||||
appEnvironment: AppEnvironment
|
||||
) async throws {
|
||||
// Select best video stream
|
||||
let videoStream = selectBestVideoStream(from: streams, maxQuality: quality)
|
||||
guard let videoStream else {
|
||||
throw DownloadError.noStreamAvailable
|
||||
}
|
||||
|
||||
// Select audio stream if needed
|
||||
var audioStream: Stream?
|
||||
if videoStream.isVideoOnly {
|
||||
audioStream = selectBestAudioStream(
|
||||
from: streams,
|
||||
preferredLanguage: appEnvironment.settingsManager.preferredAudioLanguage
|
||||
)
|
||||
}
|
||||
|
||||
// Select caption if enabled
|
||||
var caption: Caption?
|
||||
if includeSubtitles, let preferredLang = appEnvironment.settingsManager.preferredSubtitlesLanguage {
|
||||
caption = selectBestCaption(from: captions, preferredLanguage: preferredLang)
|
||||
}
|
||||
|
||||
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
|
||||
let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate
|
||||
|
||||
try await appEnvironment.downloadManager.enqueue(
|
||||
video,
|
||||
quality: videoStream.qualityLabel,
|
||||
formatID: videoStream.format,
|
||||
streamURL: videoStream.url,
|
||||
audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
|
||||
captionURL: caption?.url,
|
||||
audioLanguage: audioStream?.audioLanguage,
|
||||
captionLanguage: caption?.languageCode,
|
||||
httpHeaders: videoStream.httpHeaders,
|
||||
storyboard: storyboards.highest(),
|
||||
dislikeCount: nil,
|
||||
videoCodec: videoStream.videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
videoBitrate: videoStream.bitrate,
|
||||
audioBitrate: audioBitrate
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Stream Selection Helpers
|
||||
|
||||
private func selectBestVideoStream(from streams: [Stream], maxQuality: DownloadQuality) -> Stream? {
|
||||
let maxRes = maxQuality.maxResolution
|
||||
|
||||
let videoStreams = streams
|
||||
.filter { !$0.isAudioOnly && $0.resolution != nil }
|
||||
.filter {
|
||||
let format = StreamFormat.detect(from: $0)
|
||||
return format != .hls && format != .dash
|
||||
}
|
||||
.sorted { s1, s2 in
|
||||
let res1 = s1.resolution ?? .p360
|
||||
let res2 = s2.resolution ?? .p360
|
||||
if res1 != res2 { return res1 > res2 }
|
||||
if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
|
||||
return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
|
||||
HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
|
||||
}
|
||||
|
||||
guard let maxRes else {
|
||||
return videoStreams.first
|
||||
}
|
||||
|
||||
if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
|
||||
return stream
|
||||
}
|
||||
|
||||
return videoStreams.last
|
||||
}
|
||||
|
||||
private func selectBestAudioStream(from streams: [Stream], preferredLanguage: String?) -> Stream? {
|
||||
let audioStreams = streams.filter { $0.isAudioOnly }
|
||||
|
||||
if let preferred = preferredLanguage {
|
||||
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
|
||||
return original
|
||||
}
|
||||
|
||||
return audioStreams.first
|
||||
}
|
||||
|
||||
private func selectBestCaption(from captions: [Caption], preferredLanguage: String) -> Caption? {
|
||||
if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
|
||||
return exact
|
||||
}
|
||||
if let prefix = captions.first(where: {
|
||||
$0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage
|
||||
}) {
|
||||
return prefix
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
private enum OpenLinkError: LocalizedError {
|
||||
case noInstanceAvailable
|
||||
case noYatteeServer
|
||||
case notAVideo
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noInstanceAvailable:
|
||||
return String(localized: "openLink.noInstance")
|
||||
case .noYatteeServer:
|
||||
return String(localized: "openLink.noYatteeServer")
|
||||
case .notAVideo:
|
||||
return String(localized: "openLink.notAVideo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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"))
|
||||
}
|
||||
Reference in New Issue
Block a user