Skip to main content

Documentation

No results found.
Features

Video Hosting

Upload videos straight from the dashboard to a professional streaming provider — Cloudflare Stream, Bunny Stream, or Mux — and drop them onto any page with the video block's Hosted source. Playback is ad-free adaptive HLS with the provider'...

Upload videos straight from the dashboard to a professional streaming provider — Cloudflare Stream, Bunny Stream, or Mux — and drop them onto any page with the video block's Hosted source. Playback is ad-free adaptive HLS with the provider's auto-generated poster, and the upload never touches your web server.

Feature key: video_hosting (default OFF). Toggle at Dashboard → Settings → Features; configure at Dashboard → Videos → Settings (admin only).

How it works

  1. Configure a provider (Settings): pick the active provider and enter its credentials. Each card has a Test connection button.
  2. Upload (Dashboard → Videos): the server registers the pending video with the provider and hands the browser a one-time upload target; the bytes go directly from the browser to the provider (TUS resumable for Cloudflare/Bunny, resumable PUT for Mux) — PHP upload limits and server bandwidth don't apply.
  3. Transcode status flips uploading → processing → ready automatically: the Videos page polls while anything is pending, a video-hosting:poll LazyCron task (every 5 min) covers background updates, and optional provider webhooks make it near-instant.
  4. Use it on a page: the page editor's video block gains a fourth Hosted (Video Library) source mode with a searchable picker. The saved value is the hosted video's id; the manifest URL + poster resolve at render time, so provider-side changes propagate.
  5. Playback uses the same lazy hls.js loader as the free Adaptive mode (<video data-hls-src>): native HLS on Safari/iOS, hls.js elsewhere.

The free/core path is unchanged: the video block's Adaptive mode still accepts any pasted HLS/DASH manifest URL without this add-on.

Captions, chapters & playback stats

Every ready video gets a manage panel (the speech-bubble button on the Videos page) with three sections:

  • Captions — upload a .vtt or .srt file per language (SRT is converted to WebVTT automatically), or click Generate to have the provider auto-transcribe the audio (Cloudflare caption generation, Bunny transcription, Mux generated subtitles). Generation is asynchronous; the panel polls while a track is pending and downloads the finished VTT when it's ready. Deleting a track removes it at the provider and locally.
  • Chapters — named timeline points (1:23 + title), stored on the video and written out as a WebVTT chapters track. Provider-agnostic; players with chapter menus (e.g. Safari) pick them up natively.
  • Playback stats — views + watch time pulled from the provider: Cloudflare Stream analytics (GraphQL, last 30 days; views are playback sessions), Bunny's lifetime video totals, and Mux delivery usage (seconds streamed over the last 30 days; per-view counts need Mux Data, which isn't embedded). A daily video-hosting:sync-stats LazyCron task keeps the numbers fresh; the Refresh button pulls on demand.

Local caption mirror. Every caption track (uploaded or generated) is mirrored to storage/app/public/hosted-video-tracks/{id}/{lang}.vtt, and the public player renders plain same-origin <track kind="subtitles"> elements (plus <track kind="chapters">) inside the hosted <video>. That sidesteps provider CORS and authenticated caption endpoints entirely — the browser's native CC menu works under both native-HLS and hls.js playback. The provider copy still exists so provider-side players/manifests carry the captions too. Track files are cleaned up when a caption or the video is deleted.

Mux caveat: Mux ingests caption files by URL, so manual caption uploads require the site to be publicly reachable (it fetches the mirrored VTT from /storage/…). Auto-generated subtitles have no such constraint.

Providers

Cloudflare Stream Bunny Stream Mux
Credentials Account ID, API token (Stream:Edit), customer subdomain code Library ID, library API key, CDN hostname Access token ID + secret
Browser upload TUS (one-time URL via ?direct_user=true) TUS with presigned AuthorizationSignature Resumable PUT (@mux/upchunk)
Playback customer-{code}.cloudflarestream.com/{uid}/manifest/video.m3u8 {cdn}/{guid}/playlist.m3u8 stream.mux.com/{playback_id}.m3u8
Webhook auth Webhook-Signature HMAC none (payload treated as refresh hint only) Mux-Signature HMAC

One provider is active for new uploads (video_hosting.provider), but every video row remembers the provider it lives on — switching providers never breaks existing videos. Deleting a video also deletes it at its original provider, so keep that provider's credentials configured.

Webhooks (optional)

