mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
141
Yattee/ViewModels/OpenedLinksViewModel.swift
Normal file
141
Yattee/ViewModels/OpenedLinksViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
334
Yattee/Views/Home/OpenedLinksView.swift
Normal file
334
Yattee/Views/Home/OpenedLinksView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user