From 5bf11281966ea6dc146420134f5662461d404040 Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 12:41:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(live):=20polish=20=E2=80=94=20ended-state,?= =?UTF-8?q?=20chat,=20viewer=20actions,=20Private=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- app/Http/Controllers/LiveAuthController.php | 208 ++++++++ app/Http/Controllers/LiveStreamController.php | 292 ++++++++++ app/Models/LiveChatMessage.php | 34 ++ app/Models/LiveStream.php | 253 +++++++++ config/live.php | 55 ++ ..._08_18_000000_create_livestreams_table.php | 71 +++ ...000003_create_live_chat_messages_table.php | 37 ++ resources/views/live/broadcast.blade.php | 446 ++++++++++++++++ resources/views/live/create.blade.php | 89 ++++ resources/views/live/index.blade.php | 95 ++++ resources/views/live/watch.blade.php | 499 ++++++++++++++++++ routes/web.php | 24 + 12 files changed, 2103 insertions(+) create mode 100644 app/Http/Controllers/LiveAuthController.php create mode 100644 app/Http/Controllers/LiveStreamController.php create mode 100644 app/Models/LiveChatMessage.php create mode 100644 app/Models/LiveStream.php create mode 100644 config/live.php create mode 100644 database/migrations/2026_08_18_000000_create_livestreams_table.php create mode 100644 database/migrations/2026_10_21_000003_create_live_chat_messages_table.php create mode 100644 resources/views/live/broadcast.blade.php create mode 100644 resources/views/live/create.blade.php create mode 100644 resources/views/live/index.blade.php create mode 100644 resources/views/live/watch.blade.php diff --git a/app/Http/Controllers/LiveAuthController.php b/app/Http/Controllers/LiveAuthController.php new file mode 100644 index 0000000..b34be11 --- /dev/null +++ b/app/Http/Controllers/LiveAuthController.php @@ -0,0 +1,208 @@ +fromMediaServer($request)) { + return response('', 403); + } + + $action = (string) $request->input('action'); + + // Control-plane actions carry no stream path, so they are settled + // before any attempt to resolve one. + if (in_array($action, ['api', 'metrics', 'pprof'], true)) { + return $this->authorizeApi(); + } + + $path = (string) $request->input('path'); + + $stream = $this->resolveStream($path); + + if (! $stream) { + // An unknown path is not an error worth explaining — a publisher + // probing for valid stream ids learns nothing from this. + return response('', 403); + } + + return match ($action) { + 'publish' => $this->authorizePublish($request, $stream), + 'read' => $this->authorizeRead($stream), + default => response('', 403), + }; + } + + /** + * The media server's own control API. + * + * It authenticates against this same hook, so without an explicit allow the + * app could not read live state from the server it runs alongside. Safe + * because it is already loopback-gated above — nothing off this host can + * reach it. + */ + private function authorizeApi(): Response + { + return response('', 200); + } + + /** + * A publisher must present the short-lived token minted for this stream + * when its owner pressed "Go live". The token is single-use: it is burned + * here, so a captured one cannot start a second broadcast. + */ + private function authorizePublish(Request $request, LiveStream $stream): Response + { + // MediaMTX passes HTTP Basic credentials through as user/password. + $presented = (string) ($request->input('password') ?: $request->input('token')); + + if (! $stream->publishTokenMatches($presented)) { + Log::warning('Live publish refused', [ + 'stream' => $stream->public_id, + 'ip' => $request->input('ip'), + ]); + + return response('', 403); + } + + $stream->consumePublishToken(); + $stream->markLive(); + + Log::info('Live publish authorised', [ + 'stream' => $stream->public_id, + 'user_id' => $stream->user_id, + ]); + + return response('', 200); + } + + /** + * Reads are anonymous by design — a viewer fetching HLS parts carries no + * credentials — so the gate is the stream's own state: it must be live and + * not private. The unpredictable path is what keeps an unlisted stream + * unlisted. + */ + private function authorizeRead(LiveStream $stream): Response + { + if (! $stream->isLive()) { + return response('', 403); + } + + if (! in_array($stream->visibility, ['public', 'unlisted'], true)) { + return response('', 403); + } + + return response('', 200); + } + + /** + * Lifecycle notifications from the media server. + * + * The auth hook only fires on the way in, so without this a stream whose + * phone lost signal would read as live forever. + */ + public function hook(Request $request): Response + { + if (! $this->fromMediaServer($request)) { + return response('', 403); + } + + $stream = $this->resolveStream((string) $request->input('path')); + + if (! $stream) { + return response('', 204); + } + + match ((string) $request->input('event')) { + 'publish' => $stream->markLive(), + // Only a stream that is actually live can end. A late hook from a + // previous, failed session must not kill the one just started. + 'unpublish' => $stream->isLive() ? $stream->markEnded() : null, + 'read' => $this->viewerJoined($stream), + 'unread' => $this->viewerLeft($stream), + default => null, + }; + + return response('', 204); + } + + /* ---------------- Viewer accounting ---------------- */ + + /* + * Counted with atomic SQL increments rather than read-modify-write: viewers + * arrive concurrently, and two joins landing together would otherwise + * record one. + */ + + private function viewerJoined(LiveStream $stream): void + { + LiveStream::where('id', $stream->id)->increment('current_viewers'); + + // peak_viewers is a high-water mark, so it only ever moves up. + LiveStream::where('id', $stream->id) + ->whereColumn('current_viewers', '>', 'peak_viewers') + ->update(['peak_viewers' => DB::raw('current_viewers')]); + } + + private function viewerLeft(LiveStream $stream): void + { + // Floor at zero: a missed join (a restart, a dropped hook) must not + // drive the count negative and show "-1 watching". + LiveStream::where('id', $stream->id) + ->where('current_viewers', '>', 0) + ->decrement('current_viewers'); + } + + /* ---------------- Helpers ---------------- */ + + /** + * Only the media server on this host may ask. Deliberately checks the raw + * connection address rather than a forwarded header — a header can be + * spoofed by anyone who can reach the app. + */ + private function fromMediaServer(Request $request): bool + { + return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true); + } + + /** Turn "live/" into the stream it names. */ + private function resolveStream(string $path): ?LiveStream + { + if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) { + return null; + } + + return LiveStream::where('public_id', $m[1])->first(); + } +} diff --git a/app/Http/Controllers/LiveStreamController.php b/app/Http/Controllers/LiveStreamController.php new file mode 100644 index 0000000..6e2e8e5 --- /dev/null +++ b/app/Http/Controllers/LiveStreamController.php @@ -0,0 +1,292 @@ +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(), + ], + ]); + } +} diff --git a/app/Models/LiveChatMessage.php b/app/Models/LiveChatMessage.php new file mode 100644 index 0000000..240360a --- /dev/null +++ b/app/Models/LiveChatMessage.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + + public function livestream() + { + return $this->belongsTo(LiveStream::class, 'livestream_id'); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/LiveStream.php b/app/Models/LiveStream.php new file mode 100644 index 0000000..fd579e6 --- /dev/null +++ b/app/Models/LiveStream.php @@ -0,0 +1,253 @@ + 'datetime', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + + public const STATUS_IDLE = 'idle'; + public const STATUS_LIVE = 'live'; + public const STATUS_ENDED = 'ended'; + public const STATUS_FAILED = 'failed'; + + /** How long a freshly minted publish token stays valid. */ + public const PUBLISH_TOKEN_TTL_SECONDS = 120; + + protected static function booted(): void + { + // Every stream gets an unpredictable public id, always — never left to + // a caller to remember. + static::creating(function (self $stream) { + $stream->public_id ??= self::newPublicId(); + }); + } + + /* ---------------- Identity ---------------- */ + + /** + * 32 chars of alphanumeric entropy. Also the media path segment, which is + * why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex + * the media server is configured with. + */ + public static function newPublicId(): string + { + do { + $id = Str::random(32); + } while (self::where('public_id', $id)->exists()); + + return $id; + } + + /** Route model binding uses the unpredictable id, never the numeric key. */ + public function getRouteKeyName(): string + { + return 'public_id'; + } + + /** The path the media server knows this stream by. */ + public function mediaPath(): string + { + return 'live/'.$this->public_id; + } + + /* ---------------- Relations ---------------- */ + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** The recording, once a finished stream has been published as a video. */ + public function video(): BelongsTo + { + return $this->belongsTo(Video::class); + } + + /* ---------------- State ---------------- */ + + public function isLive(): bool + { + return $this->status === self::STATUS_LIVE; + } + + public function isOwnedBy(?User $user): bool + { + return $user !== null && $this->user_id === $user->id; + } + + /** + * May this viewer watch? + * + * Both public and unlisted streams are watchable by anyone holding the + * link — the difference is only whether the stream is listed. Ownership + * additionally grants access before it goes live, so the broadcaster can + * open their own watch page to check themselves. + */ + public function isWatchableBy(?User $user): bool + { + if ($this->isOwnedBy($user)) { + return true; + } + + return $this->isLive() && in_array($this->visibility, ['public', 'unlisted'], true); + } + + /* ---------------- Publish credential ---------------- */ + + /** + * Mint a short-lived, single-use token for the broadcaster's browser. + * + * Returned in plaintext to the owner exactly once, at the moment they press + * "Go live". Stored so the media server's auth hook can verify it, then + * consumed on first successful publish. + */ + public function issuePublishToken(): string + { + $token = Str::random(64); + + $this->forceFill([ + 'publish_token' => hash('sha256', $token), + 'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS), + ])->save(); + + return $token; + } + + /** Constant-time check of a presented token against the stored hash. */ + public function publishTokenMatches(?string $presented): bool + { + if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) { + return false; + } + + if ($this->publish_token_expires_at->isPast()) { + return false; + } + + return hash_equals($this->publish_token, hash('sha256', $presented)); + } + + /** Burn the token so a captured one cannot be replayed. */ + public function consumePublishToken(): void + { + $this->forceFill([ + 'publish_token' => null, + 'publish_token_expires_at' => null, + ])->save(); + } + + /* ---------------- Transitions ---------------- */ + + public function markLive(): void + { + $this->forceFill([ + 'status' => self::STATUS_LIVE, + 'started_at' => $this->started_at ?? now(), + 'last_seen_at' => now(), + 'ended_at' => null, + ])->save(); + } + + /** + * Put an ended stream back into a startable state. + * + * A stream is a reusable channel, not a one-shot: the owner keeps the same + * link and can go live on it again. Without this, any failed start — a lost + * signal, a refused camera, a server hiccup — would kill the stream + * permanently and force a new link on every retry. + */ + public function reArm(): void + { + if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) { + return; + } + + $this->forceFill([ + 'status' => self::STATUS_IDLE, + 'ended_at' => null, + 'current_viewers' => 0, + ])->save(); + } + + public function markEnded(): void + { + $this->forceFill([ + 'status' => self::STATUS_ENDED, + 'ended_at' => now(), + 'current_viewers' => 0, + 'publish_token' => null, + 'publish_token_expires_at' => null, + ])->save(); + } + + /* ---------------- Display ---------------- */ + + public function getDurationSecondsAttribute(): ?int + { + if (! $this->started_at) { + return null; + } + + return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at); + } + + public function getFormattedDurationAttribute(): string + { + $seconds = $this->duration_seconds; + + if ($seconds === null) { + return '00:00'; + } + + return $seconds >= 3600 + ? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60) + : sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60); + } + + /* ---------------- Scopes ---------------- */ + + public function scopeLive($query) + { + return $query->where('status', self::STATUS_LIVE); + } + + public function scopeListed($query) + { + return $query->where('visibility', 'public'); + } +} diff --git a/config/live.php b/config/live.php new file mode 100644 index 0000000..32ee352 --- /dev/null +++ b/config/live.php @@ -0,0 +1,55 @@ + env('LIVE_API_URL', 'http://127.0.0.1:9997'), + + /* + |-------------------------------------------------------------------------- + | ICE + |-------------------------------------------------------------------------- + | + | STUN lets a browser discover its public address. TURN relays the media + | when a direct path cannot be established — which, until a UDP port is + | forwarded to this server, is EVERY broadcaster outside the local network. + | + | With no TURN configured, publishing works from the LAN only. + | + */ + 'stun_url' => env('LIVE_STUN_URL', 'stun:stun.l.google.com:19302'), + + 'turn_url' => env('LIVE_TURN_URL'), + 'turn_username' => env('LIVE_TURN_USERNAME'), + 'turn_credential' => env('LIVE_TURN_CREDENTIAL'), + + /* + |-------------------------------------------------------------------------- + | Capture defaults + |-------------------------------------------------------------------------- + | + | The target the phone is ASKED for. A camera that cannot do 1080p returns + | the closest it can, and WebRTC's congestion control lowers the bitrate + | when the uplink cannot sustain it — so this is a ceiling, not a promise. + | + */ + 'video_width' => (int) env('LIVE_VIDEO_WIDTH', 1920), + 'video_height' => (int) env('LIVE_VIDEO_HEIGHT', 1080), + 'video_fps' => (int) env('LIVE_VIDEO_FPS', 30), + + // ~4.5 Mbit/s is the practical ceiling for 1080p30 over a mobile uplink. + 'max_bitrate' => (int) env('LIVE_MAX_BITRATE', 4_500_000), + +]; diff --git a/database/migrations/2026_08_18_000000_create_livestreams_table.php b/database/migrations/2026_08_18_000000_create_livestreams_table.php new file mode 100644 index 0000000..75afbbc --- /dev/null +++ b/database/migrations/2026_08_18_000000_create_livestreams_table.php @@ -0,0 +1,71 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + + // Public, unpredictable identifier. Used in the watch URL AND as the + // media path, so it must never be guessable or enumerable — knowing + // one stream's id must not hint at another's. + $table->string('public_id', 32)->unique(); + + $table->string('title'); + $table->text('description')->nullable(); + + // idle → live → ended, or failed. + $table->string('status', 16)->default('idle'); + + // public = listed on the live index + // unlisted = reachable only with the link + $table->string('visibility', 16)->default('public'); + + // Short-lived, single-use credential the browser presents to the + // media server when it starts publishing. Not a long-lived stream + // key: it is minted on "Go live" and expires in minutes, so there + // is no durable secret to leak, screenshot, or reuse. + $table->string('publish_token', 64)->nullable(); + $table->timestamp('publish_token_expires_at')->nullable(); + + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + + // Last time the media server confirmed the publisher is still + // connected — how a stream that died without saying goodbye gets + // reaped. + $table->timestamp('last_seen_at')->nullable(); + + $table->unsignedInteger('peak_viewers')->default(0); + $table->unsignedInteger('current_viewers')->default(0); + + // Set once a finished stream has been turned into a normal video. + $table->foreignId('video_id')->nullable()->constrained()->nullOnDelete(); + + $table->timestamps(); + + // The live index reads "public streams that are live, newest first". + $table->index(['status', 'visibility']); + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('livestreams'); + } +}; diff --git a/database/migrations/2026_10_21_000003_create_live_chat_messages_table.php b/database/migrations/2026_10_21_000003_create_live_chat_messages_table.php new file mode 100644 index 0000000..7e7f72c --- /dev/null +++ b/database/migrations/2026_10_21_000003_create_live_chat_messages_table.php @@ -0,0 +1,37 @@ + every few + * seconds and get back everything newer than their last-seen id. + * + * Cascades: delete the livestream, lose its chat log. Users keep theirs + * nulled out so a deleted account's historical messages still render as + * "deleted user" rather than removing them from the thread. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('live_chat_messages', function (Blueprint $table) { + $table->id(); + $table->foreignId('livestream_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('author_name', 255); // snapshot of the sender's name at time of posting + $table->text('body'); + $table->timestamp('created_at')->useCurrent(); + + // Chat pagination reads "messages in this stream newer than id X". + $table->index(['livestream_id', 'id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('live_chat_messages'); + } +}; diff --git a/resources/views/live/broadcast.blade.php b/resources/views/live/broadcast.blade.php new file mode 100644 index 0000000..717693d --- /dev/null +++ b/resources/views/live/broadcast.blade.php @@ -0,0 +1,446 @@ +@extends('layouts.app') + +@section('title', 'Go Live — '.$stream->title) + +@section('extra_styles') + +@endsection + +@section('content') +
+ +
+ + +
READY
+ + + +
+ + + +
+
+ +
+ +
+
+

