- Vue 45.1%
- CSS 32.9%
- JavaScript 12.5%
- TypeScript 9.2%
- Dockerfile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| app | ||
| content | ||
| i18n/locales | ||
| public | ||
| server | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .npmrc | ||
| .prettierrc.json | ||
| content.config.ts | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dev | ||
| nuxt.config.ts | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| README.ru.md | ||
| tailwind.config.cjs | ||
| tsconfig.json | ||
| vercel.json | ||
What this is
A multi-station internet radio front end built with Nuxt 4. Ten stations, three now-playing providers, HLS and Icecast playback, a real-time spectrum analyzer, a full-screen MilkDrop visualizer, a 10-band equalizer, cover art lookup, on-air schedules, and a UI that repaints itself to match the artwork of whatever is playing right now.
The interesting part is not the feature list — it's that adding a station takes one object in one file. No new pages, no new components, no wiring. Add it to the registry and the site grows a page, a card, a menu entry, a player thumbnail, an SSE subscription and its own SEO metadata.
Adding a station
Everything lives in app/config/stations.ts. Append one object:
{
id: 'jazz', // player mount + key in every store
slug: 'jazz', // /streams/jazz
provider: 'azuracast', // where now-playing comes from
channel: 'station:jazz', // channel name on that provider
hls: 'https://radio.example.com/hls/jazz/live.m3u8',
icecast: 'https://stream.example.com:8443/jazz',
text: {
menu: 'Jazz @ omFM', // stream picker + card button
card: 'Jazz', // caption under the card
hero: 'JazzFM', // station page heading
tagline: 'smoky rooms and brushed drums',
logo: 'Jazz', // <sup> next to the header logo
panel: 'JazzFM', // player slide-out title
thumb: 'Jazz', // player swiper bubble
tab: 'Jazz', // tab on /streams
},
look: {
font: "'Tenor Sans', sans-serif", // any CSS font-family
radial: 'radial-gradient(#0056fb29, #ff00fb26)', // interface tint
accent: 'border border-amber-500/50', // active card border
},
images: {
heroLight: '/jazz.jpg',
heroDark: '/jazz.jpg',
card: '/jazz.jpg',
thumb: 'rock-00-thumb.jpg', // from app/assets/img/
},
visualizer: {}, // omit for the built-in colours
showNext: true,
artSource: 'station', // 'itunes' to look covers up instead
historyCount: 5,
}
Run npm run generate. That's the whole procedure.
Optional fields, for when a station needs more than the basics:
| field | what it does |
|---|---|
text.heroExtra |
extra lines under the tagline; a string or an array of strings, one line each |
text.nowPlayingLabel |
caption above the track in the mini player (defaults to text.menu) |
text.playlistFallback |
what to show in the "Show:" line when the playlist name is empty |
look.homeButton |
Tailwind gradient (from-… via-… to-…) for the "Home" button on the station page |
look.heroTitleClass |
extra class for the hero title, e.g. a neon text shadow |
look.dimmed |
dim the card image more — for artwork that is too bright |
itunesFallback |
with artSource: 'station': if the track has no embedded cover, try iTunes before the placeholder |
schedule |
the station's on-air grid — see below |
Using a font of your own? Declare it with @font-face and put the family in look.font —
no CSS edits needed, the theme variables are generated from the registry.
Schedule
Give a station a schedule and its page grows a day strip with the current block marked;
clicking it opens a drawer with the full grid. Hours are written in the station's time zone,
not the visitor's — the broadcast runs on radio time:
schedule: {
timezone: 'Europe/Moscow',
note: 'Themed hours break the rotation.', // caption under the heading
slots: [
{ from: '00:00', to: '07:00', title: 'Night', description: 'Dark ambient and drone' },
{ from: '07:00', to: '21:00', title: 'Day', description: 'EBM and synthpop' },
{ from: '21:00', to: '22:00', title: 'Organ', days: [1, 2, 4, 7] }, // ISO weekdays
{ from: '21:00', to: '22:00', title: 'Romantic', days: [3, 5, 6] },
{ from: '22:00', to: '00:00', title: 'Night' }, // end before start = past midnight
],
},
A slot may also carry its own color; without one, blocks get opacity steps in order of
appearance, the same step for the same title. No schedule — no strip.
If your station is on AzuraCast, it's even easier
AzuraCast already publishes now-playing over SSE in exactly the shape this project expects, so you only fill in the station's own details — the provider handles the rest:
provider: 'azuracast',
channel: 'station:<shortcode>', // the AzuraCast station shortcode
Point the azuracast provider at your installation once, in the same file:
export const providers = {
azuracast: {
sseUrl: 'https://your-azuracast.example.com/api/live/nowplaying/sse',
dedupeBy: 'sh_id',
historyOffset: 0,
coverFallback: 'stationArt',
},
}
Every station on that server now shares one SSE connection. Ten stations, one socket.
Another backend?
Add a provider next to azuracast. This project ships with three: AzuraCast and two
Centrifugo bridges (centrifugo for omFM's own server and
centrifugo386 for the one at 386.su). A provider describes the four things that actually
differ between backends:
| field | what it answers |
|---|---|
sseUrl |
where to subscribe |
dedupeBy |
how to tell that the track changed (sh_id or the track text) |
historyOffset |
does the history list start with the current track or the previous one |
coverFallback |
when a cover lookup fails — use the station's art or a placeholder |
Stations then simply say which provider they belong to. Mixed setups work: right now six stations come from AzuraCast, two from omFM's Centrifugo and two from the 386.su one, each with its own quirks, and no component knows the difference.
What you get per station, for free
- Its own page at
/streams/<slug>— hero, play button, spectrum analyzer, now playing, next track, history — plus prerendered SEO title and description, in all three locales. - A card on the home page and a slide in the "Top stations" carousel.
- Entries in the header menu, the mobile menu, the stream picker and the
/streamstabs. - A bubble in the player's swiper and in the mobile drop-up.
- An SSE subscription, grouped with the other stations of the same provider.
- Playlists for external players:
/playlists/<slug>.m3u(Icecast) and/playlists/<slug>-hls.m3u(HLS), listed with the stream URLs on/about, plus the combinedomfm-all.m3uandomfm-all-hls.m3u. - A schedule strip, if the station has a
schedule. - Themed UI: the interface font, the radial glow and the accent colour follow the selected station.
Features
Playback — HLS via hls.js with an Icecast fallback, switchable at runtime. Volume, mute and the 10-band equalizer run through the Web Audio graph and persist between visits. Media keys and the OS now-playing widget are wired through the Media Session API.
Live data — one SSE connection per backend, automatic reconnect, per-track progress bars that keep ticking between updates.
Cover art — per station: either the artwork the backend sends with the track
(artSource: 'station', optionally backed by iTunes via itunesFallback) or an iTunes lookup
with caching (artSource: 'itunes'); when nothing is found, the provider decides between the
station's own art and a placeholder. The dominant colour of the current cover is extracted
and used to tint the player and the now-playing cards, with the text colour picked for contrast.
Visuals — canvas spectrum bars and a three-band waveform, plus optional film-grain effects (radial tint, noise, scanlines, flicker), all toggleable and remembered.
MilkDrop — a full-screen WebGL visualizer on butterchurn, the web port of Winamp's MilkDrop:
- Turning it on and off — the atom button in the header (it turns green while MilkDrop is on) or the MilkDrop button at the top of the Effects panel. Both toggle the same switch.
- Presets — the stock
butterchurn-presetspack. A random one loads on start, and every 30 seconds the visualizer blends into a random preset over about 2.7 s. There is no manual preset picker. - Sound — taken from the player's analyser after the equalizer, so the picture follows what you hear; the stream itself is untouched. Turning MilkDrop on creates the audio context if the player hasn't yet; without sound the picture stands still.
- While it runs — the canvas covers the page under the header and the player, which stay on top and usable; the header drops its background, the desktop nav links hide, and page scroll is locked.
- Performance — the pixel ratio is capped at 1 on screens under 768 px and at 2 otherwise. Turning it off stops rendering; the WebGL context is kept for the next time.
- Not remembered — every visit starts with MilkDrop off, on purpose: it is heavy on the GPU. A one-time "Now with MilkDrop!" hint points at the button until it is closed or the button is pressed.
Theme — dark and light, switched with a circular View Transition that expands from the click,
and respecting prefers-reduced-motion.
Content — a blog and static pages on Nuxt Content v3, in English, Russian and Spanish.
Settings are applied through attributes on <html> before hydration, so a static build and a
server-rendered one behave identically — no flash of default state.
Stack
Nuxt 4 · Vue 3 · Pinia · Tailwind CSS · Nuxt UI · Nuxt Content v3 · Nuxt i18n · hls.js · butterchurn · chroma-js · colorthief · Swiper
Setup
npm install
Development server on http://localhost:3000:
npm run dev
The dev server watches app/config/stations.ts and app/config/playlists.ts and reloads
the configuration when they change — theme CSS and playlists are built from the registry.
Requires Node 22.19+ (Nuxt 4). Nuxt Content v3 uses
better-sqlite3, which is a native module — on a slim Linux image you'll needpython3,makeandg++to build it.
Deployment
Static hosting (Plesk, cPanel, any web server):
npm run generate
Copy the contents of .output/public into your web root.
Node server:
npm run build
node .output/server/index.mjs
Docker:
docker compose up -d