Files
yattee/Yattee/Views/Downloads/DownloadsView.swift
Arkadiusz Fal 6df80c0e79 Use user-selected accent color in Home, Subscriptions, and Downloads
Color.accentColor and .foregroundStyle(.tint) resolve to the asset
catalog accent on macOS, so Home shortcut cards, section header
links, the Subscriptions "All channels" header, and the Downloads
per-channel group headers stayed blue when the user picked a
different accent. Read the color from SettingsManager and apply it
directly, matching the pattern already used for the Play button.
2026-04-23 22:55:48 +02:00

638 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 {
@Environment(\.appEnvironment) private var appEnvironment
let manager: DownloadManager
let settings: DownloadSettings
let searchText: String
let listStyle: VideoListStyle
let isGroupedMode: Bool
private var accentColor: Color {
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
}
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 downloadsDir = manager.downloadsDirectory()
let videoList = allDownloadsInOrder.map { $0.toVideo(downloadsDirectory: downloadsDir) }
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 downloadsDir = manager.downloadsDirectory()
let videoList = allDownloadsInOrder.map { $0.toVideo(downloadsDirectory: downloadsDir) }
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 downloadsDir = manager.downloadsDirectory()
let videoList = sortedDownloads.map { $0.toVideo(downloadsDirectory: downloadsDir) }
// 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(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(downloadsDirectory: manager.downloadsDirectory()),
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