ClipSurf

What is it?

It’s a minimalist browser based gallery that displays all the images and videos in a local folder. The design is similar to imgur/instagram/tiktok with all the social elements removed. No Javascript is required. Dependencies are PHP and optionally FFmpeg for thumbnails. Full code is available here.

Why make this?

Content disappears from the web all the time. I’ve started making a habit of saving all the fun images/videos I come across to make my own personal archive. However, operating systems don’t have a very good UI for displaying a lot of images/videos. A quick google search failed to turn up any solutions I liked, so I decided to throw together something minimalist and easy to customize.

I also wanted to learn some PHP, since all of my professional web development experience has been in Python. If you spot any bugs or bad practices, please let me know.

How does it work?

My main goal was to minimize dependencies, which led me to a single-webpage design with a PHP backend. Controls are implemented through the query string as well as an HTML form at the top of the page. Since PHP comes with a built in webserver [1], the only other dependency is FFmpeg for generating video thumbnails, which can be omitted if you don’t mind some pop-in with the videos.

Creating the base layout

For test images I picked out some cats and dogs that I liked from the Kaggle Cats and Dogs Dataset and put them into a folder called “CatsAndDogs” inside the main project folder.

Then I began by creating a four column layout using Flexbox. All the margins are hardcoded in pixels for simplicity.

<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
        <div class="root">
            <div class="row">
                <div class="column">
                    <img src="CatsAndDogs/9.jpg">
                    <img src="CatsAndDogs/18.jpg">
                </div>
                <div class="column">
                    <img src="CatsAndDogs/8.jpg">
                    <img src="CatsAndDogs/69.jpg">
                </div>
                <div class="column">
                    <img src="CatsAndDogs/103.jpg">
                    <img src="CatsAndDogs/6.jpg">
                </div>
                <div class="column">
                    <img src="CatsAndDogs/143.jpg">
                    <img src="CatsAndDogs/45.jpg">
                </div>
            </div>
        </div>
    </body>
</html>
body {
    background-color: #dcdef9;
}
div.root {
    margin-top: 20px;
    margin-left: 100px;
    margin-right: 100px;
}
div.row {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    width: 100%;
    gap: 10px;
}
div.column {
    display: flex;
    flex-direction: column;
    flex-basis: 100%;
    flex: 1;
    gap: 10px;
}
img {
    width: 100%;
    height: auto;
    border-radius: 8px;
}

Here’s what that looks like:

Making it dynamic

With the layout in place, I started working on the backend.

For the core backend data structure I picked a list of dictionaries. Each dictionary contains metadata about a single media file.

The upside to this design is that it’s very flexible and easy to extend with new information about each media entry. It’s also effortless to pass through to the frontend using encode_json. (This will most likely be needed in the future to support dynamic video loading/unloading for big folders.)

The biggest downside is there’s no schema for the data, which opens the door to a lot of annoying errors like typos in the key. Since this is a minimalist program in a language without good typing support, I think this tradeoff is fine.

I started by making a few helper functions to build the list for a directory, as well as converting the list into columns:

// Helper to identify the file type
function getMediaType($path) {
    $mime = mime_content_type($path);
    if (strpos($mime, "image/") === 0) {
        return "image";
    }
    return null;
}

// Scans through the given directory and returns a
// list of dictionaries describing each media item.
function createMediaInfoList($directory) {
    $mediaInfoList = array();    
    $files = scandir($directory);
    foreach ($files as $fileName) {
        $filePath = $directory . $fileName;
        $mediaType = getMediaType($filePath);
        if (is_null($mediaType)) {
            continue;
        }
        $entry = array(
            'path' => $filePath,
            'mediaType' => $mediaType,
        );
        array_push($mediaInfoList, $entry);
    }
    return $mediaInfoList;
}

// Converts the given list into a list of columns.
// Example: splitIntoColumns([a, b, c], 2) -> [[a, c], [b]]
function splitIntoColumns($files, $numColumns) {
    $columns = array();
    for ($i = 0; $i < $numColumns; $i++) {
        array_push($columns, array());
    }
    $elementCount = 0;
    foreach ($files as $file) {
        $col = ($elementCount % $numColumns);
        array_push($columns[$col], $file);
        $elementCount++;
    }
    return $columns;
}

