diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 96559ef..b060c4c 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -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). diff --git a/ios/README.md b/ios/README.md index 7952ce9..6556901 100644 --- a/ios/README.md +++ b/ios/README.md @@ -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. diff --git a/ios/Sources/Bridge.swift b/ios/Sources/Bridge.swift index 8adb56f..9a87cd7 100644 --- a/ios/Sources/Bridge.swift +++ b/ios/Sources/Bridge.swift @@ -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]) { diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index f109cd0..f22c58b 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -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) } diff --git a/ios/project.yml b/ios/project.yml index a549b33..fb562b0 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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] diff --git a/tests/native-bridge.ts b/tests/native-bridge.ts index 94fbdf7..a8a18dd 100644 --- a/tests/native-bridge.ts +++ b/tests/native-bridge.ts @@ -147,6 +147,29 @@ since(); await vm.runInContext('clearOffline()', ctx); ok(last('clearKept'), 'signing out left the phone holding what was kept'); +// The sleep timer and AirPlay (#429): the app's to do, once it says it can. +// The stub hands out a new element on each lookup, so the select's painting is left out. +vm.runInContext('paintSleep=()=>{}', ctx); +since(); +vm.runInContext('sleepIn(900)', ctx); +ok(!last('sleep'), 'the sleep timer went to an app that never said it has one'); +ok(vm.runInContext('sleepTimer', ctx), 'with an app that has no timer, the page did not keep the time'); +vm.runInContext('sleepIn(0)', ctx); + +ctx.window.ipxNative.on({ t: 'host', can: ['sleep', 'route'] }); +ok(vm.runInContext('hostCan.has("route")', ctx), "the app's picker was not taken up"); +since(); +vm.runInContext('sleepIn(900)', ctx); +ok(last('sleep')?.secs === 900, 'the sleep timer did not reach the app'); +ok(!vm.runInContext('sleepTimer', ctx), 'the page kept a timer of its own beside the app\'s'); +ctx.window.ipxNative.on({ t: 'slept' }); +ok(vm.runInContext('sleepAt', ctx) === 0, 'the timer still shows after the app paused for it'); +ctx.window.ipxNative.on({ t: 'sleep', left: 600 }); +ok(vm.runInContext('sleepAt', ctx) > Date.now() + 590_000, 'a reloaded page was not told how long is left'); +since(); +vm.runInContext('sleepIn(0)', ctx); +ok(last('sleep')?.secs === 0, 'turning the timer off did not reach the app'); + if (failed) { console.error(`\n${failed} failed`); process.exit(1); } -console.log('OK: native-bridge: the host takes playback and keeping, and the page follows it'); +console.log('OK: native-bridge: the host takes playback, keeping and the sleep timer, and the page follows it'); process.exit(0); diff --git a/tests/page-unit.ts b/tests/page-unit.ts index 89f9982..d242d65 100644 --- a/tests/page-unit.ts +++ b/tests/page-unit.ts @@ -79,6 +79,14 @@ test('DeadAir savings read as time', () => { assert.equal(run('shorter(150)'), '3 min shorter'); }); +test('the sleep timer reads as minutes left, rounded up', () => { + assert.equal(run('sleepLabel(0)'), 'Sleep'); + assert.equal(run('sleepLabel(-5)'), 'Sleep'); + assert.equal(run('sleepLabel(1000)'), '1 min left'); + assert.equal(run('sleepLabel(15 * 60000)'), '15 min left'); + assert.equal(run('sleepLabel(15 * 60000 + 1)'), '16 min left'); +}); + test('initials and plural', () => { assert.equal(run(`initials('The Verge')`), 'TV'); assert.equal(run(`initials('accidental tech podcast')`), 'AT'); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 75ab796..f808904 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A sleep timer in the player: 5 minutes to an hour, then playback fades out and pauses. In the iOS app the phone keeps the time, so it works with the screen locked; the app and Safari also get an AirPlay button. On a phone the title has the player's top line to itself, with the controls either side of play below it (#429). - In the iOS app, Keep on this device keeps the episode on the phone, where the app's own player plays it with no connection; until now the app had no Keep button (#425). - An episode kept on this device says so in its row of the list, On this device, not only in its files (#413). - Keep an episode on this device to listen offline: the button beside Play on a downloaded episode keeps the file, without its dead air if DeadAir shortens it for you, and it then plays from the device whether you are online or not, with your place sent once the connection is back. Press it again to take it off; signing out takes everything off (#412). diff --git a/web/app.css b/web/app.css index f2b0b7b..a479ce5 100644 --- a/web/app.css +++ b/web/app.css @@ -1383,10 +1383,19 @@ body:has(#player.on) #status{padding-bottom:3px} .acts .btn{padding:7px 10px;font-size:12.5px} .toolbar{gap:8px} #list{padding:0 12px 10px} - #player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px} + /* The title has the top line to itself, and the controls flank play: with the sleep timer and + AirPlay beside the speed (#429), one line for all of them left the title two letters wide. */ + #player{grid-template-columns:auto auto 1fr auto auto;gap:4px 8px; + grid-template-areas:"now now now now close" "sleep rate btns route dead" "seek seek seek seek seek"} + #pright,#pmid{display:contents} #pnow{grid-area:now;width:auto} - #pright{grid-area:right} - #pmid{grid-area:mid} + #pbtns{grid-area:btns} + #seekrow{grid-area:seek} + #sleep{grid-area:sleep} + #rate{grid-area:rate} + #proute{grid-area:route} + #pdead{grid-area:dead} + #pclose{grid-area:close} #pnow .art{width:38px;height:38px} #pnow .txt small,#pright #vol{display:none} .wrap.plain{padding:14px} diff --git a/web/index.html b/web/index.html index 65be1e0..10dcaaa 100644 --- a/web/index.html +++ b/web/index.html @@ -68,6 +68,12 @@
+ +