mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
631 lines
23 KiB
Swift
631 lines
23 KiB
Swift
//
|
|
// DownloadsView.swift
|
|
// Yattee
|
|
//
|
|
// Downloads management view.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if !os(tvOS)
|
|
struct DownloadsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var selectedDownload: Download?
|
|
@State private var failedDownloadToShow: Download?
|
|
@State private var searchText = ""
|
|
|
|
private var downloadManager: DownloadManager? {
|
|
appEnvironment?.downloadManager
|
|
}
|
|
|
|
private var downloadSettings: DownloadSettings? {
|
|
appEnvironment?.downloadSettings
|
|
}
|
|
|
|
/// List style from centralized settings.
|
|
private var listStyle: VideoListStyle {
|
|
appEnvironment?.settingsManager.listStyle ?? .inset
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let manager = downloadManager, let settings = downloadSettings {
|
|
downloadsList(manager, settings: settings)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "downloads.title"))
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
if let settings = downloadSettings {
|
|
sortAndGroupMenu(settings)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Downloads List
|
|
|
|
@ViewBuilder
|
|
private func downloadsList(_ manager: DownloadManager, settings: DownloadSettings) -> some View {
|
|
let hasGroupedContent = settings.groupByChannel || settings.sortOption == .name
|
|
|
|
Group {
|
|
if hasGroupedContent {
|
|
// Grouped content needs separate cards per group
|
|
groupedDownloadsList(manager, settings: settings)
|
|
} else {
|
|
// Flat list uses single card
|
|
flatDownloadsList(manager, settings: settings)
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: Text(String(localized: "downloads.search.placeholder")))
|
|
.onChange(of: selectedDownload) { _, newValue in
|
|
if let download = newValue {
|
|
if let result = downloadManager?.videoAndStream(for: download) {
|
|
appEnvironment?.playerService.openVideo(result.video, stream: result.stream, audioStream: result.audioStream, download: download)
|
|
}
|
|
selectedDownload = nil
|
|
}
|
|
}
|
|
.alert(
|
|
String(localized: "downloads.status.failed"),
|
|
isPresented: .init(
|
|
get: { failedDownloadToShow != nil },
|
|
set: { if !$0 { failedDownloadToShow = nil } }
|
|
)
|
|
) {
|
|
Button(String(localized: "downloads.retry")) {
|
|
if let download = failedDownloadToShow {
|
|
Task { await manager.resume(download) }
|
|
}
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
} message: {
|
|
if let download = failedDownloadToShow {
|
|
Text(download.error ?? String(localized: "downloads.error.unknown"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Flat Downloads List (Separate Cards for Active/Completed)
|
|
|
|
@ViewBuilder
|
|
private func flatDownloadsList(_ manager: DownloadManager, settings: DownloadSettings) -> some View {
|
|
// NOTE: Active and Completed sections are separate views to isolate observation scopes.
|
|
// This prevents completed section from re-rendering when active downloads progress updates.
|
|
// Active and completed downloads are in separate cards for visual clarity.
|
|
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
|
|
|
|
backgroundStyle.color
|
|
.ignoresSafeArea()
|
|
.overlay(
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
Spacer().frame(height: 16)
|
|
|
|
// Active downloads in its own card
|
|
ActiveDownloadsSectionContentView(
|
|
manager: manager,
|
|
searchText: searchText,
|
|
listStyle: listStyle,
|
|
isGroupedMode: true, // Always use card mode for active downloads
|
|
failedDownloadToShow: $failedDownloadToShow
|
|
)
|
|
|
|
// Completed downloads in its own card
|
|
CompletedDownloadsSectionContentView(
|
|
manager: manager,
|
|
settings: settings,
|
|
searchText: searchText,
|
|
listStyle: listStyle,
|
|
isGroupedMode: false
|
|
)
|
|
|
|
// Empty state
|
|
DownloadsEmptyStateView(
|
|
manager: manager,
|
|
searchText: searchText
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Grouped Downloads List (Separate Cards per Group)
|
|
|
|
@ViewBuilder
|
|
private func groupedDownloadsList(_ manager: DownloadManager, settings: DownloadSettings) -> some View {
|
|
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
|
|
|
|
backgroundStyle.color
|
|
.ignoresSafeArea()
|
|
.overlay(
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
Spacer().frame(height: 16)
|
|
|
|
// Active downloads in its own card (if any)
|
|
ActiveDownloadsSectionContentView(
|
|
manager: manager,
|
|
searchText: searchText,
|
|
listStyle: listStyle,
|
|
isGroupedMode: true,
|
|
failedDownloadToShow: $failedDownloadToShow
|
|
)
|
|
|
|
// Completed downloads with separate cards per group
|
|
CompletedDownloadsSectionContentView(
|
|
manager: manager,
|
|
settings: settings,
|
|
searchText: searchText,
|
|
listStyle: listStyle,
|
|
isGroupedMode: true
|
|
)
|
|
|
|
// Empty state
|
|
DownloadsEmptyStateView(
|
|
manager: manager,
|
|
searchText: searchText
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Sort and Group Menu
|
|
|
|
@ViewBuilder
|
|
private func sortAndGroupMenu(_ settings: DownloadSettings) -> some View {
|
|
Menu {
|
|
// Sort options
|
|
Section {
|
|
Picker(selection: Binding(
|
|
get: { settings.sortOption },
|
|
set: { settings.sortOption = $0 }
|
|
)) {
|
|
ForEach(DownloadSortOption.allCases, id: \.self) { option in
|
|
Label(option.displayName, systemImage: option.systemImage)
|
|
.tag(option)
|
|
}
|
|
} label: {
|
|
Label(String(localized: "downloads.sort.title"), systemImage: "arrow.up.arrow.down")
|
|
}
|
|
|
|
// Sort direction
|
|
Button {
|
|
settings.sortDirection.toggle()
|
|
} label: {
|
|
Label(
|
|
settings.sortDirection == .ascending
|
|
? String(localized: "downloads.sort.ascending")
|
|
: String(localized: "downloads.sort.descending"),
|
|
systemImage: settings.sortDirection.systemImage
|
|
)
|
|
}
|
|
}
|
|
|
|
// Grouping
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { settings.groupByChannel },
|
|
set: { settings.groupByChannel = $0 }
|
|
)) {
|
|
Label(String(localized: "downloads.groupByChannel"), systemImage: "person.2")
|
|
}
|
|
}
|
|
} label: {
|
|
Label(String(localized: "downloads.sortAndGroup"), systemImage: "line.3.horizontal.decrease.circle")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Active Downloads Section Content View (Isolated Observation Scope)
|
|
|
|
/// Separate view for active downloads to isolate @Observable tracking.
|
|
/// Only accesses manager.activeDownloads, so won't re-render when completedDownloads changes.
|
|
/// Generates rows directly for VideoListContainer (no Section wrapper).
|
|
private struct ActiveDownloadsSectionContentView: View {
|
|
let manager: DownloadManager
|
|
let searchText: String
|
|
let listStyle: VideoListStyle
|
|
let isGroupedMode: Bool
|
|
@Binding var failedDownloadToShow: Download?
|
|
|
|
private var activeFiltered: [Download] {
|
|
guard !searchText.isEmpty else { return manager.activeDownloads }
|
|
let query = searchText.lowercased()
|
|
return manager.activeDownloads.filter { download in
|
|
download.title.lowercased().contains(query) ||
|
|
download.channelName.lowercased().contains(query) ||
|
|
download.videoID.videoID.lowercased().contains(query)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if !activeFiltered.isEmpty {
|
|
if isGroupedMode {
|
|
// Grouped mode: rows in their own card
|
|
VideoListContent(listStyle: listStyle) {
|
|
activeDownloadRows
|
|
}
|
|
} else {
|
|
// Flat mode: rows inline
|
|
activeDownloadRows
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var activeDownloadRows: some View {
|
|
ForEach(Array(activeFiltered.enumerated()), id: \.element.id) { index, download in
|
|
VideoListRow(
|
|
isLast: index == activeFiltered.count - 1,
|
|
rowStyle: .regular,
|
|
listStyle: listStyle
|
|
) {
|
|
DownloadRowView(
|
|
download: download,
|
|
isActive: true
|
|
)
|
|
.onTapGesture {
|
|
if download.status == .failed {
|
|
failedDownloadToShow = download
|
|
}
|
|
}
|
|
}
|
|
.swipeActions(actionsArray: swipeActionsFor(download))
|
|
}
|
|
}
|
|
|
|
// MARK: - Section Header
|
|
|
|
private func sectionHeader(_ text: String) -> some View {
|
|
Text(text)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, listStyle == .inset ? 16 : 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: - Swipe Actions
|
|
|
|
private func swipeActionsFor(_ download: Download) -> [SwipeAction] {
|
|
var actions: [SwipeAction] = []
|
|
|
|
// Pause/Resume action based on status
|
|
if download.status == .downloading {
|
|
actions.append(SwipeAction(
|
|
symbolImage: "pause.fill",
|
|
tint: .white,
|
|
background: .orange
|
|
) { reset in
|
|
Task { await manager.pause(download) }
|
|
reset()
|
|
})
|
|
} else if download.status == .paused || download.status == .failed {
|
|
actions.append(SwipeAction(
|
|
symbolImage: "play.fill",
|
|
tint: .white,
|
|
background: .green
|
|
) { reset in
|
|
Task { await manager.resume(download) }
|
|
reset()
|
|
})
|
|
}
|
|
|
|
// Cancel action (always present)
|
|
actions.append(SwipeAction(
|
|
symbolImage: "xmark.circle.fill",
|
|
tint: .white,
|
|
background: .red
|
|
) { reset in
|
|
Task { await manager.cancel(download) }
|
|
reset()
|
|
})
|
|
|
|
return actions
|
|
}
|
|
}
|
|
|
|
// MARK: - Completed Downloads Section Content View (Isolated Observation Scope)
|
|
|
|
/// Separate view for completed downloads to isolate @Observable tracking.
|
|
/// Only accesses manager.completedDownloads, so won't re-render when activeDownloads progress updates.
|
|
/// Generates rows directly for VideoListContainer (no Section wrapper).
|
|
private struct CompletedDownloadsSectionContentView: View {
|
|
let manager: DownloadManager
|
|
let settings: DownloadSettings
|
|
let searchText: String
|
|
let listStyle: VideoListStyle
|
|
let isGroupedMode: Bool
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
private var completedFiltered: [Download] {
|
|
guard !searchText.isEmpty else { return manager.completedDownloads }
|
|
let query = searchText.lowercased()
|
|
return manager.completedDownloads.filter { download in
|
|
download.title.lowercased().contains(query) ||
|
|
download.channelName.lowercased().contains(query) ||
|
|
download.videoID.videoID.lowercased().contains(query)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if !completedFiltered.isEmpty {
|
|
if settings.groupByChannel {
|
|
groupedByChannelContent(completedFiltered)
|
|
} else if settings.sortOption == .name {
|
|
groupedByLetterContent(completedFiltered)
|
|
} else {
|
|
flatListContent(completedFiltered)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Grouped by Channel
|
|
|
|
@ViewBuilder
|
|
private func groupedByChannelContent(_ downloads: [Download]) -> some View {
|
|
let grouped = settings.groupedByChannel(downloads)
|
|
let allDownloadsInOrder = grouped.flatMap { $0.downloads }
|
|
let videoList = allDownloadsInOrder.map { $0.toVideo() }
|
|
var runningIndex = 0
|
|
|
|
ForEach(Array(grouped.enumerated()), id: \.element.channelID) { groupIndex, group in
|
|
let baseIndex = runningIndex
|
|
let _ = { runningIndex += group.downloads.count }()
|
|
|
|
// Channel header OUTSIDE the card
|
|
channelSectionHeader(group.channel, channelID: group.channelID, downloads: group.downloads)
|
|
|
|
if isGroupedMode {
|
|
// Each group in its own VideoListContent card
|
|
VideoListContent(listStyle: listStyle) {
|
|
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
|
completedDownloadRow(
|
|
download,
|
|
videoList: videoList,
|
|
index: baseIndex + localIndex,
|
|
isLast: localIndex == group.downloads.count - 1
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// Flat mode: rows inline
|
|
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
|
let isLastInGroup = localIndex == group.downloads.count - 1
|
|
let isLastGroup = groupIndex == grouped.count - 1
|
|
completedDownloadRow(
|
|
download,
|
|
videoList: videoList,
|
|
index: baseIndex + localIndex,
|
|
isLast: isLastInGroup && isLastGroup
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Grouped by Letter
|
|
|
|
@ViewBuilder
|
|
private func groupedByLetterContent(_ downloads: [Download]) -> some View {
|
|
let sortedDownloads = settings.sorted(downloads)
|
|
let groupedByLetter = groupDownloadsByFirstLetter(sortedDownloads, ascending: settings.sortDirection == .ascending)
|
|
let allDownloadsInOrder = groupedByLetter.flatMap { $0.downloads }
|
|
let videoList = allDownloadsInOrder.map { $0.toVideo() }
|
|
var runningIndex = 0
|
|
|
|
ForEach(Array(groupedByLetter.enumerated()), id: \.element.letter) { groupIndex, group in
|
|
let baseIndex = runningIndex
|
|
let _ = { runningIndex += group.downloads.count }()
|
|
|
|
// Letter header OUTSIDE the card
|
|
letterSectionHeader(group.letter)
|
|
|
|
if isGroupedMode {
|
|
// Each group in its own VideoListContent card
|
|
VideoListContent(listStyle: listStyle) {
|
|
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
|
completedDownloadRow(
|
|
download,
|
|
videoList: videoList,
|
|
index: baseIndex + localIndex,
|
|
isLast: localIndex == group.downloads.count - 1
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// Flat mode: rows inline
|
|
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
|
let isLastInGroup = localIndex == group.downloads.count - 1
|
|
let isLastGroup = groupIndex == groupedByLetter.count - 1
|
|
completedDownloadRow(
|
|
download,
|
|
videoList: videoList,
|
|
index: baseIndex + localIndex,
|
|
isLast: isLastInGroup && isLastGroup
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Flat List
|
|
|
|
@ViewBuilder
|
|
private func flatListContent(_ downloads: [Download]) -> some View {
|
|
let sortedDownloads = settings.sorted(downloads)
|
|
let videoList = sortedDownloads.map { $0.toVideo() }
|
|
|
|
// Flat list content wrapped in its own card
|
|
VideoListContent(listStyle: listStyle) {
|
|
ForEach(Array(sortedDownloads.enumerated()), id: \.element.id) { index, download in
|
|
completedDownloadRow(
|
|
download,
|
|
videoList: videoList,
|
|
index: index,
|
|
isLast: index == sortedDownloads.count - 1
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Section Headers
|
|
|
|
private func letterSectionHeader(_ letter: String) -> some View {
|
|
Text(letter)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, listStyle == .inset ? 16 : 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func channelSectionHeader(_ channel: String, channelID: String, downloads: [Download]) -> some View {
|
|
let contentSource = downloads.first?.toVideo().id.source ?? .global(provider: ContentSource.youtubeProvider)
|
|
NavigationLink(value: NavigationDestination.channel(channelID, contentSource)) {
|
|
HStack(spacing: 4) {
|
|
Text(channel)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.zoomTransitionSource(id: channelID)
|
|
.buttonStyle(.plain)
|
|
.font(.subheadline.weight(.semibold))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, listStyle == .inset ? 16 : 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: - Row and Footer
|
|
|
|
@ViewBuilder
|
|
private func completedDownloadRow(_ download: Download, videoList: [Video], index: Int, isLast: Bool) -> some View {
|
|
VideoListRow(
|
|
isLast: isLast,
|
|
rowStyle: .regular,
|
|
listStyle: listStyle
|
|
) {
|
|
DownloadRowView(
|
|
download: download,
|
|
isActive: false,
|
|
queueSource: .manual,
|
|
sourceLabel: String(localized: "queue.source.downloads"),
|
|
videoList: videoList,
|
|
videoIndex: index,
|
|
loadMoreVideos: loadMoreDownloadsCallback
|
|
)
|
|
}
|
|
.videoSwipeActions(
|
|
video: download.toVideo(),
|
|
fixedActions: [
|
|
SwipeAction(
|
|
symbolImage: "trash.fill",
|
|
tint: .white,
|
|
background: .red
|
|
) { reset in
|
|
Task { await manager.delete(download) }
|
|
reset()
|
|
}
|
|
]
|
|
)
|
|
}
|
|
|
|
@Sendable
|
|
private func loadMoreDownloadsCallback() async throws -> ([Video], String?) {
|
|
return ([], nil)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func sectionIndexLabel(for text: String) -> String {
|
|
guard let firstChar = text.first else { return "#" }
|
|
let uppercased = String(firstChar).uppercased()
|
|
return uppercased.first?.isLetter == true ? uppercased : "#"
|
|
}
|
|
|
|
private func groupDownloadsByFirstLetter(_ downloads: [Download], ascending: Bool) -> [(letter: String, downloads: [Download])] {
|
|
let grouped = Dictionary(grouping: downloads) { download -> String in
|
|
sectionIndexLabel(for: download.title)
|
|
}
|
|
|
|
return grouped.map { (letter, downloads) in
|
|
(letter: letter, downloads: downloads)
|
|
}
|
|
.sorted { ascending ? $0.letter < $1.letter : $0.letter > $1.letter }
|
|
}
|
|
}
|
|
|
|
// MARK: - Downloads Empty State View (Isolated Observation Scope)
|
|
|
|
/// Separate view for empty state to avoid re-rendering main content when checking isEmpty.
|
|
private struct DownloadsEmptyStateView: View {
|
|
let manager: DownloadManager
|
|
let searchText: String
|
|
|
|
private var hasActiveDownloads: Bool {
|
|
!manager.activeDownloads.isEmpty
|
|
}
|
|
|
|
private var hasCompletedDownloads: Bool {
|
|
!manager.completedDownloads.isEmpty
|
|
}
|
|
|
|
private var isEmpty: Bool {
|
|
if searchText.isEmpty {
|
|
return !hasActiveDownloads && !hasCompletedDownloads
|
|
}
|
|
let query = searchText.lowercased()
|
|
let hasMatchingActive = manager.activeDownloads.contains { download in
|
|
download.title.lowercased().contains(query) ||
|
|
download.channelName.lowercased().contains(query) ||
|
|
download.videoID.videoID.lowercased().contains(query)
|
|
}
|
|
let hasMatchingCompleted = manager.completedDownloads.contains { download in
|
|
download.title.lowercased().contains(query) ||
|
|
download.channelName.lowercased().contains(query) ||
|
|
download.videoID.videoID.lowercased().contains(query)
|
|
}
|
|
return !hasMatchingActive && !hasMatchingCompleted
|
|
}
|
|
|
|
var body: some View {
|
|
if isEmpty {
|
|
if !searchText.isEmpty {
|
|
ContentUnavailableView.search(text: searchText)
|
|
.padding(.top, 60)
|
|
} else {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "downloads.empty.title"), systemImage: "arrow.down.circle")
|
|
} description: {
|
|
Text(String(localized: "downloads.empty.description"))
|
|
}
|
|
.padding(.top, 60)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
DownloadsView()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|
|
#endif
|