diff --git a/README.md b/README.md index 8ef1f17..29500cf 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ For automation, `-ipx.server ` as a launch argument overrides the stored va | `ios/Sources/API.swift` | ipx's HTTP API, typed | | `ios/Sources/APIModels.swift` | what ipx sends: feeds, entries, enclosures | | `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/CookieBridge.swift` | `WKHTTPCookieStore` into `HTTPCookieStorage.shared` | | `ios/Sources/ServerSettings.swift` | which server | diff --git a/ios/Sources/Glass.swift b/ios/Sources/Glass.swift new file mode 100644 index 0000000..acec996 --- /dev/null +++ b/ios/Sources/Glass.swift @@ -0,0 +1,117 @@ +import SwiftUI + +/// The Glass theme, natively. +/// +/// Not a port. ipx's Glass is already an approximation of what UIKit gives away: its panels are +/// `color-mix(panel 70%, transparent)` under `backdrop-filter: blur(24px) saturate(180%)` with an +/// inset top highlight, which is a material; and its palette names Apple's own colours in its own +/// comments. Built here it is the thing the theme was imitating, with real backdrop sampling, +/// vibrancy and dynamic type, and light and dark at no cost. +/// +/// The values are taken from web/app.css so the two agree. Where ipx had to name a hex because +/// CSS has no system colours, this names the system colour instead. +enum Glass { + // MARK: - colour + + /// Text. ipx calls these `--fg`, `--dim` and `--faint`, and its comments give them away as + /// Apple's label colours; the system versions adapt to contrast settings as well as to dark. + static let text = Color.primary + static let dim = Color.secondary + static let faint = Color(uiColor: .tertiaryLabel) + + /// `--accent`. ipx has to use #409cff in dark and #0055aa in light because a single blue + /// cannot clear AA over its wash; the system blue already varies with appearance. + static let accent = Color.accentColor + /// `--accent2`, the amber the EQ bars and the unread dot use. + static let highlight = Color(uiColor: .systemOrange) + static let good = Color(uiColor: .systemGreen) + static let bad = Color(uiColor: .systemRed) + + /// `--line`, the hairline between panels. + static let line = Color(uiColor: .separator) + + // MARK: - surfaces + + /// What `backdrop-filter: blur(24px) saturate(180%)` over a 70% panel was reaching for. + static let panel: Material = .ultraThinMaterial + /// The toolbar and list headers, which ipx frosts a little more strongly because the list + /// scrolls under them -- the one place the blur has something to blur. + static let sticky: Material = .thinMaterial + + /// `box-shadow: inset 0 1px 0 var(--glass-hi)`: the light catching the top edge of a pane. + /// It is what stops a material reading as flat grey. + struct TopHighlight: ViewModifier { + func body(content: Content) -> some View { + content.overlay(alignment: .top) { + Rectangle() + .fill(Color.white.opacity(0.14)) + .frame(height: 1) + .blendMode(.plusLighter) + .allowsHitTesting(false) + } + } + } + + // MARK: - the wash + + /// The three radial gradients ipx lays under everything, so the frosting has colour to pick + /// up. Without it a material over a flat background is just grey. + struct Wash: View { + @Environment(\.colorScheme) private var scheme + + private var base: Color { scheme == .dark ? Color(hex: 0x0b0d14) : Color(hex: 0xeef1f7) } + + var body: some View { + GeometryReader { geo in + let w = geo.size.width, h = geo.size.height + ZStack { + base + // Positions and sizes as the stylesheet has them: 8% 0%, 92% 18%, 60% 100%. + blob(Color(hex: 0x4078ff).opacity(scheme == .dark ? 0.30 : 0.18), + at: CGPoint(x: w * 0.08, y: 0), size: CGSize(width: w * 1.2, height: h * 1.1)) + blob(Color(hex: 0xaf52de).opacity(scheme == .dark ? 0.24 : 0.14), + at: CGPoint(x: w * 0.92, y: h * 0.18), size: CGSize(width: w, height: h)) + blob(Color(hex: 0x30b0c7).opacity(scheme == .dark ? 0.20 : 0.12), + at: CGPoint(x: w * 0.60, y: h), size: CGSize(width: w * 1.2, height: h * 1.2)) + } + .ignoresSafeArea() + } + } + + private func blob(_ colour: Color, at centre: CGPoint, size: CGSize) -> some View { + RadialGradient(colors: [colour, colour.opacity(0)], + center: .center, startRadius: 0, endRadius: max(size.width, size.height) / 2) + .frame(width: size.width, height: size.height) + .position(centre) + .blendMode(.plusLighter) + } + } + + // MARK: - metrics + + /// Corner radii, from the stylesheet: cards 20, toasts 14, controls 10. + enum Radius { + static let card: CGFloat = 20 + static let toast: CGFloat = 14 + static let control: CGFloat = 10 + } +} + +extension View { + /// A frosted pane with the light on its top edge, as every panel in Glass has. + func glassPane(_ material: Material = Glass.panel) -> some View { + background(material).modifier(Glass.TopHighlight()) + } +} + +extension Color { + /// Only for the wash, where the colour is the design rather than a semantic role and there + /// is no system equivalent to ask for. + init(hex: UInt32) { + self.init(.sRGB, + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 8) & 0xff) / 255, + blue: Double(hex & 0xff) / 255, + opacity: 1) + } +} diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index b7e7af1..c274e9f 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -1,11 +1,22 @@ import AVFoundation import Foundation import MediaPlayer +import UIKit /// The host's player: the one CarPlay and a car's own buttons can reach, and the one that keeps /// going when the screen locks. Knows nothing about the web view -- the bridge drives it, and so /// will a CarPlay scene. -final class Playback: NSObject { +@MainActor +final class Playback: NSObject, ObservableObject { + /// What a view needs to draw the bar. The closures below stay as they are: the page is not + /// a SwiftUI view and the bridge still speaks to it by callback. + @Published private(set) var nowPlaying: Item? + @Published private(set) var elapsed: Double = 0 + @Published private(set) var playing = false + @Published private(set) var artwork: UIImage? + /// The length once it is known, falling back to what the feed claimed. + @Published private(set) var length: Double? + /// What is loaded, enough to keep the car's screen right and to save a position without /// asking anyone. struct Item { @@ -68,6 +79,16 @@ final class Playback: NSObject { self.item = item self.markedRead = false self.lastSaved = 0 + self.nowPlaying = item + self.elapsed = 0 + self.length = item.duration.map(Double.init) + self.artwork = nil + if let art = item.artwork { + Task { [weak self] in + let image = await self?.api.image(art) + await MainActor.run { self?.artwork = image } + } + } // The session cookie is how ipx knows who is asking, and an AVURLAsset does not read the // shared store on its own. @@ -115,12 +136,14 @@ final class Playback: NSObject { guard let player else { return } try? AVAudioSession.sharedInstance().setActive(true) player.play() + playing = true onState?(true) updateNowPlaying() } func pause() { player?.pause() + playing = false onState?(false) savePosition() updateNowPlaying() @@ -152,6 +175,11 @@ final class Playback: NSObject { player?.pause() player = nil item = nil + nowPlaying = nil + artwork = nil + elapsed = 0 + length = nil + playing = false MPNowPlayingInfoCenter.default().nowPlayingInfo = nil try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) if notify { onState?(false) } @@ -174,6 +202,9 @@ final class Playback: NSObject { private func tick(_ secs: Double) { guard secs.isFinite else { return } + elapsed = secs + if let d = duration { length = d } + playing = isPlaying onTime?(secs, duration) updateElapsed(secs) if abs(secs - lastSaved) > 10 { savePosition() } @@ -185,6 +216,7 @@ final class Playback: NSObject { @objc private func didEnd() { savePosition() markRead() + playing = false onState?(false) onEnded?() updateNowPlaying() diff --git a/ios/Sources/PlayerBarView.swift b/ios/Sources/PlayerBarView.swift new file mode 100644 index 0000000..21f297a --- /dev/null +++ b/ios/Sources/PlayerBarView.swift @@ -0,0 +1,133 @@ +import SwiftUI + +/// The bar along the bottom, in Glass. +/// +/// It draws what Playback knows, and Playback is the same object CarPlay and the lock screen +/// drive, so all three agree without anything being kept in step by hand. +struct PlayerBarView: View { + @ObservedObject var playback: Playback + /// Dragging the scrubber has to win over the clock, or the thumb fights the finger. + @State private var scrubbing: Double? + + private var item: Playback.Item? { playback.nowPlaying } + private var length: Double { playback.length ?? 0 } + private var position: Double { scrubbing ?? playback.elapsed } + + var body: some View { + if let item { + VStack(spacing: 6) { + HStack(spacing: 12) { + art + titles(item) + Spacer(minLength: 8) + controls + close + } + scrubber + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .glassPane() + .overlay(alignment: .top) { Rectangle().fill(Glass.line).frame(height: 0.5) } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + + // MARK: - pieces + + @ViewBuilder private var art: some View { + Group { + if let image = playback.artwork { + Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) + } else { + // The feed's initials, as the page does when there is no picture: two letters + // read as a thing on purpose, where a grey square reads as something missing. + ZStack { + Rectangle().fill(Glass.accent.opacity(0.18)) + Text(initials).font(.caption.weight(.semibold)).foregroundStyle(Glass.accent) + } + } + } + .frame(width: 38, height: 38) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + + private var initials: String { + let words = (item?.feedTitle ?? "").split(separator: " ").prefix(2) + let letters = words.compactMap { $0.first }.map(String.init).joined() + return letters.isEmpty ? "?" : letters.uppercased() + } + + private func titles(_ item: Playback.Item) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(item.title.isEmpty ? "(untitled)" : item.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Glass.text) + .lineLimit(1) + Text(item.feedTitle) + .font(.caption) + .foregroundStyle(Glass.dim) + .lineLimit(1) + } + } + + private var controls: some View { + HStack(spacing: 14) { + // 15 back and 30 forward, the same pair the page and the lock screen use, so every + // way of skipping moves by the same amount. + button("gobackward.15", "Back 15 seconds") { playback.seek(to: playback.elapsed - 15) } + Button { + playback.playing ? playback.pause() : playback.play() + } label: { + Image(systemName: playback.playing ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 30)) + .symbolRenderingMode(.hierarchical) + } + .buttonStyle(.plain) + .foregroundStyle(Glass.accent) + .accessibilityLabel(playback.playing ? "Pause" : "Play") + button("goforward.30", "Forward 30 seconds") { playback.seek(to: playback.elapsed + 30) } + } + } + + private var close: some View { + button("xmark", "Close the player") { playback.stop() } + } + + private func button(_ symbol: String, _ label: String, _ action: @escaping () -> Void) -> some View { + Button(action: action) { Image(systemName: symbol).font(.system(size: 15, weight: .medium)) } + .buttonStyle(.plain) + .foregroundStyle(Glass.dim) + .accessibilityLabel(label) + } + + private var scrubber: some View { + HStack(spacing: 8) { + Text(clock(position)).monospacedDigit() + Slider(value: Binding( + get: { position }, + set: { scrubbing = $0 } + ), in: 0...max(length, 1), onEditingChanged: { editing in + // Seek when the finger lifts, not continuously: seeking on every value sends a + // stream of them at a player that is still answering the last one. + if !editing, let to = scrubbing { + playback.seek(to: to) + scrubbing = nil + } + }) + .disabled(length <= 0) + .tint(Glass.accent) + Text(clock(length)).monospacedDigit() + } + .font(.caption2) + .foregroundStyle(Glass.faint) + } + + /// h:mm:ss once past an hour, m:ss below it, as the page's clock() does. + private func clock(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let total = Int(seconds) + let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60) + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } +} diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index cc97008..ea9ae46 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -1,3 +1,4 @@ +import SwiftUI import UIKit import WebKit @@ -12,6 +13,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate { private var playback: Playback! private var bridge: Bridge! + private var playerBar: UIHostingController! private let banner = UIView() private let bannerText = UILabel() private var checkedBridge = false @@ -42,7 +44,6 @@ final class WebViewController: UIViewController, WKNavigationDelegate { pageAtTop = webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) NSLayoutConstraint.activate([ pageAtTop, - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) @@ -59,10 +60,32 @@ 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 @@ -112,7 +135,16 @@ final class WebViewController: UIViewController, WKNavigationDelegate { + "s.textContent=\(css.debugDescription)})()" } + /// The page's own player bar, hidden. It is still what the play buttons drive -- they + /// post to the host either way -- but the bar itself is ours now. + static let hidePageBar = "(function(){var s=document.createElement('style');" + + "s.textContent='#player{display:none!important}';" + + "(document.head||document.documentElement).appendChild(s)})()" + static func install(into controller: WKUserContentController) { + controller.addUserScript(WKUserScript(source: hidePageBar, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true)) controller.addUserScript(WKUserScript(source: script(current), injectionTime: .atDocumentEnd, forMainFrameOnly: true)) diff --git a/ios/Tests/APITests.swift b/ios/Tests/APITests.swift index 3984d9d..a7a7af3 100644 --- a/ios/Tests/APITests.swift +++ b/ios/Tests/APITests.swift @@ -69,9 +69,15 @@ final class APITests: XCTestCase { XCTAssertFalse(enclosure.isPlayable, "a downloaded JPEG is not something to play") } + /// Note that the daemon is shared and keeps what earlier tests did to it: the event test + /// asks it to scan, which downloads another episode. So this asks what the filter means + /// rather than how many things happen to match it today. func testFilterAndSortAreTheServersWork() async throws { + let all = try await api.entries(feed: "test-show", filter: .all) let downloaded = try await api.entries(feed: "test-show", filter: .downloaded) - XCTAssertEqual(downloaded.total, 1, "one of the two episodes has a file") + XCTAssertLessThanOrEqual(downloaded.total, all.total) + XCTAssertTrue(downloaded.entries.allSatisfy { $0.enclosures.contains(where: \.isDownloaded) }, + "the downloaded filter returned an item with no file") let asc = try await api.entries(feed: "test-show", sort: .title, direction: .asc) let desc = try await api.entries(feed: "test-show", sort: .title, direction: .desc)