Restores the live streaming controllers / views / model from the
pre-audit WIP that got stashed on 2026-10-21, then applies the Phase 6
polish items:
## /live/new form
Add a Private visibility option that mirrors the upload modal's
wording — "Only you can see this stream. Perfect for testing without
an audience." Validator in LiveStreamController@store now permits
public|unlisted|private (was public|unlisted).
## Watch page ended-state
When the stream status is "ended", the overlay is a solid "Stream
ended" card showing the stream's duration (started_at → ended_at)
and, when a recording was published, a Watch Replay CTA linking to
the saved video. Previously an ended stream showed the generic
"Waiting for the broadcast to start" placeholder forever.
## Channel name link
The uploader's name in the meta row is now a real <a href> to the
channel page.
## Viewer action row
Subscribe + Share buttons below the player, visible to anyone except
the owner. Subscribe for guests uses the Phase 7 ?intended= pattern
so after sign-in they land back on the stream.
## Live chat (DB-backed polling)
New live_chat_messages table (migration 2026_10_21_000003). Viewers
GET /live/{stream}/chat?since=N every 3 seconds; signed-in users POST
to the same path (rate-limited at 60/min). Guests see the panel in
read-only mode with a "Sign in to chat" placeholder.
Private streams reject chat for anyone but the owner (404). Non-live
status rejects chat sends (422). Author name is snapshotted at post
time so a renamed account's old messages keep the name they used.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
293 lines
9.6 KiB
PHP
293 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\LiveChatMessage;
|
|
use App\Models\LiveStream;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
/**
|
|
* Live streaming — broadcaster and viewer surfaces.
|
|
*
|
|
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
|
|
* may broadcast, mints the short-lived credential the browser publishes with,
|
|
* and hands out the URLs. See LiveAuthController for the hook the media server
|
|
* calls back on.
|
|
*/
|
|
class LiveStreamController extends Controller
|
|
{
|
|
public function __construct()
|
|
{
|
|
// Watching is open; everything that creates or controls a stream needs
|
|
// an account.
|
|
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
|
|
}
|
|
|
|
/** Live now, plus the viewer's own streams when signed in. */
|
|
public function index(): \Illuminate\View\View
|
|
{
|
|
$live = LiveStream::live()
|
|
->listed()
|
|
->with('user')
|
|
->latest('started_at')
|
|
->limit(60)
|
|
->get();
|
|
|
|
$mine = Auth::check()
|
|
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
|
|
: collect();
|
|
|
|
return view('live.index', compact('live', 'mine'));
|
|
}
|
|
|
|
/** The "start a broadcast" form. */
|
|
public function create(): \Illuminate\View\View
|
|
{
|
|
return view('live.create');
|
|
}
|
|
|
|
/**
|
|
* Create the stream record. This does NOT start anything — it reserves an
|
|
* identity and a path; the browser goes live from the broadcast page.
|
|
*/
|
|
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'title' => ['required', 'string', 'max:120'],
|
|
'description' => ['nullable', 'string', 'max:2000'],
|
|
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
|
|
]);
|
|
|
|
// One live broadcast per account at a time. Without this a single user
|
|
// can pin open as many encoder slots as they can open tabs.
|
|
$alreadyLive = LiveStream::where('user_id', Auth::id())
|
|
->where('status', LiveStream::STATUS_LIVE)
|
|
->exists();
|
|
|
|
if ($alreadyLive) {
|
|
return back()
|
|
->withInput()
|
|
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
|
|
}
|
|
|
|
$stream = LiveStream::create([
|
|
'user_id' => Auth::id(),
|
|
'title' => $data['title'],
|
|
'description' => $data['description'] ?? null,
|
|
'visibility' => $data['visibility'],
|
|
'status' => LiveStream::STATUS_IDLE,
|
|
]);
|
|
|
|
return redirect()->route('live.broadcast', $stream);
|
|
}
|
|
|
|
/** The broadcaster's camera page. Owner only. */
|
|
public function broadcast(LiveStream $stream): \Illuminate\View\View
|
|
{
|
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
|
|
|
// An ended stream is not a dead one — the owner may go live on it
|
|
// again from the same link.
|
|
$stream->reArm();
|
|
|
|
return view('live.broadcast', [
|
|
'stream' => $stream,
|
|
'whipUrl' => $this->whipUrl($stream),
|
|
'watchUrl' => route('live.watch', $stream),
|
|
'iceServers' => $this->iceServers(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Mint the publish credential.
|
|
*
|
|
* Separate from the page load so the token's short life starts when the
|
|
* user actually presses "Go live", not when they opened the tab and went
|
|
* to find a tripod.
|
|
*/
|
|
public function token(LiveStream $stream): JsonResponse
|
|
{
|
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
|
|
|
// Pressing "Go live" on a stream that previously ended restarts it
|
|
// rather than being refused. A failed attempt must never burn the link.
|
|
$stream->reArm();
|
|
|
|
return response()->json([
|
|
'token' => $stream->issuePublishToken(),
|
|
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
|
|
'whip_url' => $this->whipUrl($stream),
|
|
'ice_servers' => $this->iceServers(),
|
|
]);
|
|
}
|
|
|
|
/** The viewer page. */
|
|
public function watch(LiveStream $stream): \Illuminate\View\View
|
|
{
|
|
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
|
|
|
$stream->loadMissing('user');
|
|
|
|
return view('live.watch', [
|
|
'stream' => $stream,
|
|
'whepUrl' => $this->whepUrl($stream),
|
|
'hlsUrl' => $this->hlsUrl($stream),
|
|
'isOwner' => $stream->isOwnedBy(Auth::user()),
|
|
'iceServers' => $this->iceServers(),
|
|
]);
|
|
}
|
|
|
|
/** Lightweight poll for the viewer page: is it live, how many watching. */
|
|
public function status(LiveStream $stream): JsonResponse
|
|
{
|
|
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
|
|
|
return response()->json([
|
|
'status' => $stream->status,
|
|
'live' => $stream->isLive(),
|
|
'viewers' => $stream->current_viewers,
|
|
'duration' => $stream->formatted_duration,
|
|
]);
|
|
}
|
|
|
|
/** End the broadcast. Owner only. */
|
|
public function stop(LiveStream $stream): JsonResponse
|
|
{
|
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
|
|
|
$stream->markEnded();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'status' => $stream->status,
|
|
'duration' => $stream->formatted_duration,
|
|
]);
|
|
}
|
|
|
|
/* ---------------- URLs ---------------- */
|
|
|
|
/*
|
|
* These are same-origin paths, proxied by nginx to the media server on
|
|
* loopback. Same-origin matters: the page is served over HTTPS, and a
|
|
* mixed-content or cross-origin media URL would be blocked by the browser
|
|
* before WebRTC ever started.
|
|
*/
|
|
|
|
private function whipUrl(LiveStream $stream): string
|
|
{
|
|
return url('/live-rtc/'.$stream->mediaPath().'/whip');
|
|
}
|
|
|
|
private function whepUrl(LiveStream $stream): string
|
|
{
|
|
return url('/live-rtc/'.$stream->mediaPath().'/whep');
|
|
}
|
|
|
|
private function hlsUrl(LiveStream $stream): string
|
|
{
|
|
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
|
|
}
|
|
|
|
/**
|
|
* ICE servers for the browser.
|
|
*
|
|
* No inbound UDP port is forwarded to this server, so a broadcaster outside
|
|
* the local network can only reach it through a TURN relay. On the LAN the
|
|
* host candidates work directly and TURN is never used.
|
|
*
|
|
* Configured through the environment so credentials never sit in the repo.
|
|
*/
|
|
private function iceServers(): array
|
|
{
|
|
$servers = [];
|
|
|
|
if ($stun = config('live.stun_url')) {
|
|
$servers[] = ['urls' => $stun];
|
|
}
|
|
|
|
if ($turn = config('live.turn_url')) {
|
|
$servers[] = array_filter([
|
|
'urls' => $turn,
|
|
'username' => config('live.turn_username'),
|
|
'credential' => config('live.turn_credential'),
|
|
]);
|
|
}
|
|
|
|
return $servers;
|
|
}
|
|
|
|
/**
|
|
* GET poll: viewer asks for every message in this stream newer than
|
|
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
|
|
* messages — more than enough for a 3-second polling window, bounded
|
|
* to protect the DB when a client is slow to reconnect.
|
|
*/
|
|
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
|
|
{
|
|
// Private streams are owner-only; everything else is open to viewers.
|
|
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
|
abort(404);
|
|
}
|
|
|
|
$since = (int) $request->query('since', 0);
|
|
$rows = LiveChatMessage::where('livestream_id', $stream->id)
|
|
->where('id', '>', $since)
|
|
->orderBy('id')
|
|
->limit(50)
|
|
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
|
|
|
|
return response()->json([
|
|
'messages' => $rows->map(fn ($m) => [
|
|
'id' => $m->id,
|
|
'uid' => $m->user_id,
|
|
'name' => $m->author_name,
|
|
'body' => $m->body,
|
|
'at' => $m->created_at?->toISOString(),
|
|
])->all(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST a chat message. Rate-limited to roughly one message per second
|
|
* per user via Laravel's built-in throttle, applied via the route.
|
|
* Body is capped at 400 chars (long enough for a sentence, short enough
|
|
* that the panel doesn't shear into a wall of text).
|
|
*/
|
|
public function chatSend(Request $request, LiveStream $stream): JsonResponse
|
|
{
|
|
if ($stream->status !== LiveStream::STATUS_LIVE) {
|
|
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
|
|
}
|
|
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
|
abort(404);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'body' => ['required', 'string', 'max:400'],
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
$msg = LiveChatMessage::create([
|
|
'livestream_id' => $stream->id,
|
|
'user_id' => $user->id,
|
|
'author_name' => $user->name,
|
|
'body' => trim($data['body']),
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'ok' => true,
|
|
'message' => [
|
|
'id' => $msg->id,
|
|
'uid' => $msg->user_id,
|
|
'name' => $msg->author_name,
|
|
'body' => $msg->body,
|
|
'at' => $msg->created_at?->toISOString(),
|
|
],
|
|
]);
|
|
}
|
|
}
|