You tap play, and nothing happens. You lift your finger, wait a beat, and the screen still hasn't changed.
I saw this in the player screen of my own healing-sound app one evening, and my stomach sank a little. It wasn't a bug. On a thin connection, the first bytes of the audio file simply hadn't arrived yet. But to the person holding the phone, a silent screen looks the same as a broken one, and the button got tapped two or three times, each tap queuing another play request.
What I'd fix first isn't the network. It's what the screen shows while it waits.
Treat the wait as a state
I can't shorten the time between tap and sound, so I give the waiting period its own state. I use four:
| State | Meaning | Button |
|---|---|---|
| idle | Not tapped yet | Play icon |
| waiting | Tapped, no sound yet | Spinner (disabled) |
| playing | Sound is coming out | Pause icon |
| failed | Timed out or failed to load | Retry hint |
Keeping waiting separate from playing matters. A long "pretend playing" state is exactly what invites double taps.
Derive waiting from your own flag
expo-audio's useAudioPlayerStatus returns fields such as isLoaded, isBuffering and playing. Check the exact names against the type definitions for your SDK version. I keep the decision logic in a small function, away from the component:
// What this solves: turn "tapped but silent" into an explicit UI state
type PlayUiState = 'idle' | 'waiting' | 'playing' | 'failed';
export function decidePlayUiState(args: {
requested: boolean; // did the user tap play?
playing: boolean; // status.playing
failed: boolean; // timeout or load failure
}): PlayUiState {
if (args.failed) return 'failed';
if (args.playing) return 'playing';
if (args.requested) return 'waiting';
return 'idle';
}Owning requested yourself is the key. If you rely only on the player's isBuffering, there can be a moment right after the tap when it is still false, and that single beat is where the screen looks dead. Record the tap first, and waiting starts at the moment of the tap.
Show the spinner after 400ms, give up after 8 seconds
A spinner that appears instantly flickers on a good connection, which feels busier than doing nothing. So I only show it if sound hasn't started after 400ms. That number is a design choice of mine, not a measurement.
import { useEffect, useRef, useState } from 'react';
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
const SHOW_SPINNER_AFTER_MS = 400; // avoid flicker
const GIVE_UP_AFTER_MS = 8000; // timeout
export function useStreamingPlayback(url: string) {
const player = useAudioPlayer({ uri: url });
const status = useAudioPlayerStatus(player);
const [requested, setRequested] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [failed, setFailed] = useState(false);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearTimers = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
};
const start = () => {
if (requested) return; // ignore double taps
setFailed(false);
setRequested(true);
player.play();
timers.current.push(setTimeout(() => setShowSpinner(true), SHOW_SPINNER_AFTER_MS));
timers.current.push(setTimeout(() => setFailed(true), GIVE_UP_AFTER_MS));
};
// Sound started, or we failed: clean up
useEffect(() => {
if (status.playing || failed) {
clearTimers();
setShowSpinner(false);
if (failed) setRequested(false); // allow another try
}
}, [status.playing, failed]);
useEffect(() => clearTimers, []); // clean up on unmount
return { requested, showSpinner, failed, playing: status.playing, start };
}Three things I watch here: stopping double taps at the top of start, clearing timers once sound begins, and clearing them on unmount. Skip the last one and a setState fires later on a screen you've already left.
Eight seconds is also a design value. Too short and you mark a slow connection as failed; too long and it's indistinguishable from a frozen screen.
Hand the retry back to the user
The failure message shouldn't guess at a cause. I write something like "This is taking a while. Please try again," with one next action, because the screen can't tell whether the network or the server is at fault.
I also decided not to auto-retry. In a version that did, on an unstable connection play requests piled up in the background and sounds eventually overlapped. Now there is exactly one retry, and the user triggers it. I set how long we wait, but the user holds the trigger for trying again. I keep that line every time I rebuild this screen.
Pitfalls
- Creating a new player without releasing the old one can cause playback problems; I wrote that up in why expo-audio's createAudioPlayer stops after 40 plays.
- The thinking behind the spinner connects to designing loading with skeleton screens.
- For seamless ambient sound, loop boundaries matter as much as the first load: see pitfalls of ambient loop audio in healing apps.
What to do next
Add a single requested flag to your play button and check that the button changes the instant it's tapped. Then throttle your connection on purpose and watch the gap. That was my first fix too.