How to show what you're listening to on your site
The "right now" card on my home page shows the song I'm playing, or the last one and when. I listen on YouTube Music, which has no public API. So my phone tells the site itself.
You need: an Android phone and a website. It works with any player that shows a media notification: YouTube Music, Spotify, Apple Music, even a podcast app. No Spotify API, no login to your music account.
You get: "listening to Mirrors — Justin Timberlake" while a song plays, and "last played … 12 min. ago" after.
Why not the Spotify API? It works only for Spotify, and it needs a login flow and a refresh token on your server. The phone already knows what plays, in every app.
How it works
music app (phone)
→ Pano Scrobbler reads its media notification
→ sends "playing now", then the song when it counts as listened
→ ListenBrainz.org, or your own small server
→ your page asks for the newest songPano Scrobbler is a free, open-source scrobbler. Next to Last.fm, it sends to ListenBrainz, and to any server that speaks the ListenBrainz API. That last part is the trick.
Pick one way:
- A. No server (about 10 minutes): send to ListenBrainz.org, and your page reads its public API. Works on any site, even a plain HTML page.
- B. Your own server: send to a tiny ListenBrainz-like server (a free Cloudflare Worker below). Nobody else keeps your listens. This is what my site does.
A. No server: ListenBrainz.org
- Make an account on listenbrainz.org. Copy your user token from your settings.
- Install Pano Scrobbler (Play Store, F-Droid or GitHub) and allow notification access.
- In Pano Scrobbler, add ListenBrainz and paste the token.
- In its settings, allow only your music app, so videos and podcasts stay off your site.
- Play a song. It shows on your ListenBrainz page in a few seconds.
- Put this on your page. The API needs no token to read, and it allows requests from any site:
<p id="music"></p>
<script type="module">
const api = 'https://api.listenbrainz.org/1/user/YOUR_NAME'
const line = document.getElementById('music')
const get = async (path) => (await (await fetch(api + path)).json()).payload.listens[0]
async function show() {
const now = await get('/playing-now')
if (now) {
line.textContent = `listening to ${now.track_metadata.track_name} — ${now.track_metadata.artist_name}`
return
}
const last = await get('/listens?count=1')
if (last) line.textContent = `last played ${last.track_metadata.track_name} — ${last.track_metadata.artist_name}`
}
show()
setInterval(show, 30_000)
</script>Every 30 seconds is enough. The API sends its limits in X-RateLimit-* headers if you want to
check them.
B. Your own server
A scrobbler needs only two calls from a ListenBrainz server:
GET /1/validate-token: Pano checks the token once, when you add the service.POST /1/submit-listens: every song. The song is inpayload[0].track_metadata, andlisten_typesays when:playing_now: the song starts.single: the song counts as listened (half of it, or 4 minutes).
This Cloudflare Worker is the whole server. Workers and KV are free for this.
// A tiny ListenBrainz-compatible server. Pano Scrobbler sends songs here;
// your page reads GET /now. Needs a KV namespace (MUSIC) and a secret (SCROBBLE_TOKEN).
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url)
// Your page reads this, from any site.
if (pathname === '/now') {
const [playing, last] = await Promise.all([env.MUSIC.get('playing', 'json'), env.MUSIC.get('last', 'json')])
const now = playing && playing.until > Date.now() ? playing : null
return Response.json({ playing: now, last }, { headers: { 'Access-Control-Allow-Origin': '*' } })
}
// Pano sends "Authorization: Token <your token>".
const token = request.headers.get('Authorization')?.replace(/^Token\s+/i, '')
if (!env.SCROBBLE_TOKEN || token !== env.SCROBBLE_TOKEN) {
return Response.json({ code: 401, error: 'Invalid token.' }, { status: 401 })
}
// Pano checks the token once, when you add the service.
if (pathname === '/1/validate-token') {
return Response.json({ code: 200, message: 'Token valid.', valid: true, user_name: 'me' })
}
if (pathname === '/1/submit-listens' && request.method === 'POST') {
const { listen_type, payload } = await request.json()
const listen = payload?.[0]
const song = listen?.track_metadata
if (!song) return Response.json({ code: 400, error: 'No song.' }, { status: 400 })
const { track_name: track, artist_name: artist } = song
if (listen_type === 'playing_now') {
// A paused song sends nothing, so "playing" ends when the song would.
const length = song.additional_info?.duration_ms ?? 10 * 60_000
await env.MUSIC.put('playing', JSON.stringify({ track, artist, until: Date.now() + length }))
} else {
// "single": the song counts as listened (half of it, or 4 minutes).
await env.MUSIC.put('last', JSON.stringify({ track, artist, at: (listen.listened_at ?? Date.now() / 1000) * 1000 }))
}
return Response.json({ status: 'ok' })
}
return new Response('Not found', { status: 404 })
},
}"Playing now" and "last played" are two keys, as in ListenBrainz. Pano sends the listen while the song still plays, so one key would say "stopped" too early.
- Make the Worker:
npm create cloudflare@latest, then put the code above in its main file. - Add a KV namespace called
MUSICand bind it to the Worker (the dashboard, ornpx wrangler kv namespace create MUSIC). - Make a token: any long random text, from a password manager. Add it as a secret named
SCROBBLE_TOKEN(never in the code or Git), then deploy. - In Pano Scrobbler, add a ListenBrainz-like instance. API URL: your Worker's address
with a
/at the end (https://music.you.workers.dev/). Token: the same token. - On your page, read
/nowlike in way A: showplayingif it is there, elselast.
Tips
- Android Auto plays through the phone, so it works in the car too.
- A leaked token can only post songs to your card. To change it, set a new one in both places.
- My version (worker/scrobble.ts) also checks every field, compares the token in constant time, and keeps the two rows in D1.