MantisDocs

Play videos

Lasting links for public folders, signed playback links for the rest, and players for every browser.

A video plays from its master playlist. Which link you hand the player depends on the folder's read rule:

  • A folder anyone may read gives every file a lasting publicUrl on cdn.shipmantis.com, cached at the edge. Use it as it is.
  • Any other folder has no public link. Ask for a signed playback link, which opens that one video for as long as you say, or only for one signed-in user.

Public folders

const url = `https://cdn.shipmantis.com/${projectId}/${video.id}/master.m3u8`;
// the same link uploadVideo returned as publicUrl; posterUrl is …/poster.jpg
$url = "https://cdn.shipmantis.com/{$projectId}/{$lesson->video}/master.m3u8";
$poster = "https://cdn.shipmantis.com/{$projectId}/{$lesson->video}/poster.jpg";

$projectId is the part after cdn.shipmantis.com/ in the publicUrl of any file in a public folder, the same for every file of the project; keep it in your config.

The link never expires and the files are cached at the edge for an hour, so a video watched by thousands is served from Cloudflare, not from storage. Anyone who has the link can watch: public means public.

Signed playback

app/lessons/[id]/page.tsx (server)
const url = await mantis.storage.playbackUrl(lesson.video); // works for an hour
const short = await mantis.storage.playbackUrl(lesson.video, { expiresIn: 10 * 60 });
const mine = await mantis.storage.playbackUrl(lesson.video, { userId: user.id }); // only this user
app/Http/Controllers/LessonController.php
use Shipmantis\Mantis\Laravel\Facades\Mantis;

$url = Mantis::storage()->playbackUrl($lesson->video); // works for an hour
$short = Mantis::storage()->playbackUrl($lesson->video, expiresIn: 10 * 60);
$mine = Mantis::storage()->playbackUrl($lesson->video, userId: (string) $user->id); // only this user

A link locked to one viewer needs a sign-in token Mantis can check. A Laravel app that signs people in itself hands those out with the package's own issuer: see Sign-in tokens from Laravel.

The link looks like https://cdn.shipmantis.com/play/{token}/{id}/master.m3u8.

OptionWhat it does
expiresInSeconds the link lives, from 1 to 86400 (24 hours). Default: 3600.
userIdLocks the link to one signed-in user. See One viewer only.

Who may ask for one follows the folder's read rule, like storage.url(): the secret key always, the publishable key when the rule lets the signed-in user read the video's master playlist.

How it is checked: the token sits in the link's path, and the master playlist points at its variant playlists by relative path, so every playlist the player asks for carries the same token, and every one of those requests checks it. The variant playlists point their segments at signed segment URLs (/play/c/{expires}/{signature}/...) that every viewer shares. That means:

  • One link opens one video. Any playlist under the video's id opens with it; another id, or a path that leaves the video, opens nothing. A segment URL opens that one segment.
  • Playlists are never cached. They are served private, no-store, so no cache hands the list of segments to someone without the token.
  • Segments are cached at the edge. A segment URL ends on the next six-hour boundary after the link's expiry, so every link that ends in the same window gets the same URL, and Cloudflare serves one copy to every viewer until the window ends. A video watched by a thousand buyers streams from storage about once per window.
  • Expiry is enforced when a playlist is read. A player reads the playlists when it starts, so an expired link refuses a new start or a reload, while a video already playing carries on to the end of its segment window. Give a link that outlives the viewing: the video's length plus a margin, or a few hours for a course page someone leaves open.

Players

Safari, on macOS and iOS, plays HLS in a plain <video>. Chrome, Firefox and Edge need hls.js, which plays HLS through Media Source Extensions. This component uses whichever the browser has:

components/video-player.tsx
"use client";

import Hls from "hls.js";
import { useEffect, useRef } from "react";

export function VideoPlayer({ src, poster, token }: { src: string; poster?: string; token?: () => string }) {
  const ref = useRef<HTMLVideoElement>(null);
  const currentToken = useRef(token);
  const locked = token !== undefined;

  useEffect(() => {
    currentToken.current = token;
  });

  useEffect(() => {
    const video = ref.current;
    if (!video) return;

    if (!locked && video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src;
      return;
    }
    if (!Hls.isSupported()) return;

    const hls = new Hls({
      xhrSetup: locked
        ? (xhr, url) => {
            if (new URL(url).pathname.endsWith(".m3u8")) {
              xhr.setRequestHeader("mantis-user-token", currentToken.current?.() ?? "");
            }
          }
        : undefined,
    });
    hls.loadSource(src);
    hls.attachMedia(video);
    return () => hls.destroy();
  }, [src, locked]);

  return <video ref={ref} poster={poster} controls playsInline className="aspect-video w-full" />;
}
npm install hls.js
  • playsInline keeps iPhones from jumping to full screen on play.
  • poster shows the frame at one second before play. A signed link opens the poster too: swap master.m3u8 at its end for poster.jpg. A locked link's poster needs the header like every other file, which <video poster> can't send, so ask storage.url() for the poster's path instead.
  • Destroy hls.js on unmount, or a page that switches videos keeps loading the old one.

