Building a Synchronised Internet Radio System with PHP, JS, and Zero Streaming Infrastructure

8 38 116
calendar_todayschedule9 min read
— Originally published at dev.to

Every traditional radio station has a simple promise: everyone tuning in hears the same thing at the same time. You turn on the radio at 2:05 PM, and you hear whatever programme is five minutes in, not the beginning, not a random track. You and every other listener are perfectly in sync.

I needed to build exactly that for a charity organisation's website, but online, without any live-streaming infrastructure. No Icecast. No Shoutcast. No WebRTC. No streaming servers at all.

Just PHP, JavaScript, MySQL, and a bit of maths.

The Problem

The charity had a radio page on its website, essentially a list of uploaded MP3 files that visitors could click to play individually. Good content, but no structure. No "station" feel, just a glorified media player.

What they wanted was a proper radio experience:

  • Admins upload media files and set schedules (e.g., "Morning News at 06:30, Member Spotlight at 09:00")
  • Certain tracks are designated as "loop/filler" media that play continuously when nothing is scheduled
  • When a listener opens the player, they hear whatever should be playing right now, mid-track if necessary
  • All listeners are synchronised, everyone hears the same content at the same position
  • It should work with the existing PHP stack

The constraint that changed everything: no live streaming. The charity doesn't have the budget for a streaming server, and frankly doesn't need one. All content is pre-recorded. The "live" element is purely the schedule of what plays when.

The Architecture

┌─────────────────────────────────────────────────────┐
│                    ADMIN PANEL                       │
│  Upload media → Set schedules → Manage loop playlist │
└──────────────┬──────────────────────┬────────────────┘
               │                      │
               ▼                      ▼
        ┌─────────────┐      ┌──────────────┐
        │  /uploads/   │      │    MySQL      │
        │  (MP3/MP4)   │      │  (schedules,  │
        │              │      │   media meta,  │
        │              │      │   loop order)  │
        └──────┬───────┘      └──────┬────────┘
               │                      │
               │    ┌─────────────────┘
               ▼    ▼
        ┌─────────────────┐
        │  /api/now-playing│ ◄── The brain
        │     .php         │
        └────────┬─────────┘
                 │  JSON: { media_url, offset, remaining, next }
        ┌────────▼─────────┐
        │   Player (JS)     │
        │  1. Fetch API     │
        │  2. Set src       │
        │  3. Seek to offset│
        │  4. Play          │
        │  5. Re-sync timer │
        └───────────────────┘

Four components: Upload & metadata extraction (admin uploads media; server detects duration via ffprobe with a browser fallback), Schedule management (admin assigns media to time slots), The now-playing API (given the current time, determines what should be playing and the exact offset), and The player (fetches the API, loads media, seeks to offset, plays, and periodically re-syncs).

No WebSockets. No streaming protocol. Just an HTTP API and HTML5 <audio> / <video>.

The Database

Three tables. Deliberately simple.

CREATE TABLE radio_media (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    artist VARCHAR(255) DEFAULT '',
    filename VARCHAR(255) NOT NULL,
    filepath VARCHAR(500) NOT NULL,
    media_type ENUM('audio', 'video') NOT NULL DEFAULT 'audio',
    duration FLOAT NOT NULL DEFAULT 0,
    file_size BIGINT DEFAULT 0,
    is_loop TINYINT(1) NOT NULL DEFAULT 0,
    loop_position INT NOT NULL DEFAULT 0,
    cover_image VARCHAR(500) DEFAULT NULL,
    active TINYINT(1) NOT NULL DEFAULT 1,
    INDEX idx_loop (is_loop, loop_position)
);

CREATE TABLE radio_schedule (
    id INT AUTO_INCREMENT PRIMARY KEY,
    media_id INT NOT NULL,
    title VARCHAR(255) DEFAULT NULL,
    start_time DATETIME NOT NULL,
    end_time DATETIME NOT NULL,
    active TINYINT(1) NOT NULL DEFAULT 1,
    FOREIGN KEY (media_id) REFERENCES radio_media(id) ON DELETE CASCADE,
    INDEX idx_schedule_time (start_time, end_time)
);

