Skip to content
Blog

Building a Podcast Player: Audio Streaming in Flutter

Learn how to build a podcast player in Flutter with background audio playback, playlists, progress tracking, and speed control.

Published on August 17, 2026

AI Assistant

Podcasts are pure audio apps: fetch an RSS feed, stream an MP3, keep playing in the background, and remember where the user left off. Building one in Flutter touches everything that’s interesting about mobile media — background audio, lockscreen controls, network streaming, and persistent state. The good news is the hard parts (background playback, OS media controls) are solved problems; your job is to orchestrate them well.

In this tutorial, you will learn how to build a podcast player with just_audio and audio_service, parse an RSS feed, stream episodes, show lockscreen controls, and persist playback position. Key technologies: Flutter, just_audio, audio_service, webfeed (RSS parsing), and shared_preferences.

Prerequisites

  • Flutter 3.x installed
  • A podcast RSS feed URL to test with (any public feed works)
  • An Android or iOS device/emulator (background audio needs a real device to test fully)

Core Content

Add dependencies

dependencies:
  flutter:
    sdk: flutter
  just_audio: ^0.9.0
  audio_service: ^0.18.0
  webfeed: ^0.4.0
  shared_preferences: ^2.0.0

just_audio handles audio playback (streaming, buffering, speed control). audio_service exposes your player to the OS so it keeps playing in the background and appears in the lockscreen media controls.

Create the audio handler

audio_service requires a BaseAudioHandler that bridges your player to the system. A minimal handler:

class PodcastAudioHandler extends BaseAudioHandler {
  final AudioPlayer _player = AudioPlayer();

  PodcastAudioHandler() {
    _player.playbackEventStream.listen((event) {
      playbackState.add(event.copyWith(
        controls: const [MediaControl.pause, MediaControl.play],
        systemActions: const {
          MediaAction.seek,
          MediaAction.play,
          MediaAction.pause,
        },
        processingState: AudioProcessingState.ready,
      ));
    });
    _player.positionStream.listen((pos) {
      playbackState.add(playbackState.value.copyWith(
        updatePosition: pos,
      ));
    });
  }

  @override
  Future<void> play() => _player.play();

  @override
  Future<void> pause() => _player.pause();

  @override
  Future<void> seek(Duration position) => _player.seek(position);

  @override
  Future<void> stop() => _player.stop();

  Future<void> setEpisode(Episode episode) async {
    await _player.setUrl(episode.audioUrl, initialPosition: episode.position);
    _mediaItem.add(MediaItem(
      id: episode.guid,
      title: episode.title,
      artist: episode.author,
      duration: episode.duration,
    ));
  }
}

The handler is where background playback lives. The OS calls play, pause, and seek on it, and the app never touches the player directly.

Parse the RSS feed

Podcasts distribute as RSS. webfeed parses the feed into episodes:

Future<List<Episode>> loadEpisodes(String feedUrl) async {
  final http.Response response = await http.get(Uri.parse(feedUrl));
  final feed = RsdDocument.parse(response.body);

  return feed.items!.map((item) {
    return Episode(
      guid: item.guid ?? item.title ?? '',
      title: item.title ?? 'Untitled',
      author: item.author?.name ?? item.dc?.creator ?? 'Unknown',
      audioUrl: item.enclosure?.url ?? '',
      description: item.description ?? '',
      duration: item.duration != null
          ? _parseDuration(item.duration!)
          : Duration.zero,
    );
  }).toList();
}

RSS is HTML-ish text, so strip tags from descriptions and handle missing fields — real-world feeds are messy.

Play episodes and control speed

With the handler wired to your UI, play an episode and adjust speed:

final handler = await AudioService.init(
  builder: () => PodcastAudioHandler(),
  config: const AudioServiceConfig(
    androidNotificationChannelName: 'Podcast playback',
    androidNotificationOngoing: true,
    androidStopForegroundOnPause: true,
  ),
);

await handler.setEpisode(episode);
await handler.play();
await handler.setSpeed(1.5); // playback speed via the player

AudioService.init must run before your app’s runApp. On Android, declare the foreground service permissions in the manifest so playback can continue in the background.

Persist playback position

Remembering where the user stopped is the feature listeners expect. Save the position periodically and restore it on load:

final prefs = await SharedPreferences.getInstance();
final saved = prefs.getInt('position_${episode.guid}');

// Save every 10 seconds while playing
_player.positionStream
    .where((p) => p.inSeconds % 10 == 0)
    .listen((pos) async {
  await prefs.setInt('position_${episode.guid}', pos.inSeconds);
});

// Restore when loading
if (saved != null) {
  await _player.setUrl(episode.audioUrl, initialPosition: Duration(seconds: saved));
}

Wire up the UI

A simple player screen streams position and playback state:

StreamBuilder<Duration>(
  stream: _player.positionStream,
  builder: (context, snapshot) {
    final position = snapshot.data ?? Duration.zero;
    return Slider(
      max: episode.duration.inSeconds.toDouble(),
      value: position.inSeconds.toDouble().clamp(0, episode.duration.inSeconds.toDouble()),
      onChanged: (v) => _player.seek(Duration(seconds: v.toInt())),
    );
  },
)

Putting It All Together

A complete podcast player has a feed loader, an AudioHandler for background playback, an episode list, a player screen with seek and speed controls, and position persistence. The key architecture decision — routing all playback through audio_service’s handler — is what makes background audio and lockscreen controls work on both platforms.

Conclusion & Next Steps

You’ve built a functional podcast player: it streams audio, plays in the background, shows OS media controls, remembers positions, and lets listeners change speed.

Next Steps: add a download feature (use just_audio’s offline loading), build a “new episodes” inbox with feed refresh, add bookmarks, and integrate with a Player interface that survives process death.

References: