Files
yattee/Yattee/Views/Home/OpenLinkSheet.swift
Arkadiusz Fal d422bf13e5 Add Open URL and Remote Control as sidebar items
After disabling home shortcuts on tvOS, Open URL and Remote Control had
no entry point. Add them as configurable sidebar main items. Remote
Control defaults to visible on tvOS; Open URL defaults to hidden on all
platforms.
2026-04-18 20:38:01 +02:00

888 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 {
NavigationStack {
OpenLinkFormView(prefilledURL: prefilledURL, onRequestDismiss: { dismiss() })
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
}
}
}
}
// 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 {
Form {
urlInputSection
extractionResultsSection
actionButtonsSection
yatteeServerWarningSection
}
.navigationTitle(String(localized: "openLink.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.immediately)
#endif
.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
}
// 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)")
)
}
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
}
}
/// 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)")
)
}
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
}
}
}
/// 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 {
dismissIfRequested()
}
} 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"))
}