THU.JUN.18
2026
23:37:22
← back to modules MODULE · 03 · HOMESTREAM
0 / 10 chapters complete · 0%

The Player — HTML5 Video and Audio

We've got a streaming endpoint that serves bytes. Now we build the player page — the actual UI where you click "play" and watch your stuff. Spoiler: HTML5 does most of the work. Our job is mostly to point a <video> or <audio> tag at our streaming endpoint and add a tiny bit of polish around it.
A play.php page that looks up the media by ID, decides whether to render <video> or <audio> based on the type, points the src at stream.php, and adds previous/next navigation through the library.

Why HTML5 is genuinely amazing here

Real talk for a second — building a video player from scratch would be a multi-month project. Decoding bytes, syncing audio and video tracks, drawing frames, building playback controls, fullscreen handling, keyboard shortcuts. Browsers have spent two decades getting good at this. So we just hand them a <video src="..."> tag and they do all of it for free. With controls. With keyboard support. With fullscreen. With picture-in-picture on most browsers. With cast-to-TV. For free.

This is one of those moments where the web platform does more for you than you'd expect. We're going to write maybe 80 lines of PHP and 20 lines of CSS, and the result will feel real.

The two-tag tour

HTML5 gives us two media tags. They behave almost identically — you pick one based on the type of file.

<video src="stream.php?id=42" controls autoplay></video>
<audio src="stream.php?id=42" controls autoplay></audio>

The controls attribute makes the browser render its built-in play/pause/seek bar. Without it, you'd see... nothing visible. (Useful when you're building your own custom controls in JavaScript — but for HomeStream, the defaults are great.) The autoplay attribute makes it start playing automatically when the page loads. Some browsers block autoplay for sound, which is a UX feature, not a bug — but for our personal home server, it's fine.

For video, you can also add poster="thumb.jpg" to show a frame before play. We'll wire that up in the thumbnails chapter.

Previous and next navigation

One thing that elevates this from "I made a player" to "this feels like a real app" is adding previous/next links. Click the next song in your album order. Click back to the previous video in the folder. Tiny feature, huge difference.

The trick: when we load play.php for a given media ID, also fetch the ID of the next item and the previous item (in some sensible order — alphabetical title, or added_at descending, or whatever fits). Render them as links. Skipping forward and back through the library feels natural.

$next_id = (int) db()->query(
    "SELECT id FROM media WHERE id > $id ORDER BY id LIMIT 1"
)->fetchColumn();

$prev_id = (int) db()->query(
    "SELECT id FROM media WHERE id < $id ORDER BY id DESC LIMIT 1"
)->fetchColumn();

One small wrinkle — that's not actually safe SQL because I just interpolated $id. We'll do it properly with a prepared statement in the mini-project. Just showing the concept here. Always remember to bind parameters.

Auto-advance with a sprinkle of JavaScript

Pure HTML5 doesn't auto-advance to the next track when the current one ends. But there's a tiny JS event we can listen to:

document.querySelector('video, audio').addEventListener('ended', () => {
    if (nextHref) window.location = nextHref;
});

When playback ends, navigate to the next track's URL. The new page loads, autoplays the next file, and you have a chain. About five lines of JavaScript total. Not a single framework involved.

Build: The Player Page

Time to wire it all together. This is the chapter where HomeStream stops being a database query and starts being something you actually use. Hit play, get music. Hit play, watch a video. From any device on your WiFi. Genuinely cool feeling.

  1. Create /home/erictey/server/homestream/public/play.php.
  2. Paste this in:
    <?php
    declare(strict_types=1);
    require __DIR__ . '/../lib/db.php';
    
    $id = (int)($_GET['id'] ?? 0);
    if ($id <= 0) {
        http_response_code(400);
        exit('Bad ID');
    }
    
    $stmt = db()->prepare("SELECT * FROM media WHERE id = ?");
    $stmt->execute([$id]);
    $item = $stmt->fetch();
    if (!$item) {
        http_response_code(404);
        exit('Not found');
    }
    
    // Find adjacent items in the same type, ordered by title
    $next = db()->prepare("
        SELECT id, title FROM media
        WHERE type = ? AND title > ?
        ORDER BY title ASC LIMIT 1
    ");
    $next->execute([$item['type'], $item['title']]);
    $next_item = $next->fetch();
    
    $prev = db()->prepare("
        SELECT id, title FROM media
        WHERE type = ? AND title < ?
        ORDER BY title DESC LIMIT 1
    ");
    $prev->execute([$item['type'], $item['title']]);
    $prev_item = $prev->fetch();
    
    function e(string $s): string {
        return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }
    
    $stream_url = 'stream.php?id=' . (int)$item['id'];
    $tag = $item['type'] === 'video' ? 'video' : 'audio';
    ?>
    <!DOCTYPE html>
    <html>
    <head>
      <title><?= e($item['title']) ?> — HomeStream</title>
      <style>
        body { background:#07050d; color:#f0e9ff; font-family:monospace; padding:30px; max-width:900px; margin:auto; }
        h1 { color:#ff2e88; margin-bottom: 6px; }
        .meta { color:#b9adcf; margin-bottom: 20px; }
        video, audio { width:100%; outline:1px solid #ff2e88; box-shadow: 0 0 30px #ff2e8855; background:#000; }
        .nav { display:flex; justify-content: space-between; margin-top: 20px; }
        .nav a { color:#5bf0ff; text-decoration:none; padding:6px 14px; border:1px solid #5bf0ff55; }
        .nav a:hover { background:#5bf0ff22; }
        .nav .disabled { color:#3e3756; border-color:#3e3756; pointer-events: none; }
        .back { display:inline-block; margin-top:20px; color:#ff2e88; text-decoration:none; }
      </style>
    </head>
    <body>
      <a class="back" href="index.php">← library</a>
      <h1><?= e($item['title']) ?></h1>
      <p class="meta">
        <?= e(strtoupper($item['type'])) ?>
        <?php if ($item['artist']): ?> · <?= e($item['artist']) ?><?php endif; ?>
        <?php if ($item['album']): ?> · <?= e($item['album']) ?><?php endif; ?>
      </p>
    
      <
  3. From the library page, click any track. It should open play.php and the file should start playing.
  4. For video: try dragging the scrubber. Should jump instantly (Range working).
  5. For audio: should play in the browser's built-in audio bar.
  6. Let it play to the end. Watch it auto-advance to the next item.
  7. Click prev/next to navigate manually.

Stretch goals:

  • For audio, replace the boring built-in controls with a custom UI showing album art (we'll generate that in the metadata chapter).
  • Add keyboard shortcuts: space for play/pause, J/L for prev/next.
  • Build a small "now playing" persistent bar at the bottom of the library page so playback continues while browsing.
  • Add a "shuffle" toggle that picks a random next track instead of the alphabetically-next one.

What you flexed: HTML5 <video> and <audio> with autoplay + controls, dynamic tag selection based on the type column, adjacency queries (find the row "before" and "after" by some sort order), and a tiny pinch of vanilla JavaScript for auto-advance. We're four chapters in and HomeStream genuinely works now. That's the bar — does it work? Yes. The rest is polish.