Files
ghassanandClaude Opus 4.7 5bf1128196 feat(live): polish — ended-state, chat, viewer actions, Private option
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]>
2026-10-21 12:41:56 +03:00

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(),
],
]);
}
}