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:
@@ -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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
@@ -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:"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:"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:"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(...)`:
|
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:"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:"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:"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
|
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.
|
its own clock, so both happen while the page is frozen. Video stays in the page.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AVKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@@ -13,6 +14,16 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
|
|
||||||
private weak var webView: WKWebView?
|
private weak var webView: WKWebView?
|
||||||
private let playback: Playback
|
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) {
|
init(webView: WKWebView, playback: Playback) {
|
||||||
self.webView = webView
|
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.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
|
||||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
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.onChange = { [weak self] in self?.sendKept() }
|
||||||
Offline.shared.onFail = { [weak self] path, msg in
|
Offline.shared.onFail = { [weak self] path, msg in
|
||||||
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
||||||
@@ -50,6 +62,10 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
case "ready":
|
case "ready":
|
||||||
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
||||||
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
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()
|
sendKept()
|
||||||
case "load":
|
case "load":
|
||||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
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 "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 "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
|
||||||
case "clearKept": Offline.shared.removeAll()
|
case "clearKept": Offline.shared.removeAll()
|
||||||
|
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
|
||||||
|
case "route": showRoutes(m)
|
||||||
default: break
|
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
|
// MARK: - host to page
|
||||||
|
|
||||||
private func send(_ payload: [String: Any]) {
|
private func send(_ payload: [String: Any]) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ final class Playback: NSObject {
|
|||||||
var onState: ((Bool) -> Void)?
|
var onState: ((Bool) -> Void)?
|
||||||
var onEnded: (() -> Void)?
|
var onEnded: (() -> Void)?
|
||||||
var onError: ((String) -> Void)?
|
var onError: ((String) -> Void)?
|
||||||
|
/// The sleep timer ran out and playback paused.
|
||||||
|
var onSlept: (() -> Void)?
|
||||||
|
|
||||||
private let session: URLSession = {
|
private let session: URLSession = {
|
||||||
let cfg = URLSessionConfiguration.default
|
let cfg = URLSessionConfiguration.default
|
||||||
@@ -39,6 +41,9 @@ final class Playback: NSObject {
|
|||||||
private var markedRead = false
|
private var markedRead = false
|
||||||
private var rate: Float = 1
|
private var rate: Float = 1
|
||||||
private var volume: 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 isPlaying: Bool { player?.timeControlStatus == .playing }
|
||||||
var currentTime: Double { player?.currentTime().seconds ?? 0 }
|
var currentTime: Double { player?.currentTime().seconds ?? 0 }
|
||||||
@@ -153,7 +158,36 @@ final class Playback: NSObject {
|
|||||||
player?.volume = v
|
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) {
|
func stop(notify: Bool = true) {
|
||||||
|
sleep(after: 0)
|
||||||
savePosition()
|
savePosition()
|
||||||
if let timeObserver { player?.removeTimeObserver(timeObserver) }
|
if let timeObserver { player?.removeTimeObserver(timeObserver) }
|
||||||
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
|
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: iPX
|
CFBundleDisplayName: iPX
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "6"
|
CFBundleVersion: "7"
|
||||||
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
# 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.
|
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
|
|||||||
@@ -147,6 +147,29 @@ since();
|
|||||||
await vm.runInContext('clearOffline()', ctx);
|
await vm.runInContext('clearOffline()', ctx);
|
||||||
ok(last('clearKept'), 'signing out left the phone holding what was kept');
|
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); }
|
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);
|
process.exit(0);
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ test('DeadAir savings read as time', () => {
|
|||||||
assert.equal(run('shorter(150)'), '3 min shorter');
|
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', () => {
|
test('initials and plural', () => {
|
||||||
assert.equal(run(`initials('The Verge')`), 'TV');
|
assert.equal(run(`initials('The Verge')`), 'TV');
|
||||||
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
|
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
15
web/app.css
15
web/app.css
@@ -1383,10 +1383,19 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.acts .btn{padding:7px 10px;font-size:12.5px}
|
.acts .btn{padding:7px 10px;font-size:12.5px}
|
||||||
.toolbar{gap:8px}
|
.toolbar{gap:8px}
|
||||||
#list{padding:0 12px 10px}
|
#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}
|
#pnow{grid-area:now;width:auto}
|
||||||
#pright{grid-area:right}
|
#pbtns{grid-area:btns}
|
||||||
#pmid{grid-area:mid}
|
#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 .art{width:38px;height:38px}
|
||||||
#pnow .txt small,#pright #vol{display:none}
|
#pnow .txt small,#pright #vol{display:none}
|
||||||
.wrap.plain{padding:14px}
|
.wrap.plain{padding:14px}
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="pright">
|
<div id="pright">
|
||||||
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
|
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
|
||||||
|
<select id="sleep" title="Sleep timer" aria-label="Sleep timer">
|
||||||
|
<option value="" disabled hidden selected>Sleep</option>
|
||||||
|
<option value="0">Off</option><option value="300">5 min</option><option value="900">15 min</option>
|
||||||
|
<option value="1800">30 min</option><option value="2700">45 min</option><option value="3600">1 hour</option>
|
||||||
|
</select>
|
||||||
|
<button class="iconbtn" id="proute" title="Play on another speaker" aria-label="AirPlay" data-icon="airplay" hidden></button>
|
||||||
<select id="rate" title="Speed">
|
<select id="rate" title="Speed">
|
||||||
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
|
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
|
||||||
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const ipxHost: ((m: any) => void) | null = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
if (ipxHost) installNativePlayback();
|
if (ipxHost) installNativePlayback();
|
||||||
|
// Here, after ipxHost exists: in the app the button waits for the host to say it has a picker.
|
||||||
|
paintRoute();
|
||||||
|
|
||||||
function installNativePlayback(){
|
function installNativePlayback(){
|
||||||
const post = ipxHost!;
|
const post = ipxHost!;
|
||||||
@@ -143,6 +145,9 @@ function installNativePlayback(){
|
|||||||
on(m: any){
|
on(m: any){
|
||||||
// Not playback's, so not gated on it: the app says what it keeps whether or not it plays.
|
// Not playback's, so not gated on it: the app says what it keeps whether or not it plays.
|
||||||
if(m.t==='kept'||m.t==='keepFailed'){ hostKept(m); return; }
|
if(m.t==='kept'||m.t==='keepFailed'){ hostKept(m); return; }
|
||||||
|
// Sent at ready, so a page newer than the app offers only what the app can do.
|
||||||
|
if(m.t==='host'){ hostCan.clear(); for(const c of m.can||[]) hostCan.add(c); paintRoute(); return; }
|
||||||
|
if(m.t==='sleep'||m.t==='slept'){ hostSleep(m); return; }
|
||||||
if(!N.on) return;
|
if(!N.on) return;
|
||||||
switch(m.t){
|
switch(m.t){
|
||||||
case 'time':
|
case 'time':
|
||||||
|
|||||||
@@ -177,6 +177,62 @@ $<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
|
|||||||
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||||
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
||||||
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||||
|
/// What the app said it can do itself (`{t:'host', can}`, native.ts): empty in a browser, and in
|
||||||
|
/// an app older than the page, which then gets the page's own way of doing it or none.
|
||||||
|
const hostCan=new Set<string>();
|
||||||
|
|
||||||
|
/// The sleep timer (#429): playback pauses after a while, fading out over the last seconds. In
|
||||||
|
/// the app the phone's player keeps the time, because the page is frozen once the screen locks
|
||||||
|
/// and a timer of its own would not fire until the app was opened again; a browser plays on in a
|
||||||
|
/// background tab, so there the page keeps it.
|
||||||
|
let sleepAt=0, sleepTimer=0, sleepPaint=0;
|
||||||
|
const sleepLabel=(ms: number)=>ms>0?`${Math.ceil(ms/60000)} min left`:'Sleep';
|
||||||
|
function paintSleep(){
|
||||||
|
const s=$<HTMLSelectElement>('#sleep');
|
||||||
|
s.options[0].textContent=sleepLabel(sleepAt-Date.now());
|
||||||
|
s.value='';
|
||||||
|
clearInterval(sleepPaint);
|
||||||
|
if(sleepAt) sleepPaint=setInterval(paintSleep,15000);
|
||||||
|
}
|
||||||
|
function sleepIn(secs: number){
|
||||||
|
clearTimeout(sleepTimer); sleepTimer=0;
|
||||||
|
sleepAt=secs>0?Date.now()+secs*1000:0;
|
||||||
|
if(hostCan.has('sleep')) ipxHost!({t:'sleep',secs});
|
||||||
|
else if(sleepAt) sleepTimer=setTimeout(sleepFade,Math.max(0,secs*1000-FADE_MS));
|
||||||
|
paintSleep();
|
||||||
|
}
|
||||||
|
const FADE_MS=10000;
|
||||||
|
/// A fade, not a cut: a sudden stop wakes the person it was set for.
|
||||||
|
function sleepFade(){
|
||||||
|
const v=audio.volume, steps=FADE_MS/250; let n=0;
|
||||||
|
const step=setInterval(()=>{
|
||||||
|
// Called off since it began: the volume goes back to what it was.
|
||||||
|
if(!sleepAt){ clearInterval(step); audio.volume=v; return; }
|
||||||
|
if(++n<steps){ audio.volume=v*(1-n/steps); return; }
|
||||||
|
clearInterval(step); audio.pause(); audio.volume=v; sleepDone();
|
||||||
|
},250);
|
||||||
|
}
|
||||||
|
function sleepDone(){ sleepAt=0; clearTimeout(sleepTimer); sleepTimer=0; paintSleep(); }
|
||||||
|
/// The app's side of it: how long is left after the page reloads, and that it has paused.
|
||||||
|
function hostSleep(m: {t: string, left?: number}){
|
||||||
|
if(m.t==='slept'){ sleepDone(); return; }
|
||||||
|
sleepAt=m.left?Date.now()+m.left*1000:0;
|
||||||
|
paintSleep();
|
||||||
|
}
|
||||||
|
$<HTMLSelectElement>('#sleep').onchange=(e: Event)=>sleepIn(Number((e.target as HTMLSelectElement).value));
|
||||||
|
|
||||||
|
/// Another speaker or a TV. In the app, the phone's own picker, since the phone is what plays; in
|
||||||
|
/// Safari, the browser's. Elsewhere there is none to offer.
|
||||||
|
function paintRoute(){
|
||||||
|
$<HTMLButtonElement>('#proute').hidden=ipxHost?!hostCan.has('route'):!('webkitShowPlaybackTargetPicker' in audio);
|
||||||
|
}
|
||||||
|
// First painted by native.ts, which comes later in the script and declares ipxHost.
|
||||||
|
$<HTMLButtonElement>('#proute').onclick=(e: Event)=>{
|
||||||
|
if(!ipxHost){ (audio as any).webkitShowPlaybackTargetPicker(); return; }
|
||||||
|
// Where the button is, for the picker to point at on an iPad.
|
||||||
|
const r=(e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
ipxHost({t:'route',x:r.left,y:r.top,w:r.width,h:r.height});
|
||||||
|
};
|
||||||
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
||||||
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||||
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
|
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
|
||||||
@@ -220,7 +276,7 @@ async function switchVersion(){
|
|||||||
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
$<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
$<HTMLButtonElement>('#pclose').onclick=()=>{if(sleepAt)sleepIn(0);savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||||
// Called here, not left to the pause event: closing a player already paused fires none.
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
syncListening()};
|
syncListening()};
|
||||||
(function restore(){
|
(function restore(){
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const ICON: Record<string,string>={
|
|||||||
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
|
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
|
||||||
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
||||||
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
||||||
|
// Drawn here: Font Awesome's free set has no AirPlay. A screen with a triangle under it, as Apple's.
|
||||||
|
airplay:fa('0 0 512 512','<path fill="currentColor" d="M64 48l384 0c26.5 0 48 21.5 48 48l0 256c0 26.5-21.5 48-48 48l-56 0 0-56 48 0 0-240-368 0 0 240 48 0 0 56-56 0c-26.5 0-48-21.5-48-48L16 96c0-26.5 21.5-48 48-48zM256 288l144 176-288 0z"/>'),
|
||||||
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||||||
};
|
};
|
||||||
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
||||||
|
|||||||
Reference in New Issue
Block a user