diff --git a/Yattee/Core/InstancesManager.swift b/Yattee/Core/InstancesManager.swift index 14490379..66fef417 100644 --- a/Yattee/Core/InstancesManager.swift +++ b/Yattee/Core/InstancesManager.swift @@ -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) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 6e612521..39ac786e 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Services/Handoff/HandoffManager.swift b/Yattee/Services/Handoff/HandoffManager.swift index f19cec82..fc02f977 100644 --- a/Yattee/Services/Handoff/HandoffManager.swift +++ b/Yattee/Services/Handoff/HandoffManager.swift @@ -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 } diff --git a/Yattee/Services/Navigation/LinkVideoResolver.swift b/Yattee/Services/Navigation/LinkVideoResolver.swift index 95d14fbc..66ef98b4 100644 --- a/Yattee/Services/Navigation/LinkVideoResolver.swift +++ b/Yattee/Services/Navigation/LinkVideoResolver.swift @@ -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? { diff --git a/Yattee/Services/Navigation/NavigationDestination.swift b/Yattee/Services/Navigation/NavigationDestination.swift index 8d0cfd42..cd5e5198 100644 --- a/Yattee/Services/Navigation/NavigationDestination.swift +++ b/Yattee/Services/Navigation/NavigationDestination.swift @@ -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) } } } diff --git a/Yattee/ViewModels/OpenedLinksViewModel.swift b/Yattee/ViewModels/OpenedLinksViewModel.swift new file mode 100644 index 00000000..08cb3f60 --- /dev/null +++ b/Yattee/ViewModels/OpenedLinksViewModel.swift @@ -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() + 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.. 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) + } + } +} diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index 0128b7e6..1be51bb9 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -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 { diff --git a/Yattee/Views/Home/OpenedLinksView.swift b/Yattee/Views/Home/OpenedLinksView.swift new file mode 100644 index 00000000..bc808e49 --- /dev/null +++ b/Yattee/Views/Home/OpenedLinksView.swift @@ -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) + } + } +} diff --git a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift index 805fbd68..969f1a26 100644 --- a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift +++ b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift @@ -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