// Converts from an on-disk path to a URL
function convertPathToUrl($path) {
    $documentRoot = $_SERVER['DOCUMENT_ROOT'];
    $absolutePath = realpath($path);
    return str_replace($documentRoot, '', $absolutePath);
}

Next, I converted index.html to index.php and added the code to create the content dynamically:

<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
        <div class="root">
            <div class="row">
            <?php
                require_once 'functions.php';

                $directory = $_SERVER['DOCUMENT_ROOT'] . '/CatsAndDogs/';
                $mediaInfoList = createMediaInfoList($directory);
                $columns = splitIntoColumns($mediaInfoList, 4);

                foreach ($columns as $mediaColumn) {
                    echo '<div class="column">';
                    foreach ($mediaColumn as $mediaInfo) {
                        $url = convertPathToUrl($mediaInfo['path']);
                        if ($mediaInfo['mediaType'] === "image") {
                            $imageSrc = "src=\"$url\"";
                            $imageOptions = 'loading="lazy"';
                            echo "<img $imageSrc $imageOptions>";
                        }
                    }
                    echo '</div>';
                }
                ?>
            </div>
        </div>
    </body>
</html>

The main thing to note here is the loading=”lazy” attribute on the img tag. This activates browser-level image lazy loading, which means a directory full of images won’t blow up the browser.

One thing that surprised me was that although the documentation on lazy image loading suggests adding size dimensions to all images to prevent layout shifts, I didn’t notice any problems when they weren’t specified. Maybe this is because loading from local disk is so much faster than over the network? If you have any ideas, let me know.

Whatever the reason, omitting the dimensions allows for a much simpler implementation because we don’t need to use Javascript to figure out the width of each column.

Adding controls

Next I added some basic controls using query strings and an HTML form.

First, a couple of helper functions:

$DEFAULT_COLUMNS = 4;

function getFromQueryStringOrDie($name) {
    if (!isset($_GET[$name])) {
        throw new Exception("Expected $name to be set in the query string, but it isn't");
    }
    return $_GET[$name];
}

function getFromQueryStringOrDefault($name, $default) {
    if (!isset($_GET[$name])) {
        return $default;
    }
    return $_GET[$name];
}

Then add the HTML form to control the folder as well as the number of columns to show:

<div class="row">
<form action="" method="get">
    <label for="folder">Folder:</label>
    <input 
        type="text"
        id="folder" 
        name="folder" 
        value="<?php
            require_once 'functions.php'; 
            echo getFromQueryStringOrDie('folder'); 
        ?>"
    >
    <label for="numColumns">Columns:</label>
    <input 
        type="number" 
        id="numColumns" 
        name="columns" 
        value="<?php 
            require_once 'functions.php';
            global $DEFAULT_COLUMNS;
            echo getFromQueryStringOrDefault('columns', $DEFAULT_COLUMNS); 
        ?>"
    >
    <button type="submit">Reload</button>
</form>
</div>

Finally, the main code block to pull parameters from the query string instead of hardcoding them:

$directory = $_SERVER['DOCUMENT_ROOT'] . '/' . getFromQueryStringOrDie('folder');
$lastChar = substr($directory, -1);
// Make sure the directory ends in a separator.
if ($lastChar !== '/' && lastChar !== '\\') {
    // Modern windows versions support forward slash as a separator.
    $directory .= '/';
}
if (!is_dir($directory)) {
    exit("Directory not found: " . $directory);
}

global $DEFAULT_COLUMNS;
$numColumns = $DEFAULT_COLUMNS;
if (isset($_GET['columns'])) {
    $numColumns = intval($_GET['columns']);
}

Now we have something that looks like this:

Ideally we would have a way to interactively pick the folder here instead of having to type in the path as a string, but browsers have no way to do this. The file input form will return a fake path, so this text form is the best compromise I’ve found.

Adding support for videos