Register the URLs shown on the settings page:

  • POST /video-hosting/webhook/cloudflare — register with PUT /accounts/{id}/stream/webhook; paste the returned secret into settings.
  • POST /video-hosting/webhook/bunny — set as the library's webhook URL. Bunny webhooks are unsigned, so the payload is only used to decide which video to re-fetch from the API — the posted status itself is never trusted.
  • POST /video-hosting/webhook/mux — add in Mux → Settings → Webhooks; paste the signing secret into settings.

All three controllers converge on one trust model: verify (where possible), then re-fetch truth from the provider's API via VideoHostingManager::refresh(). Without webhooks everything still works via polling.

Settings keys

Key Meaning
video_hosting.provider cloudflare | bunny | mux (default cloudflare)
video_hosting.cloudflare.account_id / .api_token / .customer_subdomain / .webhook_secret Cloudflare Stream
video_hosting.bunny.library_id / .api_key / .cdn_hostname Bunny Stream
video_hosting.mux.token_id / .token_secret / .webhook_secret Mux
video_hosting.max_upload_bytes Dashboard upload cap (default 5 GB)

Architecture

Module: app/Features/VideoHosting/ (standard feature-module layout; migrations run on toggle-on via FeatureActivator).

  • Models/HostedVideo — one row per video: provider, provider_uid, title, status (pending|uploading|processing|ready|error), duration/size/dimensions, thumbnail_url, playback_hls_url/playback_dash_url, meta json (per-provider extras like Mux asset_id/playback_id), captions json (per-language track records with local src), chapters json, and the synced stats columns (views_total, watch_seconds_total, stats_period, stats_synced_at). Public rendering reads the cached HostedVideo::playbackMapCached() map — now including ready caption tracks + the chapters URL (query-free once warm; busted on every save/delete along with the response cache, since playback URLs are baked into cached HTML).
  • Services/VideoHostingManager — resolves drivers, createUpload(), refresh(), syncStats(), deleteEverywhere().
  • Services/HostedVideoTracks — caption + chapter orchestration: uploadCaption() (VTT/SRT → local mirror + provider push), requestGeneration(), syncFromProvider() (reconciles records, downloads newly-ready generated VTTs), deleteCaption(), saveChapters() (writes the chapters WebVTT).
  • Services/VideoHostProvider — the driver interface (createDirectUpload, fetchStatus, delete, playbackUrls, thumbnailUrl, verifyWebhook, signedPlaybackUrl, plus the caption methods listCaptions/captionVtt/uploadCaption/deleteCaption/requestAutoCaption and fetchStats). Adding a fourth provider = one class + one entry in VideoHostingManager::PROVIDERS + a settings card.
  • Editor tie-in — DlSchemas\Video::schemaFields() adds a {prefix}_hosted_id field (type hosted_video, feature-gated) and the hosted source mode; x-dl.video renders the hosted branch exactly like adaptive (data-hls-src + provider thumbnail as poster). A deleted/processing video renders the neutral placeholder — never a fatal.

Reusing hosted videos from other add-ons (e.g. Courses)

Consume hosted video only through the module's public API:

use App\Features\VideoHosting\Models\HostedVideo;
use App\Features\VideoHosting\Services\VideoHostingManager;

$video = HostedVideo::query()->where('status', HostedVideo::STATUS_READY)->find($id);
$hls = $video->playback_hls_url;                            // public playback
$signed = app(VideoHostingManager::class)->signedPlaybackUrl($video, ttlSeconds: 3600); // gated playback

signedPlaybackUrl() is the gated-playback hook, implemented on all three drivers:

  • Cloudflare Stream — mints a playback token server-side (POST /stream/{uid}/token, cached for half the TTL so bursts don't mint one per view) and swaps it into the playback URL. No extra settings needed beyond the API token. Enforcement comes from setting requireSignedURLs on the video.
  • Bunny Stream — CDN token authentication: ?token=sha256(key + videoId + expires)&expires=… on the playlist URL, using the Token authentication key from the library's security settings (new field on the Videos settings page). Enforcement comes from enabling Embed View Token Authentication on the library.
  • Mux — a locally-minted RS256 JWT (kid = signing key id, sub = playback id, aud: v) appended as ?token=. Configure the Signing key ID + private key (Mux → Settings → Signing Keys) on the Videos settings page. Enforcement comes from creating playback IDs with the signed policy.

It returns null whenever the video's provider isn't configured for signing — the caller decides whether to fall back to the public URL or refuse playback. In every case the minting works even for public videos (the token is simply ignored), so Courses can adopt per-video enforcement incrementally. Store hosted_videos.id foreign keys (not provider URLs) so signing, renames, and thumbnail refreshes keep working.