English · Português · Español · Italiano
▶ Try the live demo — click play, then navigate between the pages: the music never stops.
A bottom-bar HTML5 radio player that works as a drop-in JavaScript component: two script tags inject the whole player (HTML, CSS and fonts) into any page of your site. With seamless navigation enabled (default), clicks on internal links are intercepted and only the page content is swapped — the audio keeps playing without any interruption while visitors browse your site.
🇧🇷 Player de rádio fixo no rodapé que NÃO PARA quando o visitante navega pelo site. Duas linhas de código em cada página e pronto: música tocando agora com capa, histórico, letra, clipe do YouTube sincronizado e um site demo completo (notícias, vídeos, programação, equipe) configurável sem tocar em código.
- Embeddable component — no HTML to copy; the player injects itself.
- Uninterrupted audio across pages — internal navigation swaps content without reloading (SPA-lite), so the stream never stops.
- Playback state persistence — station, playing state and volume survive full page loads; playback auto-resumes (or resumes on the first tap when the browser blocks autoplay).
- Audio playback with play/pause, volume and station switching, plus smooth volume fade in/out (no audio "pop").
- Loading spinner while the stream buffers and automatic reconnection with backoff when the network drops.
- Dynamic audio visualizer that reacts to the music in real time (off on mobile to save battery; paused when the tab is hidden).
- Track progress bar along the bottom edge of the dock, in two flavours: a plain line, or a liquid wave riding the crest of the fill. It is clipped by the dock's own rounded shape, so it follows the corner in every visual style, and only appears when the metadata API reports the track's time.
- Now playing metadata via the twj.es API — cover art comes straight from the API payload, with search.php + iTunes (music-only) as fallbacks.
- Station list with thumbnails and information.
- Song history with covers (up to 10 recently played tracks).
- Lyrics via lyrics.ovh with LRCLIB fallback — no API key required, with request caching.
- Dynamic accent color extracted from the current cover art.
- Clip mode — when the metadata API sends a
youtubeId, a floating mini-player shows the music video of the song on air, seeked to the same position; it survives page navigation. - Live TV — with
tv_urlin a station, a "TV" button opens the live video in a compact centered window (✕, click-outside or Esc to close). - One audio source at a time — starting the radio pauses any playing video (site videos, clip mode) and closes the live TV; stopping the video brings the radio back. Enforced inside
play(), so every entry point (dock button, station switch, lock screen, auto-resume,RadioPlayer.play()) obeys it. - Marquee for long titles — song and artist names that don't fit slide instead of being cut off, and only while they overflow.
- Integrated social sharing for Facebook, Twitter, and WhatsApp.
- Media Session integration (lock screen / notification controls) routed through the same play/pause path as the dock button, so fade, persisted state and the video rule apply there too.
- Mobile-first dock — on phones the song title gets the space (no cramped 24px column): cover · "on air + station" · title · artist · one big play button, extra controls in a labeled sheet, and safe-area padding for iPhones. A handle on top of the dock collapses it out of the way — on desktop too. Tapping the cover opens the station list.
- Photo gallery with lightbox (arrows, keyboard, swipe) on the demo site.
- "How to listen" card with the official app badges (Google Play / App Store) and the Alexa phrase.
- Installable (PWA) — manifest, icons, offline shell via service worker and an "install app" button.
- Five design languages — glassmorphism (default), claymorphism, minimalism, liquid glass and spatial UI, picked in the generator and applied to the site and the dock at once.
- Four spoken languages — Portuguese, English, Spanish and Italian, switchable from the header. The whole interface follows, including the dock; switching doesn't stop the audio.
-
Download the player files:
- Download or clone this repository and host the
js/,css/andassets/folders (plusconfig.jsandcustom.css) on your site.css/ui-styles.cssships insidecss/and is what powers the alternative visual styles.
- Download or clone this repository and host the
-
Configure your radio stations:
- Open the
config.jsfile. - Edit the
window.streams.stationsvariable and replace the example stations with your own. - For each station, fill in: name, hash, description, URLs for logo, album art, background cover, audio stream URL, social links, app links, etc.
- Important: use absolute URLs (or paths valid from every page) for the images, since the player can be embedded at any depth of your site.
- Open the
-
Add the component to every page of your site:
<script src="config.js"></script> <script src="js/radioplayer.js"></script>
That's it — the player builds itself at the bottom of the page. See
index.htmlandpagina2.htmlfor a working two-page demo of the uninterrupted navigation. -
(Optional) Configure the site content:
- The demo site sections (hero slides, news with full articles, YouTube videos, photo gallery, weekly schedule, team, "how to listen" card, social links, footer) all live in
content.jsand are rendered bysite.js+site.css. - Edit
content.jsby hand, or use the visual generator: opengerador.htmllocally in your browser — it pre-fills the forms with your current content, lets you add/remove items, and generates a newcontent.jsto copy or download. Replace the file at the root of the site and you're done.
- The demo site sections (hero slides, news with full articles, YouTube videos, photo gallery, weekly schedule, team, "how to listen" card, social links, footer) all live in
⚠️ Do not publishgerador.htmlto your production site. It is a local admin tool — anyone with the URL could read your whole configuration and craft replacement files. Keep it on your machine (or delete it from the server after deploying).
There's a dedicated WordPress plugin, maintained in its own repository: jailsonsb2/bottom-radioplayer-wordpress. It wraps this component with a proper wp-admin settings page (General / Stations / Appearance tabs, stations repeater with the native media picker, Clip Mode built in) — no file editing needed. It is not published on wordpress.org; the linked repo ships a ready-to-upload bottom-radioplayer.zip at its root and has the full install instructions.
Enabled by default. When a visitor clicks an internal link, the component fetches the target page, swaps the <body> content (keeping the player alive), updates the title/history and re-runs the new page's scripts. External links, target="_blank", download links and anchors are left alone.
- To disable it, set
seamless: falseinconfig.js(window.streams.seamless = false). Navigation then reloads normally and the player auto-resumes playback on the next page (on the first tap if the browser blocks autoplay). - To exclude a specific link from interception, add the
data-no-seamlessattribute to it. - Links to
/wp-admin/andwp-login.phpare always excluded automatically — WordPress's admin area isn't part of the site's front-end layout. - Pages should share the same base layout/CSS; stylesheets found in the target page's
<head>are adopted automatically.
If your now-playing metadata API returns a youtubeId field (or youtube_id) in the payload, a "Clipe" button automatically appears in the player (feature-detected — sites whose API doesn't send the field never see the button). With clip mode on:
- the floating mini-player opens with the music video of the song that is playing (radio audio pauses, video audio takes over), synchronized with the radio position (start = elapsed from the API) instead of starting from zero;
- every song change just swaps the embed to the new clip;
- songs without a clip close the video and fall back to the radio automatically;
- the video keeps playing across page navigation (
data-seamless-keep), and the preference is remembered; - the radio and a video never play at the same time. Any path that starts the radio — the dock button, switching station, the lock screen, auto-resume after a reload,
RadioPlayer.play()— first pauses the YouTube embeds and closes the live TV; pausing or finishing the video hands the audio back to the radio. Pausing the clip by hand also turns clip mode off, so the next song doesn't reopen the video over the audio you just chose.
The component also exposes each track to the site: window.RadioPlayer.currentTrack and the radioplayer:track DOM event (detail: { title, artist, art, cover, youtubeId }), plus radioplayer:ready when the player mounts.
Most listeners arrive on a phone, so the dock is laid out for that screen first (custom.css, @media (max-width: 991px)):
- The title owns the width. Previous/next switch stations, so on phones they leave the bar (they come back on tablets, ≥768px) and the cover becomes the shortcut to the station list — a chevron badge marks it. With a single station in
config.jsthe player gets asingle-stationclass and those buttons disappear at every size. - Context line —
● AO VIVO · Station nameabove the song, so the station is still identifiable while a track plays. - Extra controls with labels — the "…" button opens a 3-column sheet (TV, Clip, History, Share, Lyrics, Stations). Silent icons in circles told nobody what they did; volume is left out (hardware buttons own it, and iOS ignores
audio.volume). - Collapse handle (at every width) — the tab on top of the dock slides it off-screen so the page is fully readable; the audio keeps playing and the state survives seamless navigation. How far it has to travel comes from
--dock-bottomon#app-player .player, so a visual style that lifts or flattens the dock only has to restate that variable. - Safe area — the dock offset uses
env(safe-area-inset-bottom), clearing the iPhone home bar. - History and stations open as a full-width sheet above the dock instead of a narrow right-anchored panel.
Two content-driven sections of the demo site, both configured in content.js (or in gerador.html):
gallery: [
{ image: "photos/studio.jpg", thumb: "photos/studio-small.jpg", caption: "Main studio" },
],
apps: { android: "https://play.google.com/…", ios: "", alexa: "https://www.amazon.com/dp/…" },
listen: { title: "How to listen", text: "…", alexaPhrase: "Alexa, play My Radio" },- Gallery — a responsive grid in the
#galeriasection; clicking a photo opens a lightbox with arrows, keyboard (←/→/Esc), swipe and a counter.thumbis optional (use it to serve a lighter thumbnail); with an empty list the whole section hides itself. - How to listen — a card in the "About" section with the official store badges plus the Alexa phrase (linked to your skill when
apps.alexais filled). The sameappsentries feed the store badges in the footer, and fall back towindow.streams.stations[0].appsfromconfig.jswhen empty.
Two more optional fields live under about in content.js:
about: {
city: "São Paulo", // weather chip + footer map
donation: { url: "https://ko-fi.com/…", label: "Support us" },
}- Weather chip and map —
about.citydrives both the little temperature chip in the header and the map card in the "About" section. Leave it empty and both disappear. The map sits under the history text, in the same column, so the section doesn't leave a hole beside the side cards. - Donation button —
about.donationputs a highlighted button in the header. Emptyurlhides it.
The home page sections are stacked in whatever order content.order lists them — put your station's strongest content on top. The same order is applied to the header menu, on every page:
order: ["galeria", "noticias", "videos", "programacao", "equipe", "contato"],- Valid names are exactly those six. The hero slides are always first and the footer always last, so they aren't listed.
- The field is optional: drop it (or list only a couple of names) and the missing sections keep the default order at the end — an older
content.jsstill renders everything. - Sections with no content hide themselves, title and menu link included. A station with no videos doesn't need to touch
order— an emptyvideos: []is enough to make the section and its menu entry disappear. gerador.htmlhas an "Ordem das seções" block with ↑/↓ buttons that writes this list for you.
The demo site ships as an installable app: manifest.json, icons in assets/pwa/, the sw.js service worker and pwa.js (which registers it and shows the Install app button in the header and inside the "How to listen" card — on iOS the button explains the Share › Add to Home Screen path instead, since Safari has no install prompt).
- Requires HTTPS (or localhost). GitHub Pages, Netlify and any host with TLS work out of the box.
- Caching rules in
sw.js: everything editable — pages, CSS, JS, JSON — is network-first, so an online visitor always gets what you just published and the cache only answers when the network fails (offline, or your server down); images, icons and fonts use stale-while-revalidate; the audio stream, metadata APIs, YouTube, maps and weather are cross-origin and never touched. - If a page looks frozen in an old version, check the server is actually up: with it down, the service worker legitimately serves the offline copy. During development keep DevTools › Application › Service Workers › Bypass for network checked, or hard-reload (Ctrl+Shift+R), which skips the worker entirely.
- After deploying a new version, bump
const VERSIONat the top ofsw.js: the old cache is dropped and anyone with the site open gets a "new version available" toast. - Replace
assets/pwa/icon-*.pngand the name/colors inmanifest.jsonwith your radio's own. To drop the feature entirely, delete the<script src="pwa.js">line from the pages.
-
Images: Replace the images in the
assetsfolder with your own. -
Lyrics: the "Lyrics" button shows the current song's lyrics (lyrics.ovh with LRCLIB fallback). To turn the feature off, set
lyrics: falseinconfig.js(window.streams.lyrics = false) — the button and modal disappear and no lyrics request is ever made. -
Dock effects (bargraph and progress bar): two decorations, both set in
config.jsand both on by default.field values visualizertrue(default) /false—falsedoesn't merely hide the bars: the element never reaches the DOM, so theAudioContextis never opened.progress"wave"(default) /"simple"/false.trueis still accepted as an alias for"wave", so older config files keep working.The progress bar only shows up when the metadata API reports
now_playing.elapsedandnow_playing.duration— on a pure live stream it stays invisible whatever you set. Between polls (10 s apart) it advances on a local clock anchored to the last knownelapsed; without that it would lurch forward once every ten seconds."wave"puts two SVG sine layers on the crest of the fill, running at different lengths and speeds — the mismatch between them is what reads as liquid."simple"leaves the plain line and builds no SVG and no animation at all.With the bargraph on,
"simple"is the better pairing: the wave and the bars fight over the same 16 px band at the bottom of the dock. Keep"wave"for docks withvisualizer: false.The bar is clipped by a frame that inherits the dock's own
border-radius, so it follows the corner in every visual style — including the 999 px capsule ofliquid, where insetting the bar by the radius would have collapsed it to nothing.In the generator this is the "Efeitos do player" section, which writes
theme: { visualizer, progress }intocontent.js. That is only a fallback:config.jsalways wins, because a player embedded on someone else's site has nocontent.jsat all. -
Visual style (5 design languages): the whole thing — site and player dock — can switch design language with one field:
theme: { style: "clay" }incontent.js, or the picker at the top ofgerador.html, which shows a live miniature of each option and repaints the generator page as you click.styleLooks like glass(default)Frosted glass: blur, translucent borders, floating island dock. The original look — omit the field and nothing changes. clayClaymorphism: opaque puffy surfaces, very round corners, no borders, buttons that squish when pressed. minimalFlat: 1px hairlines, no shadow or blur, square corners, and the dock goes back to being an edge-to-edge bar. liquidLiquid Glass: thicker blur, specular highlight along the edges, capsule-shaped dock and a sheen that crosses it while playing. spatialSpatial UI: neutral glass, wide ambient shadows, larger radii, and a dock that floats clear of the bottom edge. Everything lives in
css/ui-styles.cssas a layer of--ui-*tokens whose defaults are the current glass values, applied only when adata-uiattribute is present on<html>— so a site that never setsstyle(or never loads the file) renders exactly as before. Each style also has its own light-theme palette; the dock always keeps a dark surface, since it floats over the whole page and carries white text. To tune one, edit its token block incss/ui-styles.css— the generator's preview reads the same tokens and follows along. -
Developer mode (try the 5 styles on the live site): add
?dev=1to any page URL and a floating 🎨 button appears, listing the five design languages; picking one repaints the site and the dock immediately. It exists because comparing styles otherwise means re-editingcontent.jsand reloading for every one of them. The choice is remembered inlocalStorage, so it survives seamless navigation and full reloads — the pre-boot script in each page's<head>reads it before the first paint, otherwise the picked style would flash back to the configured one on every page. Turn it off from the panel ("Sair do modo dev") or with?dev=0; both also release the style override, so the machine can't get stuck on a look that isn't the site's real one. It lives insite.jsand injects its own CSS only when enabled, so ordinary visitors download nothing extra — and since it is per browser, nobody else ever sees the button. The panel is deliberately styled with fixed colours rather than site tokens, so it doesn't change shape along with the style you are judging. -
Site accent color: one field does it —
theme: { accent: "#4dd7e0", accentLight: "" }incontent.js, or the color picker ingerador.html(live preview). From that single coloursite.jsderives the button gradient (--site-accent-2), the background glow (--site-glow-1), the text colour used on top of the accent (--site-accent-ink, picked by WCAG luminance so labels stay readable) and the player's starting accent (--accent, until the cover art sets its own). LeaveaccentLightempty and the light theme darkens the colour only as much as contrast requires. -
Menu scroll speed: clicking a menu item glides to the section instead of jumping, and
theme: { scrollDuration: 1100 }incontent.jssets how long the longest trip may take, in milliseconds (default1100). The actual duration scales with the distance travelled — a nearby section lands in about half a second, the far end of the page takes the full budget — so raise it for a slower, more deliberate glide and lower it for a snappier one. This exists because the CSSscroll-behavior: smoothalone gives you no control over timing: the browser picks it (Chrome spends roughly 300 ms on any distance), which on a long page reads as a hard cut.site.jsanimates the trip itself instead, easing in and out, and stops immediately if the visitor scrolls by hand mid-flight. Visitors whose system asks for reduced motion always jump straight to the section, whatever the value. -
Languages (pt / en / es / it): the whole interface switches language from a picker in the header — section titles, menu, weekday tabs, buttons, accessibility labels, the PWA prompts and the player's own controls (Lyrics, History, Stations, Share). Everything lives in
js/i18n.js: one dictionary per language, plusdata-i18nattributes on the static HTML. Only the chrome is translated — the news, programmes, team names and slides you write incontent.jscome out exactly as you wrote them, because they're your content, not the frame. A visitor gets their browser's language when it's one of the four, otherwise thetheme: { language: "pt" }default fromcontent.js; their pick is remembered inlocalStorageand wins from then on. Switching language re-renders the page through the player's seamless navigation, so the music doesn't stop. Dates follow the language too (10 de julho de 2026→July 10, 2026). To add a fifth language, copy one dictionary block injs/i18n.js, translate the values and add the code toLANGS; any key you miss falls back to Portuguese instead of showing a raw key. Dropjs/i18n.jsfrom the pages and the site is byte for byte the Portuguese-only one it was — everyt()call carries the original string as its fallback. -
Hover zoom:
theme: { hoverZoom: false }incontent.jsturns off the growing part of the hover — the cards that swell a little inliquidandspatial, the gallery photo and video thumbnail that zoom under their frame (those two happen in every style), the action buttons, and the dock buttons inspatial. Everything else the hover does stays: each style keeps its own lift, the border still lights up and the shadow still opens, so the card goes on answering the pointer. It works by puttingdata-hover-zoom="off"on<html>; without the field nothing is emitted and the CSS is byte for byte what it always was. Visitors whose system asks for reduced motion get no growth either way. -
Colors (player): Customize the player's colors by editing the
css/custom.cssfile. -
Behavior: Adapt the player by editing
js/radioplayer.js(the component).js/main.jsis the legacy non-component version, kept for reference. -
JavaScript API: the component exposes
window.RadioPlayerwithplay(),pause(),toggle(), theaudioelement and therootDOM node.
More free radio players from the same author:
| Project | Style |
|---|---|
| RadioPlayer | Full-page player for any stream (free now-playing API, YouTube clip mode) |
| Radioplayer_api | Multi-station player with 3 switchable layouts |
| RadioPlayer-ZenoRadio | Full-page player for Zeno.FM streams (SSE metadata) |
| metadados | The free now playing API (ICY metadata + iTunes + YouTube clips) |
| bottom-radioplayer-wordpress | WordPress plugin wrapper for this project — settings page, no file editing |
- If you have any questions or issues, please open an issue in the GitHub repository.
- Contributions are welcome! Feel free to submit pull requests with improvements, bug fixes, or new features.

