A WKWebView runs no service worker, so the page's Keep button (#412) was not shown in the app and everything played from the server. Now the page asks the app to keep the file (keep, unkeep, clearKept), and the app answers with what it holds (kept, keepFailed) whether or not anything plays. The app fetches with a background URLSession, so a download goes on with the app closed, into Application Support (not backed up; the files can be fetched again), and Playback plays from the file when there is one. The DeadAir copy is kept under its own path, as the browser keeps it. Places and finishes reached offline wait in an outbox, newest per address, and go when the network is back. Checked in the simulator against a local server: kept /media/42 and /media/42/deadair, then with the server refusing /media, a fresh launch played each from the phone and asked the server for nothing. The page itself still needs a connection to open (#435). Deploy the app (build 6) before the page: a new page in an old app shows a Keep button nothing answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
67 lines
2.9 KiB
Swift
67 lines
2.9 KiB
Swift
import Foundation
|
|
import Network
|
|
|
|
/// Writes the player made with no connection, sent when there is one (#425): where you got to in
|
|
/// an episode, and that you finished it. In a browser the page's service worker holds these; the
|
|
/// app's player is the one writing them, so it holds its own.
|
|
///
|
|
/// A newer write to the same address replaces an older one, since each says where you are now, and
|
|
/// the queue then stays as long as the episodes played offline, not as long as the time spent.
|
|
enum Outbox {
|
|
struct Write: Codable, Equatable {
|
|
var path: String
|
|
var json: Data
|
|
}
|
|
|
|
private static let key = "ipx.outbox"
|
|
private static var flushing = false
|
|
private static let monitor = NWPathMonitor()
|
|
|
|
static var pending: [Write] {
|
|
get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode([Write].self, from: $0) } ?? [] }
|
|
set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
|
|
}
|
|
|
|
/// A write that never arrived. Only the player's own two kinds are kept: anything else, such
|
|
/// as adding a feed, fails offline as it always has.
|
|
static func add(path: String, json: [String: Any]) {
|
|
guard path.hasSuffix("/position") || path.hasSuffix("/flags"),
|
|
let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) else { return }
|
|
var all = pending.filter { $0.path != path }
|
|
all.append(Write(path: path, json: data))
|
|
pending = all
|
|
}
|
|
|
|
/// In the order they were made. The first that does not reach the server stops it for the next
|
|
/// try; one the server answered at all is done, whatever it said, as the worker treats it.
|
|
static func flush() async {
|
|
guard !flushing, !pending.isEmpty else { return }
|
|
flushing = true
|
|
defer { flushing = false }
|
|
let cfg = URLSessionConfiguration.default
|
|
cfg.httpCookieStorage = .shared
|
|
cfg.timeoutIntervalForRequest = 20
|
|
let session = URLSession(configuration: cfg)
|
|
for w in pending {
|
|
guard let url = Server.resolveForPlayer(w.path) else { return }
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
|
req.httpBody = w.json
|
|
guard (try? await session.data(for: req)) != nil else { return }
|
|
// Removed by what it is, not where it was: a newer write may have replaced it while
|
|
// this one was on its way.
|
|
pending.removeAll { $0 == w }
|
|
}
|
|
}
|
|
|
|
/// Sends what waited whenever the phone gets a connection back.
|
|
static func watch() {
|
|
monitor.pathUpdateHandler = { path in
|
|
if path.status == .satisfied { Task { await flush() } }
|
|
}
|
|
monitor.start(queue: .global(qos: .utility))
|
|
}
|
|
}
|