044669669791cf7efd53ba4e22dfdae1162dbe02

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

Message

Initial commit

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