Reactive Audio Streaming in Java: Build an MP3 Player with Quarkus and Mutiny
A hands-on guide to creating a real-time web music player using Java’s most modern reactive stack.
We just got back from a short family trip to Crete. A week of sunshine, sand, and, believe it or not, very little Wi-Fi. The resort had coverage in the lobby, but once you wandered down to the beach, your phone might as well have been a paperweight.
And that turned out to be a good thing. The kids rediscovered offline games and playlists they hadn’t touched in months. I realized how much I missed the simplicity of just pressing play and listening without notifications or buffering icons. Somewhere between the sea and the hotel room balcony, it hit me: we’ve come full circle. Phones used to be our music players.
So when I got back home and opened my laptop again, I wanted to recreate that simple, self-contained experience, just for fun. No Spotify APIs, no streaming service logins, no DRM headaches. Just a clean web interface backed by a reactive Java service that streams MP3 files efficiently.
That’s how this small weekend project started: a web-based MP3 player built with Quarkus and Mutiny. It streams your local music collection reactively, serves it from the backend, and lets you play, skip, and even jump back or forward 30 seconds, all from your browser.
Let’s build it.
Prerequisites
You’ll need:
Java 17+
Maven 3.9+
Quarkus CLI or
mvnA few
.mp3files for testingBasic HTML + JS knowledge (I know .. it is what it is..)
You can take this tutorial step-by-step or just grab the code from my Github repository.
Project Setup
Create a new Quarkus project:
mvn io.quarkus:quarkus-maven-plugin:3.29.2:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=reactive-mp3-player \
-DclassName="com.example.MusicResource" \
-Dextensions="rest-jackson"
-Dpath=”/api/music”
cd reactive-mp3-playerThese provides HTTP and non-blocking file I/O.
Also add the following dependency to your pom.xml
<dependency>
<groupId>com.mpatric</groupId>
<artifactId>mp3agic</artifactId>
<version>0.9.1</version>
</dependency>When streaming audio reactively with Mutiny, you never actually load the entire MP3 file at once. The server sends it chunk by chunk, as the browser consumes it. This keeps memory usage low and startup fast, but it also means the player never gets to “see” the full file.
Without reading metadata in advance, the frontend has no way to know:
The total file size, making progress bars unreliable.
The duration of the track until playback finishes.
Basic track information like title, artist, or album.
That’s why we will add a dedicated metadata endpoint.
It reads the file’s ID3 tags using mp3agic and returns everything the player needs to display the correct information before playback starts. The audio still streams reactively, but the UI can now feel complete and responsive, showing song titles, album names, and progress from the first second.
Implement the Backend
Music Metadata Model
Create src/main/java/com/example/MusicMetadata.java:
package com.example;
public class MusicMetadata {
private String filename;
private String title;
private String artist;
private String album;
private String year;
private String genre;
private Long duration; // in seconds
private Long fileSize; // in bytes
public MusicMetadata() {
}
public MusicMetadata(String filename) {
this.filename = filename;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getArtist() {
return artist;
}
public void setArtist(String artist) {
this.artist = artist;
}
public String getAlbum() {
return album;
}
public void setAlbum(String album) {
this.album = album;
}
public String getYear() {
return year;
}
public void setYear(String year) {
this.year = year;
}
public String getGenre() {
return genre;
}
public void setGenre(String genre) {
this.genre = genre;
}
public Long getDuration() {
return duration;
}
public void setDuration(Long duration) {
this.duration = duration;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
}The MusicResource class
Update the scaffolded src/main/java/com/example/MusicResource.java:
package com.example;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.util.stream.Collectors;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.mpatric.mp3agic.ID3v1;
import com.mpatric.mp3agic.ID3v2;
import com.mpatric.mp3agic.Mp3File;
import io.smallrye.mutiny.Multi;
import io.vertx.core.file.OpenOptions;
import io.vertx.mutiny.core.Vertx;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
@Path(”/api/music”)
@Produces(MediaType.APPLICATION_JSON)
public class MusicResource {
@ConfigProperty(name = “music.directory”, defaultValue = “/path/to/your/music”)
String musicDir;
@Inject
Vertx vertx;
@GET
@Path(”/list”)
public List<String> listMusic() {
try {
return Files.list(new File(musicDir).toPath())
.filter(path -> path.toString().endsWith(”.mp3”))
.map(path -> path.getFileName().toString())
.collect(Collectors.toList());
} catch (Exception e) {
throw new WebApplicationException(”Failed to list music files”, e, 500);
}
}
@GET
@Path(”/stream/{filename}”)
@Produces(”audio/mpeg”)
public Multi<byte[]> streamMusic(@PathParam(”filename”) String filename) {
if (filename.contains(”..”)) {
throw new WebApplicationException(”Invalid filename”, 400);
}
File file = new File(musicDir, filename);
if (!file.exists()) {
throw new WebApplicationException(”File not found”, 404);
}
return vertx.fileSystem().open(file.getAbsolutePath(), new OpenOptions().setRead(true))
.onItem().transformToMulti(asyncFile -> asyncFile.toMulti()
.onItem().transform(buffer -> buffer.getBytes())
.onCompletion().invoke(asyncFile::close));
}
@GET
@Path(”/metadata/{filename}”)
public MusicMetadata getMetadata(@PathParam(”filename”) String filename) {
if (filename.contains(”..”)) {
throw new WebApplicationException(”Invalid filename”, 400);
}
File file = new File(musicDir, filename);
if (!file.exists()) {
throw new WebApplicationException(”File not found”, 404);
}
try {
MusicMetadata metadata = new MusicMetadata(filename);
metadata.setFileSize(file.length());
Mp3File mp3File = new Mp3File(file);
metadata.setDuration(mp3File.getLengthInSeconds());
if (mp3File.hasId3v2Tag()) {
ID3v2 id3v2Tag = mp3File.getId3v2Tag();
metadata.setTitle(cleanString(id3v2Tag.getTitle()));
metadata.setArtist(cleanString(id3v2Tag.getArtist()));
metadata.setAlbum(cleanString(id3v2Tag.getAlbum()));
metadata.setYear(cleanString(id3v2Tag.getYear()));
metadata.setGenre(cleanString(id3v2Tag.getGenreDescription()));
} else if (mp3File.hasId3v1Tag()) {
ID3v1 id3v1Tag = mp3File.getId3v1Tag();
metadata.setTitle(cleanString(id3v1Tag.getTitle()));
metadata.setArtist(cleanString(id3v1Tag.getArtist()));
metadata.setAlbum(cleanString(id3v1Tag.getAlbum()));
metadata.setYear(cleanString(id3v1Tag.getYear()));
metadata.setGenre(cleanString(id3v1Tag.getGenreDescription()));
}
return metadata;
} catch (Exception e) {
// If metadata reading fails, return basic info
MusicMetadata metadata = new MusicMetadata(filename);
metadata.setFileSize(file.length());
return metadata;
}
}
private String cleanString(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}Key points:
Uses Microprofile Config to point to the directory where your mp3s are.
Uses Vert.x
AsyncFilefor non-blocking streaming.Streams file chunks as a Mutiny
Multi<byte[]>.Protects against directory traversal (
..check)./listendpoint returns available MP3s for the playlist./metadata/{filename}extracts ID3 tags withmp3agic.
Enable CORS
Edit src/main/resources/application.properties:
quarkus.http.cors.enabled=true
quarkus.http.cors.origins=*
quarkus.http.cors.methods=GETFrontend (HTML + JavaScript)
Create src/main/resources/META-INF/resources/index.html:
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8” />
<title>Reactive MP3 Player</title>
<style>
</style>
</head>
<body>
<h1>🎵 RETRO MP3 PLAYER 🎵</h1>
<div id=”player”>
<audio id=”audio” controls></audio>
<div id=”trackInfo”>
<div id=”trackTitle”>-- NO TRACK --</div>
<div id=”trackArtist”>-- SELECT A TRACK --</div>
</div>
<div class=”timeDisplay”>
<span id=”currentTime”>0:00</span> / <span id=”duration”>--:--</span>
</div>
<div class=”controls-row”>
<button id=”prev”>⏮ PREV</button>
<button id=”skipBack”>⏪ -30s</button>
<button id=”playpause”>▶ PLAY</button>
<button id=”skipForward”>+30s ⏩</button>
<button id=”next”>NEXT ⏭</button>
</div>
<div style=”margin: 15px 0;”>
<div style=”color: #ffaa00; font-size: 11px; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 1px;”>VOLUME</div>
<input type=”range” id=”volume” min=”0” max=”1” step=”0.01” value=”1”>
</div>
<div style=”color: #ffaa00; font-size: 11px; margin: 15px 0 5px 0; text-transform: uppercase; letter-spacing: 1px;”>PLAYLIST</div>
<ul id=”playlist”></ul>
</div>
<script>
const audio = document.getElementById(’audio’);
const playpause = document.getElementById(’playpause’);
const prev = document.getElementById(’prev’);
const next = document.getElementById(’next’);
const skipBack = document.getElementById(’skipBack’);
const skipForward = document.getElementById(’skipForward’);
const playlist = document.getElementById(’playlist’);
const volume = document.getElementById(’volume’);
const currentTimeDisplay = document.getElementById(’currentTime’);
const durationDisplay = document.getElementById(’duration’);
const trackTitle = document.getElementById(’trackTitle’);
const trackArtist = document.getElementById(’trackArtist’);
let tracks = [];
let currentIndex = 0;
let currentMetadata = null;
async function loadPlaylist() {
const res = await fetch(’/api/music/list’);
tracks = await res.json();
playlist.innerHTML = ‘’;
tracks.forEach((t, i) => {
const li = document.createElement(’li’);
li.textContent = `${String(i + 1).padStart(2, ‘0’)}. ${t.replace(’.mp3’, ‘’)}`;
li.onclick = () => playTrack(i);
playlist.appendChild(li);
});
}
async function playTrack(index) {
currentIndex = index;
const filename = tracks[index];
audio.src = `/api/music/stream/${encodeURIComponent(filename)}`;
// Fetch and display metadata
try {
const metadataRes = await fetch(`/api/music/metadata/${encodeURIComponent(filename)}`);
currentMetadata = await metadataRes.json();
// Display track info
trackTitle.textContent = (currentMetadata.title || filename.replace(’.mp3’, ‘’)).toUpperCase();
trackArtist.textContent = (currentMetadata.artist || ‘UNKNOWN ARTIST’).toUpperCase();
// Use metadata duration if available
if (currentMetadata.duration) {
durationDisplay.textContent = formatTime(currentMetadata.duration);
}
} catch (e) {
console.error(’Failed to load metadata:’, e);
trackTitle.textContent = filename.replace(’.mp3’, ‘’).toUpperCase();
trackArtist.textContent = ‘UNKNOWN ARTIST’;
}
// Play with error handling
audio.play().catch(err => {
// Ignore AbortError - it happens when pause is called during play
if (err.name !== ‘AbortError’) {
console.error(’Play error:’, err);
}
});
}
// Playback controls
playpause.onclick = () => {
if (audio.paused) {
playpause.textContent = ‘⏸ PAUSE’;
audio.play().catch(err => {
// Ignore AbortError - it happens when pause is called during play
if (err.name !== ‘AbortError’) {
console.error(’Play error:’, err);
}
});
} else {
playpause.textContent = ‘▶ PLAY’;
audio.pause();
}
};
// Update play/pause button text based on audio state
audio.addEventListener(’play’, () => {
playpause.textContent = ‘⏸ PAUSE’;
});
audio.addEventListener(’pause’, () => {
playpause.textContent = ‘▶ PLAY’;
});
prev.onclick = () => playTrack((currentIndex - 1 + tracks.length) % tracks.length);
next.onclick = () => playTrack((currentIndex + 1) % tracks.length);
volume.oninput = () => audio.volume = volume.value;
// Skip 30 seconds back or forward
skipBack.onclick = () => {
audio.currentTime = Math.max(0, audio.currentTime - 30);
};
skipForward.onclick = () => {
audio.currentTime = Math.min(audio.duration, audio.currentTime + 30);
};
// Time display
audio.addEventListener(’timeupdate’, () => {
currentTimeDisplay.textContent = formatTime(audio.currentTime);
// Use metadata duration if available, otherwise try audio.duration
if (currentMetadata && currentMetadata.duration) {
durationDisplay.textContent = formatTime(currentMetadata.duration);
} else if (isFinite(audio.duration) && !isNaN(audio.duration)) {
durationDisplay.textContent = formatTime(audio.duration);
} else {
durationDisplay.textContent = ‘--:--’;
}
});
// Also update duration when metadata is loaded (fallback)
audio.addEventListener(’loadedmetadata’, () => {
if (!currentMetadata || !currentMetadata.duration) {
if (isFinite(audio.duration) && !isNaN(audio.duration)) {
durationDisplay.textContent = formatTime(audio.duration);
}
}
});
// Auto-play next track
audio.addEventListener(’ended’, () => next.click());
function formatTime(sec) {
if (isNaN(sec) || !isFinite(sec)) return ‘0:00’;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60).toString().padStart(2, ‘0’);
return `${m}:${s}`;
}
loadPlaylist();
</script>
</body>
</html>
Now the player displays metadata directly below the controls when a song plays.
Features:
Two skip buttons for jumping 30 seconds back and forth.
Smooth time display updates.
Auto-play next track and playlist control.
Verification
Run Quarkus in dev mode:
./mvnw quarkus:devOpen http://localhost:8080 in your browser.
You should see your reactive MP3 player UI, populated with your local .mp3 files.
Try this:
Click a track to start playback.
Use ⏪ and ⏩ to jump 30 seconds.
Adjust volume or skip to the next song.
Grab the full styling and everything else you need from my Github repository.
Production Notes
Efficiency: Mutiny’s reactive model streams audio in chunks, avoiding full-file buffering.
Security: Always validate user inputs and sanitize filenames.
Static resources: Placing the HTML under
META-INF/resourceslets Quarkus serve it natively.Native builds:
./mvnw package -Pnative
Produces a lightning-fast standalone binary.
7. Variations and Extensions
Add waveform visualization using the Web Audio API.
Display album art and metadata from ID3 tags.
Add shuffle and repeat buttons.
Support FLAC or WAV via
@Produces(”audio/*”).Serve the player over HTTPS for secure streaming.
Final line:
This small project started on a beach in Crete, without Wi-Fi or cloud dependencies. It ended as a modern, reactive web player powered by Quarkus and Mutiny—proof that sometimes, the simplest ideas are still the most fun to build.




