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

+253
View File
@@ -0,0 +1,253 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* A live broadcast.
*
* The row is the authority on whether a stream may exist; the media server
* holds no state of its own and asks this table (through LiveAuthController)
* before it accepts a publisher or a viewer.
*/
class LiveStream extends Model
{
use HasFactory;
protected $table = 'livestreams';
protected $fillable = [
'user_id', 'public_id', 'title', 'description',
'status', 'visibility', 'video_id',
];
/**
* The publish token is a credential. Keeping it out of the default array
* form means it cannot be leaked by a careless toJson() on a model that
* reached a view or an API response.
*/
protected $hidden = ['publish_token', 'publish_token_expires_at'];
protected $casts = [
'publish_token_expires_at' => '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');
}
}