After some experimentation, it turns out that thumbnails are essential to avoiding layout shifts when displaying a folder with a lot of videos. Luckily adding these can be done easily by adding another processing pass through the media info list. The actual thumbnail generation is done by calling out to FFmpeg.

// Update getMediaType to support videos
function getMediaType($path) {
    $mime = mime_content_type($path);
    if (strpos($mime, "image/") === 0) {
        return "image";
    }
    if (strpos($mime, "video/") === 0) {
        return "video";
    }
    return null;
}

// Create a thumbnail for the given video by calling out to FFmpeg.
// Does *not* overwrite existing thumbnails -- they must be manually deleted.
function createVideoThumbnail($path, $thumbDir, $width) {
    $name = pathinfo($path, PATHINFO_FILENAME);
    $thumbpath = $thumbDir . $name . ".jpg";
    if (file_exists($thumbpath)) {
        return $thumbpath;
    }
    $cmd = ("ffmpeg -n -loglevel error -ss 00:00:00.00 " .
            "-i '$path' " .
            "-vf 'scale=$width:-1:force_original_aspect_ratio=decrease' " .
            "-vframes 1 " . 
            "'$thumbpath'");
    shell_exec($cmd);
    return $thumbpath;
}

// Returns a copy of mediaInfoList with thumbnail information added
// for each video. Creates thumbnails if they don't exist.
function addThumbnails($mediaInfoList, $thumbDir, $thumbWidth = 300) {
    $updatedMediaInfoList = array();
    if (!is_dir($thumbDir)) {
        mkdir($thumbDir);
    } 
    foreach ($mediaInfoList as $mediaInfo) {
        if ($mediaInfo['mediaType'] === 'video') {
            $mediaInfo['thumbpath'] = createVideoThumbnail($mediaInfo['path'], $thumbDir, $thumbWidth);
        }
        array_push($updatedMediaInfoList, $mediaInfo);
    }
    return $updatedMediaInfoList;
}

Thumbnails are stored in the __thumbs subdirectory of the target media folder:

$thumbDir = $directory . '__thumbs/';
$mediaInfoList = addThumbnails($mediaInfoList, $thumbDir);

The code to generate the video tag HTML:

else if ($mediaInfo['mediaType'] === "video") {
    $videoSrc = "src=\"$url\"";
    $videoOptions = 'controls preload="none"';
    $videoPoster = '';
    if (isset($mediaInfo['thumbpath'])) {
        $thumbpath = $mediaInfo['thumbpath'];
        $videoPoster = "poster=\"$thumbpath\"";
    }
    echo "<video $videoSrc $videoPoster $videoOptions></video>";
}

The only required video attribute here is preload=”none”. This prevents the browser from attempting to load all of the videos at once. The other attributes are up to personal taste – for example, you could replace controls with muted autoplay if you want auto-playing videos.

Note that with this design, thumbnail generation is done on the first webpage load. There are a lot of low hanging fruit for improving the load time here. One could add multithreading to the thumbnail generation. Another option would be to allow pop-in on the first load but to kick off some kind of persistent background process that watches the directory for changes and periodically regenerates the thumbnails. I wanted to keep things minimal, so I didn’t implement either of these.

And that’s it! In ~250 lines of total code we have our viewer. The main limitation is that the displayed folder needs to be within the document root. To work around this, one can start the PHP server in the home directory.

Ideas for improvements

  • Alternative layouts. For example, Flickr uses some kind of bin-packing algorithm that could be fun to implement.
  • Add sorting: newest first, oldest first, etc.
  • It would be relatively straightforward extend this page to allow users to upload content and make a barebones social network that’s only available over local wifi.

Footnotes

[1] The built-in webserver should only be used on localhost.


Comments

2 responses to “ClipSurf”

  1. Reverend Forehead Jackson Avatar
    Reverend Forehead Jackson

    Wow, this seems really useful! Content disappearing from the web is a constant annoyance. Question: are you hosting ClipSurf on the public internet so any user can use it without having to run the PHP server locally?

    1. dvy Avatar

      Thanks!
      I thought about hosting it publicly, but decided against it because then I would need to deal with moderation.

Leave a Reply

Your email address will not be published. Required fields are marked *