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

+95
View File
@@ -0,0 +1,95 @@
@extends('layouts.app')
@section('title', 'Live')
@section('extra_styles')
<style>
.li-wrap { max-width: 1300px; margin: 0 auto; padding: 16px; }
.li-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.li-head h1 { font-size: 22px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
.li-head h1 .dot { width: 9px; height: 9px; border-radius: 50%; background: #ef4444; animation: liPulse 1.4s ease-in-out infinite; }
@keyframes liPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.li-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.li-card { display: block; text-decoration: none; color: inherit; border-radius: 14px; overflow: hidden; background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); transition: transform .15s ease, border-color .15s ease; }
.li-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.2); }
.li-thumb { position: relative; aspect-ratio: 16/9; background: linear-gradient(135deg, #1f2937, #111827); display: grid; place-items: center; }
.li-thumb i { font-size: 32px; opacity: .3; }
.li-live { position: absolute; top: 8px; left: 8px; background: #ef4444; color: #fff; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 4px; letter-spacing: .04em; }
.li-viewers { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,.75); color: #fff; font-size: 11px; padding: 3px 7px; border-radius: 4px; }
.li-body { padding: 12px 14px; }
.li-body h3 { font-size: 14px; font-weight: 600; margin: 0 0 5px; line-height: 1.35; }
.li-body p { font-size: 12px; opacity: .6; margin: 0; }
.li-empty { text-align: center; padding: 60px 20px; opacity: .55; }
.li-empty i { font-size: 40px; display: block; margin-bottom: 14px; opacity: .5; }
.li-section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .55; margin: 34px 0 14px; }
.li-status { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.1); }
.li-status.live { background: #ef4444; color: #fff; }
</style>
@endsection
@section('content')
<div class="li-wrap">
<div class="li-head">
<h1><span class="dot"></span> Live now</h1>
@auth
<a class="action-btn action-btn-primary" href="{{ route('live.create') }}">
<i class="bi bi-broadcast"></i> <span>Go Live</span>
</a>
@endauth
</div>
@if ($live->isEmpty())
<div class="li-empty">
<i class="bi bi-broadcast"></i>
<div>Nobody is streaming right now.</div>
@auth<div style="margin-top:8px;font-size:13px;">Be the first — hit <strong>Go Live</strong>.</div>@endauth
</div>
@else
<div class="li-grid">
@foreach ($live as $s)
<a class="li-card" href="{{ route('live.watch', $s) }}">
<div class="li-thumb">
<i class="bi bi-broadcast"></i>
<span class="li-live">LIVE</span>
<span class="li-viewers"><i class="bi bi-eye"></i> {{ $s->current_viewers }}</span>
</div>
<div class="li-body">
<h3>{{ $s->title }}</h3>
<p>{{ $s->user->name ?? 'Unknown' }}</p>
</div>
</a>
@endforeach
</div>
@endif
@auth
@if ($mine->isNotEmpty())
<div class="li-section-title">Your streams</div>
<div class="li-grid">
@foreach ($mine as $s)
<a class="li-card" href="{{ $s->status === 'ended' ? route('live.watch', $s) : route('live.broadcast', $s) }}">
<div class="li-thumb">
<i class="bi bi-{{ $s->isLive() ? 'broadcast' : 'camera-video-off' }}"></i>
@if ($s->isLive())<span class="li-live">LIVE</span>@endif
</div>
<div class="li-body">
<h3>{{ $s->title }}</h3>
<p>
<span class="li-status {{ $s->isLive() ? 'live' : '' }}">{{ ucfirst($s->status) }}</span>
@if ($s->started_at)
· {{ $s->formatted_duration }}
@endif
</p>
</div>
</a>
@endforeach
</div>
@endif
@endauth
</div>
@endsection