Files
yattee/Yattee/Views/Home/OpenedLinksView.swift
Arkadiusz Fal 8a7218ed1b 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.
2026-10-08 22:36:57 +02:00

335 lines
11 KiB
Swift

//
// 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)
}
}
}