HistoryView.swift
7076 bytes
1import SwiftUI
2
3struct HistoryView: View {
4 let settings: SettingsStore
5 @Binding var isSearchVisible: Bool
6
7 @Namespace private var glassNamespace
8 @State private var videos: [Video] = []
9 @State private var page = 1
10 @State private var searchText = ""
11 @State private var isLoading = false
12 @State private var alertMessage: String?
13
14 private var canShowNextPage: Bool {
15 videos.count == 100
16 }
17
18 var body: some View {
19 GlassEffectContainer {
20 List {
21 if isLoading && videos.isEmpty {
22 ProgressView()
23 .padding(32)
24 } else if videos.isEmpty {
25 ContentUnavailableView(
26 "No watched videos",
27 systemImage: "clock",
28 description: Text("Videos that you mark as watched appear here.")
29 )
30 .frame(maxWidth: .infinity)
31 .padding(.vertical, 80)
32
33 if page > 1 {
34 pageControls
35 }
36 } else if displayedVideos.isEmpty {
37 ContentUnavailableView.search(text: searchText)
38 .frame(maxWidth: .infinity)
39 .padding(.vertical, 80)
40 } else {
41 ForEach(displayedVideos) { video in
42 VideoCard(
43 video: video,
44 tint: .orange,
45 updateProgress: { progress in
46 try await updateProgress(for: video, to: progress)
47 },
48 glassNamespace: glassNamespace
49 )
50 .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
51 .listRowSeparator(.hidden)
52 .listRowBackground(Color.clear)
53 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
54 Button(role: .destructive) {
55 markUnwatched(video)
56 } label: {
57 Label("Mark Unwatched", systemImage: "arrow.uturn.backward")
58 }
59 .tint(.orange)
60 }
61 }
62
63 pageControls
64 }
65 }
66 .listStyle(.plain)
67 .scrollContentBackground(.hidden)
68 }
69 .navigationTitle(isSearchVisible ? "" : "History")
70 .toolbar {
71 if isSearchVisible {
72 ToolbarItem(placement: .topBarLeading) {
73 SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
74 }
75 } else {
76 ToolbarItemGroup(placement: .topBarTrailing) {
77 Button {
78 Task {
79 await reload()
80 }
81 } label: {
82 Image(systemName: "arrow.clockwise")
83 }
84 .accessibilityLabel("Refresh history")
85 .disabled(isLoading)
86
87 NavigationLink {
88 SettingsView(settings: settings)
89 } label: {
90 Image(systemName: "gear")
91 }
92 .accessibilityLabel("Settings")
93 }
94 }
95 }
96 .refreshable {
97 await reload()
98 }
99 .task(id: settings.serverURL + settings.token) {
100 page = 1
101 await reload()
102 }
103 .alert(
104 "Could not load history",
105 isPresented: Binding(
106 get: { alertMessage != nil },
107 set: { if !$0 { alertMessage = nil } }
108 )
109 ) {
110 Button("OK", role: .cancel) {}
111 } message: {
112 Text(alertMessage ?? "")
113 }
114 }
115
116 private var pageControls: some View {
117 HStack {
118 Button("Previous") {
119 page -= 1
120 Task {
121 await reload()
122 }
123 }
124 .buttonStyle(.glass)
125 .disabled(page == 1 || isLoading)
126
127 Spacer()
128
129 Text("Page \(page)")
130 .font(.footnote)
131 .foregroundStyle(.secondary)
132 .padding(.horizontal, 12)
133 .padding(.vertical, 8)
134 .glassEffect(in: .capsule)
135
136 Spacer()
137
138 Button("Next") {
139 page += 1
140 Task {
141 await reload()
142 }
143 }
144 .buttonStyle(.glass)
145 .disabled(!canShowNextPage || isLoading)
146 }
147 }
148
149 private var displayedVideos: [Video] {
150 guard !searchText.isEmpty else {
151 return videos
152 }
153
154 return videos.filter {
155 $0.title.localizedCaseInsensitiveContains(searchText) ||
156 $0.channelName.localizedCaseInsensitiveContains(searchText)
157 }
158 }
159
160 private func reload() async {
161 guard let configuration = settings.configuration else {
162 return
163 }
164
165 isLoading = true
166 defer { isLoading = false }
167
168 do {
169 videos = try await APIClient().fetchWatchedVideos(page: page, using: configuration)
170 } catch {
171 alertMessage = error.localizedDescription
172 }
173 }
174
175 private func updateProgress(for video: Video, to progress: String) async throws {
176 guard let configuration = settings.configuration else {
177 return
178 }
179
180 let updatedVideo = try await APIClient().updateVideoProgress(
181 videoID: video.videoID,
182 progress: progress,
183 using: configuration
184 )
185 if let index = videos.firstIndex(where: { $0.id == updatedVideo.id }) {
186 videos[index] = updatedVideo
187 }
188 }
189
190 private func markUnwatched(_ video: Video) {
191 guard let configuration = settings.configuration,
192 let index = videos.firstIndex(where: { $0.id == video.id }) else {
193 return
194 }
195
196 withAnimation(.easeInOut) {
197 _ = videos.remove(at: index)
198 }
199
200 Task {
201 do {
202 try await APIClient().markUnwatched(videoID: video.videoID, using: configuration)
203 await reload()
204 } catch {
205 if !videos.contains(where: { $0.id == video.id }) {
206 withAnimation(.easeInOut) {
207 videos.insert(video, at: min(index, videos.count))
208 }
209 }
210 alertMessage = error.localizedDescription
211 }
212 }
213 }
214}