From 01d8bd936e344f1b14600a508de2459e304a633b Mon Sep 17 00:00:00 2001 From: Ray Slakinski Date: Sun, 20 Sep 2026 09:27:03 -0400 Subject: [PATCH] A native feed list and item list (#3) The lists are native now, and the page is a button in the toolbar: it is still where signing in happens, and it is still the whole of settings, the admin page, the directory and OPML, which were never going to be rewritten. LibraryStore holds a page of items and asks for the next, because the sorting, filtering and searching are the server's work and ten thousand items have no business being in memory to be sorted here. Read and pinned are set locally and sent after, as the page's readWrites map does and for the same reason: a list asked for before the write lands answers with the old state, which put the dot back on an item just read. The interface follows the account's light or dark rather than the phone's, and defaults to dark when nobody has chosen, because that is what ipx's own theme.ts does. Following the system instead put a light list in front of a dark page. preferredColorScheme was not enough on its own -- inside a hosting controller it did not reach the hierarchy -- so the style is overridden on the controller, which also carries to the page presented over it. The sidebar had to be broken into sub-views: the whole list in one expression was more than the type checker would work through, and it said so rather than compiling it. Tested against the real library, 135 feeds and eleven thousand items, and the playback test now drives the native row rather than the page's button. Thirteen pass. Co-Authored-By: Claude Opus 5 --- README.md | 16 +++ ios/Sources/APIModels.swift | 12 ++ ios/Sources/FeedListView.swift | 164 +++++++++++++++++++++++ ios/Sources/ItemListView.swift | 191 +++++++++++++++++++++++++++ ios/Sources/LibraryStore.swift | 179 +++++++++++++++++++++++++ ios/Sources/LibraryView.swift | 36 +++++ ios/Sources/RootViewController.swift | 95 +++++++++++++ ios/Sources/SceneDelegate.swift | 2 +- ios/Sources/WebViewController.swift | 30 +---- ios/UITests/PlaybackTests.swift | 44 +++--- 10 files changed, 725 insertions(+), 44 deletions(-) create mode 100644 ios/Sources/FeedListView.swift create mode 100644 ios/Sources/ItemListView.swift create mode 100644 ios/Sources/LibraryStore.swift create mode 100644 ios/Sources/LibraryView.swift create mode 100644 ios/Sources/RootViewController.swift diff --git a/README.md b/README.md index 29500cf..82cacd0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ project had no such setting and the Mac destination simply did not exist. Set your team under Signing & Capabilities, pick your phone, and run. The project is generated from `project.yml`, so `.xcodeproj` is not in git — edit the yml, not the project. +## What is native and what is the page + +The lists, the player and the car are native. Settings, the admin page, Directory, Popular and +OPML are ipx's own page, opened from the toolbar — they are form-heavy, rarely touched, and they +already work. An item's show notes will stay HTML too, since they are feed-supplied and sanitized +server-side and there is no good native renderer for them. + +That split is the point rather than a stage: native where native is better, the page everywhere +else. The page is also still where signing in happens, and its cookies are what authenticate the +API client and the player. + ## Which server The first launch asks, with `https://ipodderx.sdf1.net` filled in. A bare host gets `https://`, so @@ -86,6 +97,11 @@ For automation, `-ipx.server ` as a launch argument overrides the stored va | `ios/Sources/Events.swift` | the `/api/events` stream, and reconnecting to it | | `ios/Sources/Glass.swift` | the Glass look: materials, palette, the wash | | `ios/Sources/PlayerBarView.swift` | the bar along the bottom | +| `ios/Sources/LibraryStore.swift` | what the lists show, and the optimistic writes | +| `ios/Sources/FeedListView.swift` | the sidebar: places, feeds, OPML folders | +| `ios/Sources/ItemListView.swift` | the item table: filter, sort, search, paging | +| `ios/Sources/LibraryView.swift` | the two beside each other, bar underneath | +| `ios/Sources/RootViewController.swift` | the native interface, with the page a button away | | `ios/Sources/CookieBridge.swift` | `WKHTTPCookieStore` into `HTTPCookieStorage.shared` | | `ios/Sources/ServerSettings.swift` | which server | diff --git a/ios/Sources/APIModels.swift b/ios/Sources/APIModels.swift index 996cacc..d0a74d6 100644 --- a/ios/Sources/APIModels.swift +++ b/ios/Sources/APIModels.swift @@ -96,6 +96,18 @@ extension IPX { /// Unique across feeds; guid alone is not. var id: String { feedId + "\n" + guid } + + /// Memberwise, so a row can be rebuilt with one flag moved. Declaring it keeps the + /// synthesised one, which a decoded type does not otherwise expose. + init(guid: String, feedId: String, title: String?, link: String?, published: Int?, + description: String?, read: Bool, flagged: Bool, image: String?, duration: Int?, + episode: Int?, season: Int?, position: Int, enclosures: [Enclosure]) { + self.guid = guid; self.feedId = feedId; self.title = title; self.link = link + self.published = published; self.description = description; self.read = read + self.flagged = flagged; self.image = image; self.duration = duration + self.episode = episode; self.season = season; self.position = position + self.enclosures = enclosures + } var playable: Enclosure? { enclosures.first(where: \.isPlayable) } enum CodingKeys: String, CodingKey { diff --git a/ios/Sources/FeedListView.swift b/ios/Sources/FeedListView.swift new file mode 100644 index 0000000..f27825c --- /dev/null +++ b/ios/Sources/FeedListView.swift @@ -0,0 +1,164 @@ +import SwiftUI + +/// The sidebar: the places first, then the feeds, with OPML subscriptions as folders. +struct FeedListView: View { + @ObservedObject var store: LibraryStore + @State private var filter = "" + @State private var expanded: Set = [] + + /// Feeds that are not inside an OPML folder, pinned ones first, then by name. + private var loose: [IPX.Feed] { + store.feeds + .filter { $0.group == nil && !isFolder($0) } + .filter(matches) + .sorted { ($0.pinned ? 0 : 1, $0.name.lowercased()) < ($1.pinned ? 0 : 1, $1.name.lowercased()) } + } + + /// An OPML subscription: other feeds name it as their group. + private func isFolder(_ feed: IPX.Feed) -> Bool { + store.feeds.contains { $0.group == feed.id } + } + + private var folders: [IPX.Feed] { + store.feeds.filter(isFolder).sorted { $0.name.lowercased() < $1.name.lowercased() } + } + + private func children(_ folder: IPX.Feed) -> [IPX.Feed] { + store.feeds.filter { $0.group == folder.id }.filter(matches) + .sorted { $0.name.lowercased() < $1.name.lowercased() } + } + + private func matches(_ feed: IPX.Feed) -> Bool { + filter.isEmpty || feed.name.localizedCaseInsensitiveContains(filter) + } + + var body: some View { + List(selection: selection) { + placesSection + feedsSection + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .searchable(text: $filter, placement: .sidebar, prompt: "Filter feeds") + .navigationTitle("iPodderX") + } + + private var selection: Binding { + Binding(get: { store.place }, set: { store.place = $0 ?? .all }) + } + + // Split out because the whole list in one expression is more than the type checker will + // work through: it gave up rather than compiling it. + @ViewBuilder private var placesSection: some View { + Section { + let unread = store.feeds.reduce(0) { $0 + $1.unread } + place(.all, "All Subscriptions", "square.3.layers.3d", count: unread) + place(.listening, "Currently Listening", "headphones", count: nil) + } + } + + @ViewBuilder private var feedsSection: some View { + Section { + ForEach(loose) { feed in row(feed) } + ForEach(folders) { folder in folderRow(folder) } + } + } + + private func folderRow(_ folder: IPX.Feed) -> some View { + DisclosureGroup(isExpanded: expansion(folder.id)) { + ForEach(children(folder)) { row($0) } + } label: { + Label(folder.name, systemImage: "folder").foregroundStyle(Glass.text) + } + } + + private func expansion(_ id: String) -> Binding { + Binding(get: { expanded.contains(id) }, + set: { open in + if open { expanded.insert(id) } else { expanded.remove(id) } + }) + } + + private func place(_ p: LibraryStore.Place, _ title: String, _ symbol: String, count: Int?) -> some View { + Label(title, systemImage: symbol) + .badge(count.map { $0 > 0 ? "\($0)" : "" } ?? "") + .tag(p) + } + + private func row(_ feed: IPX.Feed) -> some View { + HStack(spacing: 10) { + FeedArt(feed: feed, size: 30) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + if feed.pinned { + Image(systemName: "pin.fill").font(.system(size: 8)).foregroundStyle(Glass.accent) + } + Text(feed.name).lineLimit(1).foregroundStyle(Glass.text) + } + Text("\(feed.entries) items · \(feed.downloaded) downloaded") + .font(.caption2).foregroundStyle(Glass.faint).lineLimit(1) + } + Spacer(minLength: 4) + if feed.lastError != nil { + // Something is wrong with the feed itself, which the counts cannot show. + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2).foregroundStyle(Glass.bad) + } + if feed.unread > 0 { + Text("\(feed.unread)") + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Glass.accent.opacity(0.22), in: Capsule()) + .foregroundStyle(Glass.accent) + } + } + .tag(LibraryStore.Place.feed(feed.id)) + } +} + +/// A feed's picture, or its initials. Two letters read as a thing on purpose; an empty grey +/// square reads as something that failed to load. +struct FeedArt: View { + let feed: IPX.Feed? + var size: CGFloat = 30 + @State private var image: UIImage? + @Environment(\.api) private var api + + var body: some View { + Group { + if let image { + Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) + } else { + ZStack { + Rectangle().fill(Glass.accent.opacity(0.18)) + Text(initials) + .font(.system(size: size * 0.38, weight: .semibold)) + .foregroundStyle(Glass.accent) + } + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous)) + .task(id: feed?.image) { + guard let path = feed?.image, !path.isEmpty else { return } + image = await api.image(path) + } + } + + private var initials: String { + let words = (feed?.name ?? "").split(separator: " ").prefix(2) + let letters = words.compactMap(\.first).map(String.init).joined() + return letters.isEmpty ? "?" : letters.uppercased() + } +} + +private struct APIKey: EnvironmentKey { + static let defaultValue = API() +} + +extension EnvironmentValues { + var api: API { + get { self[APIKey.self] } + set { self[APIKey.self] = newValue } + } +} diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift new file mode 100644 index 0000000..f321d3e --- /dev/null +++ b/ios/Sources/ItemListView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +/// The item table: the filter tabs, the rows, and what to do with one. +struct ItemListView: View { + @ObservedObject var store: LibraryStore + @ObservedObject var playback: Playback + var play: (IPX.Entry) -> Void + + var body: some View { + VStack(spacing: 0) { + header + list + } + .background(Glass.Wash()) + .navigationTitle(store.title) + #if !targetEnvironment(macCatalyst) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { toolbar } + .searchable(text: $store.search, prompt: "Search \(store.title)") + } + + // MARK: - header + + private var header: some View { + VStack(spacing: 8) { + Picker("Show", selection: $store.filter) { + ForEach(IPX.Filter.allCases, id: \.self) { f in + Text(f.rawValue.capitalized).tag(f) + } + } + .pickerStyle(.segmented) + .disabled(store.place == .listening) + + HStack(spacing: 6) { + Text(summary).font(.caption).foregroundStyle(Glass.faint) + Spacer() + if store.loading { ProgressView().controlSize(.mini) } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassPane(Glass.sticky) + } + + private var summary: String { + if let failure = store.failure { return failure } + let unread = store.entries.filter { !$0.read }.count + return "\(store.total) items, \(unread) unread on this page" + } + + // MARK: - rows + + private var list: some View { + List(selection: $store.selected) { + ForEach(store.entries) { entry in + row(entry) + .tag(entry.id) + .onAppear { + // Paging by the last row rather than a button: fifty at a time is the + // server's page size, and a long list should not need a tap to continue. + if entry.id == store.entries.last?.id { store.loadMore() } + } + } + if store.canLoadMore { + HStack { Spacer(); ProgressView(); Spacer() } + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await store.refreshFeeds(); store.reload() } + .overlay { if store.entries.isEmpty && !store.loading { empty } } + } + + /// Written out rather than ContentUnavailableView, which is iOS 17 and this still runs on 16. + private var empty: some View { + VStack(spacing: 8) { + Image(systemName: store.search.isEmpty ? "tray" : "magnifyingglass") + .font(.largeTitle).foregroundStyle(Glass.faint) + Text(store.search.isEmpty ? "Nothing here" : "No matches") + .font(.headline).foregroundStyle(Glass.dim) + Text(store.search.isEmpty ? "Nothing in this view yet." + : "Nothing matches \u{201c}\(store.search)\u{201d}.") + .font(.footnote).foregroundStyle(Glass.faint) + } + .multilineTextAlignment(.center) + .padding() + } + + private func row(_ entry: IPX.Entry) -> some View { + HStack(spacing: 10) { + // The unread dot, and the EQ bars in its place for whatever is playing, as the page + // marks the current item. + Group { + if playback.nowPlaying?.guid == entry.guid { + Image(systemName: "waveform").foregroundStyle(Glass.highlight) + } else if !entry.read { + Circle().fill(Glass.highlight).frame(width: 8, height: 8) + } else { + Color.clear.frame(width: 8, height: 8) + } + } + .frame(width: 14) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.title ?? "(untitled)") + .font(.callout.weight(entry.read ? .regular : .semibold)) + .foregroundStyle(entry.read ? Glass.dim : Glass.text) + .lineLimit(2) + Text(subtitle(entry)).font(.caption2).foregroundStyle(Glass.faint).lineLimit(1) + } + + Spacer(minLength: 6) + + if entry.flagged { + Image(systemName: "pin.fill").font(.caption2).foregroundStyle(Glass.accent) + } + if entry.playable != nil { + Button { play(entry) } label: { Image(systemName: "play.circle") } + .buttonStyle(.plain).foregroundStyle(Glass.accent) + .accessibilityLabel("Play") + } + } + .padding(.vertical, 3) + .listRowBackground(Color.clear) + .contentShape(Rectangle()) + .swipeActions(edge: .leading) { + Button { store.setRead(entry, !entry.read) } label: { + Label(entry.read ? "Unread" : "Read", + systemImage: entry.read ? "envelope.badge" : "checkmark") + } + .tint(Glass.accent) + } + .swipeActions(edge: .trailing) { + Button { store.setPinned(entry, !entry.flagged) } label: { + Label(entry.flagged ? "Unpin" : "Pin", systemImage: "pin") + } + .tint(Glass.highlight) + } + } + + /// Feed, episode number, and how much is left if it has been started. + private func subtitle(_ entry: IPX.Entry) -> String { + var bits: [String] = [] + if store.place.feedId == nil, let feed = store.feed(entry.feedId) { bits.append(feed.name) } + let number = [entry.season.map { "S\($0)" }, entry.episode.map { "E\($0)" }] + .compactMap { $0 }.joined() + if !number.isEmpty { bits.append(number) } + if let d = entry.duration, d > 0 { + bits.append(entry.position > 10 ? "\(clock(d - entry.position)) left" : clock(d)) + } + if let published = entry.published { + bits.append(Date(timeIntervalSince1970: TimeInterval(published)) + .formatted(date: .abbreviated, time: .omitted)) + } + return bits.joined(separator: " · ") + } + + private func clock(_ seconds: Int) -> String { + let (h, m, s) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60) + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } + + // MARK: - toolbar + + @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItemGroup { + Button { store.scan() } label: { Image(systemName: "arrow.trianglehead.2.clockwise") } + .help("Check for new items") + Button { store.readAll() } label: { Image(systemName: "checkmark.circle") } + .help("Mark everything here read") + Menu { + Picker("Sort by", selection: $store.sort) { + Text("Published").tag(IPX.Sort.published) + Text("Title").tag(IPX.Sort.title) + Text("Feed").tag(IPX.Sort.feed) + Text("Size").tag(IPX.Sort.size) + Text("Type").tag(IPX.Sort.type) + } + Picker("Order", selection: $store.direction) { + Text("Newest first").tag(IPX.Direction.desc) + Text("Oldest first").tag(IPX.Direction.asc) + } + } label: { + Image(systemName: "arrow.up.arrow.down") + } + .help("Sort") + } + } +} diff --git a/ios/Sources/LibraryStore.swift b/ios/Sources/LibraryStore.swift new file mode 100644 index 0000000..6423d99 --- /dev/null +++ b/ios/Sources/LibraryStore.swift @@ -0,0 +1,179 @@ +import Foundation +import SwiftUI + +/// What the lists are showing, and how it changes. +/// +/// The sorting, filtering and searching are the server's work: `GET /api/entries` takes them all +/// and pages fifty at a time, so this holds a page and asks for the next rather than keeping ten +/// thousand items in memory and sorting them here. +@MainActor +final class LibraryStore: ObservableObject { + /// Where the list is pointed. The places come before any feed, as they do in the page. + enum Place: Hashable { + case all + case listening + case feed(String) + + var feedId: String? { if case .feed(let id) = self { return id } else { return nil } } + } + + @Published private(set) var feeds: [IPX.Feed] = [] + @Published private(set) var entries: [IPX.Entry] = [] + @Published private(set) var total = 0 + @Published private(set) var loading = false + @Published private(set) var failure: String? + /// Light or dark as the account has it, nil for auto. ipx keeps the theme on the user so it + /// follows the person rather than the browser; the app should follow the same choice rather + /// than the phone's, or it disagrees with the page it sits in front of. + @Published private(set) var appearance: ColorScheme? + + @Published var place: Place = .all { didSet { if place != oldValue { reload() } } } + @Published var filter: IPX.Filter = .all { didSet { if filter != oldValue { reload() } } } + @Published var sort: IPX.Sort = .published { didSet { reload() } } + @Published var direction: IPX.Direction = .desc { didSet { reload() } } + @Published var search = "" { didSet { searchChanged() } } + @Published var selected: IPX.Entry.ID? + + private let api: API + private var searchTask: Task? + private var loadTask: Task? + + init(api: API) { self.api = api } + + var canLoadMore: Bool { entries.count < total } + + var selectedEntry: IPX.Entry? { entries.first { $0.id == selected } } + + /// The feed a row belongs to, for its name and art. All Subscriptions mixes them, so a row + /// cannot assume the selected feed is its own. + func feed(_ id: String) -> IPX.Feed? { feeds.first { $0.id == id } } + + var title: String { + switch place { + case .all: return "All Subscriptions" + case .listening: return "Currently Listening" + case .feed(let id): return feed(id)?.name ?? id + } + } + + // MARK: - loading + + func refreshFeeds() async { + appearance = .dark + if let me = try? await api.me() { + switch me.mode { + case "light": appearance = .light + case "auto": appearance = nil // follow the system, as the page does for Auto + // Dark when nobody has chosen, because that is ipx's own default -- theme.ts + // starts at modern/dark rather than at the system. Following the phone here would + // put a light list in front of a dark page. + default: appearance = .dark + } + } + do { + feeds = try await api.feeds() + failure = nil + } catch { + failure = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + } + + func reload() { + loadTask?.cancel() + loadTask = Task { await load(offset: 0) } + } + + func loadMore() { + guard !loading, canLoadMore else { return } + loadTask = Task { await load(offset: entries.count) } + } + + private func load(offset: Int) async { + loading = true + defer { loading = false } + do { + // Currently Listening is not a feed; it is the started-but-unfinished filter, which + // the page reaches through the same route. + let page = try await api.entries( + feed: place.feedId, + filter: place == .listening ? .all : filter, + search: search, + sort: sort, + direction: direction, + offset: offset) + guard !Task.isCancelled else { return } + var rows = offset == 0 ? page.entries : entries + page.entries + if place == .listening { + rows = rows.filter { $0.position > 0 && !$0.read } + } + entries = rows + total = place == .listening ? rows.count : page.total + failure = nil + } catch is CancellationError { + } catch { + failure = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + } + + /// Typing should not fire a request per keystroke, nor wait so long it feels broken. + private func searchChanged() { + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + reload() + } + } + + // MARK: - writing + + /// Read and pinned are set here first and sent after. The page does the same, for the same + /// reason: a list asked for before the write lands answers with the old state, which put the + /// dot back on an item just read. + func setRead(_ entry: IPX.Entry, _ read: Bool) { + replace(entry.id) { $0.with(read: read) } + Task { + do { try await api.setRead(read, feed: entry.feedId, guid: entry.guid) } + catch { replace(entry.id) { $0.with(read: !read) } } + } + } + + func setPinned(_ entry: IPX.Entry, _ pinned: Bool) { + replace(entry.id) { $0.with(flagged: pinned) } + Task { + do { try await api.setPinned(pinned, feed: entry.feedId, guid: entry.guid) } + catch { replace(entry.id) { $0.with(flagged: !pinned) } } + } + } + + func readAll() { + let ids = entries.map(\.id) + for id in ids { replace(id) { $0.with(read: true) } } + Task { + try? await api.readAll(feed: place.feedId) + await refreshFeeds() + } + } + + func scan() { + Task { + try? await api.fetch(feed: place.feedId) + } + } + + private func replace(_ id: IPX.Entry.ID, _ change: (IPX.Entry) -> IPX.Entry) { + guard let at = entries.firstIndex(where: { $0.id == id }) else { return } + entries[at] = change(entries[at]) + } +} + +extension IPX.Entry { + /// A copy with one flag moved. The type is decoded from the server and has no setters, which + /// is deliberate: the only things that change locally are the two this person owns. + func with(read: Bool? = nil, flagged: Bool? = nil) -> IPX.Entry { + IPX.Entry(guid: guid, feedId: feedId, title: title, link: link, published: published, + description: description, read: read ?? self.read, + flagged: flagged ?? self.flagged, image: image, duration: duration, + episode: episode, season: season, position: position, enclosures: enclosures) + } +} diff --git a/ios/Sources/LibraryView.swift b/ios/Sources/LibraryView.swift new file mode 100644 index 0000000..3380049 --- /dev/null +++ b/ios/Sources/LibraryView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// The native interface: feeds beside items, with the player bar under both. +/// +/// What is not here yet has somewhere to go rather than being missing. The item pane is #4 and +/// the management screens are #5; until then the toolbar's Page button opens ipx's own page, +/// which is still the whole app in a web view and can do everything this cannot. +struct LibraryView: View { + @ObservedObject var store: LibraryStore + @ObservedObject var playback: Playback + var play: (IPX.Entry) -> Void + var openPage: () -> Void + + var body: some View { + NavigationSplitView { + FeedListView(store: store) + .background(Glass.Wash()) + } detail: { + ItemListView(store: store, playback: playback, play: play) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: openPage) { Image(systemName: "safari") } + .help("Open the full page: settings, the directory, OPML") + } + } + } + .tint(Glass.accent) + .task { + await store.refreshFeeds() + store.reload() + } + .safeAreaInset(edge: .bottom, spacing: 0) { + PlayerBarView(playback: playback) + } + } +} diff --git a/ios/Sources/RootViewController.swift b/ios/Sources/RootViewController.swift new file mode 100644 index 0000000..7a794c9 --- /dev/null +++ b/ios/Sources/RootViewController.swift @@ -0,0 +1,95 @@ +import Combine +import SwiftUI +import UIKit + +/// What the window holds: the native interface, with ipx's page a button away. +/// +/// The page is not a fallback here, it is the rest of the app. Settings, the admin page, the +/// directory and OPML have never been native and are not meant to be (#5), and an item's show +/// notes are still HTML (#4). Both live behind the same web view that used to be the whole +/// interface, so nothing has been lost by putting a list in front of it. +final class RootViewController: UIViewController { + private let api = API() + private var store: LibraryStore! + private var cookies: CookieBridge! + private var playback: Playback! + private var page: WebViewController! + private var host: UIHostingController! + private var watching: AnyCancellable? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + // The page is built first and kept: it owns the web view whose cookies authenticate + // everything, including the API client and the player. Signing in happens there. + page = WebViewController() + page.loadViewIfNeeded() + cookies = page.cookieBridge + playback = page.player + + store = LibraryStore(api: api) + + let root = LibraryView( + store: store, + playback: playback, + play: { [weak self] entry in self?.play(entry) }, + openPage: { [weak self] in self?.showPage() }) + .environment(\.api, api) + + host = UIHostingController(rootView: AnyView(root)) + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + host.didMove(toParent: self) + + // preferredColorScheme inside a hosting controller does not reliably reach the whole + // hierarchy -- the list stayed light against a dark account. Overriding the style on the + // controller does, and it carries to the page presented over it as well. + watching = store.$appearance.sink { [weak self] scheme in + let style: UIUserInterfaceStyle + switch scheme { + case .some(.dark): style = .dark + case .some(.light): style = .light + default: style = .unspecified + } + self?.overrideUserInterfaceStyle = style + } + } + + /// Plays a row through the same Playback the lock screen and the car drive. + private func play(_ entry: IPX.Entry) { + guard let file = entry.playable, let url = ServerSettings.url("/media/\(file.id)") else { return } + let feed = store.feed(entry.feedId) + playback.load(.init( + url: url, + feedId: entry.feedId, + guid: entry.guid, + title: entry.title ?? "", + feedTitle: feed?.name ?? entry.feedId, + artwork: entry.image ?? feed?.image, + position: entry.position, + duration: entry.duration)) + // Where it starts is the entry's own position, since there is no page here to decide it. + if entry.position > 5 { playback.seek(to: Double(entry.position)) } + playback.play() + } + + private func showPage() { + guard page.presentingViewController == nil else { return } + let nav = UINavigationController(rootViewController: page) + page.navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { [weak self] _ in + self?.dismiss(animated: true) + // The page may have changed read state or subscriptions while it was open. + Task { await self?.store.refreshFeeds(); self?.store.reload() } + }) + present(nav, animated: true) + } +} diff --git a/ios/Sources/SceneDelegate.swift b/ios/Sources/SceneDelegate.swift index 95b9212..9b09159 100644 --- a/ios/Sources/SceneDelegate.swift +++ b/ios/Sources/SceneDelegate.swift @@ -7,7 +7,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } let w = UIWindow(windowScene: windowScene) - w.rootViewController = WebViewController() + w.rootViewController = RootViewController() w.makeKeyAndVisible() window = w } diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index ea9ae46..cdad7aa 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -11,9 +11,14 @@ final class WebViewController: UIViewController, WKNavigationDelegate { private var cookies: CookieBridge! private var api: API! private var playback: Playback! + + /// The web view's cookie store is what authenticates everything else, and its Playback is + /// the one the lock screen drives, so the native interface borrows both rather than making + /// a second of either. + var cookieBridge: CookieBridge { cookies } + var player: Playback { playback } private var bridge: Bridge! - private var playerBar: UIHostingController! private let banner = UIView() private let bannerText = UILabel() private var checkedBridge = false @@ -46,6 +51,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate { pageAtTop, webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore) @@ -60,32 +66,10 @@ final class WebViewController: UIViewController, WKNavigationDelegate { Zoom.install(into: cfg.userContentController) setUpBanner() - setUpPlayerBar() if ServerSettings.isConfigured { load() } } - // MARK: - the player bar - - /// Ours, below the page, with the page's own bar hidden. Two bars for one player would be - /// two sets of buttons disagreeing about what is playing, and only one of them is the thing - /// CarPlay and the lock screen are driving. - private func setUpPlayerBar() { - playerBar = UIHostingController(rootView: PlayerBarView(playback: playback)) - playerBar.view.backgroundColor = .clear - playerBar.view.translatesAutoresizingMaskIntoConstraints = false - addChild(playerBar) - view.addSubview(playerBar.view) - playerBar.didMove(toParent: self) - - NSLayoutConstraint.activate([ - playerBar.view.topAnchor.constraint(equalTo: webView.bottomAnchor), - playerBar.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - playerBar.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - playerBar.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - // MARK: - how big the page is drawn /// The page is sized for a phone and for a browser window, and on a Mac -- where the window diff --git a/ios/UITests/PlaybackTests.swift b/ios/UITests/PlaybackTests.swift index 8080bfb..1ad2cf1 100644 --- a/ios/UITests/PlaybackTests.swift +++ b/ios/UITests/PlaybackTests.swift @@ -20,39 +20,43 @@ final class PlaybackTests: XCTestCase { func testPlayingAnEpisodeReachesTheHostAndComesBack() throws { let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? "" try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a daemon with an episode downloaded") + let base = try XCTUnwrap(URL(string: server)) + + // Unread first, so that becoming read again can only be playback's doing. Set through + // the API rather than by driving the interface: what is being tested is the playing. + try setRead(false, in: base) let app = XCUIApplication() app.launchArguments = ["-ipx.server", server] app.launch() let episode = app.staticTexts[title] - XCTAssertTrue(episode.waitForExistence(timeout: 30), "the page never listed the fixture episode") - episode.tap() + XCTAssertTrue(episode.waitForExistence(timeout: 30), "the list never showed the fixture episode") - // On a phone the files sit inside the item; the row's own buttons are hidden by the - // stylesheet, so this is the only play button on screen. - // Unread first, so that becoming read again can only be playback's doing -- opening an - // item marks it read by itself, which would otherwise answer the question before the - // test asked it. - let markUnread = app.buttons["Mark unread"].firstMatch - if markUnread.waitForExistence(timeout: 10) { markUnread.tap() } - - // On a phone the files sit inside the item; the row's own buttons are hidden by the - // stylesheet, so this is the only play button on screen. + // The row's own play button. Every row with a downloaded file has one. let play = app.buttons["Play"].firstMatch XCTAssertTrue(play.waitForExistence(timeout: 15), - "the item offers no way to play it, so it has no downloaded file") + "no row offers to play, so nothing in the list has a downloaded file") play.tap() // Read again, rather than a position: the browser suite's fixture is a sixth of a second - // long, and a position that rounds to zero is never written. Read is set at the end of an - // episode, and inside the shell the page only learns an episode ended because the host - // said so -- nothing here is playing the file itself. + // long and a position that rounds to zero is never written. Read is set at the end of an + // episode, by the host, which is the whole round trip. + XCTAssertTrue(waitForRead(in: base, timeout: 30), + "the episode never came back read, so the host never played it through") + } - XCTAssertTrue(waitForRead(in: URL(string: server)!, timeout: 30), - "the episode never came back read, so the host never played it through: " - + "either the message did not reach it, it could not fetch /media with the " - + "session cookie, or its answer never came back") + /// Sets read through ipx directly, to put the fixture in a known state first. + private func setRead(_ read: Bool, in server: URL) throws { + var components = try XCTUnwrap(URLComponents(url: server, resolvingAgainstBaseURL: false)) + components.path = "/api/entries/\(feed)/\(guid)/flags" + var req = URLRequest(url: try XCTUnwrap(components.url)) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: ["read": read]) + let done = expectation(description: "flags written") + URLSession.shared.dataTask(with: req) { _, _, _ in done.fulfill() }.resume() + wait(for: [done], timeout: 15) } private func entry(in server: URL) -> [String: Any]? {