044669669791cf7efd53ba4e22dfdae1162dbe02
- 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 b/.agents
2new file mode 120000
3index 0000000000000000000000000000000000000000..7bcb9556370366dca99e9c5cf5e0f12d8c719ac9
4--- /dev/null
5+++ b/.agents
6@@ -0,0 +1 @@
7+../.agents
8diff --git a/.gitignore b/.gitignore
9new file mode 100644
10index 0000000000000000000000000000000000000000..6f0c38179f03318ffba1012904b40d629d7ca461
11--- /dev/null
12+++ b/.gitignore
13@@ -0,0 +1,10 @@
14+.DS_Store
15+/.build
16+/Packages
17+xcuserdata/
18+DerivedData/
19+.swiftpm/configuration/registries.json
20+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
21+.netrc
22+
23+/xtool
24diff --git a/.sourcekit-lsp/config.json b/.sourcekit-lsp/config.json
25new file mode 100644
26index 0000000000000000000000000000000000000000..e7c97382fe2ce6f7ed0a3021cad7410e3bf45818
27--- /dev/null
28+++ b/.sourcekit-lsp/config.json
29@@ -0,0 +1,5 @@
30+{
31+ "swiftPM": {
32+ "swiftSDK": "arm64-apple-ios"
33+ }
34+}
35diff --git a/AGENTS.md b/AGENTS.md
36new file mode 100644
37index 0000000000000000000000000000000000000000..c8402cfe48af1f1e70c7d86753404bb2919dedc1
38--- /dev/null
39+++ b/AGENTS.md
40@@ -0,0 +1,89 @@
41+# ytrssil iOS app
42+
43+## Project
44+
45+This is a SwiftUI client for the self-hosted `ytrssil` service.
46+
47+- Swift package: `Package.swift`
48+- Deployment targets: iOS 26 and macOS 26
49+- App entry point: `Sources/ytrssil/ytrssilApp.swift`
50+- Main tabs: Feed, History, Channels, Settings
51+- Build and install: `xtool dev`
52+
53+Run `xtool dev` after app changes. It builds and installs to the connected device. Do not run production verification or add automated tests unless requested.
54+
55+## Files
56+
57+- `ContentView.swift`: configuration state and tab layout.
58+- `SettingsView.swift`: server URL and API token form.
59+- `SettingsStore.swift`: server URL persistence in `UserDefaults`; token persistence in Keychain.
60+- `ServerConfiguration.swift`: HTTPS-only server URL validation and endpoint URL creation.
61+- `KeychainStore.swift`: Keychain token storage.
62+- `APIClient.swift`: authenticated JSON transport plus video and channel response models.
63+- `FeedView.swift`: unwatched feed, local search, server fetch, custom-video entry, shared video card, thumbnails, progress editing, downloads, and external video opening.
64+- `HistoryView.swift`: paged watched history, local search, and shared video cards.
65+- `ChannelsView.swift`: subscribed channel list, channel subscription, unsubscribe confirmation, Shorts preference, and external channel opening.
66+
67+## Service contract
68+
69+Use the deployed HTTPS service only. Do not add HTTP exceptions, local-server setup, test databases, or seed data.
70+
71+Every API request must set `Authorization` to the exact configured token value. Do not use a `Bearer` prefix or send the token in a query parameter.
72+
73+Current app routes:
74+
75+- `POST /api/fetch`
76+- `POST /api/videos` with `{"video_id":"YouTube URL or ID"}`
77+- `GET /api/videos/new`
78+- `GET /api/videos/watched?page=N`
79+- `GET /api/videos/:video_id`
80+- `POST /api/videos/:video_id/watch`
81+- `POST /api/videos/:video_id/unwatch`
82+- `POST /api/videos/:video_id/progress` with `{"progress":"mm:ss"}`
83+- `POST /api/videos/:video_id/download` with `{"format":720}`
84+- `GET /api/videos/:video_id/file`
85+- `GET /api/channels`
86+- `POST /api/channels/:channel_id/subscribe`
87+- `POST /api/channels/:channel_id/unsubscribe`
88+- `POST /api/channels/:channel_id/shorts?enable=true|false`
89+
90+The progress endpoint accepts Go duration, `mm:ss`, and `hh:mm:ss` values. It returns `{"video": {...}}`.
91+
92diff --git a/Assets/AppIcon.png b/Assets/AppIcon.png
93new file mode 100644
94index 0000000000000000000000000000000000000000..9949d238cca2eefcddad94d0da0ce62da293c76d
95Binary files /dev/null and b/Assets/AppIcon.png differ
96diff --git a/Package.swift b/Package.swift
97new file mode 100644
98index 0000000000000000000000000000000000000000..3fca75f91544b9f4e33e72532867c0c1c6b4e8f7
99--- /dev/null
100+++ b/Package.swift
101@@ -0,0 +1,24 @@
102+// swift-tools-version: 6.2
103+
104+import PackageDescription
105+
106+let package = Package(
107+ name: "ytrssil",
108+ platforms: [
109+ .iOS(.v26),
110+ .macOS(.v26),
111+ ],
112+ products: [
113+ // An xtool project should contain exactly one library product,
114+ // representing the main app.
115+ .library(
116+ name: "ytrssil",
117+ targets: ["ytrssil"]
118+ ),
119+ ],
120+ targets: [
121+ .target(
122+ name: "ytrssil"
123+ ),
124+ ]
125+)
126diff --git a/Resources/AppIcon-20.png b/Resources/AppIcon-20.png
127new file mode 100644
128index 0000000000000000000000000000000000000000..d17a30149c2926ec1c79a01a45069bd652bd80a4
129Binary files /dev/null and b/Resources/AppIcon-20.png differ
130diff --git a/Resources/AppIcon-20@2x.png b/Resources/AppIcon-20@2x.png
131new file mode 100644
132index 0000000000000000000000000000000000000000..b1e652270f8c16c96335afbc2676909935155abe
133Binary files /dev/null and b/Resources/AppIcon-20@2x.png differ
134diff --git a/Resources/AppIcon-20@3x.png b/Resources/AppIcon-20@3x.png
135new file mode 100644
136index 0000000000000000000000000000000000000000..bc7c966fc6606b8e2e31d365ce2086ba304b54be
137Binary files /dev/null and b/Resources/AppIcon-20@3x.png differ
138diff --git a/Resources/AppIcon-29.png b/Resources/AppIcon-29.png
139new file mode 100644
140index 0000000000000000000000000000000000000000..15a7f62040a0e79704386eab3734ebfac31babff
141Binary files /dev/null and b/Resources/AppIcon-29.png differ
142diff --git a/Resources/AppIcon-29@2x.png b/Resources/AppIcon-29@2x.png
143new file mode 100644
144index 0000000000000000000000000000000000000000..9d04bb218f5077fcbd6bbb5ea3e6cf8c9360f7af
145Binary files /dev/null and b/Resources/AppIcon-29@2x.png differ
146diff --git a/Resources/AppIcon-29@3x.png b/Resources/AppIcon-29@3x.png
147new file mode 100644
148index 0000000000000000000000000000000000000000..121b2c732c779e7a13fcb4bfc0aacfccd23b5313
149Binary files /dev/null and b/Resources/AppIcon-29@3x.png differ
150diff --git a/Resources/AppIcon-40.png b/Resources/AppIcon-40.png
151new file mode 100644
152index 0000000000000000000000000000000000000000..4ef1366752bac5cdf909061eebc05c7fa2ffb329
153Binary files /dev/null and b/Resources/AppIcon-40.png differ
154diff --git a/Resources/AppIcon-40@2x.png b/Resources/AppIcon-40@2x.png
155new file mode 100644
156index 0000000000000000000000000000000000000000..c8de976cbd025b95ff9be99e79c1c595e310f79f
157Binary files /dev/null and b/Resources/AppIcon-40@2x.png differ
158diff --git a/Resources/AppIcon-40@3x.png b/Resources/AppIcon-40@3x.png
159new file mode 100644
160index 0000000000000000000000000000000000000000..250c35b2677e835ff73946e4049d4501e3126678
161Binary files /dev/null and b/Resources/AppIcon-40@3x.png differ
162diff --git a/Resources/AppIcon-60@2x.png b/Resources/AppIcon-60@2x.png
163new file mode 100644
164index 0000000000000000000000000000000000000000..250c35b2677e835ff73946e4049d4501e3126678
165Binary files /dev/null and b/Resources/AppIcon-60@2x.png differ
166diff --git a/Resources/AppIcon-60@3x.png b/Resources/AppIcon-60@3x.png
167new file mode 100644
168index 0000000000000000000000000000000000000000..3cf9430fbf58ef0a7caf372c5612b6ed7b83fb39
169Binary files /dev/null and b/Resources/AppIcon-60@3x.png differ
170diff --git a/Resources/AppIcon-76.png b/Resources/AppIcon-76.png
171new file mode 100644
172index 0000000000000000000000000000000000000000..323366e3122ba927ec8dbc3a46e989721207e788
173Binary files /dev/null and b/Resources/AppIcon-76.png differ
174diff --git a/Resources/AppIcon-76@2x.png b/Resources/AppIcon-76@2x.png
175new file mode 100644
176index 0000000000000000000000000000000000000000..0265f48b08786edee31ba04b2e9b065b38ebe9cf
177Binary files /dev/null and b/Resources/AppIcon-76@2x.png differ
178diff --git a/Resources/AppIcon-83.5@2x.png b/Resources/AppIcon-83.5@2x.png
179new file mode 100644
180index 0000000000000000000000000000000000000000..4cef7064f5f5932e887e52bae9a8632eaf39631e
181Binary files /dev/null and b/Resources/AppIcon-83.5@2x.png differ
182diff --git a/Resources/AppIcon.png b/Resources/AppIcon.png
183new file mode 100644
184index 0000000000000000000000000000000000000000..5edb2ea1c89c39f89b8107be17725f744e79b7ae
185Binary files /dev/null and b/Resources/AppIcon.png differ
186diff --git a/Resources/Info.plist b/Resources/Info.plist
187new file mode 100644
188index 0000000000000000000000000000000000000000..b1c5fc3f37391ca2b3140dc9495dfa8ceaca441b
189--- /dev/null
190+++ b/Resources/Info.plist
191@@ -0,0 +1,33 @@
192+<?xml version="1.0" encoding="UTF-8"?>
193+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
194+<plist version="1.0">
195+<dict>
196+ <key>CFBundleIcons</key>
197+ <dict>
198+ <key>CFBundlePrimaryIcon</key>
199+ <dict>
200+ <key>CFBundleIconFiles</key>
201+ <array>
202+ <string>AppIcon-20</string>
203+ <string>AppIcon-29</string>
204+ <string>AppIcon-40</string>
205+ <string>AppIcon-60</string>
206+ </array>
207+ </dict>
208+ </dict>
209+ <key>CFBundleIcons~ipad</key>
210+ <dict>
211+ <key>CFBundlePrimaryIcon</key>
212+ <dict>
213+ <key>CFBundleIconFiles</key>
214+ <array>
215+ <string>AppIcon-20</string>
216+ <string>AppIcon-29</string>
217+ <string>AppIcon-40</string>
218+ <string>AppIcon-76</string>
219+ <string>AppIcon-83.5</string>
220+ </array>
221+ </dict>
222+ </dict>
223+</dict>
224+</plist>
225diff --git a/Sources/ytrssil/APIClient.swift b/Sources/ytrssil/APIClient.swift
226new file mode 100644
227index 0000000000000000000000000000000000000000..32bfbdbc913cb6a172704f66ee484a19d2c4826f
228--- /dev/null
229+++ b/Sources/ytrssil/APIClient.swift
230@@ -0,0 +1,343 @@
231+import Foundation
232+
233+struct Video: Decodable, Identifiable, Sendable {
234+ let videoID: String
235+ let channelName: String
236+ let title: String
237+ let publishedTimestamp: Date
238+ let watchTimestamp: Date?
239+ let duration: Int
240+ let progress: Int
241+ let isShort: Bool
242+ let isLive: Bool
243+ let downloadedAt: Date?
244+ let downloadStatus: String?
245+ let downloadError: String?
246+
247+ var id: String { videoID }
248+
249+ enum CodingKeys: String, CodingKey {
250+ case videoID = "video_id"
251+ case channelName = "channel_name"
252+ case title
253+ case publishedTimestamp = "published_timestamp"
254+ case watchTimestamp = "watch_timestamp"
255+ case duration
256+ case progress
257+ case isShort = "short"
258+ case isLive = "is_live"
259+ case downloadedAt = "downloaded_at"
260+ case downloadStatus = "download_status"
261+ case downloadError = "download_error"
262+ }
263+}
264+
265+struct VideoListResponse: Decodable, Sendable {
266+ let videos: [Video]
267+}
268+
269+struct Channel: Decodable, Identifiable, Sendable {
270+ let channelID: String
271+ let name: String
272+ let subscribed: Bool
273+ let unwatchedCount: Int
274+ let imageURL: String
275+ var enableShorts: Bool
276+
277+ var id: String { channelID }
278+
279+ enum CodingKeys: String, CodingKey {
280+ case channelID = "channel_id"
281+ case name
282+ case subscribed
283+ case unwatchedCount = "unwatched_count"
284+ case imageURL = "image_url"
285+ case enableShorts = "enable_shorts"
286+ }
287+}
288+
289+private struct ChannelListResponse: Decodable, Sendable {
290+ let channels: [Channel]
291+}
292+
293+struct APIClient: Sendable {
294+ func fetchNewVideos(using configuration: ServerConfiguration) async throws -> [Video] {
295+ let response: VideoListResponse = try await request(
296+ endpoint: configuration.endpoint(path: "api/videos/new"),
297+ method: "GET",
298+ configuration: configuration
299+ )
300+ return response.videos
301+ }
302+
303+ func fetchWatchedVideos(page: Int, using configuration: ServerConfiguration) async throws -> [Video] {
304+ var components = URLComponents(url: configuration.endpoint(path: "api/videos/watched"), resolvingAgainstBaseURL: false)
305+ components?.queryItems = [URLQueryItem(name: "page", value: String(max(page, 1)))]
306+
307+ guard let endpoint = components?.url else {
308+ throw APIError.invalidURL
309+ }
310+
311+ let response: VideoListResponse = try await request(
312+ endpoint: endpoint,
313+ method: "GET",
314+ configuration: configuration
315+ )
316+ return response.videos
317+ }
318+
319+ func fetchVideos(using configuration: ServerConfiguration) async throws {
320+ let _: MessageResponse = try await request(
321+ endpoint: configuration.endpoint(path: "api/fetch"),
322+ method: "POST",
323+ configuration: configuration
324+ )
325+ }
326+
327+ func addCustomVideo(videoID: String, using configuration: ServerConfiguration) async throws {
328+ let requestBody = AddVideoRequest(
329+ videoID: videoID.trimmingCharacters(in: .whitespacesAndNewlines)
330diff --git a/Sources/ytrssil/ChannelsView.swift b/Sources/ytrssil/ChannelsView.swift
331new file mode 100644
332index 0000000000000000000000000000000000000000..6be96acd2618e84f2faa23ddc850e5bea2a85ed1
333--- /dev/null
334+++ b/Sources/ytrssil/ChannelsView.swift
335@@ -0,0 +1,329 @@
336+import SwiftUI
337+
338+struct ChannelsView: View {
339+ let settings: SettingsStore
340+
341+ @Environment(\.openURL) private var openURL
342+ @State private var channels: [Channel] = []
343+ @State private var isLoading = false
344+ @State private var isSubscribeSheetPresented = false
345+ @State private var channelToUnsubscribe: Channel?
346+ @State private var alertMessage: String?
347+
348+ var body: some View {
349+ List {
350+ if isLoading && channels.isEmpty {
351+ HStack {
352+ Spacer()
353+ ProgressView()
354+ .padding()
355+ Spacer()
356+ }
357+ .listRowSeparator(.hidden)
358+ } else if channels.isEmpty {
359+ ContentUnavailableView(
360+ "No channels",
361+ systemImage: "rectangle.stack.badge.plus",
362+ description: Text("Subscribe to a YouTube channel to fetch its videos.")
363+ )
364+ .frame(maxWidth: .infinity)
365+ .padding(.vertical, 80)
366+ .listRowSeparator(.hidden)
367+ } else {
368+ ForEach(channels) { channel in
369+ ChannelRow(
370+ channel: channel,
371+ openChannel: { openChannel(channel) },
372+ setShortsEnabled: { enabled in
373+ await setShortsEnabled(enabled, for: channel)
374+ }
375+ )
376+ .transition(.opacity)
377+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
378+ Button {
379+ channelToUnsubscribe = channel
380+ } label: {
381+ Label("Unsubscribe", systemImage: "minus.circle")
382+ }
383+ .tint(.red)
384+ }
385+ .confirmationDialog(
386+ "Unsubscribe from \(channel.name)?",
387+ isPresented: Binding(
388+ get: { channelToUnsubscribe?.id == channel.id },
389+ set: { if !$0 { channelToUnsubscribe = nil } }
390+ ),
391+ titleVisibility: .visible
392+ ) {
393+ Button("Unsubscribe", role: .destructive) {
394+ unsubscribe(from: channel)
395+ }
396+ Button("Cancel", role: .cancel) {}
397+ } message: {
398+ Text("Future server fetches will not include this channel. Existing videos remain available.")
399+ }
400+ }
401+ }
402+ }
403+ .navigationTitle("Channels")
404+ .toolbar {
405+ ToolbarItemGroup(placement: .topBarTrailing) {
406+ Button {
407+ isSubscribeSheetPresented = true
408+ } label: {
409+ Image(systemName: "plus")
410+ }
411+ .accessibilityLabel("Subscribe to channel")
412+ .disabled(isLoading)
413+
414+ Button {
415+ Task {
416+ await reload()
417+ }
418+ } label: {
419+ Image(systemName: "arrow.clockwise")
420+ }
421+ .accessibilityLabel("Refresh channels")
422+ .disabled(isLoading)
423+ }
424+ }
425+ .sheet(isPresented: $isSubscribeSheetPresented) {
426+ SubscribeChannelSheet { channelID in
427+ try await subscribe(to: channelID)
428+ }
429+ }
430+ .refreshable {
431+ await reload()
432+ }
433+ .task(id: settings.serverURL + settings.token) {
434+ await reload()
435diff --git a/Sources/ytrssil/ContentView.swift b/Sources/ytrssil/ContentView.swift
436new file mode 100644
437index 0000000000000000000000000000000000000000..9810f4d0c268f3fbae29bbf858be53ca73244631
438--- /dev/null
439+++ b/Sources/ytrssil/ContentView.swift
440@@ -0,0 +1,43 @@
441+import SwiftUI
442+
443+struct ContentView: View {
444+ @State private var settings = SettingsStore()
445+
446+ var body: some View {
447+ if settings.configuration == nil {
448+ NavigationStack {
449+ SettingsView(settings: settings)
450+ }
451+ } else {
452+ TabView {
453+ NavigationStack {
454+ FeedView(settings: settings)
455+ }
456+ .tabItem {
457+ Label("Feed", systemImage: "play.rectangle")
458+ }
459+
460+ NavigationStack {
461+ HistoryView(settings: settings)
462+ }
463+ .tabItem {
464+ Label("History", systemImage: "clock")
465+ }
466+
467+ NavigationStack {
468+ ChannelsView(settings: settings)
469+ }
470+ .tabItem {
471+ Label("Channels", systemImage: "rectangle.stack")
472+ }
473+
474+ NavigationStack {
475+ SettingsView(settings: settings)
476+ }
477+ .tabItem {
478+ Label("Settings", systemImage: "gear")
479+ }
480+ }
481+ }
482+ }
483+}
484diff --git a/Sources/ytrssil/FeedView.swift b/Sources/ytrssil/FeedView.swift
485new file mode 100644
486index 0000000000000000000000000000000000000000..482d64aee3f027146c623a53d5abb6a5026f0240
487--- /dev/null
488+++ b/Sources/ytrssil/FeedView.swift
489@@ -0,0 +1,956 @@
490+import SwiftUI
491+
492+#if os(iOS)
493+import UIKit
494+#endif
495+
496+struct FeedView: View {
497+ let settings: SettingsStore
498+
499+ @Namespace private var glassNamespace
500+ @State private var videos: [Video] = []
501+ @State private var isLoading = false
502+ @State private var isFetching = false
503+ @State private var isAddVideoPresented = false
504+ @State private var isSearchVisible = false
505+ @State private var searchText = ""
506+ @State private var alertMessage: String?
507+
508+ var body: some View {
509+ List {
510+ if isLoading && videos.isEmpty {
511+ ProgressView()
512+ .padding(32)
513+ } else if videos.isEmpty {
514+ ContentUnavailableView(
515+ "No unwatched videos",
516+ systemImage: "play.rectangle",
517+ description: Text("Refresh the feed or fetch videos from the server.")
518+ )
519+ .frame(maxWidth: .infinity)
520+ .padding(.vertical, 80)
521+ } else if displayedVideos.isEmpty {
522+ ContentUnavailableView.search(text: searchText)
523+ .frame(maxWidth: .infinity)
524+ .padding(.vertical, 80)
525+ } else {
526+ ForEach(displayedVideos) { video in
527+ VideoCard(
528+ video: video,
529+ tint: .blue,
530+ updateProgress: { progress in
531+ try await updateProgress(for: video, to: progress)
532+ },
533+ downloadActions: VideoDownloadActions(
534+ start: { format in
535+ try await startDownload(for: video, format: format)
536+ },
537+ poll: {
538+ try await pollDownload(for: video)
539+ },
540+ export: {
541+ try await downloadFile(for: video)
542+ }
543+ ),
544+ glassNamespace: glassNamespace
545+ )
546+ .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
547+ .listRowSeparator(.hidden)
548+ .listRowBackground(Color.clear)
549+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
550+ Button(role: .destructive) {
551+ markWatched(video)
552+ } label: {
553+ Label("Done", systemImage: "checkmark")
554+ }
555+ .tint(.blue)
556+ }
557+ }
558+ }
559+ }
560+ .listStyle(.plain)
561+ .scrollContentBackground(.hidden)
562+ .toolbar {
563+ if isSearchVisible {
564+ ToolbarItem(placement: .topBarLeading) {
565+ SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
566+ }
567+ } else {
568+ ToolbarItem(placement: .topBarLeading) {
569+ Button {
570+ isSearchVisible = true
571+ } label: {
572+ Image(systemName: "magnifyingglass")
573+ }
574+ .accessibilityLabel("Search videos")
575+ }
576+
577+ ToolbarItemGroup(placement: .topBarTrailing) {
578+ Button {
579+ Task {
580+ await reload()
581+ }
582+ } label: {
583+ Image(systemName: "arrow.clockwise")
584+ }
585+ .accessibilityLabel("Refresh feed")
586+ .disabled(isLoading || isFetching)
587+
588+ Button {
589diff --git a/Sources/ytrssil/HistoryView.swift b/Sources/ytrssil/HistoryView.swift
590new file mode 100644
591index 0000000000000000000000000000000000000000..63653dc96648ad125bd45f4000e269b8d38fb05d
592--- /dev/null
593+++ b/Sources/ytrssil/HistoryView.swift
594@@ -0,0 +1,208 @@
595+import SwiftUI
596+
597+struct HistoryView: View {
598+ let settings: SettingsStore
599+
600+ @Namespace private var glassNamespace
601+ @State private var videos: [Video] = []
602+ @State private var page = 1
603+ @State private var isSearchVisible = false
604+ @State private var searchText = ""
605+ @State private var isLoading = false
606+ @State private var alertMessage: String?
607+
608+ private var canShowNextPage: Bool {
609+ videos.count == 100
610+ }
611+
612+ var body: some View {
613+ List {
614+ if isLoading && videos.isEmpty {
615+ ProgressView()
616+ .padding(32)
617+ } else if videos.isEmpty {
618+ ContentUnavailableView(
619+ "No watched videos",
620+ systemImage: "clock",
621+ description: Text("Videos that you mark as watched appear here.")
622+ )
623+ .frame(maxWidth: .infinity)
624+ .padding(.vertical, 80)
625+ } else if displayedVideos.isEmpty {
626+ ContentUnavailableView.search(text: searchText)
627+ .frame(maxWidth: .infinity)
628+ .padding(.vertical, 80)
629+ } else {
630+ ForEach(displayedVideos) { video in
631+ VideoCard(
632+ video: video,
633+ tint: .orange,
634+ updateProgress: { progress in
635+ try await updateProgress(for: video, to: progress)
636+ },
637+ glassNamespace: glassNamespace
638+ )
639+ .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
640+ .listRowSeparator(.hidden)
641+ .listRowBackground(Color.clear)
642+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
643+ Button(role: .destructive) {
644+ markUnwatched(video)
645+ } label: {
646+ Label("Mark Unwatched", systemImage: "arrow.uturn.backward")
647+ }
648+ .tint(.orange)
649+ }
650+ }
651+
652+ pageControls
653+ }
654+ }
655+ .listStyle(.plain)
656+ .scrollContentBackground(.hidden)
657+ .navigationTitle(isSearchVisible ? "" : "History")
658+ .toolbar {
659+ if isSearchVisible {
660+ ToolbarItem(placement: .topBarLeading) {
661+ SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
662+ }
663+ } else {
664+ ToolbarItem(placement: .topBarLeading) {
665+ Button {
666+ isSearchVisible = true
667+ } label: {
668+ Image(systemName: "magnifyingglass")
669+ }
670+ .accessibilityLabel("Search videos")
671+ }
672+
673+ ToolbarItem(placement: .topBarTrailing) {
674+ Button {
675+ Task {
676+ await reload()
677+ }
678+ } label: {
679+ Image(systemName: "arrow.clockwise")
680+ }
681+ .accessibilityLabel("Refresh history")
682+ .disabled(isLoading)
683+ }
684+ }
685+ }
686+ .refreshable {
687+ await reload()
688+ }
689+ .task(id: settings.serverURL + settings.token) {
690+ page = 1
691+ await reload()
692+ }
693+ .alert(
694diff --git a/Sources/ytrssil/KeychainStore.swift b/Sources/ytrssil/KeychainStore.swift
695new file mode 100644
696index 0000000000000000000000000000000000000000..f0fc34ceb6e28bd029c15149f8fba0fb773fbdec
697--- /dev/null
698+++ b/Sources/ytrssil/KeychainStore.swift
699@@ -0,0 +1,60 @@
700+import Foundation
701+import Security
702+
703+enum KeychainStore {
704+ static func load(account: String) throws -> String? {
705+ let query = baseQuery(account: account).merging([
706+ kSecReturnData as String: true,
707+ kSecMatchLimit as String: kSecMatchLimitOne,
708+ ]) { _, new in new }
709+
710+ var result: CFTypeRef?
711+ let status = SecItemCopyMatching(query as CFDictionary, &result)
712+
713+ if status == errSecItemNotFound {
714+ return nil
715+ }
716+
717+ guard status == errSecSuccess,
718+ let data = result as? Data,
719+ let value = String(data: data, encoding: .utf8) else {
720+ throw KeychainError.unavailable(status)
721+ }
722+
723+ return value
724+ }
725+
726+ static func save(_ value: String, account: String) throws {
727+ let query = baseQuery(account: account)
728+ let attributes = [kSecValueData as String: Data(value.utf8)]
729+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
730+
731+ if updateStatus == errSecItemNotFound {
732+ let addStatus = SecItemAdd(query.merging(attributes) { _, new in new } as CFDictionary, nil)
733+ guard addStatus == errSecSuccess else {
734+ throw KeychainError.unavailable(addStatus)
735+ }
736+ return
737+ }
738+
739+ guard updateStatus == errSecSuccess else {
740+ throw KeychainError.unavailable(updateStatus)
741+ }
742+ }
743+
744+ private static func baseQuery(account: String) -> [String: Any] {
745+ [
746+ kSecClass as String: kSecClassGenericPassword,
747+ kSecAttrService as String: "com.theedgeofrage.ytrssil",
748+ kSecAttrAccount as String: account,
749+ ]
750+ }
751+}
752+
753+enum KeychainError: LocalizedError {
754+ case unavailable(OSStatus)
755+
756+ var errorDescription: String? {
757+ "The API token could not be stored securely."
758+ }
759+}
760diff --git a/Sources/ytrssil/ServerConfiguration.swift b/Sources/ytrssil/ServerConfiguration.swift
761new file mode 100644
762index 0000000000000000000000000000000000000000..caa36dda5bcb670c1c89dbfa3d65bed04e0a7068
763--- /dev/null
764+++ b/Sources/ytrssil/ServerConfiguration.swift
765@@ -0,0 +1,46 @@
766+import Foundation
767+
768+struct ServerConfiguration: Sendable {
769+ let baseURL: URL
770+ let token: String
771+
772+ init(serverURL: String, token: String) throws {
773+ let trimmedServerURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
774+
775+ guard !trimmedServerURL.isEmpty,
776+ let url = URL(string: trimmedServerURL),
777+ url.scheme?.lowercased() == "https",
778+ url.host != nil,
779+ url.user == nil,
780+ url.password == nil,
781+ url.query == nil,
782+ url.fragment == nil else {
783+ throw ConfigurationError.invalidServerURL
784+ }
785+
786+ guard !token.isEmpty else {
787+ throw ConfigurationError.missingToken
788+ }
789+
790+ baseURL = url
791+ self.token = token
792+ }
793+
794+ func endpoint(path: String) -> URL {
795+ baseURL.appendingPathComponent(path)
796+ }
797+}
798+
799+enum ConfigurationError: LocalizedError {
800+ case invalidServerURL
801+ case missingToken
802+
803+ var errorDescription: String? {
804+ switch self {
805+ case .invalidServerURL:
806+ "Enter a valid HTTPS server URL."
807+ case .missingToken:
808+ "Enter the API token."
809+ }
810+ }
811+}
812diff --git a/Sources/ytrssil/SettingsStore.swift b/Sources/ytrssil/SettingsStore.swift
813new file mode 100644
814index 0000000000000000000000000000000000000000..b744cfb39cf3876c4e38ad3284fa177bb5e88226
815--- /dev/null
816+++ b/Sources/ytrssil/SettingsStore.swift
817@@ -0,0 +1,33 @@
818+import Foundation
819+import Observation
820+
821+@MainActor
822+@Observable
823+final class SettingsStore {
824+ private static let serverURLKey = "serverURL"
825+ private static let tokenAccount = "apiToken"
826+
827+ private let defaults: UserDefaults
828+ private(set) var serverURL: String
829+ private(set) var token: String
830+
831+ init(defaults: UserDefaults = .standard) {
832+ self.defaults = defaults
833+ serverURL = defaults.string(forKey: Self.serverURLKey) ?? ""
834+ token = (try? KeychainStore.load(account: Self.tokenAccount)) ?? ""
835+ }
836+
837+ var configuration: ServerConfiguration? {
838+ try? ServerConfiguration(serverURL: serverURL, token: token)
839+ }
840+
841+ func save(serverURL: String, token: String) throws {
842+ let configuration = try ServerConfiguration(serverURL: serverURL, token: token)
843+
844+ try KeychainStore.save(configuration.token, account: Self.tokenAccount)
845+ defaults.set(configuration.baseURL.absoluteString, forKey: Self.serverURLKey)
846+
847+ self.serverURL = configuration.baseURL.absoluteString
848+ self.token = configuration.token
849+ }
850+}
851diff --git a/Sources/ytrssil/SettingsView.swift b/Sources/ytrssil/SettingsView.swift
852new file mode 100644
853index 0000000000000000000000000000000000000000..345d69161ae46916f32eecb7da2beef21c9be6c7
854--- /dev/null
855+++ b/Sources/ytrssil/SettingsView.swift
856@@ -0,0 +1,80 @@
857+import SwiftUI
858+
859+struct SettingsView: View {
860+ @Bindable var settings: SettingsStore
861+
862+ @State private var serverURL: String
863+ @State private var token: String
864+ @State private var errorMessage: String?
865+
866+ init(settings: SettingsStore) {
867+ self.settings = settings
868+ _serverURL = State(initialValue: settings.serverURL)
869+ _token = State(initialValue: settings.token)
870+ }
871+
872+ var body: some View {
873+ Form {
874+ Section("Server") {
875+ serverURLField
876+
877+ tokenField
878+ }
879+
880+ Section {
881+ Button("Save settings", action: save)
882+ .buttonStyle(.glassProminent)
883+ } footer: {
884+ Text("The app connects only to HTTPS servers. The token is stored in the device keychain.")
885+ }
886+ }
887+ .navigationTitle("Settings")
888+ .alert(
889+ "Could not save settings",
890+ isPresented: Binding(
891+ get: { errorMessage != nil },
892+ set: { if !$0 { errorMessage = nil } }
893+ )
894+ ) {
895+ Button("OK", role: .cancel) {}
896+ } message: {
897+ Text(errorMessage ?? "")
898+ }
899+ }
900+
901+ private var serverURLField: some View {
902+ let field = TextField("https://ytrssil.example.com", text: $serverURL)
903+
904+#if os(iOS)
905+ return field
906+ .textInputAutocapitalization(.never)
907+ .autocorrectionDisabled()
908+ .keyboardType(.URL)
909+ .textContentType(.URL)
910+#else
911+ return field
912+#endif
913+ }
914+
915+ private var tokenField: some View {
916+ let field = SecureField("API token", text: $token)
917+
918+#if os(iOS)
919+ return field
920+ .textInputAutocapitalization(.never)
921+ .autocorrectionDisabled()
922+#else
923+ return field
924+#endif
925+ }
926+
927+ private func save() {
928+ do {
929+ try settings.save(serverURL: serverURL, token: token)
930+ serverURL = settings.serverURL
931+ token = settings.token
932+ } catch {
933+ errorMessage = error.localizedDescription
934+ }
935+ }
936+}
937diff --git a/Sources/ytrssil/ytrssilApp.swift b/Sources/ytrssil/ytrssilApp.swift
938new file mode 100644
939index 0000000000000000000000000000000000000000..28c581ead63ea3602742921064118db18490f414
940--- /dev/null
941+++ b/Sources/ytrssil/ytrssilApp.swift
942@@ -0,0 +1,10 @@
943+import SwiftUI
944+
945+@main
946+struct ytrssilApp: App {
947+ var body: some Scene {
948+ WindowGroup {
949+ ContentView()
950+ }
951+ }
952+}
953diff --git a/xtool.yml b/xtool.yml
954new file mode 100644
955index 0000000000000000000000000000000000000000..c0c5522d302e260e547120d89574fbc1abc27355
956--- /dev/null
957+++ b/xtool.yml
958@@ -0,0 +1,19 @@
959+version: 1
960+bundleID: com.theedgeofrage.ytrssil
961+iconPath: Resources/AppIcon.png
962+infoPath: Resources/Info.plist
963+resources:
964+- Resources/AppIcon-20.png
965+- Resources/AppIcon-20@2x.png
966+- Resources/AppIcon-20@3x.png
967+- Resources/AppIcon-29.png
968+- Resources/AppIcon-29@2x.png
969+- Resources/AppIcon-29@3x.png
970+- Resources/AppIcon-40.png
971+- Resources/AppIcon-40@2x.png
972+- Resources/AppIcon-40@3x.png
973+- Resources/AppIcon-60@2x.png
974+- Resources/AppIcon-60@3x.png
975+- Resources/AppIcon-76.png
976+- Resources/AppIcon-76@2x.png
977+- Resources/AppIcon-83.5@2x.png