Shared audio file
This example keeps one HTML audio file on the same play/pause timeline across browsers. Start here when you already have an MP3, WAV, or other browser-supported audio file.
- Keep this page open in your normal window.
- Copy the private-window link, then paste it into a private or incognito window.
- Interact in either window and watch the other one update.
Copy the code
Both versions use the same shared data and behavior. The live playground runs the Vanilla HTML version.
Save this as index.html, or open it in the playground to
test and change it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shared audio file with playhtml</title>
<style>
:root {
color: #1c1c1c;
background: #f4efe5;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { display: grid; min-height: 100vh; margin: 0; padding: 1.5rem; place-items: center; }
main { width: min(34rem, 100%); }
h1 { margin: 0 0 0.5rem; font-size: clamp(2rem, 8vw, 3.5rem); line-height: 1; }
.intro { margin: 0 0 1.25rem; color: #3b3b3b; line-height: 1.5; }
.player {
padding: 1.25rem;
border: 2px solid #1c1c1c;
background: #ebe4d5;
box-shadow: 5px 5px 0 #1c1c1c;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid #1c1c1c;
background: #f4efe5;
font-family: ui-monospace, monospace;
font-size: 0.82rem;
}
.file-icon { font-size: 1.5rem; }
.controls { display: flex; flex-wrap: wrap; gap: 0.65rem; }
button {
padding: 0.65rem 0.9rem;
border: 2px solid #1c1c1c;
border-radius: 4px;
background: #f4efe5;
color: #1c1c1c;
box-shadow: 2px 2px 0 #1c1c1c;
cursor: pointer;
font: inherit;
font-weight: 700;
}
button:hover { background: #e8a63a; }
button:active { translate: 2px 2px; box-shadow: none; }
.primary { background: #274b9e; color: #f4efe5; }
.primary:hover { background: #1c3875; }
.timeline { margin-top: 1rem; }
.timeline-head {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.45rem;
}
.track { height: 16px; overflow: hidden; border: 2px solid #1c1c1c; background: #e0d8c6; }
.progress { width: 0%; height: 100%; background: #274b9e; }
.status { min-height: 1.4em; margin: 0.8rem 0 0; color: #6a6a66; font-size: 0.9rem; }
</style>
</head>
<body>
<main>
<h1>Shared audio file</h1>
<p class="intro">Every window plays the same file from the shared position.</p>
<section id="shared-audio-player" class="player" can-play>
<audio
data-audio
src="https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3"
preload="auto"
loop
></audio>
<div class="file">
<span class="file-icon" aria-hidden="true">♪</span>
<span>t-rex-roar.mp3</span>
</div>
<div class="controls">
<button type="button" data-action="enable-audio">Enable audio</button>
<button class="primary" type="button" data-action="toggle">Play for everyone</button>
<button type="button" data-action="restart">Restart</button>
</div>
<div class="timeline">
<div class="timeline-head">
<strong data-playback-state>Paused</strong>
<span data-position>0.0s</span>
</div>
<div
class="track"
role="progressbar"
aria-label="Audio position"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="progress" data-progress></div>
</div>
</div>
<p class="status" data-status role="status">Enable audio in this window before playing.</p>
</section>
</main>
<script type="module">
import { playhtml } from "playhtml";
const player = document.getElementById("shared-audio-player");
const audio = player.querySelector("[data-audio]");
let audioEnabled = false;
function durationMs() {
return Number.isFinite(audio.duration) ? audio.duration * 1000 : 2000;
}
function playbackPositionMs(data, nowMs) {
const duration = durationMs();
const elapsed = data.isPlaying ? nowMs - data.startedAtMs : data.positionMs;
return ((elapsed % duration) + duration) % duration;
}
function render(element, data, nowMs) {
const positionMs = playbackPositionMs(data, nowMs);
const progress = (positionMs / durationMs()) * 100;
element.querySelector("[data-progress]").style.width = progress + "%";
element
.querySelector("[role='progressbar']")
.setAttribute("aria-valuenow", String(Math.round(progress)));
element.querySelector("[data-position]").textContent =
(positionMs / 1000).toFixed(1) + "s";
element.querySelector("[data-playback-state]").textContent =
data.isPlaying ? "Playing" : "Paused";
element.querySelector("[data-action='toggle']").textContent =
data.isPlaying ? "Pause for everyone" : "Play for everyone";
}
function syncLocalAudio(data, nowMs) {
if (!audioEnabled) return;
const expectedSeconds = playbackPositionMs(data, nowMs) / 1000;
const driftSeconds = Math.abs(audio.currentTime - expectedSeconds);
if (driftSeconds > 0.25) audio.currentTime = expectedSeconds;
if (data.isPlaying && audio.paused) {
void audio.play().catch(() => {
audioEnabled = false;
player.querySelector("[data-status]").textContent =
"Audio is blocked in this window. Enable it again.";
});
} else if (!data.isPlaying && !audio.paused) {
audio.pause();
}
}
async function enableAudio(data) {
try {
await audio.play();
audio.pause();
audioEnabled = true;
player.querySelector("[data-action='enable-audio']").textContent =
"Audio enabled";
player.querySelector("[data-status]").textContent =
"This window can now play the shared file.";
syncLocalAudio(data, Date.now());
} catch {
player.querySelector("[data-status]").textContent =
"The audio file could not play in this window.";
}
}
player.defaultData = {
isPlaying: false,
startedAtMs: 0,
positionMs: 0,
};
player.updateElement = ({ element, data }) => {
render(element, data, Date.now());
};
player.onClick = (event, { data, setData }) => {
const action = event.target.closest("[data-action]")?.dataset.action;
if (!action) return;
if (action === "enable-audio") {
void enableAudio(data);
return;
}
const nowMs = Date.now();
if (action === "toggle") {
const positionMs = playbackPositionMs(data, nowMs);
setData((draft) => {
draft.isPlaying = !data.isPlaying;
draft.positionMs = positionMs;
draft.startedAtMs = nowMs - positionMs;
});
}
if (action === "restart") {
setData((draft) => {
draft.positionMs = 0;
draft.startedAtMs = nowMs;
});
}
};
player.onMount = ({ getData, getElement }) => {
let animationFrame = 0;
const tick = () => {
const data = getData();
const nowMs = Date.now();
render(getElement(), data, nowMs);
syncLocalAudio(data, nowMs);
animationFrame = requestAnimationFrame(tick);
};
animationFrame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(animationFrame);
audio.pause();
};
};
await playhtml.init({ developmentMode: true });
</script>
</body>
</html>
Start with a React + TypeScript project, install the packages below,
then replace src/App.tsx with the component.
npm install playhtml @playhtml/react // ABOUTME: Keeps one audio file on a shared play, pause, and restart timeline.
// ABOUTME: Loads and plays the media locally while PlayHTML syncs only transport data.
import { useEffect, useRef, useState } from "react";
import { PlayProvider, withSharedState } from "@playhtml/react";
type TransportData = {
isPlaying: boolean;
startedAtMs: number;
positionMs: number;
};
const AUDIO_URL =
"https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3";
function durationMs(audio: HTMLAudioElement | null): number {
return audio && Number.isFinite(audio.duration) ? audio.duration * 1000 : 2000;
}
function playbackPositionMs(
data: TransportData,
nowMs: number,
audio: HTMLAudioElement | null,
): number {
const duration = durationMs(audio);
const elapsed = data.isPlaying ? nowMs - data.startedAtMs : data.positionMs;
return ((elapsed % duration) + duration) % duration;
}
const SharedAudioPlayer = withSharedState<TransportData>(
{
id: "shared-audio-player",
defaultData: {
isPlaying: false,
startedAtMs: 0,
positionMs: 0,
},
},
function SharedAudioPlayerView({ data, setData }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const dataRef = useRef(data);
const audioEnabledRef = useRef(false);
const [positionMs, setPositionMs] = useState(0);
const [audioEnabled, setAudioEnabled] = useState(false);
const [status, setStatus] = useState(
"Enable audio in this window before playing.",
);
dataRef.current = data;
useEffect(() => {
let animationFrame = 0;
let cancelled = false;
const tick = () => {
const audio = audioRef.current;
const transport = dataRef.current;
const nowMs = Date.now();
const expectedMs = playbackPositionMs(transport, nowMs, audio);
setPositionMs(expectedMs);
if (audio && audioEnabledRef.current) {
const expectedSeconds = expectedMs / 1000;
if (Math.abs(audio.currentTime - expectedSeconds) > 0.25) {
audio.currentTime = expectedSeconds;
}
if (transport.isPlaying && audio.paused) {
void audio.play().catch(() => {
if (cancelled) return;
audioEnabledRef.current = false;
setAudioEnabled(false);
setStatus("Audio is blocked in this window. Enable it again.");
});
} else if (!transport.isPlaying && !audio.paused) {
audio.pause();
}
}
animationFrame = requestAnimationFrame(tick);
};
animationFrame = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(animationFrame);
audioRef.current?.pause();
};
}, []);
async function enableAudio() {
const audio = audioRef.current;
if (!audio) return;
try {
await audio.play();
audio.pause();
audioEnabledRef.current = true;
setAudioEnabled(true);
setStatus("This window can now play the shared file.");
if (dataRef.current.isPlaying) {
audio.currentTime =
playbackPositionMs(dataRef.current, Date.now(), audio) / 1000;
await audio.play();
}
} catch {
audioEnabledRef.current = false;
setAudioEnabled(false);
setStatus("The audio file could not play in this window.");
}
}
function togglePlayback() {
const nowMs = Date.now();
const position = playbackPositionMs(data, nowMs, audioRef.current);
setData((draft) => {
draft.isPlaying = !data.isPlaying;
draft.positionMs = position;
draft.startedAtMs = nowMs - position;
});
}
function restartPlayback() {
const nowMs = Date.now();
setData((draft) => {
draft.positionMs = 0;
draft.startedAtMs = nowMs;
});
}
const progress = (positionMs / durationMs(audioRef.current)) * 100;
return (
<section id="shared-audio-player" className="player">
<audio ref={audioRef} src={AUDIO_URL} preload="auto" loop />
<div className="file">
<span className="file-icon" aria-hidden="true">♪</span>
<span>t-rex-roar.mp3</span>
</div>
<div className="controls">
<button type="button" onClick={() => void enableAudio()}>
{audioEnabled ? "Audio enabled" : "Enable audio"}
</button>
<button className="primary" type="button" onClick={togglePlayback}>
{data.isPlaying ? "Pause for everyone" : "Play for everyone"}
</button>
<button type="button" onClick={restartPlayback}>Restart</button>
</div>
<div className="timeline">
<div className="timeline-head">
<strong>
{data.isPlaying ? "Playing" : "Paused"}
</strong>
<span>{(positionMs / 1000).toFixed(1)}s</span>
</div>
<div
className="track"
role="progressbar"
aria-label="Audio position"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
>
<div className="progress" style={{ width: progress + "%" }} />
</div>
</div>
<p className="status" role="status">{status}</p>
</section>
);
},
);
export default function App() {
return (
<PlayProvider initOptions={{ developmentMode: true }}>
<main>
<h1>Shared audio file</h1>
<p className="intro">
Every window plays the same file from the shared position.
</p>
<SharedAudioPlayer />
</main>
<style>{`
:root {
color: #1c1c1c;
background: #f4efe5;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; }
#root {
display: grid;
min-height: 100vh;
padding: 1.5rem;
place-items: center;
}
main { width: min(34rem, 100%); }
h1 {
margin: 0 0 0.5rem;
font-size: clamp(2rem, 8vw, 3.5rem);
line-height: 1;
}
.intro { margin: 0 0 1.25rem; color: #3b3b3b; line-height: 1.5; }
.player {
padding: 1.25rem;
border: 2px solid #1c1c1c;
background: #ebe4d5;
box-shadow: 5px 5px 0 #1c1c1c;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid #1c1c1c;
background: #f4efe5;
font-family: ui-monospace, monospace;
font-size: 0.82rem;
}
.file-icon { font-size: 1.5rem; }
.controls { display: flex; flex-wrap: wrap; gap: 0.65rem; }
button {
padding: 0.65rem 0.9rem;
border: 2px solid #1c1c1c;
border-radius: 4px;
background: #f4efe5;
color: #1c1c1c;
box-shadow: 2px 2px 0 #1c1c1c;
cursor: pointer;
font: inherit;
font-weight: 700;
}
button:hover { background: #e8a63a; }
button:active { translate: 2px 2px; box-shadow: none; }
.primary { background: #274b9e; color: #f4efe5; }
.primary:hover { background: #1c3875; }
.timeline { margin-top: 1rem; }
.timeline-head {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.45rem;
}
.track {
height: 16px;
overflow: hidden;
border: 2px solid #1c1c1c;
background: #e0d8c6;
}
.progress { height: 100%; background: #274b9e; }
.status {
min-height: 1.4em;
margin: 0.8rem 0 0;
color: #6a6a66;
font-size: 0.9rem;
}
`}</style>
</PlayProvider>
);
} Share the playback timeline
Section titled “Share the playback timeline”The page uses a normal audio element:
<audio src="/sounds/song.mp3" preload="auto" loop></audio>
PlayHTML shares only three transport values:
{
isPlaying: false,
startedAtMs: 0,
positionMs: 0,
}
Each browser loads the audio file itself. When someone presses Play, every browser calculates the current position from the shared start time and plays its local audio element from there.
The example corrects the local audio position when it drifts by more than 250 milliseconds. It keeps casual shared playback close, but it is not sample-accurate synchronization.
Enable audio in every window
Section titled “Enable audio in every window”Browsers require a local interaction before a page can play sound. Click Enable audio once in every browser window.
That permission cannot be shared. A play command still synchronizes while audio is locked, but the locked window stays silent until someone enables it there.
Use your own file
Section titled “Use your own file”Replace the src URL on the audio element:
<audio src="/sounds/your-file.mp3" preload="auto" loop></audio>
Use a URL every browser can reach. The file does not pass through PlayHTML; each browser downloads it normally.
Test in two windows
Section titled “Test in two windows”- Click Enable audio in the normal browser window.
- Copy the private-window link and open it in a private or incognito window.
- Click Enable audio in the private window.
- Press Play for everyone. Both windows should play the same file from nearly the same position.
- Pause or restart from either window. Both timelines should update.
- Start playback, wait a moment, and reload one window. Enable audio there again; it should join at the current shared position.
Synthetic sounds and one-shot cues
Section titled “Synthetic sounds and one-shot cues”Use Synchronized sound when you need generated tones, one-shot events, or a custom scheduler instead of a normal audio file.