Files
yattee/Yattee/Views/Home/HistoryListView.swift
Arkadiusz Fal 7ecd408f89 Pin search bar to right of macOS toolbar in list views
Match the global Search view by pinning the searchable field and
trailing buttons to the right edge on macOS 26 via a flexible
ToolbarSpacer at .primaryAction. Applied to History, Bookmarks,
Manage Channels, Downloads, Instance Browse, and PeerTube Explore.

In History, the clear-history item moves from .automatic to
.primaryAction so it groups with the other trailing controls.
2026-06-12 09:54:24 +02:00

452 lines
16 KiB
Swift

//
// HistoryListView.swift
// Yattee
//
// Full page view for watch history.
//
import SwiftUI
struct HistoryListView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
@State private var history: [WatchEntry] = []
@State private var videos: [Video] = [] // Cached Video conversions to avoid repeated toVideo() calls
@State private var showingClearConfirmation = false
@State private var selectedClearOption: ClearHistoryOption?
// View options (persisted)
@AppStorage("history.layout") private var layout: VideoListLayout = .list
@AppStorage("history.rowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("history.gridColumns") private var gridColumns = 2
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
// UI state
@State private var showViewOptions = false
@State private var viewWidth: CGFloat = 0
@State private var searchText = ""
// Grid layout configuration
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
}
private var dataManager: DataManager? { appEnvironment?.dataManager }
/// History entries filtered by search text.
private var filteredHistory: [WatchEntry] {
guard !searchText.isEmpty else { return history }
let query = searchText.lowercased()
return history.filter { entry in
entry.title.lowercased().contains(query) ||
entry.authorName.lowercased().contains(query) ||
entry.videoID.lowercased().contains(query)
}
}
/// Pre-computed Video objects for filtered history entries.
private var filteredVideos: [Video] {
guard !searchText.isEmpty else { return videos }
let filteredIDs = Set(filteredHistory.map { $0.videoID })
return zip(history, videos)
.filter { filteredIDs.contains($0.0.videoID) }
.map { $0.1 }
}
/// Gets the watch progress (0.0-1.0) for a watch entry, or nil if finished.
private func watchProgress(for entry: WatchEntry) -> Double? {
let progress = entry.progress
// Only show progress bar for partially watched videos
return progress > 0 && progress < 1 ? progress : nil
}
/// Queue source for history.
private var historyQueueSource: QueueSource {
.manual
}
/// Stub callback for video queue continuation.
/// History doesn't support server-side pagination,
/// so this returns empty to prevent errors.
@Sendable
private func loadMoreHistoryCallback() async throws -> ([Video], String?) {
// History is fully loaded on initial fetch
// No pagination support available
return ([], nil)
}
private var viewOptionsSheetContent: some View {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
hideWatched: nil, // No hide watched for history view
maxGridColumns: gridConfig.maxColumns
)
}
var body: some View {
GeometryReader { geometry in
#if os(tvOS)
VStack(spacing: 0) {
// tvOS: Inline search field and action button for better focus navigation
HStack(spacing: 24) {
TextField("search.history.placeholder", text: $searchText)
.textFieldStyle(.plain)
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
if !history.isEmpty {
Menu {
ForEach(ClearHistoryOption.allCases, id: \.self) { option in
Button(role: .destructive) {
selectedClearOption = option
showingClearConfirmation = true
} label: {
Label(option.localizedTitle, systemImage: option.systemImage)
}
}
} label: {
Label(String(localized: "home.history.clear"), systemImage: "trash")
}
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 20)
.padding(.bottom, 20)
// Content
Group {
if filteredHistory.isEmpty {
emptyView
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.focusSection()
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#else
Group {
if filteredHistory.isEmpty {
// Empty state
emptyView
} else {
// Content based on layout
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "home.history.title"))
.toolbarTitleDisplayMode(.inlineLarge)
.searchable(text: $searchText, prompt: Text(String(localized: "history.search.placeholder")))
.toolbar {
#if os(macOS)
// Pin the trailing group (search field + toolbar buttons) to the right edge,
// matching the global Search view.
if #available(macOS 26, *) {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
#endif
// View options button (always visible)
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "historyViewOptions", in: sheetTransition)
#if os(macOS)
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
viewOptionsSheetContent
}
#endif
}
// Clear history menu (only when not empty)
if !history.isEmpty {
ToolbarItem(placement: .primaryAction) {
Menu {
ForEach(ClearHistoryOption.allCases, id: \.self) { option in
Button(role: .destructive) {
selectedClearOption = option
showingClearConfirmation = true
} label: {
Label(option.localizedTitle, systemImage: option.systemImage)
}
}
} label: {
Image(systemName: "trash")
}
}
}
}
#endif
#if !os(macOS)
.sheet(isPresented: $showViewOptions) {
viewOptionsSheetContent
.liquidGlassSheetContent(sourceID: "historyViewOptions", in: sheetTransition)
}
#endif
.confirmationDialog(
confirmationTitle,
isPresented: $showingClearConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "home.history.clear"), role: .destructive) {
clearHistory()
}
Button(String(localized: "common.cancel"), role: .cancel) {
selectedClearOption = nil
}
}
.presentationCompactAdaptation(.sheet)
.onAppear {
loadHistory()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadHistory()
}
}
// MARK: - Empty View
@ViewBuilder
private var emptyView: some View {
if !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView {
Label(String(localized: "home.history.title"), systemImage: "clock")
} description: {
Text(String(localized: "home.history.empty"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - List Layout
private var listContent: some View {
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
// Header spacer for top padding
Spacer()
.frame(height: 16)
} content: {
ForEach(Array(filteredHistory.enumerated()), id: \.element.videoID) { index, entry in
let video = filteredVideos[index]
VideoListRow(
isLast: index == filteredHistory.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
entryRowView(entry: entry, index: index)
}
#if !os(tvOS)
.videoSwipeActions(
video: video,
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
removeEntry(entry)
reset()
}
]
)
#endif
}
}
}
/// Reusable row view for a history entry.
@ViewBuilder
private func entryRowView(entry: WatchEntry, index: Int) -> some View {
let video = filteredVideos[index]
VideoRowView(
video: video,
style: rowStyle,
watchProgress: watchProgress(for: entry),
customMetadata: entry.isFinished ? nil : String(localized: "home.history.remaining \(entry.remainingTime)")
)
.tappableVideo(
video,
startTime: entry.watchedSeconds,
customActions: [
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { removeEntry(entry) }
)
],
context: .history,
queueSource: historyQueueSource,
sourceLabel: String(localized: "queue.source.history"),
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreHistoryCallback
)
}
// MARK: - Grid Layout
private var gridContent: some View {
ScrollView {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredHistory.enumerated()), id: \.element.videoID) { index, entry in
let video = filteredVideos[index]
VideoCardView(
video: video,
watchProgress: watchProgress(for: entry),
isCompact: gridConfig.isCompactCards
)
.tappableVideo(
video,
startTime: entry.watchedSeconds,
customActions: [
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { removeEntry(entry) }
)
],
context: .history,
queueSource: historyQueueSource,
sourceLabel: String(localized: "queue.source.history"),
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreHistoryCallback
)
}
}
}
#if os(tvOS)
.scrollClipDisabled()
#endif
}
private var confirmationTitle: String {
guard let option = selectedClearOption else {
return String(localized: "home.history.clear.confirm")
}
if option == .all {
return String(localized: "home.history.clear.confirm")
}
return String(localized: "home.history.clear.confirm.timeRange \(option.localizedTitle)")
}
private func loadHistory() {
history = dataManager?.watchHistory(limit: 10000) ?? []
videos = history.map { $0.toVideo() } // Pre-compute all Video conversions once
}
private func removeEntry(_ entry: WatchEntry) {
dataManager?.removeFromHistory(videoID: entry.videoID)
loadHistory()
}
private func clearHistory() {
guard let option = selectedClearOption else { return }
if option == .all {
dataManager?.clearWatchHistory()
} else if let date = option.cutoffDate {
dataManager?.clearWatchHistory(since: date)
}
selectedClearOption = nil
loadHistory()
}
}
// MARK: - Clear History Options
private enum ClearHistoryOption: CaseIterable {
case lastHour
case lastDay
case lastWeek
case lastMonth
case all
var localizedTitle: String {
switch self {
case .lastHour:
return String(localized: "home.history.clear.lastHour")
case .lastDay:
return String(localized: "home.history.clear.lastDay")
case .lastWeek:
return String(localized: "home.history.clear.lastWeek")
case .lastMonth:
return String(localized: "home.history.clear.lastMonth")
case .all:
return String(localized: "home.history.clear.all")
}
}
var systemImage: String {
switch self {
case .lastHour:
return "clock"
case .lastDay:
return "sun.max"
case .lastWeek:
return "calendar"
case .lastMonth:
return "calendar.badge.clock"
case .all:
return "trash"
}
}
var cutoffDate: Date? {
let calendar = Calendar.current
let now = Date()
switch self {
case .lastHour:
return calendar.date(byAdding: .hour, value: -1, to: now)
case .lastDay:
return calendar.date(byAdding: .day, value: -1, to: now)
case .lastWeek:
return calendar.date(byAdding: .weekOfYear, value: -1, to: now)
case .lastMonth:
return calendar.date(byAdding: .month, value: -1, to: now)
case .all:
return nil
}
}
}