mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The batch buttons are disabled until a video resolves, and loading or failed rows are not focusable, so with every link still loading or failed the remote was stuck.
348 lines
11 KiB
Swift
348 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
|
|
#if os(tvOS)
|
|
@Environment(\.dismiss) private var dismiss
|
|
#endif
|
|
@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
|
|
|
|
// The batch buttons stay disabled until a video resolves,
|
|
// and loading or failed rows are not focusable: with every
|
|
// link still loading or failed, this is the only thing the
|
|
// remote can land on.
|
|
if viewModel.videos.isEmpty {
|
|
Button(String(localized: "common.done")) {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|