Parent directory

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}