ChannelsView.swift
10973 bytes
1import SwiftUI
2
3struct ChannelsView: View {
4 let settings: SettingsStore
5
6 @Environment(\.openURL) private var openURL
7 @State private var channels: [Channel] = []
8 @State private var isLoading = false
9 @State private var isSubscribeSheetPresented = false
10 @State private var channelToUnsubscribe: Channel?
11 @State private var alertMessage: String?
12
13 var body: some View {
14 List {
15 if isLoading && channels.isEmpty {
16 HStack {
17 Spacer()
18 ProgressView()
19 .padding()
20 Spacer()
21 }
22 .listRowSeparator(.hidden)
23 } else if channels.isEmpty {
24 ContentUnavailableView(
25 "No channels",
26 systemImage: "rectangle.stack.badge.plus",
27 description: Text("Subscribe to a YouTube channel to fetch its videos.")
28 )
29 .frame(maxWidth: .infinity)
30 .padding(.vertical, 80)
31 .listRowSeparator(.hidden)
32 } else {
33 ForEach(channels) { channel in
34 ChannelRow(
35 channel: channel,
36 openChannel: { openChannel(channel) },
37 setShortsEnabled: { enabled in
38 await setShortsEnabled(enabled, for: channel)
39 }
40 )
41 .transition(.opacity)
42 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
43 Button {
44 channelToUnsubscribe = channel
45 } label: {
46 Label("Unsubscribe", systemImage: "minus.circle")
47 }
48 .tint(.red)
49 }
50 .confirmationDialog(
51 "Unsubscribe from \(channel.name)?",
52 isPresented: Binding(
53 get: { channelToUnsubscribe?.id == channel.id },
54 set: { if !$0 { channelToUnsubscribe = nil } }
55 ),
56 titleVisibility: .visible
57 ) {
58 Button("Unsubscribe", role: .destructive) {
59 unsubscribe(from: channel)
60 }
61 Button("Cancel", role: .cancel) {}
62 } message: {
63 Text("Future server fetches will not include this channel. Existing videos remain available.")
64 }
65 }
66 }
67 }
68 .navigationTitle("Channels")
69 .toolbar {
70 ToolbarItemGroup(placement: .topBarTrailing) {
71 Button {
72 isSubscribeSheetPresented = true
73 } label: {
74 Image(systemName: "plus")
75 }
76 .accessibilityLabel("Subscribe to channel")
77 .disabled(isLoading)
78
79 Button {
80 Task {
81 await reload()
82 }
83 } label: {
84 Image(systemName: "arrow.clockwise")
85 }
86 .accessibilityLabel("Refresh channels")
87 .disabled(isLoading)
88
89 NavigationLink {
90 SettingsView(settings: settings)
91 } label: {
92 Image(systemName: "gear")
93 }
94 .accessibilityLabel("Settings")
95 }
96 }
97 .sheet(isPresented: $isSubscribeSheetPresented) {
98 SubscribeChannelSheet { channelID in
99 try await subscribe(to: channelID)
100 }
101 }
102 .refreshable {
103 await reload()
104 }
105 .task(id: settings.serverURL + settings.token) {
106 await reload()
107 }
108 .alert(
109 "Could not update channels",
110 isPresented: Binding(
111 get: { alertMessage != nil },
112 set: { if !$0 { alertMessage = nil } }
113 )
114 ) {
115 Button("OK", role: .cancel) {}
116 } message: {
117 Text(alertMessage ?? "")
118 }
119 }
120
121 private func reload() async {
122 guard let configuration = settings.configuration else {
123 return
124 }
125
126 isLoading = true
127 defer { isLoading = false }
128
129 do {
130 channels = try await APIClient().fetchChannels(using: configuration)
131 } catch {
132 alertMessage = error.localizedDescription
133 }
134 }
135
136 private func subscribe(to channelID: String) async throws {
137 guard let configuration = settings.configuration else {
138 throw APIError.invalidURL
139 }
140
141 _ = try await APIClient().subscribeToChannel(channelID: channelID, using: configuration)
142 await reload()
143 }
144
145 private func unsubscribe(from channel: Channel) {
146 guard let configuration = settings.configuration else {
147 return
148 }
149
150 channelToUnsubscribe = nil
151
152 Task {
153 do {
154 try await APIClient().unsubscribeFromChannel(
155 channelID: channel.channelID,
156 using: configuration
157 )
158 withAnimation(.easeInOut(duration: 0.25)) {
159 channels.removeAll { $0.id == channel.id }
160 }
161 } catch {
162 alertMessage = error.localizedDescription
163 }
164 }
165 }
166
167 private func setShortsEnabled(_ enabled: Bool, for channel: Channel) async {
168 guard let configuration = settings.configuration,
169 let index = channels.firstIndex(where: { $0.id == channel.id }) else {
170 return
171 }
172
173 let previousValue = channels[index].enableShorts
174 channels[index].enableShorts = enabled
175
176 do {
177 try await APIClient().setShortsEnabled(enabled, for: channel.channelID, using: configuration)
178 } catch {
179 if let currentIndex = channels.firstIndex(where: { $0.id == channel.id }) {
180 channels[currentIndex].enableShorts = previousValue
181 }
182 alertMessage = error.localizedDescription
183 }
184 }
185
186 private func openChannel(_ channel: Channel) {
187 let url = URL(string: "https://www.youtube.com/channel/\(channel.channelID)")
188 if let url {
189 openURL(url)
190 }
191 }
192}
193
194private struct ChannelRow: View {
195 let channel: Channel
196 let openChannel: () -> Void
197 let setShortsEnabled: (Bool) async -> Void
198
199 var body: some View {
200 HStack(spacing: 12) {
201 Button(action: openChannel) {
202 ChannelAvatar(url: URL(string: channel.imageURL))
203 }
204 .buttonStyle(.plain)
205 .accessibilityLabel("Open \(channel.name) on YouTube")
206
207 Button(action: openChannel) {
208 VStack(alignment: .leading, spacing: 4) {
209 Text(channel.name)
210 .font(.headline)
211 .foregroundStyle(.primary)
212 .lineLimit(1)
213
214 Text("\(channel.unwatchedCount) unwatched")
215 .font(.footnote)
216 .foregroundStyle(.secondary)
217 }
218 .frame(maxWidth: .infinity, alignment: .leading)
219 .contentShape(.rect)
220 }
221 .buttonStyle(.plain)
222 .accessibilityLabel("Open \(channel.name) on YouTube")
223
224 Button {
225 Task {
226 await setShortsEnabled(!channel.enableShorts)
227 }
228 } label: {
229 ShortsIcon()
230 .fill(channel.enableShorts ? .green : .gray)
231 .frame(width: 24, height: 24)
232 }
233 .buttonStyle(.bordered)
234 .accessibilityLabel("Include Shorts from \(channel.name)")
235 .accessibilityValue(channel.enableShorts ? "On" : "Off")
236
237 }
238 .padding(.vertical, 4)
239 }
240}
241
242private struct ChannelAvatar: View {
243 let url: URL?
244
245 var body: some View {
246 AsyncImage(url: url) { phase in
247 switch phase {
248 case let .success(image):
249 image
250 .resizable()
251 .scaledToFill()
252 case .empty:
253 Rectangle()
254 .fill(.quaternary)
255 .overlay { ProgressView() }
256 case .failure:
257 Rectangle()
258 .fill(.quaternary)
259 .overlay {
260 Image(systemName: "person.crop.circle")
261 .foregroundStyle(.secondary)
262 }
263 @unknown default:
264 EmptyView()
265 }
266 }
267 .frame(width: 44, height: 44)
268 .clipShape(.circle)
269 }
270}
271
272private struct SubscribeChannelSheet: View {
273 let subscribe: (String) async throws -> Void
274
275 @Environment(\.dismiss) private var dismiss
276 @State private var channelID = ""
277 @State private var isSubscribing = false
278 @State private var errorMessage: String?
279
280 var body: some View {
281 NavigationStack {
282 Form {
283 TextField("Channel ID or handle", text: $channelID)
284 .textInputAutocapitalization(.never)
285 .autocorrectionDisabled()
286 .disabled(isSubscribing)
287 }
288 .navigationTitle("Subscribe")
289 .toolbar {
290 ToolbarItem(placement: .cancellationAction) {
291 Button("Cancel") {
292 dismiss()
293 }
294 .disabled(isSubscribing)
295 }
296
297 ToolbarItem(placement: .confirmationAction) {
298 Button("Subscribe") {
299 Task {
300 await submit()
301 }
302 }
303 .disabled(channelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubscribing)
304 }
305 }
306 }
307 .interactiveDismissDisabled(isSubscribing)
308 .alert(
309 "Could not subscribe",
310 isPresented: Binding(
311 get: { errorMessage != nil },
312 set: { if !$0 { errorMessage = nil } }
313 )
314 ) {
315 Button("OK", role: .cancel) {}
316 } message: {
317 Text(errorMessage ?? "")
318 }
319 }
320
321 private func submit() async {
322 isSubscribing = true
323 defer { isSubscribing = false }
324
325 do {
326 try await subscribe(channelID.trimmingCharacters(in: .whitespacesAndNewlines))
327 dismiss()
328 } catch {
329 errorMessage = error.localizedDescription
330 }
331 }
332}