Show several opened links as mixed results

Load All in Open Link opens an Opened Links page that resolves each link
into a video, playlist or channel row like search results, in input
order. Video rows keep the usual context menu and swipe actions, and the
page can play, queue, bookmark or download all resolved videos.
This commit is contained in:
Arkadiusz Fal
2026-10-08 22:36:57 +02:00
parent 03be964c66
commit 8a7218ed1b
9 changed files with 669 additions and 44 deletions

View File

@@ -285,6 +285,35 @@ final class InstancesManager {
instance(for: video.id.source)
}
/// Selects the instance to load a remote playlist from: Invidious first for
/// YouTube (Invidious playlists are Invidious-specific), the playlist's own
/// server for federated content, Yattee Server for extracted content.
func playlistInstance(for source: ContentSource) -> Instance? {
switch source {
case .global:
// For global content (YouTube), prefer Invidious instances over Yattee Server
// since Invidious playlists (IVPL*) are Invidious-specific
if let invidiousInstance = enabledInstances.first(where: { $0.type == .invidious }) {
return invidiousInstance
}
// Fall back to any YouTube-compatible instance
return enabledInstances.first(where: { $0.isYouTubeInstance })
case .federated(let provider, let instanceURL):
// For federated content, find matching instance by URL
if let existingInstance = instances.first(where: { $0.url == instanceURL }) {
return existingInstance
}
// If no configured instance matches, create a temporary one for PeerTube
if provider == ContentSource.peertubeProvider {
return Instance(type: .peertube, url: instanceURL)
}
return nil
case .extracted:
// For extracted content, use Yattee Server
return yatteeServerInstances.first
}
}
/// Selects an enabled instance appropriate for the given content source.
/// - For PeerTube content: prefers the exact instance, falls back to any PeerTube instance
/// - For extracted content: requires Yattee Server (only backend with yt-dlp)

View File

