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]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:41:56 +03:00
1 parent 639279f2a2
commit 5bf1128196
12 files changed
+2103

No files matched your search

@@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Live streaming.
*
* Purely additive: one new table. No existing table is altered — the link to a
* recording is held here as video_id rather than adding a column to `videos`,
* so the music and generic video paths run against exactly the schema they run
* against today.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('livestreams', function (Blueprint $table) {
$table->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');
}
};