0c11f75e6227bbeb49001d6af9d9a0898fecb072
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/AGENTS.md b/AGENTS.md
2index c8402cfe48af1f1e70c7d86753404bb2919dedc1..2a5509c218b910c52af2dff799ffffca3af15725 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -43,13 +43,13 @@ Current app routes:
6 - `POST /api/videos/:video_id/download` with `{"format":720}`
7 - `GET /api/videos/:video_id/file`
8 - `GET /api/channels`
9-- `POST /api/channels/:channel_id/subscribe`
10+- `POST /api/channels/subscribe` with `{"channel_id":"YouTube channel ID, handle, or URL"}`
11 - `POST /api/channels/:channel_id/unsubscribe`
12 - `POST /api/channels/:channel_id/shorts?enable=true|false`
13
14 The progress endpoint accepts Go duration, `mm:ss`, and `hh:mm:ss` values. It returns `{"video": {...}}`.
15
16diff --git a/Sources/ytrssil/APIClient.swift b/Sources/ytrssil/APIClient.swift
17index 32bfbdbc913cb6a172704f66ee484a19d2c4826f..67bbed7781c03942f4d76bd2cc259449ffd7ec26 100644
18--- a/Sources/ytrssil/APIClient.swift
19+++ b/Sources/ytrssil/APIClient.swift
20@@ -117,9 +117,10 @@ struct APIClient: Sendable {
21
22 func subscribeToChannel(channelID: String, using configuration: ServerConfiguration) async throws -> Channel {
23 try await request(
24- endpoint: configuration.endpoint(path: "api/channels/\(channelID)/subscribe"),
25+ endpoint: configuration.endpoint(path: "api/channels/subscribe"),
26 method: "POST",
27- configuration: configuration
28+ configuration: configuration,
29+ body: try JSONEncoder().encode(SubscribeRequest(channelID: channelID))
30 )
31 }
32
33@@ -318,6 +319,14 @@ private struct AddVideoRequest: Encodable {
34 }
35 }
36
37+private struct SubscribeRequest: Encodable {
38+ let channelID: String
39+
40+ enum CodingKeys: String, CodingKey {
41+ case channelID = "channel_id"
42+ }
43+}
44+
45 private struct ErrorResponse: Decodable {
46 let error: String
47 }
48diff --git a/Sources/ytrssil/ChannelsView.swift b/Sources/ytrssil/ChannelsView.swift
49index 6be96acd2618e84f2faa23ddc850e5bea2a85ed1..90aec24db82f9178abfe8843c409533bb1501252 100644
50--- a/Sources/ytrssil/ChannelsView.swift
51+++ b/Sources/ytrssil/ChannelsView.swift
52@@ -85,6 +85,13 @@ struct ChannelsView: View {
53 }
54 .accessibilityLabel("Refresh channels")
55 .disabled(isLoading)
56+
57+ NavigationLink {
58+ SettingsView(settings: settings)
59+ } label: {
60+ Image(systemName: "gear")
61+ }
62+ .accessibilityLabel("Settings")
63 }
64 }
65 .sheet(isPresented: $isSubscribeSheetPresented) {
66@@ -136,28 +143,22 @@ struct ChannelsView: View {
67 }
68
69 private func unsubscribe(from channel: Channel) {
70- guard let configuration = settings.configuration,
71- let index = channels.firstIndex(where: { $0.id == channel.id }) else {
72+ guard let configuration = settings.configuration else {
73 return
74 }
75
76 channelToUnsubscribe = nil
77
78 Task {
79- try? await Task.sleep(for: .milliseconds(200))
80- withAnimation(.easeInOut(duration: 0.25)) {
81- _ = channels.remove(at: index)
82- }
83-
84 do {
85 try await APIClient().unsubscribeFromChannel(
86 channelID: channel.channelID,
87 using: configuration
88 )
89- } catch {
90 withAnimation(.easeInOut(duration: 0.25)) {
91- channels.insert(channel, at: min(index, channels.count))
92+ channels.removeAll { $0.id == channel.id }
93 }
94+ } catch {
95 alertMessage = error.localizedDescription
96 }
97 }
98@@ -175,7 +176,9 @@ struct ChannelsView: View {
99 do {
100 try await APIClient().setShortsEnabled(enabled, for: channel.channelID, using: configuration)
101 } catch {
102- channels[index].enableShorts = previousValue
103+ if let currentIndex = channels.firstIndex(where: { $0.id == channel.id }) {
104+ channels[currentIndex].enableShorts = previousValue
105+ }
106 alertMessage = error.localizedDescription
107 }
108 }
109diff --git a/Sources/ytrssil/ContentView.swift b/Sources/ytrssil/ContentView.swift
110index 9810f4d0c268f3fbae29bbf858be53ca73244631..70720663dcf626f8c83acde8827ec70e3f41a636 100644
111--- a/Sources/ytrssil/ContentView.swift
112+++ b/Sources/ytrssil/ContentView.swift
113@@ -1,7 +1,17 @@
114 import SwiftUI
115
116 struct ContentView: View {
117+ private enum Tab: Hashable {
118+ case feed
119+ case history
120+ case channels
121+ case search
122+ }
123+
124 @State private var settings = SettingsStore()
125+ @State private var selectedTab: Tab = .feed
126+ @State private var isFeedSearchVisible = false
127+ @State private var isHistorySearchVisible = false
128
129 var body: some View {
130 if settings.configuration == nil {
131@@ -9,20 +19,22 @@ struct ContentView: View {
132 SettingsView(settings: settings)
133 }
134 } else {
135- TabView {
136+ TabView(selection: $selectedTab) {
137 NavigationStack {
138- FeedView(settings: settings)
139+ FeedView(settings: settings, isSearchVisible: $isFeedSearchVisible)
140 }
141 .tabItem {
142 Label("Feed", systemImage: "play.rectangle")
143 }
144+ .tag(Tab.feed)
145
146 NavigationStack {
147- HistoryView(settings: settings)
148+ HistoryView(settings: settings, isSearchVisible: $isHistorySearchVisible)
149 }
150 .tabItem {
151 Label("History", systemImage: "clock")
152 }
153+ .tag(Tab.history)
154
155 NavigationStack {
156 ChannelsView(settings: settings)
157@@ -30,13 +42,28 @@ struct ContentView: View {
158 .tabItem {
159 Label("Channels", systemImage: "rectangle.stack")
160 }
161+ .tag(Tab.channels)
162
163- NavigationStack {
164- SettingsView(settings: settings)
165+ Color.clear
166+ .tabItem {
167+ Label("Search", systemImage: "magnifyingglass")
168+ }
169+ .tag(Tab.search)
170+ }
171+ .onChange(of: selectedTab) { previousTab, selectedTab in
172+ guard selectedTab == .search else {
173+ return
174 }
175- .tabItem {
176- Label("Settings", systemImage: "gear")
177+
178+ switch previousTab {
179+ case .feed:
180+ isFeedSearchVisible = true
181+ case .history:
182+ isHistorySearchVisible = true
183+ case .channels, .search:
184+ break
185 }
186+ self.selectedTab = previousTab
187 }
188 }
189 }
190diff --git a/Sources/ytrssil/FeedView.swift b/Sources/ytrssil/FeedView.swift
191index 482d64aee3f027146c623a53d5abb6a5026f0240..d2669f03d14830c12f99443d3bf6fe653ca9dbe1 100644
192--- a/Sources/ytrssil/FeedView.swift
193+++ b/Sources/ytrssil/FeedView.swift
194@@ -6,18 +6,19 @@ import UIKit
195
196 struct FeedView: View {
197 let settings: SettingsStore
198+ @Binding var isSearchVisible: Bool
199
200 @Namespace private var glassNamespace
201 @State private var videos: [Video] = []
202 @State private var isLoading = false
203 @State private var isFetching = false
204 @State private var isAddVideoPresented = false
205- @State private var isSearchVisible = false
206 @State private var searchText = ""
207 @State private var alertMessage: String?
208
209 var body: some View {
210- List {
211+ GlassEffectContainer {
212+ List {
213 if isLoading && videos.isEmpty {
214 ProgressView()
215 .padding(32)
216@@ -70,21 +71,13 @@ struct FeedView: View {
217 }
218 .listStyle(.plain)
219 .scrollContentBackground(.hidden)
220+ }
221 .toolbar {
222 if isSearchVisible {
223 ToolbarItem(placement: .topBarLeading) {
224 SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
225 }
226 } else {
227- ToolbarItem(placement: .topBarLeading) {
228- Button {
229- isSearchVisible = true
230- } label: {
231- Image(systemName: "magnifyingglass")
232- }
233- .accessibilityLabel("Search videos")
234- }
235-
236 ToolbarItemGroup(placement: .topBarTrailing) {
237 Button {
238 Task {
239@@ -117,6 +110,13 @@ struct FeedView: View {
240 }
241 .accessibilityLabel("Fetch videos from server")
242 .disabled(isLoading || isFetching)
243+
244+ NavigationLink {
245+ SettingsView(settings: settings)
246+ } label: {
247+ Image(systemName: "gear")
248+ }
249+ .accessibilityLabel("Settings")
250 }
251 }
252 }
253@@ -248,16 +248,14 @@ struct FeedView: View {
254 return
255 }
256
257- withAnimation(.easeInOut) {
258- _ = videos.remove(at: index)
259- }
260+ videos.remove(at: index)
261
262 Task {
263 do {
264 try await APIClient().markWatched(videoID: video.videoID, using: configuration)
265 } catch {
266- withAnimation(.easeInOut) {
267- videos.insert(video, at: index)
268+ if !videos.contains(where: { $0.id == video.id }) {
269+ videos.insert(video, at: min(index, videos.count))
270 }
271 alertMessage = error.localizedDescription
272 }
273diff --git a/Sources/ytrssil/HistoryView.swift b/Sources/ytrssil/HistoryView.swift
274index 63653dc96648ad125bd45f4000e269b8d38fb05d..6356388981a1679cc73be9b77af1f1c03d224761 100644
275--- a/Sources/ytrssil/HistoryView.swift
276+++ b/Sources/ytrssil/HistoryView.swift
277@@ -2,11 +2,11 @@ import SwiftUI
278
279 struct HistoryView: View {
280 let settings: SettingsStore
281+ @Binding var isSearchVisible: Bool
282
283 @Namespace private var glassNamespace
284 @State private var videos: [Video] = []
285 @State private var page = 1
286- @State private var isSearchVisible = false
287 @State private var searchText = ""
288 @State private var isLoading = false
289 @State private var alertMessage: String?
290@@ -16,7 +16,8 @@ struct HistoryView: View {
291 }
292
293 var body: some View {
294- List {
295+ GlassEffectContainer {
296+ List {
297 if isLoading && videos.isEmpty {
298 ProgressView()
299 .padding(32)
300@@ -28,6 +29,10 @@ struct HistoryView: View {
301 )
302 .frame(maxWidth: .infinity)
303 .padding(.vertical, 80)
304+
305+ if page > 1 {
306+ pageControls
307+ }
308 } else if displayedVideos.isEmpty {
309 ContentUnavailableView.search(text: searchText)
310 .frame(maxWidth: .infinity)
311@@ -59,7 +64,8 @@ struct HistoryView: View {
312 }
313 }
314 .listStyle(.plain)
315- .scrollContentBackground(.hidden)
316+ .scrollContentBackground(.hidden)
317+ }
318 .navigationTitle(isSearchVisible ? "" : "History")
319 .toolbar {
320 if isSearchVisible {
321@@ -67,16 +73,7 @@ struct HistoryView: View {
322 SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
323 }
324 } else {
325- ToolbarItem(placement: .topBarLeading) {
326- Button {
327- isSearchVisible = true
328- } label: {
329- Image(systemName: "magnifyingglass")
330- }
331- .accessibilityLabel("Search videos")
332- }
333-
334- ToolbarItem(placement: .topBarTrailing) {
335+ ToolbarItemGroup(placement: .topBarTrailing) {
336 Button {
337 Task {
338 await reload()
339@@ -86,6 +83,13 @@ struct HistoryView: View {
340 }
341 .accessibilityLabel("Refresh history")
342 .disabled(isLoading)
343+
344+ NavigationLink {
345+ SettingsView(settings: settings)
346+ } label: {
347+ Image(systemName: "gear")
348+ }
349+ .accessibilityLabel("Settings")
350 }
351 }
352 }
353@@ -198,8 +202,10 @@ struct HistoryView: View {
354 try await APIClient().markUnwatched(videoID: video.videoID, using: configuration)
355 await reload()
356 } catch {
357- withAnimation(.easeInOut) {
358- videos.insert(video, at: index)
359+ if !videos.contains(where: { $0.id == video.id }) {
360+ withAnimation(.easeInOut) {
361+ videos.insert(video, at: min(index, videos.count))
362+ }
363 }
364 alertMessage = error.localizedDescription
365 }
366diff --git a/Sources/ytrssil/SettingsView.swift b/Sources/ytrssil/SettingsView.swift
367index 345d69161ae46916f32eecb7da2beef21c9be6c7..3a192a9e2df0d25a41681390647e84470235766c 100644
368--- a/Sources/ytrssil/SettingsView.swift
369+++ b/Sources/ytrssil/SettingsView.swift
370@@ -29,6 +29,7 @@ struct SettingsView: View {
371 }
372 }
373 .navigationTitle("Settings")
374+ .navigationBarTitleDisplayMode(.inline)
375 .alert(
376 "Could not save settings",
377 isPresented: Binding(