DEV Community

Cover image for Zenith: 45 streamed tracks and a sleep timer that stops on time
Stacklyn Labs
Stacklyn Labs

Posted on

Zenith: 45 streamed tracks and a sleep timer that stops on time

11:40 pm. Someone opens a sleep app, picks heavy rain with rolling thunder underneath, sets a 30-minute timer and locks the phone.

From that moment the app has three jobs. Keep playing with the screen off. Get out of the way when something more important happens. And stop when it was told to stop, not at 4 am.

Zenith is our Flutter template for sleep, focus and breathing apps, built with Riverpod 3. This post follows that one night, but first there was a decision we had to make before any of it: where the audio lives.

Before the night: stream it, list it in JSON

Zenith has 45 tracks: 15 ambient sounds, 10 mood tracks and 20 Lofi radio tracks. None of them are packaged inside the app. They're streamed from cloud storage, which keeps the download small.

The catalogue itself is plain JSON in assets/data. Each entry has a title, an image and an audio URL (simplified, field names illustrative):

{
  "title": "Heavy Rain",
  "image": "assets/images/heavy_rain.webp",
  "audioUrl": "https://your-cdn.example.com/audio/heavy-rain.mp3"
}
Enter fullscreen mode Exit fullscreen mode

A Riverpod provider turns that file into typed models:

// simplified
final soundsProvider = FutureProvider<List<Sound>>((ref) async {
  final raw = await rootBundle.loadString('assets/data/sounds.json');
  final items = jsonDecode(raw) as List;
  return items.map((e) => Sound.fromJson(e as Map<String, dynamic>)).toList();
});
Enter fullscreen mode Exit fullscreen mode

The result: to rename a track, swap its artwork or point it at a different file, you edit the JSON and rebuild. No Dart changes. The audio URLs can point at your own server, a CDN or GitHub storage, and the cloud links that ship with the template work out of the box if you don't want to host anything yet.

11:40 pm: the phone locks

Playback is built on just_audio, with audio_service and audio_session around it. That gives an Android foreground service, iOS background audio and lock-screen media controls. In version 1.1.0 the iOS background audio mode is configured, so audio keeps playing when the phone is locked.

The lock-screen and notification controls show the real buffering state, position and seek. That sounds minor until a stream is slow at midnight and the only feedback is a frozen play button.

The mixer is where most nights start. Users tap sound tiles and blend any two layers, each with its own volume slider, and can run them under the Lofi radio or a mood track.

Zenith ambient white noise sound mixer with rain, thunder, ocean waves and per-layer volume sliders

11:52 pm: something interrupts

A call comes in. Or the headphones come out. A sleep app that keeps blasting rain through the speaker at that point gets uninstalled.

Zenith's audio session lowers the volume for other sounds, and pauses for phone calls or when headphones are unplugged. Streams also fail sometimes, so a failed track gets one retry.

// simplified
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());

session.interruptionEventStream.listen((event) {
  if (event.begin) player.pause();
});
session.becomingNoisyEventStream.listen((_) => player.pause());

Future<void> playUrl(String url) async {
  try {
    await player.setUrl(url);
  } catch (_) {
    await player.setUrl(url); // one retry, then let it fail
  }
  await player.play();
}
Enter fullscreen mode Exit fullscreen mode

If the connection drops entirely, retrying won't help. The app detects the lost connection and tells the user.

12:10 am: the timer runs out

The sleep timer offers 15, 30 or 45 minutes, or 1 hour. Five minutes before playback stops, a local notification gives a gentle heads-up.

As of 1.1.0, the timer runs inside the audio service. That's the part of the app that's still alive with the screen off, so it's the part that should own the countdown.

// simplified, inside the audio handler
Timer? _stopTimer;
Timer? _warnTimer;

void startSleepTimer(Duration length) {
  _stopTimer?.cancel();
  _warnTimer?.cancel();
  _warnTimer = Timer(length - const Duration(minutes: 5), showStopSoonNotification);
  _stopTimer = Timer(length, stop);
}
Enter fullscreen mode Exit fullscreen mode

Zenith full-screen music player with sleep timer and lock-screen controls

Where Zenith falls short

Worth saying plainly, because streamed audio comes with trade-offs.

  • It needs a connection. The tracks are streamed, not bundled. Without internet there's nothing to play.
  • Somebody hosts the audio. The included cloud links work, but if you want control over the files, the server, CDN or GitHub storage is yours to run.
  • Content changes need a rebuild. The JSON catalogue ships inside the app, so editing it means a new build, not a live update.
  • Two layers in the mixer. You can blend any two sounds, not five.
  • It has ads. AdMob interstitials, capped per session, with Google UMP consent shown before any ad loads.
  • The web build is a demo. Ads, notifications and background audio are mobile-only.

Try it

Zenith is $29. It needs Flutter 3.44+ and runs on Android (minSdk 21) and iOS.

If you've shipped an app that streams audio, where did you host the files, and did the bandwidth bill match what you expected?

Top comments (0)