DevOps by Default Blog

Radiophonia 0.8.1: 27 Badges Evaluated in the Background, an AVPlayer That Survives Backgrounding, and a Catalogue That Won't Go Dark


Radiophonia 0.8.1 is the first release that adds a feedback loop to listening. Every minute of playback, every station saved, every song liked now feeds into 27 achievement badges that unlock through normal app usage — no streaks, no daily check-ins, no penalties. Behind the shiny pixel-art sprites, this release also fixes the iOS playback bug that turned every long-backgrounded app into a (-1004) Could not connect to the server error, and hardens browse against RadioBrowser outages.

Explorer badge — discover 10 stations

The Problem

A badge system sounds trivial until you try to ship one. Twenty-seven conditions, six categories (time milestones, discovery, engagement, favourites, liked songs, premium features), all needing to evaluate the moment a relevant state change happens — and none of them allowed to drain battery or block the UI thread. The naive implementation is a periodic poll: every N seconds, check every condition against every stat. That works for a desktop demo and dies on a phone.

The second problem is that “unlock” is a one-shot event but the conditions are continuous. Listen for 100 hours doesn’t fire when the 100th hour ticks over — it fires the first time the cumulative threshold crosses, and it must never fire again. A badge evaluator that re-fires on every recomputation is a regression waiting to happen.

The Solution

BadgeService is a ChangeNotifier that listens to four event sources: stats updates (listening time, station play counts, country counts), history appends (song plays per station), favourites changes, and liked-song changes. When any of those fire, only the affected badge categories are re-evaluated. A stats tick from “played one more song on this station” doesn’t trigger a re-check of the Premium Supporter badge — only the discovery and engagement categories.

Each badge has an id, title, description, icon, color, and a bool isUnlocked(BadgeContext) predicate. The BadgeContext is a snapshot of cumulative state — total listening seconds, set of countries played, set of genres played, favourite count, liked song count, action flags. The predicate is pure: same context, same result. This makes evaluation trivially testable and idempotent.

Unlocked state persists in a Hive box keyed by badge id. The unlock stream is a StreamController<Badge>.broadcast() that emits only on the transition false → true. Re-evaluating an already-unlocked badge short-circuits at the persistence layer, so even if the predicate re-fires, the notification doesn’t.

What the User Sees

Badges appear. They appear at the moment the qualifying event happens, not on the next app launch. They appear once. They survive reinstalls (Hive box is included in iCloud and Android Auto Backup).

The Lesson

Badge systems are evaluation engines, not UI features. The UI is the easy part — the value is in a pure, idempotent predicate layer that can be re-run as often as the event stream demands without producing duplicate unlocks. If your unlock path isn’t a state machine transition, you’ve built a regression machine.

The Fullscreen Celebration: Confetti, Chimes, and an Off Switch

Century badge — listen for 100 hours

The Problem

Once you have a badge unlock stream, the temptation is to ship a SnackBar and move on. We did not move on. The brief was: this should feel like an arcade cabinet lighting up, but it must never block playback, never feel compulsory, and never annoy anyone who hates it.

The constraint that shaped everything: the celebration overlays the Now Playing screen, which is the primary UI. Whatever we build cannot intercept touches that the user might be directing at transport controls. It also cannot break when the user backgrounds the app mid-celebration.

The Solution

The overlay is a Stack layer at the root of the main shell, conditionally rendered when _currentBadgeBanner != null. It has three pieces, all wrapped in Positioned.fill + IgnorePointer:

  1. BadgeFlash — a brief AnimatedOpacity sweep across the full screen using the badge’s own colour. Sets the visual mood without committing to a colour theme.
  2. AchievementBanner card — centered, constrained to 85% of screen width. Animates in with a Tween<double>(begin: 0.3, end: 1.0) on scale, curve Curves.elasticOut, over the first 40% of the celebration duration. The elastic curve gives the bouncy pop-in feel of an arcade score reveal.
  3. 60-particle confetti burstCustomPainter drawing particles with per-particle gravity physics, mixed colours from the badge colour plus a rainbow palette. Painted on top of the card so the burst visibly rains over the artwork.

Sound is the 8-bit achievement_unlock.wav chime. Haptics are triple-tap: heavyImpact → 100ms → mediumImpact → 100ms → heavyImpact. Total celebration duration is 4 seconds, after which _fadeAnimation (curve Curves.easeIn over the final 30%) fades the whole stack out and _currentBadgeBanner returns to null.

The off switch lives at Settings → Badges → “Show achievement notifications”. When disabled, the listener short-circuits before any of the above runs — no flash, no card, no confetti, no chime, no haptics. The badge row on the Discover screen also disappears, so the gamification is invisible to anyone who wants it to be. Badges are still tracked silently underneath, so flipping the toggle back restores the full grid without losing progress.

The Lesson

Feedback loops need an off switch that actually turns off the loop, not just the visible UI. The toggle here gates the entire pipeline at the listener — if you flip it off, the celebration never starts. If we’d only hidden the banner, we’d still be playing the chime and firing the haptics in the background, which is the kind of “feature” that gets uninstalled.

The iOS Backgrounding Bug: AVPlayer’s Dying Socket

The Problem

Reports of (-1004) Could not connect to the server errors on iOS, always after the app had been backgrounded for three minutes or more. Force-quit and relaunch fixed it; nothing else did. The error came from AVPlayer trying to read from the localhost socket where StreamProxyService serves the proxied audio.

