mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
412 lines
14 KiB
Swift
412 lines
14 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)
|
|
}
|
|
|
|
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", text: $searchText)
|
|
.textFieldStyle(.plain)
|
|
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
}
|
|
.focusSection()
|
|
.padding(.horizontal, 48)
|
|
.padding(.top, 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
|
|
}
|
|
.navigationTitle(String(localized: "home.history.title"))
|
|
#if !os(tvOS)
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
.searchable(text: $searchText, prompt: Text(String(localized: "history.search.placeholder")))
|
|
.toolbar {
|
|
// 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)
|
|
}
|
|
|
|
// Clear history menu (only when not empty)
|
|
if !history.isEmpty {
|
|
ToolbarItem(placement: .automatic) {
|
|
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
|
|
.sheet(isPresented: $showViewOptions) {
|
|
ViewOptionsSheet(
|
|
layout: $layout,
|
|
rowStyle: $rowStyle,
|
|
gridColumns: $gridColumns,
|
|
hideWatched: nil, // No hide watched for history view
|
|
maxGridColumns: gridConfig.maxColumns
|
|
)
|
|
.liquidGlassSheetContent(sourceID: "historyViewOptions", in: sheetTransition)
|
|
}
|
|
.confirmationDialog(
|
|
confirmationTitle,
|
|
isPresented: $showingClearConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "home.history.clear"), role: .destructive) {
|
|
clearHistory()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
selectedClearOption = nil
|
|
}
|
|
}
|
|
.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)
|
|
} else {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "home.history.title"), systemImage: "clock")
|
|
} description: {
|
|
Text(String(localized: "home.history.empty"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|