diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 2c4d1a9..5e4db68 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -8,7 +8,7 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1. - iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418). - 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). Reading in the app and new items arriving while it is open set it at once, and while an episode plays it is checked every quarter of an hour. - 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). - iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435). diff --git a/ios/README.md b/ios/README.md index 22e7cef..5f20539 100644 --- a/ios/README.md +++ b/ios/README.md @@ -90,6 +90,7 @@ Page to app, on `webkit.messageHandlers.ipx`: | `{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:"unread", n}` | All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) | | `{t:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) | | `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) | diff --git a/ios/Sources/Badge.swift b/ios/Sources/Badge.swift index 34f1116..d38069f 100644 --- a/ios/Sources/Badge.swift +++ b/ios/Sources/Badge.swift @@ -38,6 +38,23 @@ enum Badge { UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } } + /// The page's own count, sent as its list changes (#421). + @MainActor static func set(_ n: Int) { + lastSet = Date() + Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) } + } + + /// When the icon was last set from a count, by either way. + @MainActor private static var lastSet = Date.distantPast + + /// While an episode plays with the screen locked the app is awake, unlike the page: so new + /// items reach the icon every quarter of an hour, which background refresh does not promise. + @MainActor static func refreshIfStale() { + guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return } + lastSet = Date() + Task { await refresh() } + } + /// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer /// with a feed list: an old number is better than a wrong one. @discardableResult @@ -52,7 +69,7 @@ enum Badge { guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200, let n = unread(inAll: data) else { return false } - try? await UNUserNotificationCenter.current().setBadgeCount(n) + await MainActor.run { set(n) } return true } diff --git a/ios/Sources/Bridge.swift b/ios/Sources/Bridge.swift index 06b817b..210de61 100644 --- a/ios/Sources/Bridge.swift +++ b/ios/Sources/Bridge.swift @@ -94,6 +94,7 @@ final class Bridge: NSObject, WKScriptMessageHandler { case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0) case "route": showRoutes(m) case "haptic": haptic(m["kind"] as? String) + case "unread": if let n = m["n"] as? Int { Badge.set(n) } default: break } } diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index f22c58b..6a7148b 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -227,6 +227,7 @@ final class Playback: NSObject { onTime?(secs, duration) updateElapsed(secs) if abs(secs - lastSaved) > 10 { savePosition() } + Badge.refreshIfStale() // Heard, not just started: the same 90% the page uses, so an item does not leave Unread // the moment it is pressed. The page does the same while it runs; this is for when it // does not. diff --git a/ios/project.yml b/ios/project.yml index cc54dc5..e6b7407 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -25,7 +25,7 @@ targets: properties: CFBundleDisplayName: iPX CFBundleShortVersionString: $(MARKETING_VERSION) - CFBundleVersion: "9" + CFBundleVersion: "10" # 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 387984a..bbdc691 100644 --- a/tests/native-bridge.ts +++ b/tests/native-bridge.ts @@ -175,6 +175,11 @@ since(); vm.runInContext('toast("Unable to play", true)', ctx); ok(last('haptic')?.kind === 'warn', 'an error was not felt'); +// The icon's count (#421): the page's own, sent when it changes. +since(); +vm.runInContext('hostBadge(7); hostBadge(7); hostBadge(3)', ctx); +ok(posted.filter(m => m.t === 'unread').map(m => m.n).join() === '7,3', 'the app was not told the unread count once per change'); + if (failed) { console.error(`\n${failed} failed`); process.exit(1); } console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it'); process.exit(0); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 57873c5..d05ea29 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 +- In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421). - In the iOS app, presses are felt: play, pause, flag, pull to refresh, marking read and errors each give the phone's haptic for it (#430). - 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). diff --git a/web/src/feeds.ts b/web/src/feeds.ts index e10cfda..3f22106 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -108,6 +108,7 @@ function renderFeeds(){ for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); } list.innerHTML=''; const unreadAll=unreadInAll(); + hostBadge(unreadAll); const places=document.createElement('div'); places.className='places'; for(const [id,v] of Object.entries(VIEWS)){ diff --git a/web/src/native.ts b/web/src/native.ts index b0782e1..f1aad32 100644 --- a/web/src/native.ts +++ b/web/src/native.ts @@ -25,6 +25,15 @@ const ipxHost: ((m: any) => void) | null = (() => { if (ipxHost) installNativePlayback(); +/// All Subscriptions' unread count, for the app's icon (#421), each time the list is drawn with a +/// new one: items read in the page and new ones arriving set it at once, where the app's own fetch +/// waited for the app to come forward again. +let badgeSent=-1; +function hostBadge(n: number){ + // As haptic: the list can be drawn before this file has declared what it uses. + try{ if(n!==badgeSent){ badgeSent=n; ipxHost?.({t:'unread', n}); } }catch{} +} + /// A press felt as well as seen, in the app (#430): the phone's haptics, which a page cannot reach. /// `tap` for play, pause and the like, `done` for something finished (marked read), `warn` for an /// error. An app without them ignores the message; a browser has no host to send it to.