Safari, on macOS and iOS, plays HLS in a plain <video>. Chrome, Firefox and Edge need hls.js, which plays HLS through Media Source Extensions. This Blade component uses whichever the browser has:

resources/views/components/video-player.blade.php
@props(['src', 'poster' => null, 'locked' => false])

<video data-hls="{{ $src }}" @if ($locked) data-token-url="{{ route('mantis.token') }}" @endif
    @if ($poster) poster="{{ $poster }}" @endif controls playsinline
    {{ $attributes->merge(['class' => 'aspect-video w-full']) }}></video>

@once
    <script type="module">
        import Hls from "https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.mjs";

        const tokens = {};
        async function freshToken(url) {
            const now = Date.now();
            if (!tokens[url] || tokens[url].until < now) {
                const answer = await fetch(url, { credentials: "same-origin" }).then((r) => r.json());
                tokens[url] = { token: answer.token, until: Date.parse(answer.expiresAt) - 60_000 };
            }
            return tokens[url].token;
        }

        for (const video of document.querySelectorAll("video[data-hls]")) {
            const src = video.dataset.hls;
            const tokenUrl = video.dataset.tokenUrl;
            if (!tokenUrl && video.canPlayType("application/vnd.apple.mpegurl")) {
                video.src = src;
                continue;
            }
            if (!Hls.isSupported()) continue;

            let token = tokenUrl ? await freshToken(tokenUrl) : null;
            const hls = new Hls({
                xhrSetup: token
                    ? (xhr, url) => {
                          if (new URL(url).pathname.endsWith(".m3u8")) xhr.setRequestHeader("mantis-user-token", token);
                      }
                    : undefined,
            });
            if (tokenUrl) setInterval(async () => (token = await freshToken(tokenUrl)), 60_000);
            hls.loadSource(src);
            hls.attachMedia(video);
        }
    </script>
@endonce
<x-video-player :src="$url" :poster="$posterUrl" />
<x-video-player :src="$lockedUrl" locked />

locked reads the viewer's token from the package's /mantis/token route (see Sign-in tokens from Laravel) and sends it on every playlist request.

  • playsinline keeps iPhones from jumping to full screen on play.
  • poster shows the frame at one second before play. A signed link opens the poster too: swap master.m3u8 at its end for poster.jpg.
  • Bundle hls.js with Vite (npm install hls.js, then import Hls from "hls.js") instead of the CDN import when the app already builds its scripts.

One viewer only

A link made with userId plays only for that user. Every playlist request must carry the user's current sign-in token in a mantis-user-token header:

RequestAnswer
No token401
A token for someone else403, "This playback link is locked to another user."
A token Mantis can't check, or no auth in the schema401
The user's own tokenthe file

So a link copied into a group chat plays for nobody else. Segments are not checked per viewer: they are cached for everyone and a segment URL, without its playlist, is six seconds of video. The secret key may lock a link to any user; the publishable key may lock one only to the signed-in user itself.

A plain <video src> can't send a header, so locked links always need hls.js, Safari included. Pass token to the component above. It is read on every request, so a token that refreshes mid-video keeps working, and a new function on each render doesn't restart the video.

Locking needs auth in the schema, so Mantis can check the user's token.

Answers

StatusMeans
200The playlist or segment. Playlists are application/vnd.apple.mpegurl.
401A locked link without a valid user token.
403The link expired, it is locked to another user, or Mantis did not make it.
404The video has no such file, or the path leaves the video.
502, 503Storage did not answer, or is not set up on this server. Retry.

Browsers can reach every playback file from any origin (Access-Control-Allow-Origin: *), with the mantis-user-token and Range headers allowed, so a player on your own domain needs no setup.

Asking for a link answers 422 for an id that isn't a folder and a name (videos/intro) or an expiresIn outside 1 to 86400, 403 when the folder's read rule refuses the caller, and 404 when the video has no master playlist yet.

Edge cases

  • The link is made per page view, not stored. Make it where you render the page; a stored link expires and a public one would never need storing.
  • A long watch on a short link stops at the expiry. For a viewer who might pause for hours, either give 24 hours or ask the server for a new link and call hls.loadSource(newUrl), then seek back to video.currentTime.
  • A segment URL works for anyone holding it until its window ends, at most six hours after the link's expiry; the playlist that lists them stays locked.
  • Download managers and ffmpeg -i <link> can save a video from any link they can play. A locked link narrows that to the user's own session; nothing stops a user recording their own screen.
  • Moving a video from a public folder to a private one doesn't recall copies Cloudflare holds: public files stay cached for up to an hour.
  • A video still uploading has no master playlist, so a signed link is refused with 404 and a public link answers 404 too until the upload finishes.

On this page