The cause is iOS itself. When the app backgrounds, AVPlayer’s connection to a localhost-bound socket is invalidated after a few minutes — the OS reclaims the socket state. Returning to the foreground does not heal the connection. AVPlayer still thinks it has a valid socket; the socket is dead; every subsequent play() attempt hits the same (-1004).

The Solution

AudioPlayerService is a WidgetsBindingObserver. On AppLifecycleState.paused or hidden, it records _backgroundedAt. On AppLifecycleState.resumed, it computes the gap and compares against _iosPlayerRecreateThreshold = Duration(minutes: 3). Below the threshold, no action — brief tab-switches and notification swipes don’t invalidate the socket, and a fresh player for every 10-second backgrounding would be its own bug.

Above the threshold, the recreate is atomic: capture the current station and wasPlaying flag, cancel the three subscriptions (_playerStateSubscription, _metadataSubscription, _nativeIcySubscription), swap _player for a fresh AudioPlayer with the same audio load configuration, stop the proxy so the fresh AVPlayer gets a clean localhost server, dispose the old player, and call _playInternal(station, clearRadioContext: false) to reconnect.

There is a ~1–2 second audio gap during the reconnect. That is the cost of avoiding the persistent dead-socket state. The alternative — leaving the broken player in place — required a force-quit to recover.

The Lesson

Native resources don’t survive backgrounding. AVPlayer’s localhost socket is one of those things that “works on the simulator until it doesn’t” — the invalidation is timing-dependent and only manifests on real devices after the OS decides your app has been idle long enough. Lifecycle hooks exist for a reason; the only question is whether you use them defensively (recreate on every resume) or selectively (recreate only when the resource is likely dead).

We chose selectively, because the defensive option churns the player on every notification swipe and there’s no way to tell the OS “we’re coming right back.” Three minutes is a guess, but it’s a guess that matches the failure reports.

Catalogue Resilience: When RadioBrowser Falls Over

The Problem

RadioBrowser is excellent and community-run, which means its mirrors occasionally return 502s, time out, or fall over entirely. Browse and search would go dark until the mirrors recovered, even though we had a perfectly good cached station list sitting in Hive. Worse, when a single catalogue call failed, every screen that needed station data would issue its own retry, hammering the failing mirror from multiple directions.

The Solution

Three changes:

Static snapshot fallback — when all live RadioBrowser mirrors are unreachable, browse and search fall back to a static radiophonia.app snapshot of the catalogue. The snapshot is not a substitute for the live directory — it’s stale by design — but it keeps browse functional during outages instead of showing an empty screen.

In-flight call coalescing — duplicate station-list requests for the same endpoint within the same window are now de-duplicated. Ten widgets asking for the same country list result in one network call, not ten.

Stale-while-revalidate on catalogue outages — when a fresh fetch fails, the app serves the stale cached result instead of erroring. The user sees yesterday’s station list, which is almost always fine for a community-maintained directory where stations don’t disappear overnight.

The Lesson

Community directories are dependencies that go down. Treat them the way you’d treat any third-party dependency: cache aggressively, coalesce duplicates, and serve stale data rather than no data. Users do not care that the upstream is having a bad day — they care that your app stopped working.

Smaller Things

Stripe refund webhook — the charge.refunded event now revokes premium on full refunds. A stripe_customers lookup collection written at purchase time maps Stripe customer ID → Firebase uid, so the webhook can find the user without parsing custom claims. The client-side Firestore listener picks up the change in real time. Partial refunds do not revoke (consistent with non-consumable one-time purchase semantics).

Compact landscape navigationNavigationBar height drops to 56px in landscape with labels shown only for the selected tab. The previous fixed height was wasting vertical space on phones in landscape, which is exactly when vertical space matters most.

Now Playing top padding — removed the SafeArea top inset so the song history panel has more vertical room when the Now Playing sheet is open. Two lines of layout code, but the difference is visible on every device.

Dependency upgrades — every Node and Flutter dependency bumped to latest stable, closing transitive vulnerabilities including an RCE/DoS in serialize-javascript (via mocha 10 → 11), a DoS in diff (via sinon 19 → 22), and updates across the Firebase suite, file_picker, share_plus, sign_in_with_apple, and other packages. None of these were directly exploitable in our deployment, but the supply-chain audit was overdue.

By the Numbers

  • 0 streaks, leaderboards, or time-gated badges
  • 3 minute iOS backgrounding threshold before player recreate
  • 4 second celebration duration
  • 8 badge sprite images bundled for the celebration and grid
  • 27 achievement badges shipped
  • 60 confetti particles per celebration
  • 100% of platform dependency bumps without breaking changes

Lessons

The badge engine taught us that gamification is an evaluation engine, not a UI feature. The UI is the easy part; the value is in a pure, idempotent predicate layer that can re-run on every event without producing duplicate unlocks. Once the engine is right, adding badges becomes a configuration problem.

The iOS backgrounding fix taught us that native resources don’t survive the lifecycle we tell users about. AVPlayer’s localhost socket dies after three minutes in the background, and the only recovery is to recreate the player. This is not documented anywhere obvious; it’s the kind of thing you learn from crash reports and replicate on a real device.

The catalogue resilience work taught us that community directories are dependencies that go down. The fix is not to make them more reliable — we can’t — but to make our consumption of them tolerant of failure. Cache aggressively, coalesce duplicates, serve stale data. Users do not care that RadioBrowser is having a bad day; they care that your app stopped working.


Radiophonia 0.8.1 is available from radiophonia.app. Direct-download artifacts are published on the Radiophonia v0.8.1 release page.