CREATE TABLE radio_settings (
    setting_key VARCHAR(100) PRIMARY KEY,
    setting_value TEXT NOT NULL
);

end_time is stored, not computed. The server calculates end_time = start_time + duration at creation time, making the "what's playing now?" query a simple range check.

is_loop and loop_position convert any track into a filler playlist item, reorderable via drag-and-drop. radio_settings stores the loop_epoch — a fixed reference timestamp critical to the loop sync algorithm.

The Sync Algorithm

Two distinct sync problems: scheduled content and loop content.

Scheduled Content Sync

Straightforward. If a 30-minute programme starts at 14:00 and a listener connects at 14:12:30:

offset = 14:12:30 - 14:00:00 = 750 seconds

Every listener calling the API at the same moment gets the same offset. The SQL:

$stmt = $db->prepare("
    SELECT s.*, m.*
    FROM radio_schedule s
    JOIN radio_media m ON s.media_id = m.id
    WHERE s.active = 1 AND m.active = 1
      AND s.start_time <= ? AND s.end_time > ?
    ORDER BY s.start_time DESC
    LIMIT 1
");
$stmt->execute([$now, $now]);

The ORDER BY start_time DESC LIMIT 1 means overlapping schedules resolve by last-start-wins — letting admins override a running programme by scheduling on top of it.

Loop Content Sync

The loop playlist has no "start time" — it plays continuously when nothing is scheduled. How do you synchronise listeners connecting at different moments?

The answer: a fixed epoch and modular arithmetic.

$epoch = strtotime(getSetting('loop_epoch', '2024-01-01 00:00:00'));
$totalLoopDuration = array_sum(array_column($loopMedia, 'duration'));

$elapsed = time() - $epoch;
$posInCycle = fmod($elapsed, $totalLoopDuration);

Imagine the loop playlist started at the epoch and has played non-stop since, repeating at the end. At any moment you can calculate exactly where we'd be: total seconds since epoch, modulo total loop duration, then walk the track list:

$accumulated = 0;
foreach ($loopMedia as $track) {
    if ($accumulated + $track['duration'] > $posInCycle) {
        $currentTrack = $track;
        $trackOffset = $posInCycle - $accumulated;
        $trackRemaining = $track['duration'] - $trackOffset;
        break;
    }
    $accumulated += $track['duration'];
}

This is purely deterministic. A thousand listeners all compute the same result for the same timestamp. No shared state, no "last track played" tracking. Just maths.

Why a fixed epoch? Because fmod(elapsed, totalDuration) needs a stable reference. If you used "the time the loop started," you'd need to persist and synchronise that state. The epoch is just a constant in the database — any past date works.

What if the loop playlist changes? The total duration shifts, which moves everyone's position. Acceptable, no different from a station changing its playlist. Best done during scheduled content or off-peak hours.

Schedule-Loop Transitions

When a loop track is playing and a scheduled programme is about to start, the API adjusts the next_check_in value:

$nextSchedule = getNextScheduled($db, $nowDt);
$nextCheckIn = $trackRemaining;

if ($nextSchedule) {
    $scheduleStartsIn = strtotime($nextSchedule['start_time']) - $now;
    if ($scheduleStartsIn > 0 && $scheduleStartsIn < $trackRemaining) {
        $nextCheckIn = $scheduleStartsIn;
    }
}

Normally next_check_in equals the remaining track duration. But if a schedule starts sooner, the player re-fetches earlier and seamlessly transitions.

The Now-Playing API

The response from /api/now-playing.php:

{
    "status": "scheduled",
    "server_time": 1738465200,
    "media": {
        "id": 42,
        "title": "Morning News Bulletin",
        "url": "/radio/uploads/media_abc123.mp3",
        "media_type": "audio",
        "duration": 1800,
        "cover_image": "/radio/uploads/covers/cover_xyz.jpg"
    },
    "offset": 750.23,
    "remaining": 1049.77,
    "next_check_in": 1049.77
}

status: "scheduled", "loop", or "offline". offset: where to seek. next_check_in: when to call the API again.

The Player: Sync, Autoplay, and Latency

Vanilla JavaScript, native <audio>/<video> elements, custom UI on top.

Core Playback Loop

async function fetchNowPlaying() {
    const requestStartTime = Date.now();
    const resp = await fetch(BASE + '/api/now-playing.php?_=' + Date.now());
    const data = await resp.json();
    const requestDuration = (Date.now() - requestStartTime) / 1000;

    const mediaChanged = !currentMedia || currentMedia.id !== data.media.id;

    if (mediaChanged) {
        loadMedia(data, requestDuration);
    } else {
        syncPosition(data.offset + requestDuration);
    }
    scheduleNextCheck(data.next_check_in);
}

Latency Compensation

Between the server calculating the offset and the player receiving it, time passes (50ms–2s depending on connection). The fix: measure request duration and add it to the offset.

activePlayer.currentTime = data.offset + requestDuration;

Periodic Drift Correction

Players drift over time; buffering stalls, background tab throttling, device sleep. Every 30 seconds, re-fetch and compare:

function syncPosition(expectedOffset) {
    if (!activePlayer || !isPlaying) return;
    const drift = Math.abs(activePlayer.currentTime - expectedOffset);
    if (drift > MAX_DRIFT) { // default: 2 seconds
        activePlayer.currentTime = expectedOffset;
    }
}

Under 2 seconds of drift is imperceptible. Over 2 seconds, we force-correct.

The Autoplay Problem

Browsers block autoplay without user interaction. A shouldAutoPlay flag tracks intent:

let shouldAutoPlay = false;

function play() {
    shouldAutoPlay = true;
    if (!activePlayer || !activePlayer.src) {
        fetchNowPlaying();
        return;
    }
    activePlayer.play().then(() => setPlayingState(true)).catch(() => {});
}

function pause() {
    shouldAutoPlay = false;
    activePlayer.pause();
    setPlayingState(false);
}

Once the user clicks play, shouldAutoPlay stays true through track transitions, the browser allows continued playback because the original gesture established an active audio context. This means loadMedia can auto-play on track changes:

function loadMedia(data, requestDuration = 0) {
    currentMedia = data.media;
    activePlayer.src = data.media.url;
    activePlayer.currentTime = data.offset + requestDuration;

    if (shouldAutoPlay) {
        activePlayer.play()
            .then(() => setPlayingState(true))
            .catch(() => setPlayingState(false));
    }
}

Track Transitions

Both proactive and reactive: the API's next_check_in schedules a re-fetch, and ended event listeners provide a fallback.

audioEl.addEventListener('ended', fetchNowPlaying);
function scheduleNextCheck(seconds) {
    clearTimeout(checkTimer);
    checkTimer = setTimeout(fetchNowPlaying, Math.max(seconds, 2) * 1000);
}

Duration Detection

To schedule media, you need its duration. A two-tier approach handles environments with or without ffprobe:

Server-side (if ffmpeg is available):

function getMediaDuration(string $filepath): ?float {
    $cmd = 'ffprobe -v error -show_entries format=duration '
         . '-of csv=p=0 ' . escapeshellarg($filepath);
    $output = trim(shell_exec($cmd) ?? '');
    return is_numeric($output) ? (float) $output : null;
}

Browser-side fallback : during upload, the admin panel creates a temporary media element:

function handleFile(file) {
    const url = URL.createObjectURL(file);
    const el = file.type.startsWith('video')
        ? document.createElement('video')
        : document.createElement('audio');
    el.preload = 'metadata';
    el.onloadedmetadata = () => {
        detectedDuration = el.duration;
        URL.revokeObjectURL(url);
    };
    el.src = url;
}

As a final safety net, the player reports duration back to the server for any media with duration = 0. The first person to play the media "teaches" the server its duration, and scheduling becomes available from that point.

Upload Handling

Uploads go through /api/upload.php:

$uniqueName = uniqid('media_', true) . '.' . $ext;
$destPath = UPLOAD_DIR . '/' . $uniqueName;
move_uploaded_file($file['tmp_name'], $destPath);

$duration = getMediaDuration($destPath);
if ($duration === null && !empty($_POST['duration'])) {
    $duration = (float) $_POST['duration'];
}

Files are stored with unique generated names (not the original filename) to avoid collisions and path traversal issues. For large media files, adjust PHP's upload limits:

upload_max_filesize = 500M
post_max_size = 500M
max_execution_time = 300

And if using Nginx: client_max_body_size 500M;

Schedule Overlap Handling

What happens if an admin creates overlapping schedules? The system allows it and applies a simple rule: the most recently started schedule wins. The ORDER BY start_time DESC LIMIT 1 in the query handles this automatically.

The API warns about conflicts when creating a schedule:

$stmt = $db->prepare("
    SELECT s.id, s.start_time, s.end_time, m.title
    FROM radio_schedule s
    JOIN radio_media m ON s.media_id = m.id
    WHERE s.active = 1
      AND s.start_time < ?  AND s.end_time > ?
");
$stmt->execute([$endTime, $startTime]);
$conflicts = $stmt->fetchAll();

if (!empty($conflicts)) {
    $response['warnings'] = ['This schedule overlaps with existing entries.'];
}

This is deliberate, an admin might want to interrupt regular programming with an urgent announcement by simply scheduling on top.

What About Video?

The system supports both audio and video. The player switches between elements based on media type:

function loadMedia(data, requestDuration = 0) {
    const isVideo = data.media.media_type === 'video';
    if (isVideo) {
        audioEl.pause(); audioEl.src = '';
        activePlayer = videoEl;
        document.getElementById('videoWrap').classList.add('visible');
    } else {
        videoEl.pause(); videoEl.src = '';
        activePlayer = audioEl;
        document.getElementById('videoWrap').classList.remove('visible');
    }
    activePlayer.src = data.media.url;
    activePlayer.currentTime = data.offset + requestDuration;
}

Performance Considerations

Each listener calls the API once on load, once every 30 seconds, and once per track transition. For 100 concurrent listeners, that's ~200 requests/minute, trivial for standard PHP hosting. The queries hit indexed columns, keeping response times under 10ms.

Media files are served as static files by Apache/Nginx. The HTML5 media element handles range requests natively, meaning listeners only download from the seek point forward, no PHP process tied up during media delivery. This is a crucial advantage over streaming solutions where every byte flows through your application layer.

The API includes cache busting to prevent stale responses:

fetch(BASE + '/api/now-playing.php?_=' + Date.now())

And server-side:

header('Cache-Control: no-cache, no-store, must-revalidate');

Limitations

Seek latency on slow connections, a few seconds of buffering before audio starts. VBR MP3s can have inaccurate seeks; CBR works best. No sub-second sync, listeners are within 1–2 seconds of each other, fine for radio, not for live concerts. No adaptive bitrate, every listener gets the same file. Loop playlist changes shift everyone's position since total duration changes; best done during scheduled content.

For a charity radio station with pre-recorded content, these trade-offs are entirely acceptable.

Embedding the Player

The player can be embedded anywhere via an iframe:

<iframe src="/radio/player.php" width="500" height="700"
    frameborder="0" allow="autoplay"></iframe>

Or use the API directly for a custom mini-player:

async function initMiniRadio() {
    const resp = await fetch('/radio/api/now-playing.php');
    const data = await resp.json();
    if (data.status === 'offline') return;

    const audio = document.getElementById('radio-audio');
    audio.src = data.media.url;
    audio.currentTime = data.offset;
    document.getElementById('track-name').textContent = data.media.title;
}

Because the sync logic lives entirely in the API, any player that can make an HTTP request and seek an audio element can participate; React, mobile app, or CLI.

Wrapping Up

The core insight: you don't need streaming infrastructure for a radio station with pre-recorded content. The problem reduces to: store the schedule, compute what should be playing and where, tell the player to seek there, and periodically re-sync.

The fmod() trick for loop synchronisation is the technique I'm most proud of; elegant, stateless, and trivially scalable. A million listeners and the computation is identical: one modulo operation and a walk through an array.

The full codebase includes the admin panel, API endpoints, and player. It runs on any standard PHP hosting with MySQL.

Github Link: https://github.com/IAmMasterCraft/online-radio-system

4 Comments

3 votes
1
2 votes
1
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

5 Web Dev Pitfalls That Are Silently Killing Your Projects (With Real Fixes)

Dharanidharan - Mar 3

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23
chevron_left
3.1k Points162 Badges
Earth ????
38Posts
124Comments
4Connections
Just a geek interested in 0's and 1's

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!