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:
1 parent
639279f2a2
commit
5bf1128196
12 files changed
+2103
No files matched your search
@@ -0,0 +1,446 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Go Live — '.$stream->title)
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.lv-wrap { max-width: 1100px; margin: 0 auto; padding: 16px; }
|
||||
|
||||
/* The preview owns the screen on a phone. Everything else floats on it. */
|
||||
.lv-stage {
|
||||
position: relative; background: #000; border-radius: 16px; overflow: hidden;
|
||||
aspect-ratio: 16 / 9; box-shadow: 0 10px 40px rgba(0,0,0,.45);
|
||||
}
|
||||
.lv-stage video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
||||
|
||||
/* Mirror only the selfie camera — a mirrored rear camera looks broken. */
|
||||
.lv-stage video.mirrored { transform: scaleX(-1); }
|
||||
|
||||
.lv-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(0,0,0,.6); color: #fff; backdrop-filter: blur(8px);
|
||||
}
|
||||
.lv-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; }
|
||||
.lv-badge.is-live { background: rgba(239,68,68,.92); }
|
||||
.lv-badge.is-live .dot { background: #fff; animation: lvPulse 1.4s ease-in-out infinite; }
|
||||
@keyframes lvPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.75); } }
|
||||
|
||||
.lv-stats {
|
||||
position: absolute; top: 12px; right: 12px; display: flex; flex-direction: column; gap: 4px;
|
||||
padding: 8px 12px; border-radius: 12px; background: rgba(0,0,0,.55); backdrop-filter: blur(8px);
|
||||
color: #e5e7eb; font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; min-width: 120px;
|
||||
}
|
||||
.lv-stats b { color: #fff; font-weight: 600; }
|
||||
.lv-stats .warn { color: #fbbf24; }
|
||||
.lv-stats .bad { color: #f87171; }
|
||||
|
||||
.lv-controls {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; padding: 16px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
|
||||
}
|
||||
.lv-btn {
|
||||
width: 52px; height: 52px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
|
||||
background: rgba(255,255,255,.12); color: #fff; font-size: 20px; cursor: pointer;
|
||||
display: grid; place-items: center; backdrop-filter: blur(8px); transition: transform .12s ease, background .15s ease;
|
||||
}
|
||||
.lv-btn:hover { background: rgba(255,255,255,.22); }
|
||||
.lv-btn:active { transform: scale(.92); }
|
||||
.lv-btn.off { background: rgba(239,68,68,.85); border-color: transparent; }
|
||||
.lv-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.lv-golive {
|
||||
min-width: 150px; height: 52px; padding: 0 26px; border-radius: 999px; border: 0;
|
||||
background: #ef4444; color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 9px; transition: transform .12s ease, background .15s ease;
|
||||
}
|
||||
.lv-golive:hover { background: #dc2626; }
|
||||
.lv-golive:active { transform: scale(.97); }
|
||||
.lv-golive:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.lv-golive.is-live { background: #111827; border: 1px solid rgba(255,255,255,.25); }
|
||||
|
||||
.lv-meta { margin-top: 18px; display: grid; gap: 14px; grid-template-columns: 1fr; }
|
||||
@media (min-width: 860px) { .lv-meta { grid-template-columns: 1.4fr 1fr; } }
|
||||
.lv-card { background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); border-radius: 14px; padding: 16px; }
|
||||
.lv-card h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .6; margin: 0 0 10px; }
|
||||
.lv-share { display: flex; gap: 8px; }
|
||||
.lv-share input {
|
||||
flex: 1; min-width: 0; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 10px; padding: 10px 12px; color: inherit; font-size: 13px;
|
||||
}
|
||||
.lv-note { font-size: 12px; opacity: .6; line-height: 1.6; margin-top: 10px; }
|
||||
|
||||
.lv-msg { margin-top: 14px; padding: 12px 14px; border-radius: 12px; font-size: 14px; display: none; }
|
||||
.lv-msg.err { display: block; background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.35); color: #fca5a5; }
|
||||
.lv-msg.ok { display: block; background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.35); color: #86efac; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="lv-wrap">
|
||||
|
||||
<div class="lv-stage">
|
||||
<video id="lvPreview" autoplay playsinline muted></video>
|
||||
|
||||
<div class="lv-badge" id="lvBadge"><span class="dot"></span><span id="lvBadgeText">READY</span></div>
|
||||
|
||||
<div class="lv-stats" id="lvStats" style="display:none;">
|
||||
<div><b id="lvRes">—</b> · <b id="lvFps">—</b> fps</div>
|
||||
<div><b id="lvBitrate">—</b></div>
|
||||
<div id="lvHealth">—</div>
|
||||
<div><b id="lvTimer">00:00</b></div>
|
||||
</div>
|
||||
|
||||
<div class="lv-controls">
|
||||
<button class="lv-btn" id="lvMic" title="Mute microphone"><i class="bi bi-mic-fill"></i></button>
|
||||
<button class="lv-golive" id="lvGoLive"><i class="bi bi-broadcast"></i><span id="lvGoLiveText">Go Live</span></button>
|
||||
<button class="lv-btn" id="lvFlip" title="Switch camera"><i class="bi bi-arrow-repeat"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lv-msg" id="lvMsg"></div>
|
||||
|
||||
<div class="lv-meta">
|
||||
<div class="lv-card">
|
||||
<h3>{{ $stream->title }}</h3>
|
||||
@if ($stream->description)
|
||||
<p style="margin:0 0 10px;font-size:14px;opacity:.8;">{{ $stream->description }}</p>
|
||||
@endif
|
||||
<div class="lv-share">
|
||||
<input type="text" id="lvWatchUrl" value="{{ $watchUrl }}" readonly onclick="this.select()">
|
||||
<button class="action-btn" type="button" id="lvCopy"><i class="bi bi-clipboard"></i> <span>Copy</span></button>
|
||||
</div>
|
||||
<p class="lv-note">
|
||||
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.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="lv-card">
|
||||
<h3>Quality</h3>
|
||||
<p class="lv-note" style="margin-top:0;">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CFG = {
|
||||
whipUrl: @json($whipUrl),
|
||||
tokenUrl: @json(route('live.token', $stream)),
|
||||
stopUrl: @json(route('live.stop', $stream)),
|
||||
csrf: @json(csrf_token()),
|
||||
iceServers: @json($iceServers),
|
||||
width: {{ (int) config('live.video_width') }},
|
||||
height: {{ (int) config('live.video_height') }},
|
||||
fps: {{ (int) config('live.video_fps') }},
|
||||
maxBitrate: {{ (int) config('live.max_bitrate') }}
|
||||
};
|
||||
|
||||
var el = function (id) { return document.getElementById(id); };
|
||||
var preview = el('lvPreview'), badge = el('lvBadge'), badgeText = el('lvBadgeText');
|
||||
var goLive = el('lvGoLive'), goLiveText = el('lvGoLiveText');
|
||||
var msg = el('lvMsg'), stats = el('lvStats');
|
||||
|
||||
var stream = null, pc = null, statsTimer = null, clockTimer = null;
|
||||
var facing = 'environment', live = false, startedAt = 0;
|
||||
|
||||
function say(text, kind) {
|
||||
msg.textContent = text;
|
||||
msg.className = 'lv-msg ' + (kind || 'err');
|
||||
}
|
||||
function clearSay() { msg.className = 'lv-msg'; msg.textContent = ''; }
|
||||
|
||||
/* ---------------- Camera ---------------- */
|
||||
|
||||
// Ask for 1080p as an IDEAL, never `exact`: a phone whose camera cannot do
|
||||
// it should hand back its best effort, not refuse to open at all.
|
||||
function constraints() {
|
||||
return {
|
||||
video: {
|
||||
width: { ideal: CFG.width },
|
||||
height: { ideal: CFG.height },
|
||||
frameRate: { ideal: CFG.fps },
|
||||
facingMode: { ideal: facing }
|
||||
},
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function openCamera() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
say('This browser cannot open a camera. On iPhone use Safari; on Android use Chrome.');
|
||||
goLive.disabled = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// getUserMedia only exists in a secure context. Saying so plainly beats
|
||||
// a bare "Permission denied" the user cannot act on.
|
||||
if (!window.isSecureContext) {
|
||||
say('The camera needs a secure (HTTPS) connection. Open this page over https.');
|
||||
goLive.disabled = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (stream) { stream.getTracks().forEach(function (t) { t.stop(); }); }
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints());
|
||||
} catch (e) {
|
||||
if (e && (e.name === 'NotAllowedError' || e.name === 'SecurityError')) {
|
||||
say('Camera and microphone access was blocked. Allow it in your browser settings, then reload.');
|
||||
} else if (e && e.name === 'NotFoundError') {
|
||||
say('No camera was found on this device.');
|
||||
} else if (e && e.name === 'NotReadableError') {
|
||||
say('The camera is already in use by another app. Close it and try again.');
|
||||
} else {
|
||||
say('Could not start the camera: ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
goLive.disabled = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
preview.srcObject = stream;
|
||||
preview.classList.toggle('mirrored', facing === 'user');
|
||||
goLive.disabled = false;
|
||||
clearSay();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------- Publish (WHIP) ---------------- */
|
||||
|
||||
async function startBroadcast() {
|
||||
goLive.disabled = true;
|
||||
goLiveText.textContent = 'Starting…';
|
||||
clearSay();
|
||||
|
||||
try {
|
||||
// The publish credential is minted now, not at page load, so its
|
||||
// short life starts when the user actually goes live.
|
||||
var tokenRes = await fetch(CFG.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!tokenRes.ok) { throw new Error('Could not get permission to publish (' + tokenRes.status + ').'); }
|
||||
var auth = await tokenRes.json();
|
||||
|
||||
pc = new RTCPeerConnection({
|
||||
iceServers: auth.ice_servers && auth.ice_servers.length ? auth.ice_servers : CFG.iceServers,
|
||||
bundlePolicy: 'max-bundle'
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', function () {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
say('The connection to the server dropped. Check your signal and go live again.');
|
||||
stopBroadcast(true);
|
||||
}
|
||||
});
|
||||
|
||||
stream.getTracks().forEach(function (track) {
|
||||
// maintain-resolution keeps 1920x1080 and sheds frame rate when
|
||||
// the uplink tightens. For a match or a performance, a sharp
|
||||
// 20fps reads better than a soft, blocky 30.
|
||||
if (track.kind === 'video') { track.contentHint = 'detail'; }
|
||||
pc.addTrack(track, stream);
|
||||
});
|
||||
|
||||
var videoSender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
|
||||
if (videoSender) {
|
||||
var params = videoSender.getParameters();
|
||||
params.encodings = params.encodings && params.encodings.length ? params.encodings : [{}];
|
||||
params.encodings[0].maxBitrate = CFG.maxBitrate;
|
||||
params.encodings[0].maxFramerate = CFG.fps;
|
||||
params.degradationPreference = 'maintain-resolution';
|
||||
try { await videoSender.setParameters(params); } catch (e) { /* older browsers ignore this */ }
|
||||
}
|
||||
|
||||
var offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await iceGatheringComplete(pc);
|
||||
|
||||
var res = await fetch(auth.whip_url || CFG.whipUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
// Basic auth keeps the credential out of the URL, and so
|
||||
// out of proxy and access logs.
|
||||
'Authorization': 'Basic ' + btoa('publisher:' + auth.token)
|
||||
},
|
||||
body: pc.localDescription.sdp
|
||||
});
|
||||
|
||||
if (res.status === 401 || res.status === 403) { throw new Error('The server refused the broadcast. Reload the page and try again.'); }
|
||||
if (!res.ok) { throw new Error('The server rejected the stream (' + res.status + ').'); }
|
||||
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
||||
|
||||
live = true;
|
||||
startedAt = Date.now();
|
||||
badge.classList.add('is-live');
|
||||
badgeText.textContent = 'LIVE';
|
||||
goLive.classList.add('is-live');
|
||||
goLiveText.textContent = 'End Stream';
|
||||
goLive.disabled = false;
|
||||
stats.style.display = 'flex';
|
||||
say('You are live. Share the link below.', 'ok');
|
||||
|
||||
statsTimer = setInterval(sampleStats, 1000);
|
||||
clockTimer = setInterval(tickClock, 1000);
|
||||
|
||||
} catch (e) {
|
||||
say(e && e.message ? e.message : String(e));
|
||||
await stopBroadcast(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for ICE to finish rather than trickling: WHIP is a single
|
||||
// offer/answer exchange, so the offer must already carry the candidates.
|
||||
function iceGatheringComplete(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(); }
|
||||
});
|
||||
// Never hang on a candidate that will not arrive.
|
||||
setTimeout(finish, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopBroadcast(silent) {
|
||||
clearInterval(statsTimer); statsTimer = null;
|
||||
clearInterval(clockTimer); clockTimer = null;
|
||||
|
||||
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
|
||||
|
||||
if (live) {
|
||||
try {
|
||||
await fetch(CFG.stopUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (e) { /* the server also reaps on unpublish */ }
|
||||
}
|
||||
|
||||
live = false;
|
||||
badge.classList.remove('is-live');
|
||||
badgeText.textContent = 'READY';
|
||||
goLive.classList.remove('is-live');
|
||||
goLiveText.textContent = 'Go Live';
|
||||
goLive.disabled = false;
|
||||
stats.style.display = 'none';
|
||||
|
||||
if (!silent) { say('Stream ended.', 'ok'); }
|
||||
}
|
||||
|
||||
/* ---------------- Telemetry ---------------- */
|
||||
|
||||
var lastBytes = 0, lastAt = 0;
|
||||
|
||||
async function sampleStats() {
|
||||
if (!pc) { return; }
|
||||
var report = await pc.getStats();
|
||||
var kbps = null, fps = null, w = null, h = null, lossPct = null;
|
||||
|
||||
report.forEach(function (s) {
|
||||
if (s.type === 'outbound-rtp' && s.kind === 'video') {
|
||||
var now = s.timestamp;
|
||||
if (lastAt && s.bytesSent > lastBytes) {
|
||||
kbps = Math.round(((s.bytesSent - lastBytes) * 8) / ((now - lastAt) / 1000) / 1000);
|
||||
}
|
||||
lastBytes = s.bytesSent; lastAt = now;
|
||||
if (s.framesPerSecond) { fps = Math.round(s.framesPerSecond); }
|
||||
if (s.frameWidth) { w = s.frameWidth; h = s.frameHeight; }
|
||||
}
|
||||
if (s.type === 'remote-inbound-rtp' && s.kind === 'video' && s.packetsLost != null && s.packetsReceived) {
|
||||
lossPct = (s.packetsLost / (s.packetsLost + s.packetsReceived)) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
if (w && h) { el('lvRes').textContent = w + '×' + h; }
|
||||
if (fps !== null) { el('lvFps').textContent = fps; }
|
||||
if (kbps !== null) { el('lvBitrate').textContent = (kbps / 1000).toFixed(1) + ' Mbps'; }
|
||||
|
||||
// Report the connection honestly — a broadcaster who can see the uplink
|
||||
// struggling can move rather than wonder why the picture went soft.
|
||||
var health = el('lvHealth');
|
||||
if (lossPct === null) { health.textContent = 'connection ok'; health.className = ''; }
|
||||
else if (lossPct < 2) { health.textContent = 'connection good'; health.className = ''; }
|
||||
else if (lossPct < 8) { health.textContent = 'connection weak'; health.className = 'warn'; }
|
||||
else { health.textContent = 'connection poor'; health.className = 'bad'; }
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
var s = Math.floor((Date.now() - startedAt) / 1000);
|
||||
var m = Math.floor(s / 60), sec = s % 60;
|
||||
el('lvTimer').textContent = (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
|
||||
}
|
||||
|
||||
/* ---------------- Controls ---------------- */
|
||||
|
||||
goLive.addEventListener('click', function () {
|
||||
if (live) { stopBroadcast(false); } else { startBroadcast(); }
|
||||
});
|
||||
|
||||
el('lvFlip').addEventListener('click', async function () {
|
||||
facing = (facing === 'environment') ? 'user' : 'environment';
|
||||
var wasLive = live;
|
||||
var ok = await openCamera();
|
||||
if (!ok) { return; }
|
||||
|
||||
// Swap the track inside the existing connection so flipping the camera
|
||||
// mid-broadcast does not drop the stream.
|
||||
if (wasLive && pc) {
|
||||
var newTrack = stream.getVideoTracks()[0];
|
||||
var sender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
|
||||
if (sender && newTrack) { try { await sender.replaceTrack(newTrack); } catch (e) {} }
|
||||
}
|
||||
});
|
||||
|
||||
el('lvMic').addEventListener('click', function () {
|
||||
if (!stream) { return; }
|
||||
var track = stream.getAudioTracks()[0];
|
||||
if (!track) { return; }
|
||||
track.enabled = !track.enabled;
|
||||
this.classList.toggle('off', !track.enabled);
|
||||
this.innerHTML = track.enabled ? '<i class="bi bi-mic-fill"></i>' : '<i class="bi bi-mic-mute-fill"></i>';
|
||||
});
|
||||
|
||||
el('lvCopy').addEventListener('click', function () {
|
||||
var input = el('lvWatchUrl');
|
||||
input.select();
|
||||
navigator.clipboard && navigator.clipboard.writeText(input.value);
|
||||
var span = this.querySelector('span');
|
||||
var original = span.textContent;
|
||||
span.textContent = 'Copied';
|
||||
setTimeout(function () { span.textContent = original; }, 1400);
|
||||
});
|
||||
|
||||
// A broadcaster who closes the tab must not leave a stream stuck live.
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (live && navigator.sendBeacon) {
|
||||
var body = new FormData();
|
||||
body.append('_token', CFG.csrf);
|
||||
navigator.sendBeacon(CFG.stopUrl, body);
|
||||
}
|
||||
});
|
||||
|
||||
openCamera();
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,89 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Go Live')
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.lc-wrap { max-width: 560px; margin: 0 auto; padding: 24px 16px 60px; }
|
||||
.lc-wrap h1 { font-size: 22px; font-weight: 700; margin: 0 0 6px; display: flex; align-items: center; gap: 10px; }
|
||||
.lc-sub { font-size: 13px; opacity: .6; margin: 0 0 26px; line-height: 1.6; }
|
||||
.lc-field { margin-bottom: 18px; }
|
||||
.lc-field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 7px; }
|
||||
.lc-field input[type=text], .lc-field textarea {
|
||||
width: 100%; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14);
|
||||
border-radius: 11px; padding: 11px 13px; color: inherit; font-size: 14px; font-family: inherit;
|
||||
}
|
||||
.lc-field input:focus, .lc-field textarea:focus { outline: none; border-color: #ef4444; }
|
||||
.lc-field textarea { min-height: 88px; resize: vertical; }
|
||||
.lc-err { font-size: 12px; color: #f87171; margin-top: 6px; }
|
||||
|
||||
.lc-vis { display: grid; gap: 10px; }
|
||||
.lc-vis label {
|
||||
display: flex; gap: 12px; align-items: flex-start; padding: 13px 15px; cursor: pointer;
|
||||
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; transition: border-color .15s ease, background .15s ease;
|
||||
}
|
||||
.lc-vis label:hover { background: rgba(255,255,255,.04); }
|
||||
.lc-vis input { margin-top: 3px; accent-color: #ef4444; }
|
||||
.lc-vis .t { font-size: 14px; font-weight: 600; }
|
||||
.lc-vis .d { font-size: 12px; opacity: .6; margin-top: 2px; line-height: 1.5; }
|
||||
.lc-vis input:checked + div .t { color: #ef4444; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="lc-wrap">
|
||||
<h1><i class="bi bi-broadcast" style="color:#ef4444;"></i> Go Live</h1>
|
||||
<p class="lc-sub">Stream straight from your phone's camera. You'll get a link to share the moment you start.</p>
|
||||
|
||||
<form method="POST" action="{{ route('live.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="lc-field">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" value="{{ old('title') }}" maxlength="120" required
|
||||
placeholder="What are you streaming?" autofocus>
|
||||
@error('title')<div class="lc-err">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="lc-field">
|
||||
<label for="description">Description <span style="opacity:.5;font-weight:400;">(optional)</span></label>
|
||||
<textarea id="description" name="description" maxlength="2000" placeholder="Tell viewers what to expect">{{ old('description') }}</textarea>
|
||||
@error('description')<div class="lc-err">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="lc-field">
|
||||
<label>Who can find it</label>
|
||||
<div class="lc-vis">
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="public" {{ old('visibility', 'public') === 'public' ? 'checked' : '' }}>
|
||||
<div>
|
||||
<div class="t">Public</div>
|
||||
<div class="d">Listed on the Live page — anyone can discover and watch it.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="unlisted" {{ old('visibility') === 'unlisted' ? 'checked' : '' }}>
|
||||
<div>
|
||||
<div class="t">Unlisted</div>
|
||||
<div class="d">Not listed anywhere. Only people you send the link to can watch.</div>
|
||||
</div>
|
||||
</label>
|
||||
{{-- Mirror the upload-modal's Private wording: only the owner
|
||||
sees it. Useful for test runs and private rehearsals. --}}
|
||||
<label>
|
||||
<input type="radio" name="visibility" value="private" {{ old('visibility') === 'private' ? 'checked' : '' }}>
|
||||
<div>
|
||||
<div class="t">Private</div>
|
||||
<div class="d">Only you can see this stream. Perfect for testing without an audience.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@error('visibility')<div class="lc-err">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<button type="submit" class="action-btn action-btn-primary" style="width:100%;justify-content:center;height:46px;">
|
||||
<i class="bi bi-camera-video"></i> <span>Set up camera</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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
|
||||
@@ -0,0 +1,499 @@
|
||||
@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')
|
||||
<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>
|
||||
@endsection
|
||||
Reference in new issue
Block a user