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]? {