@@ -7219,6 +7219,87 @@
},
"Open Section" : {
},
"openedLinks.actions" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Actions"
}
}
}
},
"openedLinks.addAllToQueue" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add All Videos to Queue"
}
}
}
},
"openedLinks.bookmarkAll" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bookmark All Videos"
}
}
}
},
"openedLinks.bookmarked.subtitle %lld" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld videos"
}
}
}
},
"openedLinks.bookmarked.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Added to Bookmarks"
}
}
}
},
"openedLinks.downloadAll" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Download All Videos"
}
}
}
},
"openedLinks.playAll" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Play All Videos"
}
}
}
},
"openedLinks.title" : {
"comment" : "Title of the page listing several links opened at once",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Opened Links"
}
}
}
},
"openLink.allFailed.subtitle %lld" : {
"comment" : "Toast subtitle when all URLs failed",
@@ -7374,6 +7455,17 @@
}
}
},
"openLink.loadAll" : {
"comment" : "Button label: show several links as mixed results",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Load All"
}
}
}
},
"openLink.noInstance" : {
"comment" : "Error message",
"localizations" : {

View File

@@ -239,7 +239,7 @@ final class HandoffManager {
// Don't create handoff activities for these local-only destinations
case .settings, .mediaSources, .mediaSource, .mediaBrowser,
.importSubscriptions, .importPlaylists, .downloadsStorage, .directMedia:
.importSubscriptions, .importPlaylists, .downloadsStorage, .directMedia, .openedLinks:
return false
}

View File

@@ -149,6 +149,38 @@ struct LinkVideoResolver {
)
}
/// Resolves each link's streams and enqueues it for download.
/// - Returns: How many links were queued and how many failed.
func downloadLinks(
_ urls: [URL],
quality: DownloadQuality,
includeSubtitles: Bool
) async -> (succeeded: Int, failed: Int) {
var succeeded = 0
var failed = 0
for url in urls {
do {
let (video, streams, captions, storyboards) = try await resolve(url)
try await enqueueDownload(
video: video,
streams: streams,
captions: captions,
storyboards: storyboards,
quality: quality,
includeSubtitles: includeSubtitles
)
succeeded += 1
} catch {
LoggingService.shared.warning(
"Open link download failed for \(url.absoluteString): \(error.localizedDescription)",
category: .downloads
)
failed += 1
}
}
return (succeeded, failed)
}
// MARK: - Stream Selection Helpers
private func selectBestVideoStream(from streams: [Stream], maxQuality: DownloadQuality) -> Stream? {

View File

@@ -48,6 +48,8 @@ enum NavigationDestination: Hashable {
case importSubscriptions(instance: Instance)
/// Import playlists from an instance.
case importPlaylists(instance: Instance)
/// Several links opened at once, resolved into mixed results.
case openedLinks([URL])
}
extension NavigationDestination {
@@ -149,6 +151,8 @@ extension NavigationDestination {
ImportSubscriptionsView(instance: instance)
case .importPlaylists(let instance):
ImportPlaylistsView(instance: instance)
case .openedLinks(let urls):
OpenedLinksView(urls: urls)
}
}
}

View File

@@ -0,0 +1,141 @@
//
// OpenedLinksViewModel.swift
// Yattee
//
// Resolves several opened links into videos, playlists and channels for
// the Opened Links page.
//
import Foundation
/// One link on the Opened Links page, in the order it was entered.
struct OpenedLink: Identifiable {
enum State {
case loading
case video(Video)
case playlist(Playlist, destination: NavigationDestination)
case channel(Channel, destination: NavigationDestination)
/// A screen the link opens whose details couldn't be loaded here
/// (the screen itself shows why).
case unresolved(NavigationDestination, message: String)
case failed(String)
}
let id: Int
let url: URL
var state: State = .loading
}
@Observable
@MainActor
final class OpenedLinksViewModel {
private(set) var links: [OpenedLink]
private var hasStarted = false
/// Links resolved at once - extraction on Yattee Server is slow, but
/// one link at a time would make long lists crawl.
private static let maxConcurrentResolutions = 4
init(urls: [URL]) {
var seen = Set<String>()
let unique = urls.filter { seen.insert($0.absoluteString).inserted }
links = unique.enumerated().map { OpenedLink(id: $0.offset, url: $0.element) }
}
/// Resolved videos, in link order.
var videos: [Video] {
links.compactMap { link in
if case .video(let video) = link.state { return video }
return nil
}
}
/// Links of the videos in `videos`, for re-resolving their streams.
var videoLinks: [URL] {
links.compactMap { link in
if case .video = link.state { return link.url }
return nil
}
}
var isLoading: Bool {
links.contains { link in
if case .loading = link.state { return true }
return false
}
}
/// Starts resolving once. Owned by the model rather than the view's
/// `.task`, which is cancelled when a result's screen is pushed and would
/// fail the links still loading.
func start(appEnvironment: AppEnvironment) {
guard !hasStarted else { return }
hasStarted = true
Task { await load(appEnvironment: appEnvironment) }
}
private func load(appEnvironment: AppEnvironment) async {
let links = self.links
await withTaskGroup(of: (Int, OpenedLink.State).self) { group in
var next = 0
func enqueueNext() {
guard next < links.count else { return }
let link = links[next]
next += 1
group.addTask { @MainActor in
(link.id, await Self.resolve(link.url, appEnvironment: appEnvironment))
}
}
for _ in 0..<Self.maxConcurrentResolutions { enqueueNext() }
for await (id, state) in group {
if let index = self.links.firstIndex(where: { $0.id == id }) {
self.links[index].state = state
}
enqueueNext()
}
}
}
private static func resolve(_ url: URL, appEnvironment: AppEnvironment) async -> OpenedLink.State {
let destination: NavigationDestination
do {
destination = try await LinkVideoResolver(appEnvironment: appEnvironment).resolveDestination(url)
} catch {
return .failed(error.localizedDescription)
}
switch destination {
case .video(.loaded(let video), _):
return .video(video)
case .playlist(.remote(let playlistID, let instance, _, _)):
guard let instance = instance
?? playlistID.source.flatMap({ appEnvironment.instancesManager.playlistInstance(for: $0) }) else {
return .unresolved(destination, message: String(localized: "openLink.noInstance"))
}
do {
let playlist = try await appEnvironment.contentService.playlist(id: playlistID.playlistID, instance: instance)
return .playlist(playlist, destination: destination)
} catch {
return .unresolved(destination, message: error.localizedDescription)
}
case .channel(let channelID, let source):
guard let instance = appEnvironment.instancesManager.instance(for: source) else {
return .unresolved(destination, message: String(localized: "openLink.noInstance"))
}
do {
let channel = try await appEnvironment.contentService.channel(id: channelID, instance: instance)
return .channel(channel, destination: destination)
} catch {
return .unresolved(destination, message: error.localizedDescription)
}
default:
return .unresolved(destination, message: url.absoluteString)
}
}
}

View File

@@ -294,17 +294,15 @@ struct OpenLinkFormView: View {
.disabled(!isValidInput)
}
if !isMultipleURLs {
Button {
isTextEditorFocused = false
Task { await loadURL() }
} label: {
Label(String(localized: "openLink.load"), systemImage: "arrow.right.circle")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: .command)
.disabled(!isValidInput)
Button {
isTextEditorFocused = false
Task { await loadURLs() }
} label: {
Label(loadButtonTitle, systemImage: "arrow.right.circle")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: .command)
.disabled(!isValidInput)
}
}
}
@@ -544,17 +542,16 @@ struct OpenLinkFormView: View {
.foregroundStyle(.secondary)
}
} else {
// Load: show the link's video, playlist or channel screen
if !isMultipleURLs {
Button {
isTextEditorFocused = false
Task { await loadURL() }
} label: {
Label(String(localized: "openLink.load"), systemImage: "arrow.right.circle")
.fontWeight(.semibold)
}
.disabled(!isValidInput)
// 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
@@ -698,6 +695,24 @@ struct OpenLinkFormView: View {
// 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 }
if urls.count > 1 {
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 {

View File

@@ -0,0 +1,334 @@
//
// OpenedLinksView.swift
// Yattee
//
// Several links opened from Open Link, shown like mixed search results:
// videos, playlists and channels in the order they were entered, filled in
// as each link resolves.
//
import SwiftUI
struct OpenedLinksView: View {
@Environment(\.appEnvironment) private var appEnvironment
@AppStorage("searchRowStyle") private var rowStyle: VideoRowStyle = .regular
@State private var viewModel: OpenedLinksViewModel
#if !os(tvOS)
@State private var showingDownloadSheet = false
#endif
init(urls: [URL]) {
_viewModel = State(initialValue: OpenedLinksViewModel(urls: urls))
}
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
private var title: String {
String(localized: "openedLinks.title")
}
var body: some View {
content
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .primaryAction) {
// Buttons disable themselves: a macOS toolbar item doesn't
// re-render on state changes, menu content does on open.
Menu {
batchActions
} label: {
Label(String(localized: "openedLinks.actions"), systemImage: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showingDownloadSheet) {
BatchDownloadQualitySheet(videoCount: viewModel.videoLinks.count) { quality, includeSubtitles in
Task { await download(quality: quality, includeSubtitles: includeSubtitles) }
}
}
#endif
.onAppear {
guard let appEnvironment else { return }
viewModel.start(appEnvironment: appEnvironment)
}
}
@ViewBuilder
private var content: some View {
#if os(tvOS)
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack(spacing: 24) {
batchActions
}
.padding(.horizontal, 48)
resultsList
}
}
.scrollClipDisabled()
#else
(listStyle == .inset ? ListBackgroundStyle.grouped : ListBackgroundStyle.plain).color
.ignoresSafeArea()
.overlay(
ScrollView {
resultsList
}
)
#endif
}
private var resultsList: some View {
VideoListContent(listStyle: listStyle) {
ForEach(viewModel.links) { link in
VideoListRow(
isLast: link.id == viewModel.links.last?.id,
rowStyle: rowStyle,
listStyle: listStyle,
contentWidth: isChannel(link) ? rowStyle.thumbnailHeight : nil
) {
row(for: link)
}
}
}
}
private func isChannel(_ link: OpenedLink) -> Bool {
if case .channel = link.state { return true }
return false
}
// MARK: - Rows
@ViewBuilder
private func row(for link: OpenedLink) -> some View {
switch link.state {
case .loading:
LinkStatusRow(url: link.url, message: nil, style: rowStyle, status: .loading)
case .video(let video):
let videos = viewModel.videos
VideoRowView(video: video, style: rowStyle)
.tappableVideo(
video,
queueSource: .manual,
sourceLabel: title,
videoList: videos,
videoIndex: videos.firstIndex(where: { $0.id == video.id })
)
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
case .playlist(let playlist, _):
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
SearchPlaylistRowView(playlist: playlist, style: rowStyle)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
case .channel(let channel, let destination):
NavigationLink(value: destination) {
ChannelRowView(channel: channel, style: rowStyle)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: channel.id.channelID)
.buttonStyle(.plain)
case .unresolved(let destination, let message):
NavigationLink(value: destination) {
LinkStatusRow(url: link.url, message: message, style: rowStyle, status: .unresolved)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .failed(let message):
LinkStatusRow(url: link.url, message: message, style: rowStyle, status: .failed)
}
}
// MARK: - Batch Actions
@ViewBuilder
private var batchActions: some View {
let noVideos = viewModel.videos.isEmpty
Button {
playAll()
} label: {
Label(String(localized: "openedLinks.playAll"), systemImage: "play.fill")
}
.disabled(noVideos)
if appEnvironment?.settingsManager.queueEnabled == true {
Button {
addAllToQueue()
} label: {
Label(String(localized: "openedLinks.addAllToQueue"), systemImage: "text.badge.plus")
}
.disabled(noVideos)
}
Button {
bookmarkAll()
} label: {
Label(String(localized: "openedLinks.bookmarkAll"), systemImage: "bookmark")
}
.disabled(noVideos)
#if !os(tvOS)
Button {
startDownload()
} label: {
Label(String(localized: "openedLinks.downloadAll"), systemImage: "arrow.down.circle")
}
.disabled(noVideos)
#endif
}
private func playAll() {
let videos = viewModel.videos
guard let appEnvironment, !videos.isEmpty else { return }
appEnvironment.queueManager.playFromList(
videos: videos,
index: 0,
queueSource: .manual,
sourceLabel: title
)
}
private func addAllToQueue() {
let videos = viewModel.videos
guard let appEnvironment, !videos.isEmpty else { return }
appEnvironment.queueManager.addToQueue(videos, queueSource: .manual)
appEnvironment.toastManager.showSuccess(
String(localized: "openLink.queuedSuccess.title"),
subtitle: String(localized: "openLink.queuedSuccess.subtitle \(videos.count)")
)
}
private func bookmarkAll() {
let videos = viewModel.videos
guard let appEnvironment, !videos.isEmpty else { return }
for video in videos {
appEnvironment.dataManager.addBookmark(for: video)
}
appEnvironment.toastManager.showSuccess(
String(localized: "openedLinks.bookmarked.title"),
subtitle: String(localized: "openedLinks.bookmarked.subtitle \(videos.count)")
)
}
#if !os(tvOS)
private func startDownload() {
guard let appEnvironment, !viewModel.videoLinks.isEmpty else { return }
let settings = appEnvironment.downloadSettings
if settings.preferredDownloadQuality == .ask {
showingDownloadSheet = true
} else {
Task {
await download(
quality: settings.preferredDownloadQuality,
includeSubtitles: settings.includeSubtitlesInAutoDownload
)
}
}
}
private func download(quality: DownloadQuality, includeSubtitles: Bool) async {
guard let appEnvironment else { return }
let (succeeded, failed) = await LinkVideoResolver(appEnvironment: appEnvironment)
.downloadLinks(viewModel.videoLinks, quality: quality, includeSubtitles: includeSubtitles)
if failed == 0 {
appEnvironment.toastManager.showSuccess(
String(localized: "openLink.downloadQueued.title"),
subtitle: String(localized: "openLink.downloadQueued.subtitle \(succeeded)")
)
} else {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "openLink.downloadPartial.title"),
subtitle: String(localized: "openLink.downloadPartial.subtitle \(succeeded) \(failed)")
)
}
}
#endif
}
// MARK: - Link Status Row
/// Placeholder row for a link that is loading, failed, or couldn't be
/// previewed but still opens a screen.
private struct LinkStatusRow: View {
enum Status {
case loading
case unresolved
case failed
}
let url: URL
let message: String?
let style: VideoRowStyle
let status: Status
var body: some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
icon
}
.frame(width: style.thumbnailHeight * 16 / 9, height: style.thumbnailHeight)
VStack(alignment: .leading, spacing: 4) {
Text(url.host ?? url.absoluteString)
.font(.headline)
.lineLimit(1)
Text(url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
if let message, status == .failed || message != url.absoluteString {
Text(message)
.font(.caption2)
.foregroundStyle(status == .failed ? .red : .secondary)
.lineLimit(2)
}
}
Spacer(minLength: 0)
if status == .unresolved {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 6)
}
@ViewBuilder
private var icon: some View {
switch status {
case .loading:
ProgressView()
case .unresolved:
Image(systemName: "link")
.foregroundStyle(.secondary)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
}

View File

@@ -1153,29 +1153,7 @@ struct UnifiedPlaylistDetailView: View {
/// Returns the appropriate instance for the playlist's content source.
private func instanceForSource(_ source: ContentSource, instancesManager: InstancesManager) -> Instance? {
switch source {
case .global:
// For global content (YouTube), prefer Invidious instances over Yattee Server
// since Invidious playlists (IVPL*) are Invidious-specific
if let invidiousInstance = instancesManager.enabledInstances.first(where: { $0.type == .invidious }) {
return invidiousInstance
}
// Fall back to any YouTube-compatible instance
return instancesManager.enabledInstances.first(where: { $0.isYouTubeInstance })
case .federated(let provider, let instanceURL):
// For federated content, find matching instance by URL
if let existingInstance = instancesManager.instances.first(where: { $0.url == instanceURL }) {
return existingInstance
}
// If no configured instance matches, create a temporary one for PeerTube
if provider == ContentSource.peertubeProvider {
return Instance(type: .peertube, url: instanceURL)
}
return nil
case .extracted:
// For extracted content, use Yattee Server
return instancesManager.yatteeServerInstances.first
}
instancesManager.playlistInstance(for: source)
}
// MARK: - Playback Actions