Covers Private visibility acceptance, ended-state Watch page shows "Stream ended" (and omits the "Waiting for the broadcast" placeholder from the connection JS), channel-name link resolves to the user's channel URL, chat poll returns messages newer than ?since=, chat send rejects guests (401), chat send rejects offline streams (422), and private-stream chat 404s for non-owners. Also broadens LiveStream::isWatchableBy to keep public/unlisted streams permalinked after they end — viewers now see the Stream Ended card and the Watch Replay CTA instead of a hard 404. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
502 lines
22 KiB
PHP
502 lines
22 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', $stream->title.' — Live')
|
|
|
|
@section('extra_styles')
|
|
<style>
|
|
.lw-wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
|
.lw-stage { position: relative; background: #000; border-radius: 16px; overflow: hidden; aspect-ratio: 16 / 9; }
|
|
.lw-stage video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
|
|
|
|
.lw-badge {
|
|
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
|
|
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
|
|
background: rgba(239,68,68,.92); color: #fff;
|
|
}
|
|
.lw-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: lwPulse 1.4s ease-in-out infinite; }
|
|
.lw-badge.offline { background: rgba(0,0,0,.65); }
|
|
.lw-badge.offline .dot { background: #9ca3af; animation: none; }
|
|
@keyframes lwPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
|
|
|
.lw-mode {
|
|
position: absolute; top: 12px; right: 12px; padding: 5px 10px; border-radius: 999px;
|
|
background: rgba(0,0,0,.55); color: #d1d5db; font-size: 11px; backdrop-filter: blur(8px);
|
|
}
|
|
|
|
.lw-overlay {
|
|
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
|
|
background: rgba(0,0,0,.72); color: #e5e7eb; padding: 24px; gap: 10px;
|
|
}
|
|
.lw-overlay i { font-size: 34px; opacity: .5; display: block; margin-bottom: 10px; }
|
|
.lw-overlay .sub { font-size: 13px; opacity: .65; margin-top: 6px; }
|
|
|
|
.lw-head { display: flex; gap: 14px; align-items: flex-start; margin-top: 18px; flex-wrap: wrap; }
|
|
.lw-head h1 { font-size: 20px; font-weight: 700; margin: 0 0 6px; }
|
|
.lw-meta { font-size: 13px; opacity: .65; display: flex; gap: 14px; flex-wrap: wrap; }
|
|
.lw-desc { margin-top: 14px; font-size: 14px; line-height: 1.65; opacity: .85; white-space: pre-wrap; }
|
|
.lw-spin { width: 28px; height: 28px; border: 3px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: lwSpin .8s linear infinite; }
|
|
@keyframes lwSpin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
@endsection
|
|
|
|
@section('content')
|
|
<div class="lw-wrap">
|
|
@php
|
|
$isEnded = $stream->status === \App\Models\LiveStream::STATUS_ENDED;
|
|
$isLive = $stream->status === \App\Models\LiveStream::STATUS_LIVE;
|
|
@endphp
|
|
|
|
<div class="lw-stage">
|
|
<video id="lwVideo" autoplay playsinline controls></video>
|
|
|
|
<div class="lw-badge" id="lwBadge"><span class="dot"></span><span id="lwBadgeText">LIVE</span></div>
|
|
<div class="lw-mode" id="lwMode" style="display:none;"></div>
|
|
|
|
{{-- Ended stream: show "Stream ended" + duration right from the
|
|
server; if a recording exists, surface a Watch Replay CTA. --}}
|
|
@if ($isEnded)
|
|
<div class="lw-overlay" id="lwOverlay" style="background: rgba(0,0,0,.86);">
|
|
<div>
|
|
<i class="bi bi-stop-circle" aria-hidden="true" style="opacity:.85;color:#f87171;"></i>
|
|
<div style="font-size:18px;font-weight:600;margin-bottom:4px;">Stream ended</div>
|
|
@if ($stream->started_at && $stream->ended_at)
|
|
<div class="sub">
|
|
{{ $stream->ended_at->diffForHumans($stream->started_at, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}
|
|
· ended {{ $stream->ended_at->diffForHumans() }}
|
|
</div>
|
|
@endif
|
|
@if ($stream->video_id)
|
|
<a href="{{ route('videos.show', ['video' => $stream->video->getRouteKey()]) }}"
|
|
class="action-btn action-btn-primary"
|
|
style="margin-top:16px;">
|
|
<i class="bi bi-play-fill" aria-hidden="true"></i>
|
|
<span>Watch replay</span>
|
|
</a>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="lw-overlay" id="lwOverlay">
|
|
<div>
|
|
<div class="lw-spin" style="margin:0 auto 12px;"></div>
|
|
<div id="lwOverlayText">Connecting…</div>
|
|
<div class="sub" id="lwOverlaySub"></div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="lw-head">
|
|
<div style="flex:1;min-width:240px;">
|
|
<h1>{{ $stream->title }}</h1>
|
|
<div class="lw-meta">
|
|
{{-- Channel name is now a real link to the uploader's channel. --}}
|
|
@if ($stream->user)
|
|
<a href="{{ route('channel', $stream->user->channel) }}"
|
|
style="color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px;">
|
|
<i class="bi bi-person-circle" aria-hidden="true"></i>
|
|
<span style="color:var(--text-primary);">{{ $stream->user->name }}</span>
|
|
</a>
|
|
@endif
|
|
<span id="lwViewers"><i class="bi bi-eye" aria-hidden="true"></i> {{ $stream->current_viewers }} watching</span>
|
|
@if ($stream->started_at)
|
|
<span><i class="bi bi-clock" aria-hidden="true"></i> started {{ $stream->started_at->diffForHumans() }}</span>
|
|
@endif
|
|
</div>
|
|
@if ($stream->description)
|
|
<div class="lw-desc">{{ $stream->description }}</div>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($isOwner)
|
|
<a class="action-btn" href="{{ route('live.broadcast', $stream) }}"><i class="bi bi-broadcast" aria-hidden="true"></i> <span>Broadcast controls</span></a>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- 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))
|
|
<div class="lw-actions" style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
|
|
@auth
|
|
@php $isSub = Auth::user()->isSubscribedTo($stream->user); @endphp
|
|
<button type="button"
|
|
class="action-btn {{ $isSub ? '' : 'action-btn-primary' }}"
|
|
onclick="(function(){
|
|
const url = {{ Js::from(route('channel.subscribe', $stream->user_id)) }};
|
|
const csrf = document.querySelector('meta[name=\'csrf-token\']')?.content || '';
|
|
fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, 'Accept':'application/json' }})
|
|
.then(r=>r.json()).then(d=>{
|
|
if (d.subscribed !== undefined) {
|
|
event.target.closest('button').classList.toggle('action-btn-primary', !d.subscribed);
|
|
event.target.closest('button').querySelector('span').textContent = d.subscribed ? 'Subscribed' : 'Subscribe';
|
|
}
|
|
}).catch(()=>{});
|
|
})()">
|
|
<i class="bi {{ $isSub ? 'bi-bell-fill' : 'bi-bell' }}" aria-hidden="true"></i>
|
|
<span>{{ $isSub ? 'Subscribed' : 'Subscribe' }}</span>
|
|
</button>
|
|
@else
|
|
<button type="button" class="action-btn action-btn-primary"
|
|
onclick="if(typeof showToast==='function')showToast('Sign in to subscribe','info');setTimeout(()=>{location.href='{{ route('login') }}?intended='+encodeURIComponent(location.pathname+location.search);},600);">
|
|
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
|
|
</button>
|
|
@endauth
|
|
|
|
<button type="button" class="action-btn"
|
|
onclick="(function(){
|
|
const url = window.location.href;
|
|
if (navigator.share) { navigator.share({ title: {{ Js::from($stream->title) }}, url: url }).catch(()=>{}); return; }
|
|
navigator.clipboard?.writeText(url).then(()=>{ if(typeof showToast==='function')showToast('Link copied','success'); });
|
|
})()">
|
|
<i class="bi bi-share" aria-hidden="true"></i>
|
|
<span>Share</span>
|
|
</button>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── Live chat panel ─────────────────────────────── --}}
|
|
@if ($isLive)
|
|
<div class="lw-chat" id="lwChat"
|
|
data-poll-url="{{ route('live.chat.poll', $stream) }}"
|
|
data-send-url="{{ route('live.chat.send', $stream) }}"
|
|
data-signed-in="{{ Auth::check() ? '1' : '0' }}"
|
|
data-login-url="{{ route('login') }}?intended={{ urlencode('/live/' . $stream->public_id) }}">
|
|
<div class="lw-chat-head">
|
|
<i class="bi bi-chat-dots-fill" aria-hidden="true" style="color:#ef4444;"></i>
|
|
<span>Live chat</span>
|
|
</div>
|
|
<div class="lw-chat-messages" id="lwChatMessages" aria-live="polite" aria-atomic="false">
|
|
<div style="color:var(--text-secondary);font-size:13px;text-align:center;padding:16px;">Say hi 👋</div>
|
|
</div>
|
|
<form class="lw-chat-form" id="lwChatForm" onsubmit="return lwChatSubmit(event);">
|
|
@csrf
|
|
<input type="text" id="lwChatInput" maxlength="400"
|
|
placeholder="{{ Auth::check() ? 'Say something…' : 'Sign in to chat' }}"
|
|
{{ Auth::check() ? '' : 'disabled' }}
|
|
aria-label="Chat message">
|
|
<button type="submit" aria-label="Send"
|
|
{{ Auth::check() ? '' : 'disabled' }}>
|
|
<i class="bi bi-send-fill" aria-hidden="true"></i>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<style>
|
|
.lw-chat {
|
|
margin-top: 20px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
background: var(--bg-secondary);
|
|
display: flex; flex-direction: column;
|
|
max-height: 420px;
|
|
}
|
|
.lw-chat-head {
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-weight: 600; font-size: 14px;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.lw-chat-messages {
|
|
flex: 1; overflow-y: auto; padding: 10px 14px;
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
}
|
|
.lw-chat-msg {
|
|
font-size: 13px; line-height: 1.4;
|
|
display: flex; gap: 6px; align-items: flex-start;
|
|
padding: 4px 0;
|
|
}
|
|
.lw-chat-msg .n { font-weight: 600; color: #93c5fd; }
|
|
.lw-chat-msg .b { color: var(--text-primary); word-break: break-word; min-width: 0; }
|
|
.lw-chat-form {
|
|
display: flex; gap: 8px; padding: 10px 12px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
.lw-chat-form input {
|
|
flex: 1; background: var(--bg-dark);
|
|
border: 1px solid var(--border-color); border-radius: 20px;
|
|
padding: 8px 14px; color: var(--text-primary); font-size: 13px;
|
|
}
|
|
.lw-chat-form input:focus { outline: none; border-color: #ef4444; }
|
|
.lw-chat-form button {
|
|
width: 36px; height: 36px; border-radius: 50%;
|
|
background: #ef4444; color: #fff; border: none;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
cursor: pointer; flex: 0 0 auto;
|
|
}
|
|
.lw-chat-form button:disabled {
|
|
background: var(--border-color); color: var(--text-secondary); cursor: not-allowed;
|
|
}
|
|
</style>
|
|
<script>
|
|
(function () {
|
|
const panel = document.getElementById('lwChat');
|
|
if (!panel) return;
|
|
const list = document.getElementById('lwChatMessages');
|
|
const input = document.getElementById('lwChatInput');
|
|
const form = document.getElementById('lwChatForm');
|
|
const pollU = panel.dataset.pollUrl;
|
|
const sendU = panel.dataset.sendUrl;
|
|
const loginU = panel.dataset.loginUrl;
|
|
const authed = panel.dataset.signedIn === '1';
|
|
let since = 0, timer = null, firstPaint = true;
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
function append(msg) {
|
|
const row = document.createElement('div');
|
|
row.className = 'lw-chat-msg';
|
|
row.innerHTML = '<span class="n">' + escapeHtml(msg.name) + ':</span>'
|
|
+ ' <span class="b">' + escapeHtml(msg.body) + '</span>';
|
|
list.appendChild(row);
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
async function poll() {
|
|
try {
|
|
const r = await fetch(pollU + '?since=' + since, { credentials: 'same-origin', headers: {'Accept':'application/json'} });
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
if (d.messages && d.messages.length) {
|
|
if (firstPaint) { list.innerHTML = ''; firstPaint = false; }
|
|
d.messages.forEach(m => { append(m); if (m.id > since) since = m.id; });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
window.lwChatSubmit = function (ev) {
|
|
ev.preventDefault();
|
|
if (!authed) { location.href = loginU; return false; }
|
|
const body = (input.value || '').trim();
|
|
if (!body) return false;
|
|
input.disabled = true;
|
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
fetch(sendU, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
|
body: JSON.stringify({ body }),
|
|
}).then(r => r.json()).then(d => {
|
|
if (d.message) { append(d.message); since = Math.max(since, d.message.id); }
|
|
input.value = '';
|
|
}).catch(() => {
|
|
if (typeof showToast === 'function') showToast('Could not send message.', 'error');
|
|
}).finally(() => { input.disabled = false; input.focus(); });
|
|
return false;
|
|
};
|
|
poll();
|
|
timer = setInterval(poll, 3000);
|
|
window.addEventListener('beforeunload', () => clearInterval(timer));
|
|
})();
|
|
</script>
|
|
@endif
|
|
</div>
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
@unless($isEnded)
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var CFG = {
|
|
whep: @json($whepUrl),
|
|
hls: @json($hlsUrl),
|
|
statusUrl: @json(route('live.status', $stream)),
|
|
iceServers: @json($iceServers),
|
|
isLive: @json($stream->isLive())
|
|
};
|
|
|
|
var video = document.getElementById('lwVideo');
|
|
var overlay = document.getElementById('lwOverlay');
|
|
var overlayText = document.getElementById('lwOverlayText');
|
|
var overlaySub = document.getElementById('lwOverlaySub');
|
|
var badge = document.getElementById('lwBadge');
|
|
var badgeText = document.getElementById('lwBadgeText');
|
|
var modeChip = document.getElementById('lwMode');
|
|
|
|
var pc = null, hls = null, started = false;
|
|
|
|
function showOverlay(text, sub, spinning) {
|
|
overlay.style.display = 'grid';
|
|
overlayText.textContent = text;
|
|
overlaySub.textContent = sub || '';
|
|
overlay.querySelector('.lw-spin').style.display = spinning === false ? 'none' : 'block';
|
|
}
|
|
function hideOverlay() { overlay.style.display = 'none'; }
|
|
|
|
function setMode(label) {
|
|
modeChip.textContent = label;
|
|
modeChip.style.display = 'block';
|
|
}
|
|
|
|
function setOffline() {
|
|
badge.classList.add('offline');
|
|
badgeText.textContent = 'OFFLINE';
|
|
showOverlay('This stream has ended', 'The recording may appear here shortly.', false);
|
|
teardown();
|
|
}
|
|
|
|
function teardown() {
|
|
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
|
|
if (hls) { try { hls.destroy(); } catch (e) {} hls = null; }
|
|
}
|
|
|
|
/* ---------------- WHEP: sub-second path ---------------- */
|
|
|
|
// Tried first because it is the lowest latency. If anything about it fails
|
|
// — no UDP path, a restrictive network, an old browser — we fall through
|
|
// to LL-HLS rather than showing the viewer an error.
|
|
async function tryWhep() {
|
|
if (!window.RTCPeerConnection) { return false; }
|
|
|
|
try {
|
|
pc = new RTCPeerConnection({ iceServers: CFG.iceServers || [], bundlePolicy: 'max-bundle' });
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
|
|
var streamIn = new MediaStream();
|
|
pc.addEventListener('track', function (ev) {
|
|
streamIn.addTrack(ev.track);
|
|
video.srcObject = streamIn;
|
|
});
|
|
|
|
var offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
await gathered(pc);
|
|
|
|
var res = await fetch(CFG.whep, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/sdp' },
|
|
body: pc.localDescription.sdp
|
|
});
|
|
|
|
if (!res.ok) { throw new Error('whep ' + res.status); }
|
|
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
|
|
|
return await new Promise(function (resolve) {
|
|
var settled = false;
|
|
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
|
|
|
|
pc.addEventListener('connectionstatechange', function () {
|
|
if (pc.connectionState === 'connected') { decide(true); }
|
|
if (pc.connectionState === 'failed') { decide(false); }
|
|
});
|
|
|
|
// Don't let a half-open connection stall the viewer forever.
|
|
setTimeout(function () { decide(pc && pc.connectionState === 'connected'); }, 6000);
|
|
});
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function gathered(pc) {
|
|
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
|
|
return new Promise(function (resolve) {
|
|
var done = false;
|
|
function finish() { if (!done) { done = true; resolve(); } }
|
|
pc.addEventListener('icegatheringstatechange', function () {
|
|
if (pc.iceGatheringState === 'complete') { finish(); }
|
|
});
|
|
setTimeout(finish, 3000);
|
|
});
|
|
}
|
|
|
|
/* ---------------- LL-HLS: the path that always works ---------------- */
|
|
|
|
function loadHlsLibrary() {
|
|
return new Promise(function (resolve, reject) {
|
|
if (window.Hls) { return resolve(); }
|
|
var s = document.createElement('script');
|
|
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js';
|
|
s.onload = resolve;
|
|
s.onerror = reject;
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
|
|
async function tryHls() {
|
|
// Safari plays HLS natively and does it better than hls.js does.
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
video.src = CFG.hls;
|
|
return true;
|
|
}
|
|
|
|
try { await loadHlsLibrary(); } catch (e) { return false; }
|
|
if (!window.Hls || !window.Hls.isSupported()) { return false; }
|
|
|
|
return await new Promise(function (resolve) {
|
|
hls = new Hls({ lowLatencyMode: true, backBufferLength: 30, liveSyncDurationCount: 2 });
|
|
var settled = false;
|
|
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(function () {}); decide(true); });
|
|
hls.on(Hls.Events.ERROR, function (_e, data) {
|
|
if (data && data.fatal) { decide(false); }
|
|
});
|
|
|
|
hls.loadSource(CFG.hls);
|
|
hls.attachMedia(video);
|
|
setTimeout(function () { decide(false); }, 12000);
|
|
});
|
|
}
|
|
|
|
/* ---------------- Orchestration ---------------- */
|
|
|
|
async function connect() {
|
|
if (started) { return; }
|
|
started = true;
|
|
|
|
showOverlay('Connecting…', 'Finding the fastest route');
|
|
|
|
if (await tryWhep()) {
|
|
setMode('Ultra-low latency');
|
|
hideOverlay();
|
|
video.play().catch(function () {});
|
|
return;
|
|
}
|
|
|
|
teardown();
|
|
showOverlay('Connecting…', 'Using the standard route');
|
|
|
|
if (await tryHls()) {
|
|
setMode('Low latency');
|
|
hideOverlay();
|
|
return;
|
|
}
|
|
|
|
started = false;
|
|
showOverlay('Could not start playback', 'The stream may still be starting up. Retrying…', false);
|
|
setTimeout(function () { if (!started) { connect(); } }, 5000);
|
|
}
|
|
|
|
/* ---------------- Live state ---------------- */
|
|
|
|
// Polling is a deliberate, cheap fallback: this platform has no realtime
|
|
// channel of its own, and one small request every 10s per viewer is far
|
|
// less costly than a socket layer built only for this page.
|
|
async function pollStatus() {
|
|
try {
|
|
var res = await fetch(CFG.statusUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
|
|
if (!res.ok) { return; }
|
|
var data = await res.json();
|
|
|
|
document.getElementById('lwViewers').innerHTML = '<i class="bi bi-eye"></i> ' + data.viewers + ' watching';
|
|
|
|
if (data.live && !started) { connect(); }
|
|
if (!data.live && data.status === 'ended') { setOffline(); }
|
|
} catch (e) { /* transient */ }
|
|
}
|
|
|
|
if (CFG.isLive) {
|
|
connect();
|
|
} else {
|
|
showOverlay('Waiting for the broadcast to start', 'This page will begin automatically.', true);
|
|
}
|
|
|
|
setInterval(pollStatus, 10000);
|
|
window.addEventListener('pagehide', teardown);
|
|
})();
|
|
</script>
|
|
@endunless
|
|
@endsection
|