Files
yattee/Yattee/Views/Downloads/DownloadsView.swift
Arkadiusz Fal 2efa0708c8 Refresh expired thumbnail URLs for downloads and video info
Proxied thumbnail URLs from Invidious/Piped/Yattee server expire over
time. Two paths were left holding stale URLs: the Video Info carousel
kept the original list copy even after fresh details arrived, and
downloaded videos rendered from the remote URL snapshot taken at
download time while the local thumbnail on disk was ignored.

Evict stale URLs from the Nuke cache when fresh video details load,
pass the fresh details through to the videoCard thumbnail, and resolve
downloads' thumbnails from the local file when localThumbnailPath is
set.
2026-04-18 20:38:02 +02:00

634 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 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(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(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