{{ $stream->title }}

+ @if ($stream->description) +

{{ $stream->description }}

+ @endif +
+ + +
+

+ Anyone with this link can watch while you are live. + {{ $stream->visibility === 'public' ? 'This stream is also listed on the Live page.' : 'This stream is unlisted — only people with the link can find it.' }} +

+
+ +
+

Quality

+

+ Targeting {{ config('live.video_width') }}×{{ config('live.video_height') }} at {{ config('live.video_fps') }} fps. + If your upload speed drops, the picture softens instead of freezing — that is the connection adapting, not a fault. + Wi-Fi or 5G will hold 1080p most reliably. +

+
+
+
+@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/live/create.blade.php b/resources/views/live/create.blade.php new file mode 100644 index 0000000..96ad123 --- /dev/null +++ b/resources/views/live/create.blade.php @@ -0,0 +1,89 @@ +@extends('layouts.app') + +@section('title', 'Go Live') + +@section('extra_styles') + +@endsection + +@section('content') +
+

Go Live

+

Stream straight from your phone's camera. You'll get a link to share the moment you start.

+ +
+ @csrf + +
+ + + @error('title')
{{ $message }}
@enderror +
+ +
+ + + @error('description')
{{ $message }}
@enderror +
+ +
+ +
+ + + {{-- Mirror the upload-modal's Private wording: only the owner + sees it. Useful for test runs and private rehearsals. --}} + +
+ @error('visibility')
{{ $message }}
@enderror +
+ + +
+
+@endsection diff --git a/resources/views/live/index.blade.php b/resources/views/live/index.blade.php new file mode 100644 index 0000000..a24ed7e --- /dev/null +++ b/resources/views/live/index.blade.php @@ -0,0 +1,95 @@ +@extends('layouts.app') + +@section('title', 'Live') + +@section('extra_styles') + +@endsection + +@section('content') +
+ + + + @if ($live->isEmpty()) +
+ +
Nobody is streaming right now.
+ @auth
Be the first — hit Go Live.
@endauth +
+ @else + + @endif + + @auth + @if ($mine->isNotEmpty()) +
Your streams
+ + @endif + @endauth +
+@endsection diff --git a/resources/views/live/watch.blade.php b/resources/views/live/watch.blade.php new file mode 100644 index 0000000..5114efa --- /dev/null +++ b/resources/views/live/watch.blade.php @@ -0,0 +1,499 @@ +@extends('layouts.app') + +@section('title', $stream->title.' — Live') + +@section('extra_styles') + +@endsection + +@section('content') +
+ @php + $isEnded = $stream->status === \App\Models\LiveStream::STATUS_ENDED; + $isLive = $stream->status === \App\Models\LiveStream::STATUS_LIVE; + @endphp + +
+ + +
LIVE
+ + + {{-- Ended stream: show "Stream ended" + duration right from the + server; if a recording exists, surface a Watch Replay CTA. --}} + @if ($isEnded) +
+
+ +
Stream ended
+ @if ($stream->started_at && $stream->ended_at) +
+ {{ $stream->ended_at->diffForHumans($stream->started_at, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) }} + · ended {{ $stream->ended_at->diffForHumans() }} +
+ @endif + @if ($stream->video_id) + + + Watch replay + + @endif +
+
+ @else +
+
+
+
Connecting…
+
+
+
+ @endif +
+ +
+
+

{{ $stream->title }}

+
+ {{-- Channel name is now a real link to the uploader's channel. --}} + @if ($stream->user) + + + {{ $stream->user->name }} + + @endif + {{ $stream->current_viewers }} watching + @if ($stream->started_at) + started {{ $stream->started_at->diffForHumans() }} + @endif +
+ @if ($stream->description) +
{{ $stream->description }}
+ @endif +
+ + @if ($isOwner) + Broadcast controls + @endif +
+ + {{-- Viewer actions row — mirrors the watch page's action bar on a smaller + canvas. Share works for everyone; Subscribe is auth-gated with the + Phase-7 ?intended= bounce; the owner never sees Subscribe. --}} + @if ($stream->user && (! Auth::check() || Auth::id() !== $stream->user_id)) +
+ @auth + @php $isSub = Auth::user()->isSubscribedTo($stream->user); @endphp + + @else + + @endauth + + +
+ @endif + + {{-- ── Live chat panel ─────────────────────────────── --}} + @if ($isLive) +
+
+ + Live chat +
+
+
Say hi 👋
+
+
+ @csrf + + +
+
+ + + @endif +
+@endsection + +@section('scripts') + +@endsection diff --git a/routes/web.php b/routes/web.php index 92d5164..47a5261 100644 --- a/routes/web.php +++ b/routes/web.php @@ -200,6 +200,30 @@ Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVi // in UserController::channel() to prefer a dedicated `handle` column. // The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice` // — Laravel's router accepts both because `@` is allowed in URI path segments. +// ─── Live streaming ───────────────────────────────────── +use App\Http\Controllers\LiveAuthController; +use App\Http\Controllers\LiveStreamController; + +Route::get('/live', [LiveStreamController::class, 'index'])->name('live.index'); +Route::get('/live/new', [LiveStreamController::class, 'create'])->name('live.create'); +Route::post('/live', [LiveStreamController::class, 'store'])->name('live.store')->middleware('throttle:10,1'); + +Route::get('/live/{stream}/broadcast', [LiveStreamController::class, 'broadcast'])->name('live.broadcast'); +Route::post('/live/{stream}/token', [LiveStreamController::class, 'token'])->name('live.token')->middleware('throttle:30,1'); +Route::post('/live/{stream}/stop', [LiveStreamController::class, 'stop'])->name('live.stop')->middleware('throttle:30,1'); + +Route::get('/live/{stream}', [LiveStreamController::class, 'watch'])->name('live.watch'); +Route::get('/live/{stream}/status', [LiveStreamController::class, 'status'])->name('live.status')->middleware('throttle:120,1'); + +// Live chat — polling by viewers, send by signed-in users (rate-limited). +Route::get('/live/{stream}/chat', [LiveStreamController::class, 'chatPoll']) + ->name('live.chat.poll')->middleware('throttle:120,1'); +Route::post('/live/{stream}/chat', [LiveStreamController::class, 'chatSend']) + ->name('live.chat.send')->middleware('throttle:60,1'); + +Route::post('/api/live/auth', [LiveAuthController::class, 'authenticate'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); +Route::post('/api/live/hook', [LiveAuthController::class, 'hook'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); + Route::get('/@{handle}', [UserController::class, 'channel']) ->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*') ->name('channel.handle');