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