Player: a sleep timer, and AirPlay in the app (#429)

The sleep timer pauses after 5 minutes to an hour, fading out over the
last ten seconds. In the iOS app the phone's player keeps the time:
with the screen locked the web view is frozen, and a timer in the page
would not fire until the app was opened again. A browser plays on in a
background tab, so there the page keeps it.

The app says at ready what it does itself ({t:"host", can}), so a page
deployed ahead of the app falls back to its own timer and shows no
AirPlay button an older app would ignore. This deploy order problem
came up with #425.

AirPlay opens AVRoutePickerView by pressing its button from code; there
is no API to present it. On iOS 27 the picker opens on the button's
primary action, not touch-up, so that is what is sent, with touch-up
for versions that have nothing on it. The simulator has no AirPlay
devices and never showed the picker, so this is unverified until it
runs on a phone.

On a phone the player's one line left the title two letters wide once
the two controls were added, so the title takes the top line and the
controls flank play below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 16:54:08 -05:00
parent ecc248307e
commit 2e031763fa
13 changed files with 193 additions and 6 deletions

View File

@@ -10,3 +10,4 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
- Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).

View File

@@ -71,6 +71,8 @@ Page to app, on `webkit.messageHandlers.ipx`:
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
| `{t:"clearKept"}` | signing out: everything kept goes |
| `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) |
| `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
App to page, as `window.ipxNative.on(...)`:
@@ -83,6 +85,9 @@ App to page, as `window.ipxNative.on(...)`:
| `{t:"error", message}` | the page says so |
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
| `{t:"keepFailed", url, message}` | one that could not be kept, and why |
| `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`), so a page newer than the app offers only those |
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
| `{t:"slept"}` | the timer ran out and playback paused |
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
its own clock, so both happen while the page is frozen. Video stays in the page.

View File

@@ -1,3 +1,4 @@
import AVKit
import Foundation
import WebKit
@@ -13,6 +14,16 @@ final class Bridge: NSObject, WKScriptMessageHandler {
private weak var webView: WKWebView?
private let playback: Playback
/// The system's AirPlay picker, which has no API to open it but its own button: kept on the
/// web view, invisible, and pressed when the page's button is.
private lazy var routePicker: AVRoutePickerView = {
let v = AVRoutePickerView()
v.alpha = 0
v.isUserInteractionEnabled = false
v.prioritizesVideoDevices = false
webView?.addSubview(v)
return v
}()
init(webView: WKWebView, playback: Playback) {
self.webView = webView
@@ -29,6 +40,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
Offline.shared.onChange = { [weak self] in self?.sendKept() }
Offline.shared.onFail = { [weak self] path, msg in
self?.send(["t": "keepFailed", "url": path, "message": msg])
@@ -50,6 +62,10 @@ final class Bridge: NSObject, WKScriptMessageHandler {
case "ready":
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
// What this build can do, so a newer page offers nothing it would ask for in vain.
send(["t": "host", "can": ["sleep", "route"]])
// A page reloaded while the timer runs is told how long is left.
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
sendKept()
case "load":
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
@@ -73,10 +89,31 @@ final class Bridge: NSObject, WKScriptMessageHandler {
case "keep": if let url = m["url"] as? String { Offline.shared.keep(url) }
case "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
case "clearKept": Offline.shared.removeAll()
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
case "route": showRoutes(m)
default: break
}
}
/// Opens the picker over the page's button: on an iPhone a sheet, on an iPad a popover
/// pointing at it. The page sends where the button is, in the web view's points.
private func showRoutes(_ m: [String: Any]) {
let n = { (k: String) in m[k] as? Double ?? 0 }
routePicker.frame = CGRect(x: n("x"), y: n("y"), width: max(1, n("w")), height: max(1, n("h")))
// Its button is made on layout, and opens the picker on its primary action; a version
// with nothing on that, as older ones may have, gets the touch that used to do it.
routePicker.layoutIfNeeded()
guard let button = Self.controls(in: routePicker).first else { return }
let primary = button.allTargets.contains {
button.actions(forTarget: $0, forControlEvent: .primaryActionTriggered)?.isEmpty == false
}
button.sendActions(for: primary ? .primaryActionTriggered : .touchUpInside)
}
private static func controls(in v: UIView) -> [UIControl] {
v.subviews.flatMap { ($0 as? UIControl).map { [$0] } ?? controls(in: $0) }
}
// MARK: - host to page
private func send(_ payload: [String: Any]) {

View File

@@ -23,6 +23,8 @@ final class Playback: NSObject {
var onState: ((Bool) -> Void)?
var onEnded: (() -> Void)?
var onError: ((String) -> Void)?
/// The sleep timer ran out and playback paused.
var onSlept: (() -> Void)?
private let session: URLSession = {
let cfg = URLSessionConfiguration.default
@@ -39,6 +41,9 @@ final class Playback: NSObject {
private var markedRead = false
private var rate: Float = 1
private var volume: Float = 1
private var sleepTimer: Timer?
/// When the sleep timer pauses playback, if it is set.
private(set) var sleepAt: Date?
var isPlaying: Bool { player?.timeControlStatus == .playing }
var currentTime: Double { player?.currentTime().seconds ?? 0 }
@@ -153,7 +158,36 @@ final class Playback: NSObject {
player?.volume = v
}
/// The sleep timer (#429): pauses after `secs`, the last ten fading out; 0 turns it off. Kept
/// here, not in the page: with the screen locked the web view is frozen, and a timer of its own
/// would not fire until the app was opened again. This one fires because the app is playing.
func sleep(after secs: Double) {
sleepTimer?.invalidate()
sleepTimer = nil
sleepAt = nil
player?.volume = volume
guard secs > 0 else { return }
sleepAt = Date(timeIntervalSinceNow: secs)
sleepTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.sleepTick() }
}
}
private func sleepTick() {
guard let sleepAt else { return }
let left = sleepAt.timeIntervalSinceNow
// A fade, not a cut: a sudden stop wakes the person it was set for.
if left > 0 {
if left < 10 { player?.volume = volume * Float(left / 10) }
return
}
sleep(after: 0)
pause()
onSlept?()
}
func stop(notify: Bool = true) {
sleep(after: 0)
savePosition()
if let timeObserver { player?.removeTimeObserver(timeObserver) }
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }

View File

@@ -25,7 +25,7 @@ targets:
properties:
CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "6"
CFBundleVersion: "7"
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
UIBackgroundModes: [audio, fetch]