0c11f75e6227bbeb49001d6af9d9a0898fecb072

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Misc improvements

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(