Compare commits
23
Commits
8e719be105
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab59fc1bb | ||
|
|
e76ac39f99 | ||
|
|
c59b36ebf7 | ||
|
|
8fef257c86 | ||
|
|
5bf1128196 | ||
|
|
639279f2a2 | ||
|
|
8c6746e547 | ||
|
|
45505e6419 | ||
|
|
59513f2051 | ||
|
|
677e8711dd | ||
|
|
f7c3b236ab | ||
|
|
e550d8d15b | ||
|
|
7ba272edf6 | ||
|
|
4d9bc0f501 | ||
|
|
73de5bc97e | ||
|
|
341e94a4f8 | ||
|
|
574c9581bb | ||
|
|
22d6b1507d | ||
|
|
5aef41201a | ||
|
|
5adafc7616 | ||
|
|
05ccb45fe4 | ||
|
|
b7f3f6989b | ||
|
|
0e8bcfd47b |
No files matched your search
@@ -0,0 +1,357 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs up the database, and optionally mirrors the media library.
|
||||||
|
*
|
||||||
|
* Three things make this a backup rather than a file copy:
|
||||||
|
*
|
||||||
|
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
|
||||||
|
* checkpoint, so the artifact is a coherent point-in-time copy even while
|
||||||
|
* the app is writing. Copying the file by hand mid-write is not.
|
||||||
|
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
|
||||||
|
* back before it counts as a success. An unverified backup is a rumour.
|
||||||
|
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
|
||||||
|
* not a dead disk, so a configured remote disk receives a copy and the
|
||||||
|
* command warns loudly on every run where none is set.
|
||||||
|
*
|
||||||
|
* The media library is deliberately NOT part of the default run. It is
|
||||||
|
* gigabytes of immutable uploads; archiving it nightly would produce hours of
|
||||||
|
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
|
||||||
|
* incrementally with rsync instead.
|
||||||
|
*
|
||||||
|
* Exits non-zero on any failure so a cron surfaces the problem instead of
|
||||||
|
* quietly producing corrupt files.
|
||||||
|
*
|
||||||
|
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
|
||||||
|
* up the same way and restored the same way.
|
||||||
|
*/
|
||||||
|
class BackupCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'takeone:backup
|
||||||
|
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
|
||||||
|
{--media-only : Mirror media and skip the database}
|
||||||
|
{--keep-local : Do not delete the local copy after an off-server upload}';
|
||||||
|
|
||||||
|
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
|
||||||
|
|
||||||
|
private array $artifacts = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dir = (string) config('backup.path');
|
||||||
|
|
||||||
|
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
|
||||||
|
$this->error("Cannot create backup directory: {$dir}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = now()->format('Ymd-His');
|
||||||
|
$failed = false;
|
||||||
|
|
||||||
|
if (! $this->option('media-only')) {
|
||||||
|
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('media') || $this->option('media-only')) {
|
||||||
|
$failed = ! $this->mirrorMedia() || $failed;
|
||||||
|
} else {
|
||||||
|
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->shipOffServer();
|
||||||
|
$this->prune($dir);
|
||||||
|
|
||||||
|
if ($failed) {
|
||||||
|
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
|
||||||
|
$this->error('Backup FAILED — see the errors above.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Backup complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Database ---------------- */
|
||||||
|
|
||||||
|
private function backupDatabase(string $dir, string $stamp): bool
|
||||||
|
{
|
||||||
|
$connection = config('database.default');
|
||||||
|
$driver = config("database.connections.{$connection}.driver");
|
||||||
|
|
||||||
|
return match ($driver) {
|
||||||
|
'sqlite' => $this->backupSqlite($dir, $stamp),
|
||||||
|
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
|
||||||
|
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
|
||||||
|
private function backupSqlite(string $dir, string $stamp): bool
|
||||||
|
{
|
||||||
|
$target = $dir."/db-{$stamp}.sqlite";
|
||||||
|
|
||||||
|
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
|
||||||
|
// something cryptic, say so plainly — a backup that quietly produces
|
||||||
|
// nothing is the worst possible outcome.
|
||||||
|
if (DB::transactionLevel() > 0) {
|
||||||
|
$this->error('Cannot snapshot the database inside an open transaction.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Harmless when the database is not in WAL mode.
|
||||||
|
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Database snapshot failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verifySqlite($target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artifacts[] = $target;
|
||||||
|
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the snapshot back. A file that exists but cannot be opened, or that
|
||||||
|
* holds no migrations and no videos, is not a backup worth trusting.
|
||||||
|
*/
|
||||||
|
private function verifySqlite(string $path): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = new PDO('sqlite:'.$path);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
|
||||||
|
$this->error("Snapshot failed integrity check: {$path}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
|
||||||
|
|
||||||
|
if ($migrations < 1) {
|
||||||
|
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a real application table too. `migrations` alone would still
|
||||||
|
// be present in a snapshot taken of an empty database.
|
||||||
|
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
|
||||||
|
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Snapshot could not be verified: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backupMysql(string $dir, string $stamp, string $connection): bool
|
||||||
|
{
|
||||||
|
$target = $dir."/db-{$stamp}.sql.gz";
|
||||||
|
$c = config("database.connections.{$connection}");
|
||||||
|
|
||||||
|
$command = sprintf(
|
||||||
|
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
|
||||||
|
escapeshellarg((string) $c['host']),
|
||||||
|
escapeshellarg((string) $c['port']),
|
||||||
|
escapeshellarg((string) $c['username']),
|
||||||
|
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
|
||||||
|
escapeshellarg((string) $c['database']),
|
||||||
|
escapeshellarg($target),
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $out, $status);
|
||||||
|
|
||||||
|
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
|
||||||
|
$this->error('mysqldump failed (exit '.$status.').');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the dump is readable and non-trivial rather than trusting the
|
||||||
|
// exit code — a gzip of an error message also exits 0.
|
||||||
|
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
|
||||||
|
|
||||||
|
if ($verify !== 0) {
|
||||||
|
$this->error('Dump failed its gzip integrity check — discarding.');
|
||||||
|
@unlink($target);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artifacts[] = $target;
|
||||||
|
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Media ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the media library with rsync.
|
||||||
|
*
|
||||||
|
* Uploaded video cannot be regenerated from anything — losing it is the
|
||||||
|
* one unrecoverable outcome on this platform. rsync is used rather than an
|
||||||
|
* archive because the library is large and almost entirely immutable, so
|
||||||
|
* each run copies only what is new.
|
||||||
|
*/
|
||||||
|
private function mirrorMedia(): bool
|
||||||
|
{
|
||||||
|
$destination = (string) config('backup.media_destination');
|
||||||
|
|
||||||
|
if ($destination === '') {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
|
||||||
|
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
|
||||||
|
$this->error("Media destination is not reachable: {$destination}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
|
||||||
|
|
||||||
|
if (! $paths) {
|
||||||
|
$this->line(' <comment>–</comment> media nothing to mirror');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --delete is opt-in and off by default: a mirror that deletes is one
|
||||||
|
// wrong path away from erasing the copy it exists to protect.
|
||||||
|
$flags = '-a --partial --info=stats2';
|
||||||
|
|
||||||
|
if (config('backup.media_delete')) {
|
||||||
|
$flags .= ' --delete';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = true;
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
// Trailing slash on the source, basename on the destination, so
|
||||||
|
// each folder lands in its own directory rather than merging.
|
||||||
|
$command = sprintf(
|
||||||
|
'rsync %s %s %s 2>&1',
|
||||||
|
$flags,
|
||||||
|
escapeshellarg(rtrim($path, '/').'/'),
|
||||||
|
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $out, $status);
|
||||||
|
|
||||||
|
if ($status !== 0) {
|
||||||
|
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
|
||||||
|
$ok = false;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Off-server ---------------- */
|
||||||
|
|
||||||
|
private function shipOffServer(): void
|
||||||
|
{
|
||||||
|
$disk = config('backup.disk');
|
||||||
|
|
||||||
|
if (! $disk) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
|
||||||
|
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->artifacts as $artifact) {
|
||||||
|
try {
|
||||||
|
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
|
||||||
|
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
|
||||||
|
|
||||||
|
if (! $this->option('keep-local')) {
|
||||||
|
@unlink($artifact);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never delete the local copy when the upload failed — it is
|
||||||
|
// the only copy left.
|
||||||
|
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Rotation ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete artifacts past the retention window, but always keep the newest
|
||||||
|
* few — a box that sat idle for a month must still have something to
|
||||||
|
* restore from.
|
||||||
|
*/
|
||||||
|
private function prune(string $dir): void
|
||||||
|
{
|
||||||
|
$days = (int) config('backup.retain_days', 14);
|
||||||
|
|
||||||
|
if ($days <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = max(0, (int) config('backup.keep_minimum', 3));
|
||||||
|
$cutoff = now()->subDays($days)->getTimestamp();
|
||||||
|
$removed = 0;
|
||||||
|
|
||||||
|
$files = glob($dir.'/db-*') ?: [];
|
||||||
|
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
|
||||||
|
|
||||||
|
foreach (array_slice($files, $keep) as $file) {
|
||||||
|
if (filemtime($file) < $cutoff && @unlink($file)) {
|
||||||
|
$removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($removed) {
|
||||||
|
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function humanSize(string $path): string
|
||||||
|
{
|
||||||
|
$bytes = (int) @filesize($path);
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
|
||||||
|
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes, 1).' '.$units[$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
|
|||||||
->weeklyOn(1, '09:00')
|
->weeklyOn(1, '09:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
|
||||||
|
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
|
||||||
|
// and (when BACKUP_DISK is set) ships a copy off-server.
|
||||||
|
$schedule->command('takeone:backup')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -206,4 +206,56 @@ class CommentController extends Controller
|
|||||||
|
|
||||||
return response()->json(['liked' => $liked, 'count' => $count]);
|
return response()->json(['liked' => $liked, 'count' => $count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the "creator heart" on a comment. Only the video owner can
|
||||||
|
* drop a heart; anyone else gets a 403.
|
||||||
|
*/
|
||||||
|
public function heart(Comment $comment)
|
||||||
|
{
|
||||||
|
$video = $comment->video;
|
||||||
|
if (! $video || $video->user_id !== Auth::id()) {
|
||||||
|
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->hearted = ! $comment->hearted;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'hearted' => (bool) $comment->hearted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle "pinned" on a top-level comment. Only the video owner can
|
||||||
|
* pin, and pinned comments sort ahead of everything else in the list.
|
||||||
|
* At most one comment is pinned at a time — pinning a new one unpins
|
||||||
|
* the previous.
|
||||||
|
*/
|
||||||
|
public function pin(Comment $comment)
|
||||||
|
{
|
||||||
|
$video = $comment->video;
|
||||||
|
if (! $video || $video->user_id !== Auth::id()) {
|
||||||
|
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
|
||||||
|
}
|
||||||
|
if ($comment->parent_id) {
|
||||||
|
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wasPinned = (bool) $comment->pinned;
|
||||||
|
if (! $wasPinned) {
|
||||||
|
// Unpin any previously-pinned comment on this video first so
|
||||||
|
// the UI invariant "at most one pinned" is maintained at the
|
||||||
|
// DB level, not just in the view.
|
||||||
|
Comment::where('video_id', $video->id)
|
||||||
|
->where('pinned', true)
|
||||||
|
->update(['pinned' => false]);
|
||||||
|
}
|
||||||
|
$comment->pinned = ! $wasPinned;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'pinned' => (bool) $comment->pinned,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\LiveStream;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media server's authorisation hook.
|
||||||
|
*
|
||||||
|
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
|
||||||
|
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
|
||||||
|
* keeps every "who may do what" decision inside the application, next to the
|
||||||
|
* users table, rather than duplicated in a media server config.
|
||||||
|
*
|
||||||
|
* Deny by default, in the strongest sense: if this app is down, the media
|
||||||
|
* server can authorise nothing at all.
|
||||||
|
*
|
||||||
|
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
|
||||||
|
* of the public API and reject anything arriving from elsewhere.
|
||||||
|
*/
|
||||||
|
class LiveAuthController extends Controller
|
||||||
|
{
|
||||||
|
/** Loopback addresses the media server may call from. */
|
||||||
|
private const LOCAL_IPS = ['127.0.0.1', '::1'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide a publish or read attempt.
|
||||||
|
*
|
||||||
|
* Payload from MediaMTX: user, password, token, ip, action, path,
|
||||||
|
* protocol, id, query.
|
||||||
|
*/
|
||||||
|
public function authenticate(Request $request): Response
|
||||||
|
{
|
||||||
|
if (! $this->fromMediaServer($request)) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = (string) $request->input('action');
|
||||||
|
|
||||||
|
// Control-plane actions carry no stream path, so they are settled
|
||||||
|
// before any attempt to resolve one.
|
||||||
|
if (in_array($action, ['api', 'metrics', 'pprof'], true)) {
|
||||||
|
return $this->authorizeApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) $request->input('path');
|
||||||
|
|
||||||
|
$stream = $this->resolveStream($path);
|
||||||
|
|
||||||
|
if (! $stream) {
|
||||||
|
// An unknown path is not an error worth explaining — a publisher
|
||||||
|
// probing for valid stream ids learns nothing from this.
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($action) {
|
||||||
|
'publish' => $this->authorizePublish($request, $stream),
|
||||||
|
'read' => $this->authorizeRead($stream),
|
||||||
|
default => response('', 403),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media server's own control API.
|
||||||
|
*
|
||||||
|
* It authenticates against this same hook, so without an explicit allow the
|
||||||
|
* app could not read live state from the server it runs alongside. Safe
|
||||||
|
* because it is already loopback-gated above — nothing off this host can
|
||||||
|
* reach it.
|
||||||
|
*/
|
||||||
|
private function authorizeApi(): Response
|
||||||
|
{
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A publisher must present the short-lived token minted for this stream
|
||||||
|
* when its owner pressed "Go live". The token is single-use: it is burned
|
||||||
|
* here, so a captured one cannot start a second broadcast.
|
||||||
|
*/
|
||||||
|
private function authorizePublish(Request $request, LiveStream $stream): Response
|
||||||
|
{
|
||||||
|
// MediaMTX passes HTTP Basic credentials through as user/password.
|
||||||
|
$presented = (string) ($request->input('password') ?: $request->input('token'));
|
||||||
|
|
||||||
|
if (! $stream->publishTokenMatches($presented)) {
|
||||||
|
Log::warning('Live publish refused', [
|
||||||
|
'stream' => $stream->public_id,
|
||||||
|
'ip' => $request->input('ip'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream->consumePublishToken();
|
||||||
|
$stream->markLive();
|
||||||
|
|
||||||
|
Log::info('Live publish authorised', [
|
||||||
|
'stream' => $stream->public_id,
|
||||||
|
'user_id' => $stream->user_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads are anonymous by design — a viewer fetching HLS parts carries no
|
||||||
|
* credentials — so the gate is the stream's own state: it must be live and
|
||||||
|
* not private. The unpredictable path is what keeps an unlisted stream
|
||||||
|
* unlisted.
|
||||||
|
*/
|
||||||
|
private function authorizeRead(LiveStream $stream): Response
|
||||||
|
{
|
||||||
|
if (! $stream->isLive()) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($stream->visibility, ['public', 'unlisted'], true)) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle notifications from the media server.
|
||||||
|
*
|
||||||
|
* The auth hook only fires on the way in, so without this a stream whose
|
||||||
|
* phone lost signal would read as live forever.
|
||||||
|
*/
|
||||||
|
public function hook(Request $request): Response
|
||||||
|
{
|
||||||
|
if (! $this->fromMediaServer($request)) {
|
||||||
|
return response('', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = $this->resolveStream((string) $request->input('path'));
|
||||||
|
|
||||||
|
if (! $stream) {
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
match ((string) $request->input('event')) {
|
||||||
|
'publish' => $stream->markLive(),
|
||||||
|
// Only a stream that is actually live can end. A late hook from a
|
||||||
|
// previous, failed session must not kill the one just started.
|
||||||
|
'unpublish' => $stream->isLive() ? $stream->markEnded() : null,
|
||||||
|
'read' => $this->viewerJoined($stream),
|
||||||
|
'unread' => $this->viewerLeft($stream),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Viewer accounting ---------------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Counted with atomic SQL increments rather than read-modify-write: viewers
|
||||||
|
* arrive concurrently, and two joins landing together would otherwise
|
||||||
|
* record one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private function viewerJoined(LiveStream $stream): void
|
||||||
|
{
|
||||||
|
LiveStream::where('id', $stream->id)->increment('current_viewers');
|
||||||
|
|
||||||
|
// peak_viewers is a high-water mark, so it only ever moves up.
|
||||||
|
LiveStream::where('id', $stream->id)
|
||||||
|
->whereColumn('current_viewers', '>', 'peak_viewers')
|
||||||
|
->update(['peak_viewers' => DB::raw('current_viewers')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewerLeft(LiveStream $stream): void
|
||||||
|
{
|
||||||
|
// Floor at zero: a missed join (a restart, a dropped hook) must not
|
||||||
|
// drive the count negative and show "-1 watching".
|
||||||
|
LiveStream::where('id', $stream->id)
|
||||||
|
->where('current_viewers', '>', 0)
|
||||||
|
->decrement('current_viewers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Helpers ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the media server on this host may ask. Deliberately checks the raw
|
||||||
|
* connection address rather than a forwarded header — a header can be
|
||||||
|
* spoofed by anyone who can reach the app.
|
||||||
|
*/
|
||||||
|
private function fromMediaServer(Request $request): bool
|
||||||
|
{
|
||||||
|
return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn "live/<public_id>" into the stream it names. */
|
||||||
|
private function resolveStream(string $path): ?LiveStream
|
||||||
|
{
|
||||||
|
if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LiveStream::where('public_id', $m[1])->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\LiveChatMessage;
|
||||||
|
use App\Models\LiveStream;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live streaming — broadcaster and viewer surfaces.
|
||||||
|
*
|
||||||
|
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
|
||||||
|
* may broadcast, mints the short-lived credential the browser publishes with,
|
||||||
|
* and hands out the URLs. See LiveAuthController for the hook the media server
|
||||||
|
* calls back on.
|
||||||
|
*/
|
||||||
|
class LiveStreamController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// Watching is open; everything that creates or controls a stream needs
|
||||||
|
// an account.
|
||||||
|
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live now, plus the viewer's own streams when signed in. */
|
||||||
|
public function index(): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
$live = LiveStream::live()
|
||||||
|
->listed()
|
||||||
|
->with('user')
|
||||||
|
->latest('started_at')
|
||||||
|
->limit(60)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$mine = Auth::check()
|
||||||
|
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return view('live.index', compact('live', 'mine'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "start a broadcast" form. */
|
||||||
|
public function create(): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
return view('live.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the stream record. This does NOT start anything — it reserves an
|
||||||
|
* identity and a path; the browser goes live from the broadcast page.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:120'],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// One live broadcast per account at a time. Without this a single user
|
||||||
|
// can pin open as many encoder slots as they can open tabs.
|
||||||
|
$alreadyLive = LiveStream::where('user_id', Auth::id())
|
||||||
|
->where('status', LiveStream::STATUS_LIVE)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyLive) {
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = LiveStream::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'visibility' => $data['visibility'],
|
||||||
|
'status' => LiveStream::STATUS_IDLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('live.broadcast', $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The broadcaster's camera page. Owner only. */
|
||||||
|
public function broadcast(LiveStream $stream): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||||
|
|
||||||
|
// An ended stream is not a dead one — the owner may go live on it
|
||||||
|
// again from the same link.
|
||||||
|
$stream->reArm();
|
||||||
|
|
||||||
|
return view('live.broadcast', [
|
||||||
|
'stream' => $stream,
|
||||||
|
'whipUrl' => $this->whipUrl($stream),
|
||||||
|
'watchUrl' => route('live.watch', $stream),
|
||||||
|
'iceServers' => $this->iceServers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint the publish credential.
|
||||||
|
*
|
||||||
|
* Separate from the page load so the token's short life starts when the
|
||||||
|
* user actually presses "Go live", not when they opened the tab and went
|
||||||
|
* to find a tripod.
|
||||||
|
*/
|
||||||
|
public function token(LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||||
|
|
||||||
|
// Pressing "Go live" on a stream that previously ended restarts it
|
||||||
|
// rather than being refused. A failed attempt must never burn the link.
|
||||||
|
$stream->reArm();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'token' => $stream->issuePublishToken(),
|
||||||
|
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
|
||||||
|
'whip_url' => $this->whipUrl($stream),
|
||||||
|
'ice_servers' => $this->iceServers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewer page. */
|
||||||
|
public function watch(LiveStream $stream): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||||
|
|
||||||
|
$stream->loadMissing('user');
|
||||||
|
|
||||||
|
return view('live.watch', [
|
||||||
|
'stream' => $stream,
|
||||||
|
'whepUrl' => $this->whepUrl($stream),
|
||||||
|
'hlsUrl' => $this->hlsUrl($stream),
|
||||||
|
'isOwner' => $stream->isOwnedBy(Auth::user()),
|
||||||
|
'iceServers' => $this->iceServers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight poll for the viewer page: is it live, how many watching. */
|
||||||
|
public function status(LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $stream->status,
|
||||||
|
'live' => $stream->isLive(),
|
||||||
|
'viewers' => $stream->current_viewers,
|
||||||
|
'duration' => $stream->formatted_duration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End the broadcast. Owner only. */
|
||||||
|
public function stop(LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||||
|
|
||||||
|
$stream->markEnded();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'status' => $stream->status,
|
||||||
|
'duration' => $stream->formatted_duration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- URLs ---------------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These are same-origin paths, proxied by nginx to the media server on
|
||||||
|
* loopback. Same-origin matters: the page is served over HTTPS, and a
|
||||||
|
* mixed-content or cross-origin media URL would be blocked by the browser
|
||||||
|
* before WebRTC ever started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private function whipUrl(LiveStream $stream): string
|
||||||
|
{
|
||||||
|
return url('/live-rtc/'.$stream->mediaPath().'/whip');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function whepUrl(LiveStream $stream): string
|
||||||
|
{
|
||||||
|
return url('/live-rtc/'.$stream->mediaPath().'/whep');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hlsUrl(LiveStream $stream): string
|
||||||
|
{
|
||||||
|
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICE servers for the browser.
|
||||||
|
*
|
||||||
|
* No inbound UDP port is forwarded to this server, so a broadcaster outside
|
||||||
|
* the local network can only reach it through a TURN relay. On the LAN the
|
||||||
|
* host candidates work directly and TURN is never used.
|
||||||
|
*
|
||||||
|
* Configured through the environment so credentials never sit in the repo.
|
||||||
|
*/
|
||||||
|
private function iceServers(): array
|
||||||
|
{
|
||||||
|
$servers = [];
|
||||||
|
|
||||||
|
if ($stun = config('live.stun_url')) {
|
||||||
|
$servers[] = ['urls' => $stun];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($turn = config('live.turn_url')) {
|
||||||
|
$servers[] = array_filter([
|
||||||
|
'urls' => $turn,
|
||||||
|
'username' => config('live.turn_username'),
|
||||||
|
'credential' => config('live.turn_credential'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET poll: viewer asks for every message in this stream newer than
|
||||||
|
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
|
||||||
|
* messages — more than enough for a 3-second polling window, bounded
|
||||||
|
* to protect the DB when a client is slow to reconnect.
|
||||||
|
*/
|
||||||
|
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
// Private streams are owner-only; everything else is open to viewers.
|
||||||
|
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = (int) $request->query('since', 0);
|
||||||
|
$rows = LiveChatMessage::where('livestream_id', $stream->id)
|
||||||
|
->where('id', '>', $since)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(50)
|
||||||
|
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'messages' => $rows->map(fn ($m) => [
|
||||||
|
'id' => $m->id,
|
||||||
|
'uid' => $m->user_id,
|
||||||
|
'name' => $m->author_name,
|
||||||
|
'body' => $m->body,
|
||||||
|
'at' => $m->created_at?->toISOString(),
|
||||||
|
])->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a chat message. Rate-limited to roughly one message per second
|
||||||
|
* per user via Laravel's built-in throttle, applied via the route.
|
||||||
|
* Body is capped at 400 chars (long enough for a sentence, short enough
|
||||||
|
* that the panel doesn't shear into a wall of text).
|
||||||
|
*/
|
||||||
|
public function chatSend(Request $request, LiveStream $stream): JsonResponse
|
||||||
|
{
|
||||||
|
if ($stream->status !== LiveStream::STATUS_LIVE) {
|
||||||
|
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
|
||||||
|
}
|
||||||
|
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string', 'max:400'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$msg = LiveChatMessage::create([
|
||||||
|
'livestream_id' => $stream->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'author_name' => $user->name,
|
||||||
|
'body' => trim($data['body']),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => [
|
||||||
|
'id' => $msg->id,
|
||||||
|
'uid' => $msg->user_id,
|
||||||
|
'name' => $msg->author_name,
|
||||||
|
'body' => $msg->body,
|
||||||
|
'at' => $msg->created_at?->toISOString(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creator studio — the content management surface YouTube calls "YouTube
|
||||||
|
* Studio". A skeleton for now: a filterable table of the signed-in user's
|
||||||
|
* videos plus a bulk visibility change endpoint. Analytics, stepped upload
|
||||||
|
* and schedule are future work.
|
||||||
|
*/
|
||||||
|
class StudioController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->middleware('auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The studio dashboard — defaults to the content tab.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$filter = $request->get('filter', 'all');
|
||||||
|
|
||||||
|
$query = Video::where('user_id', $user->id)->latest();
|
||||||
|
|
||||||
|
switch ($filter) {
|
||||||
|
case 'public': $query->where('visibility', 'public'); break;
|
||||||
|
case 'unlisted': $query->where('visibility', 'unlisted'); break;
|
||||||
|
case 'private': $query->where('visibility', 'private'); break;
|
||||||
|
case 'shorts': $query->where('is_shorts', true); break;
|
||||||
|
case 'draft': $query->whereIn('status', ['pending', 'processing', 'failed']); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$videos = $query->paginate(24)->withQueryString();
|
||||||
|
|
||||||
|
// Headline numbers for the top bar.
|
||||||
|
$totals = [
|
||||||
|
'total' => Video::where('user_id', $user->id)->count(),
|
||||||
|
'public' => Video::where('user_id', $user->id)->where('visibility', 'public')->count(),
|
||||||
|
'unlisted' => Video::where('user_id', $user->id)->where('visibility', 'unlisted')->count(),
|
||||||
|
'private' => Video::where('user_id', $user->id)->where('visibility', 'private')->count(),
|
||||||
|
'processing' => Video::where('user_id', $user->id)->whereIn('status', ['pending', 'processing'])->count(),
|
||||||
|
'failed' => Video::where('user_id', $user->id)->where('status', 'failed')->count(),
|
||||||
|
'total_views' => \DB::table('video_views')
|
||||||
|
->whereIn('video_id', Video::where('user_id', $user->id)->pluck('id'))
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('studio.index', [
|
||||||
|
'videos' => $videos,
|
||||||
|
'filter' => $filter,
|
||||||
|
'totals' => $totals,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk visibility change. POSTs { video_ids: [...], visibility: '...' }.
|
||||||
|
* Guards against setting visibility on videos the user doesn't own, so a
|
||||||
|
* crafted request can't flip anyone else's content.
|
||||||
|
*/
|
||||||
|
public function bulkVisibility(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'video_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'video_ids.*' => ['integer'],
|
||||||
|
'visibility' => ['required', 'in:public,unlisted,private'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = Video::whereIn('id', $request->video_ids)
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->update(['visibility' => $request->visibility]);
|
||||||
|
|
||||||
|
return response()->json(['updated' => $updated]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk delete. Same owner-only guard as above.
|
||||||
|
*/
|
||||||
|
public function bulkDelete(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'video_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'video_ids.*' => ['integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleted = Video::whereIn('id', $request->video_ids)
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['deleted' => $deleted]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
|
|||||||
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
||||||
|
|
||||||
// ── Storage ───────────────────────────────────────────────
|
// ── Storage ───────────────────────────────────────────────
|
||||||
$disk = Storage::disk('public');
|
// NAS is the primary store; walking the local disk here would miss
|
||||||
|
// every file that's been pushed to NAS and purged locally. We use
|
||||||
|
// the DB-captured file sizes instead: videos.size is set at upload
|
||||||
|
// time, and thumbnail / avatar counts × a rough per-file average
|
||||||
|
// give us a usable picture. True NAS-side bytes could be queried
|
||||||
|
// with `smbclient du`, but that's slow and locks the dashboard; the
|
||||||
|
// DB figure is accurate for everything uploaded through this app.
|
||||||
|
$sizeVideos = (int) Video::sum('size'); // bytes
|
||||||
|
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
|
||||||
|
$avatarCount = User::whereNotNull('avatar')->count();
|
||||||
|
$bannerCount = User::whereNotNull('banner')->count();
|
||||||
|
$postImageCount = \DB::table('post_images')->count();
|
||||||
|
|
||||||
$sizeVideos = $this->dirSize($disk, 'videos');
|
// Images are variable but ~120 KB is a solid conservative estimate
|
||||||
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
|
// for a web-sized JPEG/PNG upload.
|
||||||
$sizeAvatars = $this->dirSize($disk, 'avatars');
|
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
|
||||||
$sizeImages = $this->dirSize($disk, 'images');
|
$sizeThumbnails = $thumbnailCount * 120 * 1024;
|
||||||
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
|
$sizeAvatars = $avatarCount * 120 * 1024;
|
||||||
|
$totalPublicSize = $sizeVideos + $sizeImages;
|
||||||
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
||||||
|
|
||||||
// Convert to MB
|
|
||||||
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
||||||
$storage = [
|
$storage = [
|
||||||
'videos' => $toMb($sizeVideos),
|
'videos' => $toMb($sizeVideos),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class UserController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['channel']);
|
$this->middleware('auth')->except(['channel', 'channelVideosMore']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typeahead search for members (used by the "Share to member" picker)
|
// Typeahead search for members (used by the "Share to member" picker)
|
||||||
@@ -325,29 +325,41 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watch history
|
// Watch history
|
||||||
public function history()
|
public function history(Request $request)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Get videos the user has watched, ordered by most recently watched
|
// Latest-first by watched_at. Paginate at the join so a user who
|
||||||
// Include private videos since they are the user's own
|
// has watched tens of thousands of videos doesn't pull every row
|
||||||
$videoIds = \DB::table('video_views')
|
// into memory on each page load. Fetch the videos keyed by latest
|
||||||
->where('user_id', $user->id)
|
// view so sort order is deterministic even when the same video has
|
||||||
->orderBy('watched_at', 'desc')
|
// been watched more than once.
|
||||||
->pluck('video_id')
|
//
|
||||||
->unique();
|
// Each history row is one (video_views + videos) pair, with the
|
||||||
|
// uniqueness constraint deferred to presentation: duplicates per
|
||||||
$videos = Video::whereIn('id', $videoIds)
|
// video are expected (one row per view event) and the UI collapses
|
||||||
|
// them with view-count badges.
|
||||||
|
$per = 48;
|
||||||
|
$rows = \DB::table('video_views')
|
||||||
|
->join('videos', 'videos.id', '=', 'video_views.video_id')
|
||||||
|
->where('video_views.user_id', $user->id)
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('videos.visibility', '!=', 'private')
|
||||||
->orWhere('user_id', $user->id);
|
->orWhere('videos.user_id', $user->id);
|
||||||
})
|
})
|
||||||
->get()
|
->orderBy('video_views.watched_at', 'desc')
|
||||||
->sortBy(function ($video) use ($videoIds) {
|
->select('videos.id as id', 'video_views.watched_at')
|
||||||
return $videoIds->search($video->id);
|
->simplePaginate($per);
|
||||||
});
|
|
||||||
|
|
||||||
return view('user.history', compact('videos'));
|
$videoIds = collect($rows->items())->pluck('id')->unique();
|
||||||
|
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
|
||||||
|
$videos = collect($rows->items())
|
||||||
|
->map(fn ($r) => $videoMap->get($r->id))
|
||||||
|
->filter()
|
||||||
|
->unique('id') // collapse re-watches of the same video on this page
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear watch history
|
// Clear watch history
|
||||||
@@ -360,6 +372,38 @@ class UserController extends Controller
|
|||||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON endpoint powering the "Load more" button on the channel Videos
|
||||||
|
* tab. Takes a username + optional cursor and returns a page of
|
||||||
|
* server-rendered video cards + next cursor. Reuses the cursorPaginate
|
||||||
|
* pattern Phase 2 established for the home feed.
|
||||||
|
*/
|
||||||
|
public function channelVideosMore(Request $request, string $username)
|
||||||
|
{
|
||||||
|
$owner = User::where('username', $username)->firstOrFail();
|
||||||
|
$viewer = Auth::user();
|
||||||
|
$isOwner = $viewer && $viewer->id === $owner->id;
|
||||||
|
|
||||||
|
$query = $isOwner
|
||||||
|
? Video::where('user_id', $owner->id)
|
||||||
|
: Video::public()->where('user_id', $owner->id);
|
||||||
|
|
||||||
|
$paginator = $query->where('is_shorts', false)
|
||||||
|
->latest('id')
|
||||||
|
->cursorPaginate(24);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($paginator->items() as $video) {
|
||||||
|
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $html,
|
||||||
|
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||||
|
'has_more' => $paginator->hasMorePages(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscriptions feed — the public videos uploaded by every channel the
|
* Subscriptions feed — the public videos uploaded by every channel the
|
||||||
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
||||||
@@ -392,14 +436,18 @@ class UserController extends Controller
|
|||||||
public function liked()
|
public function liked()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
// Include private videos in liked (user's own private videos)
|
// Include private videos in liked (user's own private videos).
|
||||||
|
// Cursor pagination matches the Phase 2 feed endpoints — stable
|
||||||
|
// across inserts (unlike ?page=N which can duplicate or skip rows
|
||||||
|
// when a new like lands between page fetches) and loads a bounded
|
||||||
|
// slice regardless of how many thousands of likes the user has.
|
||||||
$videos = $user->likes()
|
$videos = $user->likes()
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('visibility', '!=', 'private')
|
||||||
->orWhere('videos.user_id', $user->id);
|
->orWhere('videos.user_id', $user->id);
|
||||||
})
|
})
|
||||||
->latest()
|
->latest('videos.id')
|
||||||
->paginate(12);
|
->cursorPaginate(24);
|
||||||
|
|
||||||
return view('user.liked', compact('videos'));
|
return view('user.liked', compact('videos'));
|
||||||
}
|
}
|
||||||
@@ -437,6 +485,9 @@ class UserController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$video->likes()->attach($user->id);
|
$video->likes()->attach($user->id);
|
||||||
$liked = true;
|
$liked = true;
|
||||||
|
// Toggling to like clears any existing dislike — mirrors YT's
|
||||||
|
// behavior where a video can be liked OR disliked, not both.
|
||||||
|
$video->dislikes()->detach($user->id);
|
||||||
if ($video->user_id && $video->user_id !== $user->id) {
|
if ($video->user_id && $video->user_id !== $user->id) {
|
||||||
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
@@ -444,7 +495,32 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'liked' => $liked,
|
'liked' => $liked,
|
||||||
|
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
|
||||||
'like_count' => $video->like_count,
|
'like_count' => $video->like_count,
|
||||||
|
'dislike_count' => $video->dislike_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle dislike (API). Mutually exclusive with the like state.
|
||||||
|
public function toggleDislike(Video $video)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($video->isDislikedBy($user)) {
|
||||||
|
$video->dislikes()->detach($user->id);
|
||||||
|
$disliked = false;
|
||||||
|
} else {
|
||||||
|
$video->dislikes()->attach($user->id);
|
||||||
|
$disliked = true;
|
||||||
|
// Clear any existing like.
|
||||||
|
$video->likes()->detach($user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'disliked' => $disliked,
|
||||||
|
'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true
|
||||||
|
'like_count' => $video->like_count,
|
||||||
|
'dislike_count' => $video->dislike_count,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ class Comment extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'parent_id',
|
'parent_id',
|
||||||
'body',
|
'body',
|
||||||
|
'hearted',
|
||||||
|
'pinned',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'hearted' => 'boolean',
|
||||||
|
'pinned' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class LiveChatMessage extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'live_chat_messages';
|
||||||
|
|
||||||
|
public $timestamps = false; // explicit created_at only, set via useCurrent()
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'livestream_id',
|
||||||
|
'user_id',
|
||||||
|
'author_name',
|
||||||
|
'body',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function livestream()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(LiveStream::class, 'livestream_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility first — private streams stay owner-only no matter what.
|
||||||
|
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live streams are watchable while they're on; ended streams remain
|
||||||
|
// permalinked so viewers see "Stream ended" and the replay CTA
|
||||||
|
// instead of a hard 404.
|
||||||
|
return $this->isLive() || $this->status === self::STATUS_ENDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,11 @@ class Video extends Model
|
|||||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dislikes()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
public function viewers()
|
public function viewers()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class, 'video_views')
|
return $this->belongsToMany(User::class, 'video_views')
|
||||||
@@ -177,6 +182,20 @@ class Video extends Model
|
|||||||
return $this->likes()->count();
|
return $this->likes()->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dislike helpers — same shape as the like pair above.
|
||||||
|
public function isDislikedBy($user)
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->dislikes()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDislikeCountAttribute()
|
||||||
|
{
|
||||||
|
return $this->dislikes()->count();
|
||||||
|
}
|
||||||
|
|
||||||
// Get view count - use direct query to avoid timestamp issues
|
// Get view count - use direct query to avoid timestamp issues
|
||||||
public function getViewCountAttribute()
|
public function getViewCountAttribute()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Backup
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Settings for `php artisan takeone:backup`.
|
||||||
|
|
|
||||||
|
| The database and the media library are backed up by DIFFERENT mechanisms on
|
||||||
|
| purpose. The database is small and must be captured as a consistent
|
||||||
|
| point-in-time snapshot, so it is copied in full on every run. The media
|
||||||
|
| library is gigabytes of immutable uploads, so it is mirrored incrementally
|
||||||
|
| and only when asked — tarring it nightly would burn hours of CPU and fill
|
||||||
|
| the disk with near-identical archives.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// Where snapshots are written. Lives under /data, which is git-ignored, so
|
||||||
|
// a backup can never be committed by accident.
|
||||||
|
'path' => env('BACKUP_PATH', base_path('data/backups')),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Retention
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Artifacts older than retain_days are pruned, but the newest keep_minimum
|
||||||
|
| are ALWAYS kept regardless of age — a box that sat idle for a month must
|
||||||
|
| still have something to restore from.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
|
||||||
|
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Off-server copy
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A filesystems.php disk name. A snapshot sitting on the same disk as the
|
||||||
|
| data survives a bad migration but not a dead disk, so set this to a
|
||||||
|
| remote disk. The command warns on every run while it is unset.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'disk' => env('BACKUP_DISK'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Media library
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
|
||||||
|
| from anything. It is mirrored with rsync (incremental, resumable) to
|
||||||
|
| media_destination, which should be a NAS mount or another machine.
|
||||||
|
|
|
||||||
|
| Only runs when --media is passed AND a destination is set.
|
||||||
|
|
|
||||||
|
| media_delete stays false by default: a mirror that deletes is one bad
|
||||||
|
| path away from erasing the copy it was meant to protect.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'media_paths' => [
|
||||||
|
base_path('data/app/users'),
|
||||||
|
base_path('data/app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'media_destination' => env('BACKUP_MEDIA_DEST'),
|
||||||
|
|
||||||
|
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Live streaming
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The media plane runs as MediaMTX on loopback; nginx proxies /live-rtc and
|
||||||
|
| /live-hls to it. Nothing here is a secret except the TURN credential, which
|
||||||
|
| comes from the environment.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// MediaMTX's control API (localhost only). Used to read live publisher and
|
||||||
|
// viewer state.
|
||||||
|
'api_url' => env('LIVE_API_URL', 'http://127.0.0.1:9997'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ICE
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| STUN lets a browser discover its public address. TURN relays the media
|
||||||
|
| when a direct path cannot be established — which, until a UDP port is
|
||||||
|
| forwarded to this server, is EVERY broadcaster outside the local network.
|
||||||
|
|
|
||||||
|
| With no TURN configured, publishing works from the LAN only.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'stun_url' => env('LIVE_STUN_URL', 'stun:stun.l.google.com:19302'),
|
||||||
|
|
||||||
|
'turn_url' => env('LIVE_TURN_URL'),
|
||||||
|
'turn_username' => env('LIVE_TURN_USERNAME'),
|
||||||
|
'turn_credential' => env('LIVE_TURN_CREDENTIAL'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Capture defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The target the phone is ASKED for. A camera that cannot do 1080p returns
|
||||||
|
| the closest it can, and WebRTC's congestion control lowers the bitrate
|
||||||
|
| when the uplink cannot sustain it — so this is a ceiling, not a promise.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'video_width' => (int) env('LIVE_VIDEO_WIDTH', 1920),
|
||||||
|
'video_height' => (int) env('LIVE_VIDEO_HEIGHT', 1080),
|
||||||
|
'video_fps' => (int) env('LIVE_VIDEO_FPS', 30),
|
||||||
|
|
||||||
|
// ~4.5 Mbit/s is the practical ceiling for 1080p30 over a mobile uplink.
|
||||||
|
'max_bitrate' => (int) env('LIVE_MAX_BITRATE', 4_500_000),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dislike counterpart to video_likes.
|
||||||
|
*
|
||||||
|
* One row per (user, video) pair. The unique index on the pair prevents a
|
||||||
|
* user from racking up multiple dislikes against the same video, and the
|
||||||
|
* foreign keys cascade so cleaning up a user or video drops their dislikes
|
||||||
|
* for free.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('video_dislikes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['video_id', 'user_id']);
|
||||||
|
$table->index('video_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('video_dislikes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creator-engagement flags on comments.
|
||||||
|
*
|
||||||
|
* - hearted: the video owner has acknowledged the comment with a red
|
||||||
|
* heart (YouTube's "creator heart"). Visible to everyone.
|
||||||
|
* - pinned: the owner has anchored the comment to the top of the list.
|
||||||
|
* The comment component sorts pinned comments ahead of everything
|
||||||
|
* else, newest-pinned first.
|
||||||
|
*
|
||||||
|
* Both default to false and are indexed only on `pinned` because that's the
|
||||||
|
* one the comments query sorts by.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->boolean('hearted')->default(false)->after('body');
|
||||||
|
$table->boolean('pinned')->default(false)->after('hearted');
|
||||||
|
$table->index('pinned');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['pinned']);
|
||||||
|
$table->dropColumn(['hearted', 'pinned']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live chat messages — one row per message, scoped to a livestream. Simple
|
||||||
|
* polling model: viewers hit /live/{public_id}/chat?since=<id> every few
|
||||||
|
* seconds and get back everything newer than their last-seen id.
|
||||||
|
*
|
||||||
|
* Cascades: delete the livestream, lose its chat log. Users keep theirs
|
||||||
|
* nulled out so a deleted account's historical messages still render as
|
||||||
|
* "deleted user" rather than removing them from the thread.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('live_chat_messages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('livestream_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('author_name', 255); // snapshot of the sender's name at time of posting
|
||||||
|
$table->text('body');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
// Chat pagination reads "messages in this stream newer than id X".
|
||||||
|
$table->index(['livestream_id', 'id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('live_chat_messages');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -351,24 +351,40 @@
|
|||||||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||||||
</div>
|
</div>
|
||||||
<div class="al-page-links">
|
<div class="al-page-links">
|
||||||
|
{{-- First --}}
|
||||||
@if($logs->onFirstPage())
|
@if($logs->onFirstPage())
|
||||||
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
|
<span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
|
||||||
@else
|
@else
|
||||||
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
|
<a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Prev --}}
|
||||||
|
@if($logs->onFirstPage())
|
||||||
|
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
|
||||||
|
@else
|
||||||
|
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||||||
@if($page == $logs->currentPage())
|
@if($page == $logs->currentPage())
|
||||||
<span class="active">{{ $page }}</span>
|
<span class="active" aria-current="page">{{ $page }}</span>
|
||||||
@else
|
@else
|
||||||
<a href="{{ $url }}">{{ $page }}</a>
|
<a href="{{ $url }}">{{ $page }}</a>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
|
{{-- Next --}}
|
||||||
@if($logs->hasMorePages())
|
@if($logs->hasMorePages())
|
||||||
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
|
<a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
|
||||||
@else
|
@else
|
||||||
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
|
<span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Last --}}
|
||||||
|
@if($logs->currentPage() < $logs->lastPage())
|
||||||
|
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
|
||||||
|
@else
|
||||||
|
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,26 +64,74 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{-- Log lines --}}
|
{{-- Log lines, grouped — each entry's stack trace (lines beginning with
|
||||||
|
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
|
||||||
|
header line. Headers show their level color; stack trace lines
|
||||||
|
render in a muted indent and preserve their natural #0 → #N order
|
||||||
|
inside each group even when the overall list is newest-first. --}}
|
||||||
@if(empty($lines))
|
@if(empty($lines))
|
||||||
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
|
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
|
||||||
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
|
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
|
||||||
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
|
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
@php
|
||||||
|
// Group the flat list of lines into [{header, trace[]}, …].
|
||||||
|
// Any line that is NOT a header starts/continues the current
|
||||||
|
// group's trace. Lines that look like a header (ERROR/WARNING/
|
||||||
|
// INFO/DEBUG) start a new group.
|
||||||
|
$groups = [];
|
||||||
|
$current = null;
|
||||||
|
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($isHeader($line) || $current === null) {
|
||||||
|
if ($current) $groups[] = $current;
|
||||||
|
$current = ['header' => $line, 'trace' => []];
|
||||||
|
} else {
|
||||||
|
$current['trace'][] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($current) $groups[] = $current;
|
||||||
|
@endphp
|
||||||
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
|
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
|
||||||
@foreach($lines as $line)
|
@foreach($groups as $gi => $g)
|
||||||
@php
|
@php
|
||||||
$color = '#aaa';
|
$color = '#aaa';
|
||||||
if (str_contains($line, '.ERROR:')) $color = '#f87171';
|
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
|
||||||
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
|
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
|
||||||
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
|
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
|
||||||
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
|
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
|
||||||
|
$hasTrace = !empty($g['trace']);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
|
<div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
|
||||||
|
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
|
||||||
|
@if($hasTrace)
|
||||||
|
<button type="button"
|
||||||
|
onclick="this.closest('.log-group').classList.toggle('open')"
|
||||||
|
aria-label="Toggle stack trace"
|
||||||
|
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
|
||||||
|
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<span style="flex:none;display:inline-block;width:20px;"></span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $g['header'] }}</span>
|
||||||
|
</div>
|
||||||
|
@if($hasTrace)
|
||||||
|
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
|
||||||
|
@foreach($g['trace'] as $tl)
|
||||||
|
<div>{{ $tl }}</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.log-group.open .log-trace { display: block !important; }
|
||||||
|
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
|
||||||
|
</style>
|
||||||
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
{{-- Full title in the title attribute so the
|
||||||
|
admin can hover and read the whole thing
|
||||||
|
when the visible row ellipsizes. --}}
|
||||||
|
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||||
|
title="{{ $video->title }}">
|
||||||
{{ $video->title }}
|
{{ $video->title }}
|
||||||
</div>
|
</div>
|
||||||
@if($video->description)
|
@if($video->description)
|
||||||
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
@php
|
||||||
{{ Str::limit($video->description, 55) }}
|
// Descriptions are stored as sanitized rich HTML
|
||||||
|
// (see HtmlSanitizer). Blade's {{ }} would show
|
||||||
|
// the escaped tags verbatim in the admin table,
|
||||||
|
// so strip them to a plain-text preview — and
|
||||||
|
// put the full stripped text in the tooltip too.
|
||||||
|
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
|
||||||
|
@endphp
|
||||||
|
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
|
||||||
|
title="{{ $descPlain }}">
|
||||||
|
{{ Str::limit($descPlain, 55) }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,25 +158,44 @@
|
|||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
{{-- Guest Subscribe: showToast first so the user understands what's
|
||||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
happening, then redirect to the login page with intended=
|
||||||
|
preserving the current URL so after login they land back on
|
||||||
|
this video. 600 ms gives the toast a moment to be seen. --}}
|
||||||
|
<button type="button" class="action-btn desktop-action"
|
||||||
|
onclick="(function(){ if (typeof showToast==='function') showToast('Sign in to subscribe to {{ addslashes($video->user->name ?? 'this channel') }}', 'info'); setTimeout(function(){ window.location.href='{{ route('login') }}?intended=' + encodeURIComponent(window.location.pathname + window.location.search); }, 600); })();">
|
||||||
|
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
|
||||||
</button>
|
</button>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
|
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
|
||||||
|
one clears the other via the controllers' detach() call. --}}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
||||||
data-video-id="{{ $video->id }}"
|
data-video-id="{{ $video->id }}"
|
||||||
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
|
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
|
||||||
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
|
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
|
||||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
aria-label="{{ $video->isLikedBy(Auth::user()) ? 'Remove like' : 'Like' }}">
|
||||||
|
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}" aria-hidden="true"></i>
|
||||||
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
|
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="action-btn desktop-action dislike-toggle-btn {{ $video->isDislikedBy(Auth::user()) ? 'disliked' : '' }}"
|
||||||
|
data-video-id="{{ $video->id }}"
|
||||||
|
data-toggle-url="{{ route('videos.toggleDislike', $video) }}"
|
||||||
|
data-disliked="{{ $video->isDislikedBy(Auth::user()) ? 'true' : 'false' }}"
|
||||||
|
aria-label="{{ $video->isDislikedBy(Auth::user()) ? 'Remove dislike' : 'Dislike' }}">
|
||||||
|
<i class="bi {{ $video->isDislikedBy(Auth::user()) ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down' }}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
@else
|
@else
|
||||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
||||||
<i class="bi bi-hand-thumbs-up"></i>
|
<i class="bi bi-hand-thumbs-up" aria-hidden="true"></i>
|
||||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action" aria-label="Dislike">
|
||||||
|
<i class="bi bi-hand-thumbs-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
@if ($video->isShareable())
|
@if ($video->isShareable())
|
||||||
@@ -451,6 +470,46 @@
|
|||||||
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
|
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Dislike button ─────────────────────────────────────────────
|
||||||
|
// Dislike is mutually exclusive with like on the server side; whenever
|
||||||
|
// the server returns a new state, we reconcile BOTH buttons in one
|
||||||
|
// sweep so the pair always reflects the authoritative truth.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.dislike-toggle-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
var url = btn.dataset.toggleUrl;
|
||||||
|
var videoId = btn.dataset.videoId;
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
// Reconcile dislike pair.
|
||||||
|
document.querySelectorAll('.dislike-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
|
||||||
|
var icon = b.querySelector('i');
|
||||||
|
b.dataset.disliked = data.disliked ? 'true' : 'false';
|
||||||
|
b.classList.toggle('disliked', data.disliked);
|
||||||
|
if (icon) icon.className = 'bi ' + (data.disliked ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down');
|
||||||
|
b.setAttribute('aria-label', data.disliked ? 'Remove dislike' : 'Dislike');
|
||||||
|
});
|
||||||
|
// Reconcile like pair in parallel — may have flipped off.
|
||||||
|
document.querySelectorAll('.like-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
|
||||||
|
var icon = b.querySelector('i');
|
||||||
|
var label = b.querySelector('.like-count-label');
|
||||||
|
b.dataset.liked = data.liked ? 'true' : 'false';
|
||||||
|
b.classList.toggle('liked', data.liked);
|
||||||
|
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
|
||||||
|
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (typeof toast === 'function') toast('Could not update dislike. Try again.', 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Slideshow video generation progress ──────────────────────────────
|
// ── Slideshow video generation progress ──────────────────────────────
|
||||||
|
|||||||
@@ -10,7 +10,17 @@
|
|||||||
// user's nationality. Batched so the comments list stays a single query.
|
// user's nationality. Batched so the comments list stays a single query.
|
||||||
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
|
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
|
||||||
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
|
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
|
||||||
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
|
// Pinned comments bubble to the top of the list regardless of recency,
|
||||||
|
// then everything else sorts newest-first. reorder() wipes the
|
||||||
|
// ->latest() default the comments() relation already applied, so
|
||||||
|
// pinned DESC becomes the PRIMARY sort instead of a tiebreaker.
|
||||||
|
$topComments = $video->comments()
|
||||||
|
->whereNull('parent_id')
|
||||||
|
->with('user', 'replies.user')
|
||||||
|
->reorder()
|
||||||
|
->orderByDesc('pinned')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
$topLevelCount = $topComments->count();
|
$topLevelCount = $topComments->count();
|
||||||
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
|
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
|
||||||
|
|
||||||
@@ -105,8 +115,16 @@
|
|||||||
{{-- ── Comments list ── --}}
|
{{-- ── Comments list ── --}}
|
||||||
<div id="ytcList">
|
<div id="ytcList">
|
||||||
@forelse($topComments as $comment)
|
@forelse($topComments as $comment)
|
||||||
@php $isOwn = $comment->user_id === $currentUserId; @endphp
|
@php
|
||||||
<div class="ytc-comment" id="comment-{{ $comment->id }}">
|
$isOwn = $comment->user_id === $currentUserId;
|
||||||
|
$isVideoOwner = $currentUserId === $video->user_id;
|
||||||
|
@endphp
|
||||||
|
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
|
||||||
|
@if($comment->pinned)
|
||||||
|
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
|
||||||
|
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
|
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
|
||||||
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
|
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
|
||||||
</a>
|
</a>
|
||||||
@@ -119,6 +137,13 @@
|
|||||||
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
|
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
|
||||||
title="Copy link to this comment"
|
title="Copy link to this comment"
|
||||||
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
|
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
|
||||||
|
@if($comment->hearted)
|
||||||
|
<span class="ytc-heart-badge"
|
||||||
|
aria-label="Hearted by the creator"
|
||||||
|
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
|
||||||
|
<i class="bi bi-heart-fill" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
||||||
@@ -149,6 +174,23 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@auth
|
@auth
|
||||||
|
{{-- Creator-only heart + pin toggles. The server
|
||||||
|
rejects anyone else, but hiding the buttons
|
||||||
|
keeps the UI uncluttered for regular viewers. --}}
|
||||||
|
@if($isVideoOwner)
|
||||||
|
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
|
||||||
|
data-comment-id="{{ $comment->id }}"
|
||||||
|
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
|
||||||
|
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
|
||||||
|
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
|
||||||
|
data-comment-id="{{ $comment->id }}"
|
||||||
|
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
|
||||||
|
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
|
||||||
|
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
|
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
|
||||||
@endauth
|
@endauth
|
||||||
@if($llmEnabled)
|
@if($llmEnabled)
|
||||||
@@ -234,6 +276,15 @@
|
|||||||
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
||||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
@auth
|
||||||
|
{{-- Reply to a reply: opens the parent comment's reply form and
|
||||||
|
prefills "@AuthorName " so the responder's reader knows which
|
||||||
|
reply they're answering. The DB still stores it as a one-level
|
||||||
|
thread (parent_id = top-level), matching the existing model. --}}
|
||||||
|
<button class="ytc-reply-btn _comment-reply-nested-trigger"
|
||||||
|
data-parent-id="{{ $comment->id }}"
|
||||||
|
data-handle="{{ $reply->user->username ?? $reply->user->name ?? '' }}">Reply</button>
|
||||||
|
@endauth
|
||||||
@if($llmEnabled)
|
@if($llmEnabled)
|
||||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
|
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
||||||
@@ -319,6 +370,49 @@
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Creator engagement (heart + pin) ── */
|
||||||
|
.ytc-pinned-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ytc-pinned-badge i { color: #ef4444; }
|
||||||
|
.ytc-comment.ytc-pinned {
|
||||||
|
background: rgba(239, 68, 68, 0.03);
|
||||||
|
border-left: 2px solid rgba(239, 68, 68, 0.5);
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
.ytc-heart-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ytc-creator-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
|
||||||
|
.ytc-creator-btn.active { color: #ef4444; }
|
||||||
|
.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); }
|
||||||
|
|
||||||
/* Comment permalink highlight — brief glow on the targeted row when a
|
/* Comment permalink highlight — brief glow on the targeted row when a
|
||||||
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
|
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
|
||||||
.ytc-comment.ytc-highlight,
|
.ytc-comment.ytc-highlight,
|
||||||
@@ -515,6 +609,31 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Read more clamp ──
|
||||||
|
Long comments collapse to ~4 lines by default; the "Read more" button
|
||||||
|
under the body expands them. The clamp uses line-clamp (not a fixed
|
||||||
|
max-height) so the first few lines are always complete, never cut
|
||||||
|
mid-word, and the fade-mask eases into the trailing ellipsis.
|
||||||
|
*/
|
||||||
|
.ytc-text.ytc-clamp {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ytc-read-more {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.ytc-read-more:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
/* ── Actions ── */
|
/* ── Actions ── */
|
||||||
.ytc-actions {
|
.ytc-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -999,6 +1118,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
|||||||
const rt = e.target.closest('._comment-reply-trigger');
|
const rt = e.target.closest('._comment-reply-trigger');
|
||||||
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
|
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
|
||||||
|
|
||||||
|
// Replying to a reply — open the parent-level reply form and
|
||||||
|
// prefill it with the reply author's @handle so the thread stays
|
||||||
|
// readable even though the DB only stores one-level nesting.
|
||||||
|
const rtn = e.target.closest('._comment-reply-nested-trigger');
|
||||||
|
if (rtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const parentId = rtn.dataset.parentId;
|
||||||
|
const handle = (rtn.dataset.handle || '').replace(/\s+/g, '');
|
||||||
|
toggleReplyForm(parentId);
|
||||||
|
const ta = document.getElementById('replyBody' + parentId);
|
||||||
|
if (ta && handle) {
|
||||||
|
const mention = '@' + handle + ' ';
|
||||||
|
if (!ta.value.startsWith(mention)) ta.value = mention + ta.value;
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||||
|
autoResize(ta);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Edit trigger
|
// Edit trigger
|
||||||
const et = e.target.closest('._comment-edit-trigger');
|
const et = e.target.closest('._comment-edit-trigger');
|
||||||
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
||||||
@@ -1027,6 +1166,53 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
|||||||
const db = e.target.closest('.ytc-dislike-btn');
|
const db = e.target.closest('.ytc-dislike-btn');
|
||||||
if (db) { e.stopPropagation(); toggleDislike(db); return; }
|
if (db) { e.stopPropagation(); toggleDislike(db); return; }
|
||||||
|
|
||||||
|
// Creator heart — owner-only, server rejects anyone else
|
||||||
|
const hb = e.target.closest('.ytc-heart-toggle');
|
||||||
|
if (hb) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = hb.dataset.commentId;
|
||||||
|
fetch('/comments/' + id + '/heart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if (d.hearted !== undefined) {
|
||||||
|
hb.classList.toggle('active', d.hearted);
|
||||||
|
hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false');
|
||||||
|
hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart');
|
||||||
|
// Also toggle the visible heart badge in the comment meta.
|
||||||
|
const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta');
|
||||||
|
let badge = meta?.querySelector('.ytc-heart-badge');
|
||||||
|
if (d.hearted && !badge && meta) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.className = 'ytc-heart-badge';
|
||||||
|
badge.innerHTML = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
|
||||||
|
meta.appendChild(badge);
|
||||||
|
} else if (!d.hearted && badge) {
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creator pin — owner-only, pins top-level comments to the list head
|
||||||
|
const pb = e.target.closest('.ytc-pin-toggle');
|
||||||
|
if (pb) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = pb.dataset.commentId;
|
||||||
|
fetch('/comments/' + id + '/pin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if (d.pinned !== undefined) {
|
||||||
|
// Reload to re-sort — pin changes the order of top-level
|
||||||
|
// comments and the DOM move is cleaner server-side than
|
||||||
|
// trying to shuffle the live list.
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Translate — bound at `document` level once, dispatches to the LATEST
|
// Translate — bound at `document` level once, dispatches to the LATEST
|
||||||
@@ -1467,6 +1653,32 @@ function toggleDislike(btn) {
|
|||||||
function initLikeStates() {} // server-rendered state is already in the HTML
|
function initLikeStates() {} // server-rendered state is already in the HTML
|
||||||
|
|
||||||
// ── Timestamp & @mention parsing ─────────────────────
|
// ── Timestamp & @mention parsing ─────────────────────
|
||||||
|
// ── Read-more clamp ──
|
||||||
|
// Flags long bodies (>4 lines once rendered) so a "Read more" button
|
||||||
|
// below the body expands them. Measured in computed line-height × 4.
|
||||||
|
function maybeClampBody(el) {
|
||||||
|
// Deferred one microtask so el.scrollHeight is correct even if the
|
||||||
|
// enclosing comment card just got inserted into the DOM.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const lh = parseFloat(getComputedStyle(el).lineHeight) || 22;
|
||||||
|
const limit = lh * 4;
|
||||||
|
if (el.scrollHeight <= limit + 1) return;
|
||||||
|
|
||||||
|
el.classList.add('ytc-clamp');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'ytc-read-more';
|
||||||
|
btn.textContent = 'Read more';
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const expanded = el.classList.toggle('ytc-clamp') === false;
|
||||||
|
btn.textContent = expanded ? 'Show less' : 'Read more';
|
||||||
|
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
el.insertAdjacentElement('afterend', btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function enhanceBody(el) {
|
function enhanceBody(el) {
|
||||||
if (!el || el.dataset._commentEnhanced === '1') return;
|
if (!el || el.dataset._commentEnhanced === '1') return;
|
||||||
let text = el.textContent || '';
|
let text = el.textContent || '';
|
||||||
@@ -1496,6 +1708,9 @@ function enhanceBody(el) {
|
|||||||
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
|
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Add the Read more affordance after enhancing (so links and timestamps
|
||||||
|
// are rendered first and the measured height is final).
|
||||||
|
maybeClampBody(el);
|
||||||
}
|
}
|
||||||
function enhanceAll() {
|
function enhanceAll() {
|
||||||
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
||||||
|
|||||||
@@ -205,12 +205,99 @@
|
|||||||
</div>{{-- /chrome-controls --}}
|
</div>{{-- /chrome-controls --}}
|
||||||
</div>{{-- /chrome-bottom --}}
|
</div>{{-- /chrome-bottom --}}
|
||||||
|
|
||||||
|
{{-- End screen: shown when the video reaches its 'ended' event. Carries
|
||||||
|
a thumbnail + title for the next recommended video and a radial
|
||||||
|
countdown ring; auto-advances when the ring completes, cancelled on
|
||||||
|
any click inside the player. --}}
|
||||||
|
<div class="ytp-end-screen" id="ytpEndScreen" aria-hidden="true">
|
||||||
|
<div class="ytp-end-inner">
|
||||||
|
<div class="ytp-end-label">Up next</div>
|
||||||
|
<a class="ytp-end-card" id="ytpEndCard" href="#">
|
||||||
|
<div class="ytp-end-thumb">
|
||||||
|
<img id="ytpEndThumb" src="" alt="" loading="lazy">
|
||||||
|
<div class="ytp-end-countdown">
|
||||||
|
<svg viewBox="0 0 36 36" aria-hidden="true">
|
||||||
|
<circle class="ytp-end-track" cx="18" cy="18" r="16"></circle>
|
||||||
|
<circle class="ytp-end-fill" cx="18" cy="18" r="16" id="ytpEndRing"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="ytp-end-timer" id="ytpEndTimer">5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ytp-end-title" id="ytpEndTitle">Loading…</div>
|
||||||
|
</a>
|
||||||
|
<div class="ytp-end-actions">
|
||||||
|
<button type="button" class="ytp-end-btn ytp-end-cancel" id="ytpEndCancel">Cancel</button>
|
||||||
|
<button type="button" class="ytp-end-btn ytp-end-play" id="ytpEndPlay">Play now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>{{-- /ytp --}}
|
</div>{{-- /ytp --}}
|
||||||
</div>{{-- /ytp-wrap --}}
|
</div>{{-- /ytp-wrap --}}
|
||||||
|
|
||||||
|
|
||||||
@once
|
@once
|
||||||
<style>
|
<style>
|
||||||
|
/* ── End screen (autoplay countdown) ─────────────────────── */
|
||||||
|
.ytp-end-screen {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.78);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 20;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ytp-end-screen.visible { display: flex; }
|
||||||
|
.ytp-end-inner {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 16px; max-width: 560px; padding: 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.ytp-end-label {
|
||||||
|
font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.ytp-end-card {
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
text-decoration: none; color: inherit; cursor: pointer;
|
||||||
|
border-radius: 12px; padding: 12px; max-width: 420px;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.ytp-end-card:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||||
|
.ytp-end-thumb {
|
||||||
|
position: relative; width: 100%; aspect-ratio: 16/9;
|
||||||
|
border-radius: 10px; overflow: hidden; background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.ytp-end-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.ytp-end-countdown {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.ytp-end-countdown svg { width: 72px; height: 72px; transform: rotate(-90deg); }
|
||||||
|
.ytp-end-track { fill: none; stroke: rgba(255,255,255,.25); stroke-width: 3; }
|
||||||
|
.ytp-end-fill { fill: none; stroke: #fff; stroke-width: 3;
|
||||||
|
stroke-dasharray: 100.5; stroke-dashoffset: 100.5;
|
||||||
|
transition: stroke-dashoffset .1s linear; }
|
||||||
|
.ytp-end-timer {
|
||||||
|
position: absolute; font-size: 22px; font-weight: 700;
|
||||||
|
color: #fff; text-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
.ytp-end-title {
|
||||||
|
font-size: 16px; font-weight: 500;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ytp-end-actions { display: flex; gap: 10px; }
|
||||||
|
.ytp-end-btn {
|
||||||
|
padding: 8px 18px; border-radius: 20px; font-size: 13px; font-weight: 600;
|
||||||
|
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
|
||||||
|
cursor: pointer; transition: background .15s;
|
||||||
|
}
|
||||||
|
.ytp-end-btn:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.ytp-end-play { background: #ef4444; border-color: #ef4444; }
|
||||||
|
.ytp-end-play:hover { background: #dc2626; }
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════
|
||||||
YTP PLAYER — YouTube-identical custom player
|
YTP PLAYER — YouTube-identical custom player
|
||||||
══════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════ */
|
||||||
@@ -1304,17 +1391,96 @@ function navigatePrev() {
|
|||||||
}
|
}
|
||||||
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
||||||
|
|
||||||
|
// ── End screen + autoplay countdown ─────────────────────
|
||||||
|
// Picks the first "Up Next" sidebar card as the next target and shows a
|
||||||
|
// 5-second countdown overlay. If the viewer doesn't cancel, we hand off to
|
||||||
|
// the existing _plOnVideoEnd / navigateNext flow.
|
||||||
|
const endScreen = document.getElementById('ytpEndScreen');
|
||||||
|
const endRing = document.getElementById('ytpEndRing');
|
||||||
|
const endTimer = document.getElementById('ytpEndTimer');
|
||||||
|
const endCard = document.getElementById('ytpEndCard');
|
||||||
|
const endThumb = document.getElementById('ytpEndThumb');
|
||||||
|
const endTitle = document.getElementById('ytpEndTitle');
|
||||||
|
const endCancel = document.getElementById('ytpEndCancel');
|
||||||
|
const endPlay = document.getElementById('ytpEndPlay');
|
||||||
|
let endInterval = null;
|
||||||
|
let endTargetUrl = null;
|
||||||
|
|
||||||
|
function closeEndScreen() {
|
||||||
|
if (endScreen) endScreen.classList.remove('visible');
|
||||||
|
if (endInterval) { clearInterval(endInterval); endInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findNextRecCard() {
|
||||||
|
// Prefer the first unwatched recommendation; fall back to the first card.
|
||||||
|
const list = document.querySelector('#recList');
|
||||||
|
if (!list) return null;
|
||||||
|
return list.querySelector('.sidebar-video-card[data-rec-url]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEndScreen() {
|
||||||
|
if (!endScreen) {
|
||||||
|
// No end-screen markup (e.g. shorts). Fall back to the original behavior.
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else if (autoplayOn || isLooping) navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = _findNextRecCard();
|
||||||
|
if (!card) {
|
||||||
|
// Nothing to count down to — restore the old no-UI behavior.
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else if (autoplayOn || isLooping) navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endTargetUrl = card.dataset.recUrl;
|
||||||
|
endCard.href = endTargetUrl;
|
||||||
|
const img = card.querySelector('img');
|
||||||
|
const title = card.querySelector('.sidebar-title');
|
||||||
|
if (img && endThumb) endThumb.src = img.src;
|
||||||
|
if (title && endTitle) endTitle.textContent = title.textContent.trim();
|
||||||
|
|
||||||
|
endScreen.classList.add('visible');
|
||||||
|
|
||||||
|
let left = 5;
|
||||||
|
endTimer.textContent = left;
|
||||||
|
endRing.style.strokeDashoffset = '100.5';
|
||||||
|
const start = Date.now();
|
||||||
|
const total = 5000;
|
||||||
|
endInterval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
const remaining = Math.max(0, total - elapsed);
|
||||||
|
left = Math.ceil(remaining / 1000);
|
||||||
|
endTimer.textContent = left;
|
||||||
|
endRing.style.strokeDashoffset = String(100.5 * (remaining / total));
|
||||||
|
if (remaining <= 0) {
|
||||||
|
closeEndScreen();
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else navigateNext();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
endCancel?.addEventListener('click', closeEndScreen);
|
||||||
|
endPlay?.addEventListener('click', () => {
|
||||||
|
closeEndScreen();
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else navigateNext();
|
||||||
|
});
|
||||||
|
|
||||||
video.addEventListener('ended', () => {
|
video.addEventListener('ended', () => {
|
||||||
updatePlayIcon();
|
updatePlayIcon();
|
||||||
largePlay.classList.add('visible');
|
largePlay.classList.add('visible');
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
if (window._plOnVideoEnd) {
|
if (isLooping) { navigateNext(); return; }
|
||||||
window._plOnVideoEnd();
|
if (autoplayOn || window._plOnVideoEnd) {
|
||||||
} else if (autoplayOn || isLooping) {
|
openEndScreen();
|
||||||
navigateNext();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Any interaction inside the player cancels the countdown so the viewer
|
||||||
|
// can scrub back, replay, or just stop.
|
||||||
|
video.addEventListener('play', closeEndScreen);
|
||||||
|
video.addEventListener('seeking', closeEndScreen);
|
||||||
video.addEventListener('volumechange', updateVolumeIcon);
|
video.addEventListener('volumechange', updateVolumeIcon);
|
||||||
|
|
||||||
// ── Keyboard shortcuts ────────────────────────────────
|
// ── Keyboard shortcuts ────────────────────────────────
|
||||||
@@ -1339,10 +1505,39 @@ document.addEventListener('keydown', e => {
|
|||||||
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
||||||
case 'i': case 'I':
|
case 'i': case 'I':
|
||||||
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
||||||
|
case 'c': case 'C':
|
||||||
|
// Captions / lyrics toggle — click the lyrics button when the
|
||||||
|
// page has one (music type). On a video that has no lyrics
|
||||||
|
// the key is a no-op; we don't throw up a toast because the
|
||||||
|
// viewer may just be hitting letters next to K / L / M.
|
||||||
|
{
|
||||||
|
const lyr = document.getElementById('ytpLyricsBtn');
|
||||||
|
if (lyr && lyr.offsetParent !== null) { e.preventDefault(); lyr.click(); showControls(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'j': case 'J':
|
case 'j': case 'J':
|
||||||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||||||
case 'l': case 'L':
|
case 'l': case 'L':
|
||||||
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
|
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
|
||||||
|
case '>':
|
||||||
|
case '.':
|
||||||
|
// YouTube parity: shift+. (which produces ">") steps playback
|
||||||
|
// rate up in 0.25 jumps. "." alone also works for keyboards
|
||||||
|
// where ">" needs a modifier the browser doesn't surface.
|
||||||
|
if (e.shiftKey || e.key === '>') {
|
||||||
|
e.preventDefault();
|
||||||
|
video.playbackRate = Math.min(2, Math.round((video.playbackRate + 0.25) * 100) / 100);
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '<':
|
||||||
|
case ',':
|
||||||
|
if (e.shiftKey || e.key === '<') {
|
||||||
|
e.preventDefault();
|
||||||
|
video.playbackRate = Math.max(0.25, Math.round((video.playbackRate - 0.25) * 100) / 100);
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault(); seekRelative(-5); showControls(); break;
|
e.preventDefault(); seekRelative(-5); showControls(); break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
|
|||||||
@@ -57,6 +57,10 @@
|
|||||||
<i class="bi bi-hand-thumbs-up-fill"></i>
|
<i class="bi bi-hand-thumbs-up-fill"></i>
|
||||||
<span>Liked Videos</span>
|
<span>Liked Videos</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-sliders"></i>
|
||||||
|
<span>Studio</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
|
|||||||
@@ -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,501 @@
|
|||||||
|
@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
|
||||||
@@ -37,14 +37,25 @@ a.pag-item:hover {
|
|||||||
@endonce
|
@endonce
|
||||||
<nav class="pag-nav" aria-label="Pagination">
|
<nav class="pag-nav" aria-label="Pagination">
|
||||||
|
|
||||||
{{-- Prev --}}
|
{{-- First --}}
|
||||||
@if ($paginator->onFirstPage())
|
@if ($paginator->onFirstPage())
|
||||||
<span class="pag-item disabled" aria-disabled="true">
|
<span class="pag-item disabled" aria-disabled="true" title="First page">
|
||||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous">
|
<a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
|
||||||
<i class="bi bi-chevron-left" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Prev --}}
|
||||||
|
@if ($paginator->onFirstPage())
|
||||||
|
<span class="pag-item disabled" aria-disabled="true" title="Previous page">
|
||||||
|
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" title="Previous page">
|
||||||
|
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -66,12 +77,23 @@ a.pag-item:hover {
|
|||||||
|
|
||||||
{{-- Next --}}
|
{{-- Next --}}
|
||||||
@if ($paginator->hasMorePages())
|
@if ($paginator->hasMorePages())
|
||||||
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next">
|
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" title="Next page">
|
||||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<span class="pag-item disabled" aria-disabled="true">
|
<span class="pag-item disabled" aria-disabled="true" title="Next page">
|
||||||
<i class="bi bi-chevron-right" style="font-size:11px;"></i>
|
<i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Last --}}
|
||||||
|
@if ($paginator->hasMorePages() && $paginator->lastPage() > $paginator->currentPage())
|
||||||
|
<a class="pag-item" href="{{ $paginator->url($paginator->lastPage()) }}" aria-label="Last page" title="Last page ({{ $paginator->lastPage() }})">
|
||||||
|
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span class="pag-item disabled" aria-disabled="true" title="Last page">
|
||||||
|
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Studio | ' . config('app.name'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<style>
|
||||||
|
.st-head { padding: 24px 32px 8px; }
|
||||||
|
.st-head h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.st-head h1 i { color: #ef4444; }
|
||||||
|
.st-head p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
|
||||||
|
|
||||||
|
.st-stat-cards {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 10px; margin: 12px 0 20px;
|
||||||
|
}
|
||||||
|
.st-stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 10px; padding: 12px 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.st-stat-card .k { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); }
|
||||||
|
.st-stat-card .v { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.st-filter-bar {
|
||||||
|
display: flex; gap: 8px; overflow-x: auto;
|
||||||
|
padding: 4px 0; scrollbar-width: none;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.st-filter-bar::-webkit-scrollbar { display: none; }
|
||||||
|
.st-filter-pill {
|
||||||
|
padding: 6px 12px; border-radius: 999px;
|
||||||
|
background: var(--bg-secondary); color: var(--text-primary);
|
||||||
|
font-size: 12px; font-weight: 600; text-decoration: none;
|
||||||
|
white-space: nowrap; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.st-filter-pill:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
|
||||||
|
.st-filter-pill.active { background: #ef4444; color: #fff; }
|
||||||
|
|
||||||
|
.st-toolbar {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 10px 14px; margin-bottom: 8px;
|
||||||
|
background: var(--bg-secondary); border-radius: 10px;
|
||||||
|
}
|
||||||
|
.st-toolbar.hidden { display: none; }
|
||||||
|
.st-toolbar-count { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.st-toolbar-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.st-visibility-select {
|
||||||
|
background: var(--bg-dark); color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color); border-radius: 6px;
|
||||||
|
padding: 6px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.st-table th, .st-table td {
|
||||||
|
padding: 10px 12px; border-bottom: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.st-table th { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); font-weight: 600; }
|
||||||
|
.st-table tbody tr:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.st-table .st-check { width: 32px; }
|
||||||
|
.st-table .st-thumb { width: 76px; height: 44px; object-fit: cover; border-radius: 6px; }
|
||||||
|
.st-table .st-title-cell { display: flex; gap: 10px; align-items: flex-start; max-width: 320px; }
|
||||||
|
.st-table .st-title-name {
|
||||||
|
font-weight: 500; color: var(--text-primary);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.st-table .st-title-desc {
|
||||||
|
font-size: 11px; color: var(--text-secondary); margin-top: 2px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.st-badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.st-badge.pub { background: rgba(34,197,94,.15); color: #4ade80; }
|
||||||
|
.st-badge.unl { background: rgba(234,179,8,.15); color: #fde047; }
|
||||||
|
.st-badge.priv { background: rgba(100,116,139,.2); color: #cbd5e1; }
|
||||||
|
.st-status { font-size: 11px; color: var(--text-secondary); }
|
||||||
|
.st-row-link { color: var(--text-primary); text-decoration: none; }
|
||||||
|
.st-row-link:hover { color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="st-head">
|
||||||
|
<h1><i class="bi bi-sliders" aria-hidden="true"></i> Studio</h1>
|
||||||
|
<p>Manage every video you've uploaded. Select rows for bulk visibility changes.</p>
|
||||||
|
|
||||||
|
<div class="st-stat-cards">
|
||||||
|
<div class="st-stat-card"><span class="k">Total videos</span> <span class="v">{{ number_format($totals['total']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Public</span> <span class="v">{{ number_format($totals['public']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Unlisted</span> <span class="v">{{ number_format($totals['unlisted']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Private</span> <span class="v">{{ number_format($totals['private']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Processing</span> <span class="v">{{ number_format($totals['processing']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Failed</span> <span class="v">{{ number_format($totals['failed']) }}</span></div>
|
||||||
|
<div class="st-stat-card"><span class="k">Total views</span> <span class="v">{{ number_format($totals['total_views']) }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="st-filter-bar" role="navigation" aria-label="Filter videos">
|
||||||
|
@foreach (['all'=>'All', 'public'=>'Public', 'unlisted'=>'Unlisted', 'private'=>'Private', 'shorts'=>'Shorts', 'draft'=>'Processing / Failed'] as $k => $label)
|
||||||
|
<a href="{{ route('studio', ['filter' => $k]) }}"
|
||||||
|
class="st-filter-pill {{ $filter === $k ? 'active' : '' }}"
|
||||||
|
@if($filter === $k) aria-current="page" @endif>{{ $label }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 0 32px 32px;">
|
||||||
|
<div class="st-toolbar hidden" id="stToolbar" aria-live="polite">
|
||||||
|
<span class="st-toolbar-count"><span id="stSelectedCount">0</span> selected</span>
|
||||||
|
<div class="st-toolbar-actions">
|
||||||
|
<select class="st-visibility-select" id="stBulkVisibility">
|
||||||
|
<option value="">Change visibility…</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="action-btn" onclick="stApplyBulkVisibility()">Apply</button>
|
||||||
|
<button type="button" class="action-btn action-btn-danger" onclick="stApplyBulkDelete()">Delete selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($videos->isEmpty())
|
||||||
|
<div class="yt-empty" style="text-align:center; padding:60px 20px;">
|
||||||
|
<i class="bi bi-camera-video" style="font-size:48px;color:var(--text-secondary);display:block;margin-bottom:12px;" aria-hidden="true"></i>
|
||||||
|
<h2>No videos in this filter</h2>
|
||||||
|
<p style="color:var(--text-secondary);">Switch filters above or upload a new video from the sidebar.</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<table class="st-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="st-check">
|
||||||
|
<input type="checkbox" id="stSelectAll" onclick="stToggleAll(this)" aria-label="Select all rows">
|
||||||
|
</th>
|
||||||
|
<th>Video</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Views</th>
|
||||||
|
<th>Likes</th>
|
||||||
|
<th>Comments</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($videos as $v)
|
||||||
|
<tr>
|
||||||
|
<td class="st-check"><input type="checkbox" class="st-row-check" value="{{ $v->id }}" onclick="stUpdateToolbar()" aria-label="Select {{ $v->title }}"></td>
|
||||||
|
<td>
|
||||||
|
<div class="st-title-cell">
|
||||||
|
@if ($v->thumbnail)
|
||||||
|
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="st-thumb" alt="" loading="lazy">
|
||||||
|
@else
|
||||||
|
<div class="st-thumb" style="background:#1a1a1a;"></div>
|
||||||
|
@endif
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<a class="st-row-link" href="{{ route('videos.show', $v) }}" target="_blank">
|
||||||
|
<div class="st-title-name" title="{{ $v->title }}">{{ $v->title }}</div>
|
||||||
|
</a>
|
||||||
|
@if ($v->description)
|
||||||
|
@php $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $v->description))); @endphp
|
||||||
|
<div class="st-title-desc" title="{{ $plain }}">{{ Str::limit($plain, 70) }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@switch($v->visibility)
|
||||||
|
@case('public') <span class="st-badge pub">Public</span> @break
|
||||||
|
@case('unlisted') <span class="st-badge unl">Unlisted</span> @break
|
||||||
|
@case('private') <span class="st-badge priv">Private</span> @break
|
||||||
|
@endswitch
|
||||||
|
@if ($v->status !== 'ready')
|
||||||
|
<div class="st-status">{{ ucfirst($v->status) }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>{{ number_format($v->view_count) }}</td>
|
||||||
|
<td>{{ number_format($v->likes()->count()) }}</td>
|
||||||
|
<td>{{ number_format($v->comments()->count()) }}</td>
|
||||||
|
<td>{{ $v->created_at->format('M j, Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px;">{{ $videos->links() }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function stCheckedIds() {
|
||||||
|
return Array.from(document.querySelectorAll('.st-row-check:checked')).map(c => parseInt(c.value, 10));
|
||||||
|
}
|
||||||
|
function stUpdateToolbar() {
|
||||||
|
const n = stCheckedIds().length;
|
||||||
|
const t = document.getElementById('stToolbar');
|
||||||
|
document.getElementById('stSelectedCount').textContent = n;
|
||||||
|
t.classList.toggle('hidden', n === 0);
|
||||||
|
}
|
||||||
|
function stToggleAll(cb) {
|
||||||
|
document.querySelectorAll('.st-row-check').forEach(c => { c.checked = cb.checked; });
|
||||||
|
stUpdateToolbar();
|
||||||
|
}
|
||||||
|
async function stApplyBulkVisibility() {
|
||||||
|
const ids = stCheckedIds();
|
||||||
|
const sel = document.getElementById('stBulkVisibility');
|
||||||
|
if (!ids.length || !sel.value) return;
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('{{ route('studio.bulkVisibility') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ video_ids: ids, visibility: sel.value }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (typeof showToast === 'function') showToast('Updated ' + d.updated + ' video(s).', 'success');
|
||||||
|
setTimeout(() => location.reload(), 400);
|
||||||
|
} catch { if (typeof showToast === 'function') showToast('Could not update videos.', 'error'); }
|
||||||
|
}
|
||||||
|
async function stApplyBulkDelete() {
|
||||||
|
const ids = stCheckedIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!window.confirm('Delete ' + ids.length + ' video(s)? This cannot be undone.')) return;
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('{{ route('studio.bulkDelete') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ video_ids: ids }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (typeof showToast === 'function') showToast('Deleted ' + d.deleted + ' video(s).', 'success');
|
||||||
|
setTimeout(() => location.reload(), 400);
|
||||||
|
} catch { if (typeof showToast === 'function') showToast('Could not delete videos.', 'error'); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@@ -701,6 +701,56 @@ $headerSocialMap = [
|
|||||||
<span>No videos match your search</span>
|
<span>No videos match your search</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Cursor-paginated Load More. The sentinel sits below the grid and
|
||||||
|
fetches the next slice when scrolled into view. A manual Load More
|
||||||
|
button is the fallback for browsers without IntersectionObserver. --}}
|
||||||
|
<div id="chVidSentinel"
|
||||||
|
data-username="{{ $user->username }}"
|
||||||
|
data-more-url="{{ route('channel.videos.more', ['username' => $user->username]) }}"
|
||||||
|
style="height:1px;"></div>
|
||||||
|
<div id="chVidLoadMoreWrap" style="display:none; text-align:center; padding:16px 0;">
|
||||||
|
<button type="button" id="chVidLoadMoreBtn" class="action-btn">Load more</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const grid = document.getElementById('chVidGrid');
|
||||||
|
const sentinel = document.getElementById('chVidSentinel');
|
||||||
|
const btnWrap = document.getElementById('chVidLoadMoreWrap');
|
||||||
|
const btn = document.getElementById('chVidLoadMoreBtn');
|
||||||
|
if (!grid || !sentinel) return;
|
||||||
|
let cursor = '', busy = false, done = false;
|
||||||
|
const url = sentinel.dataset.moreUrl;
|
||||||
|
async function loadMore() {
|
||||||
|
if (busy || done) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
if (cursor) u.searchParams.set('cursor', cursor);
|
||||||
|
const r = await fetch(u.toString(), {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.items) grid.insertAdjacentHTML('beforeend', d.items);
|
||||||
|
cursor = d.next_cursor || '';
|
||||||
|
done = !d.has_more;
|
||||||
|
if (done) { sentinel.style.display = 'none'; btnWrap.style.display = 'none'; }
|
||||||
|
} catch (e) {
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
btn?.addEventListener('click', loadMore);
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
new IntersectionObserver(entries => {
|
||||||
|
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||||
|
}, { rootMargin: '400px 0px' }).observe(sentinel);
|
||||||
|
} else {
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@else
|
@else
|
||||||
<div class="ch-empty">
|
<div class="ch-empty">
|
||||||
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
||||||
|
|||||||
@@ -146,7 +146,8 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div>
|
{{-- Cursor paginator has no "N pages" concept — just prev/next. --}}
|
||||||
|
<div class="mt-4">{{ $videos->links() }}</div>
|
||||||
@endif
|
@endif
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@@ -64,6 +64,20 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Poster shim shown behind the <video> (and visible on inactive slots).
|
||||||
|
Keeps the slot filled with content when the shared player node has
|
||||||
|
moved to a different slot. */
|
||||||
|
.short-poster {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-size: cover; background-position: center;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
/* Hidden preloader elements that fetch metadata for the next 1-2 shorts
|
||||||
|
without rendering anything visible. preload="metadata" is the
|
||||||
|
smallest-possible warmup; the browser pulls the moov atom + first
|
||||||
|
keyframe, enough that the switch to playback is instant. */
|
||||||
|
.short-preloader { display: none; }
|
||||||
|
|
||||||
/* ── Tap-to-pause icon ── */
|
/* ── Tap-to-pause icon ── */
|
||||||
.short-pause-icon {
|
.short-pause-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -337,18 +351,21 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Video player --}}
|
{{-- Video player wrap. The actual <video> element is a SINGLE shared
|
||||||
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
|
node (defined once at the bottom of the feed) that gets moved
|
||||||
|
between slots as the active short changes. Each slot carries its
|
||||||
|
own src + poster on data- attributes and the slot's inner div
|
||||||
|
shows the poster until the player docks here. --}}
|
||||||
|
<div class="short-player-wrap" id="wrap-{{ $index }}"
|
||||||
|
data-src="{{ $src }}"
|
||||||
|
data-poster="{{ $thumb }}"
|
||||||
|
onclick="toggleShortPlay({{ $index }})">
|
||||||
|
|
||||||
<video
|
{{-- Poster shim: covers the slot before the shared <video> moves in.
|
||||||
id="sv-{{ $index }}"
|
Also stays visible on inactive slots so the viewport doesn't
|
||||||
src="{{ $src }}"
|
go blank when the user is between shorts. --}}
|
||||||
poster="{{ $thumb }}"
|
<div class="short-poster"
|
||||||
loop
|
@if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
|
||||||
playsinline
|
|
||||||
preload="{{ $loop->first ? 'auto' : 'none' }}"
|
|
||||||
style="width:100%;height:100%;object-fit:cover;">
|
|
||||||
</video>
|
|
||||||
|
|
||||||
{{-- Pause icon --}}
|
{{-- Pause icon --}}
|
||||||
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
||||||
@@ -387,11 +404,14 @@
|
|||||||
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
|
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{-- Comments --}}
|
{{-- Comments — opens the side panel on desktop, bottom sheet on
|
||||||
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
|
mobile (CSS does the breakpoint; the button toggles a shared
|
||||||
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
|
.shorts-comments-pane element populated via fetch). --}}
|
||||||
|
<button type="button" class="short-action-btn"
|
||||||
|
onclick="shortOpenComments('{{ $video->getRouteKey() }}')">
|
||||||
|
<div class="short-action-icon"><i class="bi bi-chat-fill" aria-hidden="true"></i></div>
|
||||||
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
|
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
{{-- Share --}}
|
{{-- Share --}}
|
||||||
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
||||||
@@ -426,18 +446,103 @@
|
|||||||
|
|
||||||
@endforelse
|
@endforelse
|
||||||
|
|
||||||
|
{{-- Single shared <video> moved between slots as the active short changes.
|
||||||
|
Starts out detached (parked here in the DOM); the IntersectionObserver
|
||||||
|
relocates it into .short-player-wrap of the active slot. --}}
|
||||||
|
<video id="svGlobal"
|
||||||
|
loop playsinline
|
||||||
|
preload="metadata"
|
||||||
|
style="width:100%;height:100%;object-fit:cover;display:block;position:absolute;inset:0;">
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{{-- Preloaders for the next 1-2 shorts. Hidden; preload="metadata" is
|
||||||
|
enough that switching to playback is instant. We assign srcs
|
||||||
|
dynamically — defining them here keeps the DOM shape stable. --}}
|
||||||
|
<video id="svPre1" class="short-preloader" preload="metadata" muted></video>
|
||||||
|
<video id="svPre2" class="short-preloader" preload="metadata" muted></video>
|
||||||
|
|
||||||
|
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
|
||||||
|
Populated via fetch of the regular watch page's #ytcSection block so
|
||||||
|
all the comment features (likes, replies, translate, heart/pin) work
|
||||||
|
identically. --}}
|
||||||
|
<div id="shortsCommentsPane" class="shorts-comments-pane" aria-hidden="true">
|
||||||
|
<div class="shorts-comments-head">
|
||||||
|
<span>Comments</span>
|
||||||
|
<button type="button" class="shorts-comments-close" onclick="shortCloseComments()" aria-label="Close comments">
|
||||||
|
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="shorts-comments-body" id="shortsCommentsBody">
|
||||||
|
<div class="shorts-comments-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="shortsCommentsBackdrop" class="shorts-comments-backdrop" onclick="shortCloseComments()"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shorts-comments-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||||
|
z-index: 59; display: none;
|
||||||
|
}
|
||||||
|
.shorts-comments-backdrop.visible { display: block; }
|
||||||
|
.shorts-comments-pane {
|
||||||
|
position: fixed;
|
||||||
|
right: 0; top: 56px; bottom: 0; width: 420px;
|
||||||
|
background: var(--bg-dark); color: var(--text-primary);
|
||||||
|
box-shadow: -4px 0 24px rgba(0,0,0,.4);
|
||||||
|
z-index: 60; display: none; flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.shorts-comments-pane.visible { display: flex; }
|
||||||
|
.shorts-comments-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.shorts-comments-close {
|
||||||
|
background: none; border: none; color: var(--text-primary);
|
||||||
|
cursor: pointer; font-size: 16px; padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.shorts-comments-close:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.shorts-comments-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.shorts-comments-loading {
|
||||||
|
text-align: center; padding: 40px 16px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: bottom sheet instead of right drawer. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.shorts-comments-pane {
|
||||||
|
left: 0; right: 0; top: auto; bottom: 56px;
|
||||||
|
width: 100%; height: 70vh;
|
||||||
|
border-left: none; border-top: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const main = document.getElementById('main');
|
const main = document.getElementById('main');
|
||||||
|
const video = document.getElementById('svGlobal');
|
||||||
|
const pre1 = document.getElementById('svPre1');
|
||||||
|
const pre2 = document.getElementById('svPre2');
|
||||||
// Persist the mute state across sessions. Default to muted so autoplay
|
// Persist the mute state across sessions. Default to muted so autoplay
|
||||||
// policies don't silently fail on first load; the user can unmute once
|
// policies don't silently fail on first load; the user can unmute once
|
||||||
// and we remember it from then on.
|
// and we remember it from then on.
|
||||||
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
|
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
|
||||||
let current = -1;
|
let current = -1;
|
||||||
|
|
||||||
|
// Build an ordered list of slot wraps so neighbor lookup is a plain
|
||||||
|
// array index — we need to know "the slot at position N+1" to know
|
||||||
|
// which short to preload next.
|
||||||
|
const wraps = Array.from(document.querySelectorAll('.short-player-wrap'));
|
||||||
|
|
||||||
// ── Deep-link to /shorts/{id} — scroll the matching item into view
|
// ── Deep-link to /shorts/{id} — scroll the matching item into view
|
||||||
// before the observer starts, so autoplay fires on the target short
|
// before the observer starts, so autoplay fires on the target short
|
||||||
// rather than the first in document order. ────────────────────
|
// rather than the first in document order. ────────────────────
|
||||||
@@ -448,27 +553,62 @@
|
|||||||
})();
|
})();
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
function _dockVideoIn(wrap) {
|
||||||
|
// Move the shared <video> element into the active wrap. The browser
|
||||||
|
// keeps the active media context across the move, so playback
|
||||||
|
// doesn't restart just because we re-parented the node.
|
||||||
|
if (video.parentElement !== wrap) {
|
||||||
|
// Append after the poster so the poster acts as background
|
||||||
|
// while the video decodes its first frame.
|
||||||
|
wrap.appendChild(video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _primePreloaders(idx) {
|
||||||
|
// Preload metadata for the next 1-2 shorts so switching feels
|
||||||
|
// instant. preload="metadata" grabs just the moov atom + first
|
||||||
|
// keyframe; the browser doesn't fetch the whole file.
|
||||||
|
[pre1, pre2].forEach((pre, i) => {
|
||||||
|
const target = wraps[idx + 1 + i];
|
||||||
|
const nextSrc = target?.dataset.src || '';
|
||||||
|
if (pre.src !== nextSrc) pre.src = nextSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseShort(idx) {
|
||||||
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
|
if (video && video.parentElement === wrap) {
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = 0;
|
||||||
|
}
|
||||||
|
if (wrap) wrap.classList.remove('paused');
|
||||||
|
const prog = document.getElementById('prog-' + idx);
|
||||||
|
if (prog) prog.style.width = '0%';
|
||||||
|
}
|
||||||
|
|
||||||
// ── IntersectionObserver — auto-play visible short ─────────
|
// ── IntersectionObserver — auto-play visible short ─────────
|
||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const item = entry.target;
|
const item = entry.target;
|
||||||
const idx = parseInt(item.dataset.index);
|
const idx = parseInt(item.dataset.index);
|
||||||
const video = document.getElementById('sv-' + idx);
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
if (!video) return;
|
if (!wrap) return;
|
||||||
|
|
||||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||||
if (current !== -1 && current !== idx) pauseShort(current);
|
if (current !== -1 && current !== idx) pauseShort(current);
|
||||||
current = idx;
|
current = idx;
|
||||||
|
|
||||||
// ── Reflect the active short in the URL (no history entry) ──
|
// ── Reflect the active short in the URL (no history entry) ──
|
||||||
// Users can copy-paste the address bar and get back here; the
|
|
||||||
// back button still exits the feed instead of walking through
|
|
||||||
// every short they viewed.
|
|
||||||
try {
|
try {
|
||||||
const vid = item.dataset.id;
|
const vid = item.dataset.id;
|
||||||
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
_dockVideoIn(wrap);
|
||||||
|
const nextSrc = wrap.dataset.src || '';
|
||||||
|
if (video.src !== nextSrc) video.src = nextSrc;
|
||||||
|
video.poster = wrap.dataset.poster || '';
|
||||||
|
|
||||||
video.muted = muted;
|
video.muted = muted;
|
||||||
video.volume = 1;
|
video.volume = 1;
|
||||||
video.play().catch(() => {
|
video.play().catch(() => {
|
||||||
@@ -479,7 +619,9 @@
|
|||||||
updateMuteIcon(idx);
|
updateMuteIcon(idx);
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
document.getElementById('wrap-' + idx).classList.remove('paused');
|
wrap.classList.remove('paused');
|
||||||
|
|
||||||
|
_primePreloaders(idx);
|
||||||
} else {
|
} else {
|
||||||
pauseShort(idx);
|
pauseShort(idx);
|
||||||
}
|
}
|
||||||
@@ -490,9 +632,8 @@
|
|||||||
|
|
||||||
// ── Play / pause on tap ────────────────────────────────────
|
// ── Play / pause on tap ────────────────────────────────────
|
||||||
window.toggleShortPlay = function(idx) {
|
window.toggleShortPlay = function(idx) {
|
||||||
const video = document.getElementById('sv-' + idx);
|
|
||||||
const wrap = document.getElementById('wrap-' + idx);
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
if (!video) return;
|
if (!wrap || video.parentElement !== wrap) return;
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
wrap.classList.remove('paused');
|
wrap.classList.remove('paused');
|
||||||
@@ -502,31 +643,23 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function pauseShort(idx) {
|
// ── Progress bar — a single listener on the shared video updates the
|
||||||
const video = document.getElementById('sv-' + idx);
|
// bar in whichever slot it's currently docked. --
|
||||||
const wrap = document.getElementById('wrap-' + idx);
|
|
||||||
if (video) { video.pause(); video.currentTime = 0; }
|
|
||||||
if (wrap) wrap.classList.remove('paused');
|
|
||||||
const prog = document.getElementById('prog-' + idx);
|
|
||||||
if (prog) prog.style.width = '0%';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress bars ──────────────────────────────────────────
|
|
||||||
document.querySelectorAll('.short-item video').forEach(video => {
|
|
||||||
const idx = video.id.replace('sv-', '');
|
|
||||||
video.addEventListener('timeupdate', () => {
|
video.addEventListener('timeupdate', () => {
|
||||||
if (!video.duration) return;
|
if (!video.duration) return;
|
||||||
|
const wrap = video.parentElement;
|
||||||
|
if (!wrap || !wrap.classList.contains('short-player-wrap')) return;
|
||||||
|
const idx = wrap.id.replace('wrap-', '');
|
||||||
const prog = document.getElementById('prog-' + idx);
|
const prog = document.getElementById('prog-' + idx);
|
||||||
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mute toggle ────────────────────────────────────────────
|
// ── Mute toggle ────────────────────────────────────────────
|
||||||
window.toggleMute = function(idx) {
|
window.toggleMute = function(idx) {
|
||||||
muted = !muted;
|
muted = !muted;
|
||||||
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
||||||
const video = document.getElementById('sv-' + idx);
|
// The shared video is the only one playing; mute it directly.
|
||||||
if (video) video.muted = muted;
|
video.muted = muted;
|
||||||
updateMuteIcon(idx);
|
updateMuteIcon(idx);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -593,6 +726,37 @@
|
|||||||
@endauth
|
@endauth
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Comments pane ──────────────────────────────────────
|
||||||
|
window.shortOpenComments = function(videoKey) {
|
||||||
|
const pane = document.getElementById('shortsCommentsPane');
|
||||||
|
const backdrop = document.getElementById('shortsCommentsBackdrop');
|
||||||
|
const body = document.getElementById('shortsCommentsBody');
|
||||||
|
if (!pane || !body) return;
|
||||||
|
body.innerHTML = '<div class="shorts-comments-loading">Loading…</div>';
|
||||||
|
pane.classList.add('visible');
|
||||||
|
pane.setAttribute('aria-hidden', 'false');
|
||||||
|
backdrop?.classList.add('visible');
|
||||||
|
fetch('/videos/' + videoKey, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const section = doc.getElementById('ytcSection');
|
||||||
|
if (section) { body.innerHTML = ''; body.appendChild(section); }
|
||||||
|
else { body.innerHTML = '<div class="shorts-comments-loading">Comments unavailable.</div>'; }
|
||||||
|
})
|
||||||
|
.catch(() => { body.innerHTML = '<div class="shorts-comments-loading">Could not load comments.</div>'; });
|
||||||
|
};
|
||||||
|
window.shortCloseComments = function() {
|
||||||
|
const pane = document.getElementById('shortsCommentsPane');
|
||||||
|
const backdrop = document.getElementById('shortsCommentsBackdrop');
|
||||||
|
pane?.classList.remove('visible');
|
||||||
|
pane?.setAttribute('aria-hidden', 'true');
|
||||||
|
backdrop?.classList.remove('visible');
|
||||||
|
};
|
||||||
|
|
||||||
// ── Share ──────────────────────────────────────────────────
|
// ── Share ──────────────────────────────────────────────────
|
||||||
window.shortShare = function(videoKey, title) {
|
window.shortShare = function(videoKey, title) {
|
||||||
const url = window.location.origin + '/videos/' + videoKey;
|
const url = window.location.origin + '/videos/' + videoKey;
|
||||||
|
|||||||
@@ -317,14 +317,12 @@
|
|||||||
|
|
||||||
/* No rotation animation */
|
/* No rotation animation */
|
||||||
|
|
||||||
/* Hide sidebar by default */
|
{{-- Dead-code removal (2026-10-21 audit): the previous `.events-sidebar
|
||||||
.events-sidebar {
|
{ display: none }` + `.show { display: flex }` pair here was
|
||||||
display: none;
|
overridden further down (search for the `display: none !important`
|
||||||
}
|
definition under /* ===== Match Events Sidebar ===== */). The
|
||||||
|
!important forms silently won every cascade; this pair never
|
||||||
.events-sidebar.show {
|
rendered. --}}
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Playlist Navigation Controls ===== */
|
/* ===== Playlist Navigation Controls ===== */
|
||||||
.playlist-controls {
|
.playlist-controls {
|
||||||
@@ -2009,15 +2007,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 1300px) {
|
{{-- Dead-code removal (2026-10-21 audit): a prior
|
||||||
.yt-sidebar-container {
|
@media (max-width: 1300px) block sized .yt-sidebar-container +
|
||||||
width: 300px;
|
.events-sidebar to 300 px. The immediately-following
|
||||||
}
|
@media (max-width: 1300px) rule overrides both to 100% with
|
||||||
|
!important as part of the stacking layout, so the 300 px pair
|
||||||
.events-sidebar {
|
never applied. --}}
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Narrow desktop: too little room for a side rail ──────────────
|
/* ── Narrow desktop: too little room for a side rail ──────────────
|
||||||
Between 992px and 1300px the left nav (240px) and the 300px rail
|
Between 992px and 1300px the left nav (240px) and the 300px rail
|
||||||
@@ -2418,6 +2413,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- ===== END EVENT HEADER ===== -->
|
<!-- ===== END EVENT HEADER ===== -->
|
||||||
|
|
||||||
|
{{-- Channel row — the generic + music watch pages render this
|
||||||
|
just under the title. The match page used to omit it so
|
||||||
|
the only way to reach the uploader's channel from a match
|
||||||
|
video was the small avatar hidden inside the Match Overview
|
||||||
|
section. --}}
|
||||||
|
<x-channel-row :video="$video" />
|
||||||
|
|
||||||
@include('videos.partials.description-box', ['video' => $video])
|
@include('videos.partials.description-box', ['video' => $video])
|
||||||
|
|
||||||
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
|
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||||
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
|
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
|
||||||
|
Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe + notifications — auth but NOT email verification required
|
// Subscribe + notifications — auth but NOT email verification required
|
||||||
@@ -155,6 +156,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
||||||
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
|
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
|
||||||
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
|
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
|
||||||
|
Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart');
|
||||||
|
Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin');
|
||||||
});
|
});
|
||||||
|
|
||||||
// User routes
|
// User routes
|
||||||
@@ -174,6 +177,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
||||||
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
|
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
|
||||||
|
|
||||||
|
// Creator studio
|
||||||
|
Route::get('/studio', [\App\Http\Controllers\StudioController::class, 'index'])->name('studio');
|
||||||
|
Route::post('/studio/bulk/visibility', [\App\Http\Controllers\StudioController::class, 'bulkVisibility'])->name('studio.bulkVisibility');
|
||||||
|
Route::post('/studio/bulk/delete', [\App\Http\Controllers\StudioController::class, 'bulkDelete'])->name('studio.bulkDelete');
|
||||||
|
|
||||||
// Logout all other devices
|
// Logout all other devices
|
||||||
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
|
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
|
||||||
|
|
||||||
@@ -185,15 +193,55 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
||||||
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
||||||
|
Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVideosMore'])
|
||||||
|
->name('channel.videos.more');
|
||||||
// YouTube-style @handle alias. For now `handle` is just the existing
|
// YouTube-style @handle alias. For now `handle` is just the existing
|
||||||
// `username` slug — if the two ever diverge, flip the controller lookup
|
// `username` slug — if the two ever diverge, flip the controller lookup
|
||||||
// in UserController::channel() to prefer a dedicated `handle` column.
|
// in UserController::channel() to prefer a dedicated `handle` column.
|
||||||
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
|
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
|
||||||
// — Laravel's router accepts both because `@` is allowed in URI path segments.
|
// — Laravel's router accepts both because `@` is allowed in URI path segments.
|
||||||
|
// ─── Live streaming ─────────────────────────────────────
|
||||||
|
use App\Http\Controllers\LiveAuthController;
|
||||||
|
use App\Http\Controllers\LiveStreamController;
|
||||||
|
|
||||||
|
Route::get('/live', [LiveStreamController::class, 'index'])->name('live.index');
|
||||||
|
Route::get('/live/new', [LiveStreamController::class, 'create'])->name('live.create');
|
||||||
|
Route::post('/live', [LiveStreamController::class, 'store'])->name('live.store')->middleware('throttle:10,1');
|
||||||
|
|
||||||
|
Route::get('/live/{stream}/broadcast', [LiveStreamController::class, 'broadcast'])->name('live.broadcast');
|
||||||
|
Route::post('/live/{stream}/token', [LiveStreamController::class, 'token'])->name('live.token')->middleware('throttle:30,1');
|
||||||
|
Route::post('/live/{stream}/stop', [LiveStreamController::class, 'stop'])->name('live.stop')->middleware('throttle:30,1');
|
||||||
|
|
||||||
|
Route::get('/live/{stream}', [LiveStreamController::class, 'watch'])->name('live.watch');
|
||||||
|
Route::get('/live/{stream}/status', [LiveStreamController::class, 'status'])->name('live.status')->middleware('throttle:120,1');
|
||||||
|
|
||||||
|
// Live chat — polling by viewers, send by signed-in users (rate-limited).
|
||||||
|
Route::get('/live/{stream}/chat', [LiveStreamController::class, 'chatPoll'])
|
||||||
|
->name('live.chat.poll')->middleware('throttle:120,1');
|
||||||
|
Route::post('/live/{stream}/chat', [LiveStreamController::class, 'chatSend'])
|
||||||
|
->name('live.chat.send')->middleware('throttle:60,1');
|
||||||
|
|
||||||
|
Route::post('/api/live/auth', [LiveAuthController::class, 'authenticate'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
Route::post('/api/live/hook', [LiveAuthController::class, 'hook'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
|
||||||
Route::get('/@{handle}', [UserController::class, 'channel'])
|
Route::get('/@{handle}', [UserController::class, 'channel'])
|
||||||
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
||||||
->name('channel.handle');
|
->name('channel.handle');
|
||||||
|
|
||||||
|
// Tab aliases: /@handle/videos lands on the Videos tab, /@handle/shorts on
|
||||||
|
// Shorts, etc. These bridge to the single-page channel view by forwarding
|
||||||
|
// the tab name as a hash — the channel.blade.php JS already handles
|
||||||
|
// #wall/#videos/#shorts/#playlists/#about/#posts on load.
|
||||||
|
Route::get('/@{handle}/{tab}', function (string $handle, string $tab) {
|
||||||
|
$allowed = ['wall', 'videos', 'shorts', 'playlists', 'posts', 'about'];
|
||||||
|
if (! in_array($tab, $allowed, true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$user = \App\Models\User::where('username', $handle)->firstOrFail();
|
||||||
|
return redirect(route('channel', $user->channel) . '#' . $tab, 302);
|
||||||
|
})->where(['handle' => '[A-Za-z0-9][A-Za-z0-9._-]*', 'tab' => '[a-z]+'])
|
||||||
|
->name('channel.handle.tab');
|
||||||
|
|
||||||
// Post routes - auth required
|
// Post routes - auth required
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
|
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin polish follow-ups to Phase 7 — storage math, pagination controls,
|
||||||
|
* HTML-stripped descriptions and the grouped error log viewer.
|
||||||
|
*/
|
||||||
|
class AdminPolishTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pagination partial: First + Last buttons ─────
|
||||||
|
|
||||||
|
public function test_pagination_partial_renders_first_and_last_controls(): void
|
||||||
|
{
|
||||||
|
$paginator = new LengthAwarePaginator(
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
total: 100,
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 5,
|
||||||
|
options: ['path' => '/videos']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = view('partials.pagination', [
|
||||||
|
'paginator' => $paginator,
|
||||||
|
'elements' => $paginator->linkCollection()->toArray(),
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('aria-label="First page"', $html);
|
||||||
|
$this->assertStringContainsString('aria-label="Last page"', $html);
|
||||||
|
$this->assertStringContainsString('aria-label="Previous page"', $html);
|
||||||
|
$this->assertStringContainsString('aria-label="Next page"', $html);
|
||||||
|
|
||||||
|
// The anchors should link to page 1 and page 10 respectively.
|
||||||
|
$this->assertStringContainsString('href="/videos?page=1"', $html);
|
||||||
|
$this->assertStringContainsString('href="/videos?page=10"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pagination_disables_first_on_page_one(): void
|
||||||
|
{
|
||||||
|
// Two-page paginator — if there's only one page the partial
|
||||||
|
// short-circuits via @if($paginator->hasPages()) and renders
|
||||||
|
// nothing, which isn't what we're testing.
|
||||||
|
$paginator = new LengthAwarePaginator(
|
||||||
|
items: ['a'],
|
||||||
|
total: 20,
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
options: ['path' => '/videos']
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = view('partials.pagination', [
|
||||||
|
'paginator' => $paginator,
|
||||||
|
'elements' => $paginator->linkCollection()->toArray(),
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
// On page 1 the First button is a disabled span, not an anchor.
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/<span[^>]+class="pag-item disabled"[^>]+title="First page"/',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin videos list: HTML-stripped description ─
|
||||||
|
|
||||||
|
public function test_admin_video_description_renders_without_html_tags(): void
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create([
|
||||||
|
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||||
|
'role' => 'super_admin',
|
||||||
|
]);
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'The Full Video Title That Will Be Ellipsized',
|
||||||
|
'description' => '<p>Hello <strong>world</strong> <a href="#">link</a></p>',
|
||||||
|
'filename' => 'users/x/videos/y/video.mp4',
|
||||||
|
'path' => 'users/x/videos/y/video.mp4',
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$html = $this->get('/admin/videos')->getContent();
|
||||||
|
|
||||||
|
// Admin list must render plain text, not literal <p> / <strong>.
|
||||||
|
$this->assertStringNotContainsString('<p>Hello', $html,
|
||||||
|
'Description must be stripped of HTML tags before display.');
|
||||||
|
$this->assertStringContainsString('Hello world link', $html,
|
||||||
|
'Stripped text should still be visible.');
|
||||||
|
// Title tooltip in both the title column and description preview.
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'title="The Full Video Title That Will Be Ellipsized"',
|
||||||
|
$html,
|
||||||
|
'Admin title column must carry a title="" attribute for the full name.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard: Ready label ──────────────────────
|
||||||
|
|
||||||
|
public function test_dashboard_ready_stat_card_uses_the_new_label(): void
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create([
|
||||||
|
'username' => 'ad'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||||
|
'role' => 'super_admin',
|
||||||
|
]);
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$html = $this->get('/admin/dashboard')->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('>Ready<', $html);
|
||||||
|
$this->assertStringNotContainsString('>Live Videos<', $html,
|
||||||
|
'The old "Live Videos" stat-card label should be gone.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the bulk cleanup of deferred items:
|
||||||
|
* end-screen markup, C hotkey, guest subscribe prompt, /@handle tab aliases,
|
||||||
|
* channel videos cursor endpoint, shorts comments pane, studio skeleton.
|
||||||
|
*/
|
||||||
|
class DeferredCleanupTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Player ─────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_player_component_ships_the_end_screen_markup(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('id="ytpEndScreen"', $html);
|
||||||
|
$this->assertStringContainsString('ytp-end-countdown', $html, 'countdown ring class hook');
|
||||||
|
$this->assertStringContainsString('id="ytpEndRing"', $html);
|
||||||
|
$this->assertStringContainsString('id="ytpEndCancel"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_player_binds_c_hotkey_to_lyrics_button(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString("case 'c':", $html);
|
||||||
|
$this->assertStringContainsString('ytpLyricsBtn', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guest Subscribe prompt ────────────────────
|
||||||
|
|
||||||
|
public function test_guest_subscribe_button_preserves_intended_url(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-actions', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('?intended=', $html,
|
||||||
|
'Guest Subscribe button must redirect to login with ?intended=.');
|
||||||
|
$this->assertStringContainsString('encodeURIComponent', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── /@handle tab aliases ───────────────────────
|
||||||
|
|
||||||
|
public function test_handle_tab_alias_302_redirects_to_hash_url(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create(['username' => 'hta-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
|
||||||
|
|
||||||
|
$r = $this->get('/@' . $u->username . '/videos');
|
||||||
|
$r->assertStatus(302);
|
||||||
|
$this->assertStringEndsWith('#videos', $r->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handle_tab_alias_404s_on_unknown_tab(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create(['username' => 'htn-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
|
||||||
|
|
||||||
|
$this->get('/@' . $u->username . '/nonsense')->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channel videos cursor endpoint ─────────────
|
||||||
|
|
||||||
|
public function test_channel_videos_more_returns_cursor_json(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
// Twice the page size so has_more is true.
|
||||||
|
for ($i = 0; $i < 30; $i++) {
|
||||||
|
$this->makeVideo($u, ['title' => 'Vid '.$i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = $this->getJson(route('channel.videos.more', ['username' => $u->username]));
|
||||||
|
$r->assertOk()
|
||||||
|
->assertJsonStructure(['items', 'next_cursor', 'has_more'])
|
||||||
|
->assertJson(['has_more' => true]);
|
||||||
|
$this->assertStringContainsString('yt-video-card', $r->json('items'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Studio ─────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_studio_requires_authentication(): void
|
||||||
|
{
|
||||||
|
$this->get(route('studio'))->assertStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_renders_only_my_videos(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me, ['title' => 'MyStudioVideo']);
|
||||||
|
$theirs = $this->makeVideo($other, ['title' => 'TheirVideo']);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$html = $this->get(route('studio'))->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('MyStudioVideo', $html);
|
||||||
|
$this->assertStringNotContainsString('TheirVideo', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_bulk_visibility_refuses_other_users_videos(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me, ['visibility' => 'public']);
|
||||||
|
$theirs = $this->makeVideo($other, ['visibility' => 'public']);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$this->postJson(route('studio.bulkVisibility'), [
|
||||||
|
'video_ids' => [$mine->id, $theirs->id],
|
||||||
|
'visibility' => 'private',
|
||||||
|
])->assertOk()->assertJson(['updated' => 1]);
|
||||||
|
|
||||||
|
$this->assertEquals('private', $mine->fresh()->visibility);
|
||||||
|
$this->assertEquals('public', $theirs->fresh()->visibility,
|
||||||
|
'A crafted request body must not flip another user\'s video.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_bulk_delete_scoped_to_owner(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me);
|
||||||
|
$theirs = $this->makeVideo($other);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$this->postJson(route('studio.bulkDelete'), [
|
||||||
|
'video_ids' => [$mine->id, $theirs->id],
|
||||||
|
])->assertOk()->assertJson(['deleted' => 1]);
|
||||||
|
|
||||||
|
$this->assertNull(Video::find($mine->id));
|
||||||
|
$this->assertNotNull(Video::find($theirs->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeVideo(User $u, array $attrs = []): Video
|
||||||
|
{
|
||||||
|
return Video::create(array_merge([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'T',
|
||||||
|
'description' => '',
|
||||||
|
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||||
|
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
], $attrs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video dislike toggle + creator heart + creator pin.
|
||||||
|
*
|
||||||
|
* The in-memory DB these tests run against gets the two post-audit migrations
|
||||||
|
* applied automatically by RefreshDatabase, so no manual migrate step is
|
||||||
|
* required to exercise the full feature.
|
||||||
|
*/
|
||||||
|
class DislikeHeartPinTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dislike toggle ──────────────────────────────
|
||||||
|
|
||||||
|
public function test_toggle_dislike_adds_then_removes_the_row(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleDislike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['disliked' => true, 'dislike_count' => 1]);
|
||||||
|
$this->assertDatabaseHas('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleDislike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['disliked' => false, 'dislike_count' => 0]);
|
||||||
|
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disliking_clears_an_existing_like(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$video->likes()->attach($viewer->id);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleDislike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['disliked' => true, 'liked' => false, 'like_count' => 0]);
|
||||||
|
$this->assertDatabaseMissing('video_likes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_liking_clears_an_existing_dislike(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$video->dislikes()->attach($viewer->id);
|
||||||
|
|
||||||
|
$this->postJson(route('videos.toggleLike', $video))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['liked' => true, 'disliked' => false, 'like_count' => 1, 'dislike_count' => 0]);
|
||||||
|
$this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heart ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_owner_can_heart_a_comment(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$comment = Comment::create([
|
||||||
|
'user_id' => $viewer->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'body' => 'nice video!',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.heart', $comment))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['hearted' => true]);
|
||||||
|
$this->assertEquals(true, $comment->fresh()->hearted);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.heart', $comment))
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['hearted' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_owner_cannot_heart_a_comment(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$comment = Comment::create([
|
||||||
|
'user_id' => $viewer->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'body' => 'nice video!',
|
||||||
|
]);
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.heart', $comment))->assertStatus(403);
|
||||||
|
$this->assertEquals(false, $comment->fresh()->hearted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pin ────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_owner_pins_at_most_one_comment_at_a_time(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$a = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'A']);
|
||||||
|
$b = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'B']);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.pin', $a))->assertOk()->assertJson(['pinned' => true]);
|
||||||
|
$this->assertTrue((bool) $a->fresh()->pinned);
|
||||||
|
$this->assertFalse((bool) $b->fresh()->pinned);
|
||||||
|
|
||||||
|
// Pinning B must unpin A.
|
||||||
|
$this->postJson(route('comments.pin', $b))->assertOk()->assertJson(['pinned' => true]);
|
||||||
|
$this->assertFalse((bool) $a->fresh()->pinned);
|
||||||
|
$this->assertTrue((bool) $b->fresh()->pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_pin_a_reply(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$parent = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'top']);
|
||||||
|
$reply = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'parent_id' => $parent->id, 'body' => 'r']);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.pin', $reply))->assertStatus(422);
|
||||||
|
$this->assertFalse((bool) $reply->fresh()->pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_owner_cannot_pin_a_comment(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$comment = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'self']);
|
||||||
|
|
||||||
|
$this->postJson(route('comments.pin', $comment))->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pinned comments sort first in the component ──
|
||||||
|
|
||||||
|
public function test_pinned_comments_render_before_newer_unpinned_ones(): void
|
||||||
|
{
|
||||||
|
[$owner, $viewer, $video] = $this->makeOwnerViewerVideo();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$pinned = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'OLDPINNED', 'pinned' => true]);
|
||||||
|
// Date it older than the second one so sort would normally send it to the bottom.
|
||||||
|
$pinned->created_at = now()->subDays(5); $pinned->save();
|
||||||
|
Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'NEWFRESH']);
|
||||||
|
|
||||||
|
$html = view('components.video-comments', ['video' => $video])->render();
|
||||||
|
// Match on the unique comment-id markup so count/header text can't
|
||||||
|
// interfere with the order check.
|
||||||
|
$pOld = strpos($html, 'id="comment-' . $pinned->id . '"');
|
||||||
|
$pNew = strpos($html, 'id="comment-' . ($pinned->id + 1) . '"');
|
||||||
|
|
||||||
|
$this->assertNotFalse($pOld);
|
||||||
|
$this->assertNotFalse($pNew);
|
||||||
|
$this->assertLessThan($pNew, $pOld, 'Pinned comment must render before a newer unpinned one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeOwnerViewerVideo(): array
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create(['username' => 'own-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
|
||||||
|
$viewer = User::factory()->create(['username' => 'vwr-'.substr(bin2hex(random_bytes(4)), 0, 6)]);
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'test',
|
||||||
|
'description' => '',
|
||||||
|
'filename' => 'users/x/videos/y/video.mp4',
|
||||||
|
'path' => 'users/x/videos/y/video.mp4',
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
return [$owner, $viewer, $video];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,29 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
class ExampleTest extends TestCase
|
||||||
{
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A basic test example.
|
* The home page must render for an anonymous visitor on an empty database.
|
||||||
|
*
|
||||||
|
* Previously 500'd because this test skipped RefreshDatabase, so the
|
||||||
|
* home controller tried to query a `videos` table that didn't exist in
|
||||||
|
* the in-memory testing DB.
|
||||||
*/
|
*/
|
||||||
public function test_the_application_returns_a_successful_response(): void
|
public function test_the_application_returns_a_successful_response(): void
|
||||||
{
|
{
|
||||||
$response = $this->get('/');
|
// phpunit.xml + .env.testing + TestCase::assertSafeTestDatabase() point
|
||||||
|
// this at :memory: — matching the trio every Phase test uses.
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$this->get('/')->assertStatus(200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\LiveChatMessage;
|
||||||
|
use App\Models\LiveStream;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live polish regression tests. Covers:
|
||||||
|
* - Private visibility accepted by /live/new validation.
|
||||||
|
* - Ended-state watch page shows "Stream ended" + duration.
|
||||||
|
* - Chat poll endpoint returns everything newer than ?since=.
|
||||||
|
* - Chat send endpoint rejects guests, non-live streams, and
|
||||||
|
* anyone but the owner on a private stream.
|
||||||
|
*/
|
||||||
|
class LivePolishTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private visibility accepted ─────────────────
|
||||||
|
|
||||||
|
public function test_create_live_stream_accepts_private_visibility(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$this->actingAs($u);
|
||||||
|
|
||||||
|
$this->post(route('live.store'), [
|
||||||
|
'title' => 'My private test stream',
|
||||||
|
'description' => 'just testing',
|
||||||
|
'visibility' => 'private',
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('livestreams', [
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'My private test stream',
|
||||||
|
'visibility' => 'private',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ended state renders "Stream ended" ──────────
|
||||||
|
|
||||||
|
public function test_ended_stream_watch_page_shows_stream_ended(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, [
|
||||||
|
'status' => LiveStream::STATUS_ENDED,
|
||||||
|
'started_at' => now()->subMinutes(10),
|
||||||
|
'ended_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = $this->get(route('live.watch', $s))->assertOk()->getContent();
|
||||||
|
$this->assertStringContainsString('Stream ended', $html);
|
||||||
|
$this->assertStringNotContainsString('Waiting for the broadcast to start', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channel name is a link ──────────────────────
|
||||||
|
|
||||||
|
public function test_watch_page_channel_name_is_a_real_link(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
|
||||||
|
$expected = route('channel', $u->channel);
|
||||||
|
|
||||||
|
$html = $this->get(route('live.watch', $s))->assertOk()->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('href="' . $expected . '"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat poll ───────────────────────────────────
|
||||||
|
|
||||||
|
public function test_chat_poll_returns_messages_newer_than_since(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
|
||||||
|
|
||||||
|
$a = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'hello', 'created_at' => now()]);
|
||||||
|
$b = LiveChatMessage::create(['livestream_id' => $s->id, 'user_id' => $u->id, 'author_name' => 'A', 'body' => 'world', 'created_at' => now()]);
|
||||||
|
|
||||||
|
$this->getJson(route('live.chat.poll', $s) . '?since=' . $a->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'messages')
|
||||||
|
->assertJsonPath('messages.0.body', 'world');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat send ───────────────────────────────────
|
||||||
|
|
||||||
|
public function test_chat_send_rejects_guests(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_LIVE]);
|
||||||
|
|
||||||
|
// postJson sets Accept: application/json so the auth middleware
|
||||||
|
// returns 401 instead of redirecting to login.
|
||||||
|
$this->postJson(route('live.chat.send', $s), ['body' => 'hi'])
|
||||||
|
->assertStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_chat_send_rejects_offline_streams(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$s = $this->makeStream($u, ['status' => LiveStream::STATUS_ENDED]);
|
||||||
|
$this->actingAs($u);
|
||||||
|
|
||||||
|
$this->postJson(route('live.chat.send', $s), ['body' => 'hi'])
|
||||||
|
->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_private_stream_chat_rejects_non_owner(): void
|
||||||
|
{
|
||||||
|
$owner = $this->makeUser();
|
||||||
|
$viewer = $this->makeUser();
|
||||||
|
$s = $this->makeStream($owner, ['status' => LiveStream::STATUS_LIVE, 'visibility' => 'private']);
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
|
||||||
|
$this->postJson(route('live.chat.send', $s), ['body' => 'sneak'])
|
||||||
|
->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => 'lu'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeStream(User $u, array $attrs = []): LiveStream
|
||||||
|
{
|
||||||
|
return LiveStream::create(array_merge([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'public_id' => substr(bin2hex(random_bytes(10)), 0, 16),
|
||||||
|
'title' => 'Test stream',
|
||||||
|
'description' => '',
|
||||||
|
'status' => LiveStream::STATUS_IDLE,
|
||||||
|
'visibility' => 'public',
|
||||||
|
], $attrs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\SportsMatch;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-audit polish — the deferred items we tackled in bulk after Phase 7:
|
||||||
|
* match channel row, read-more clamp, nested-reply prefill, player hotkeys,
|
||||||
|
* cursor pagination on /liked and /history.
|
||||||
|
*/
|
||||||
|
class PostAuditPolishTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Match page: channel row ─────────────────────
|
||||||
|
|
||||||
|
public function test_match_watch_page_renders_the_channel_row(): void
|
||||||
|
{
|
||||||
|
$owner = $this->makeUser('match-owner');
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'MatchTitle',
|
||||||
|
'description' => '',
|
||||||
|
'filename' => 'users/x/sports/y/video.mp4',
|
||||||
|
'path' => 'users/x/sports/y/video.mp4',
|
||||||
|
'type' => 'match',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
// Minimal sports_match so the match blade renders without null checks exploding.
|
||||||
|
SportsMatch::create([
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'MatchTitle',
|
||||||
|
'participants' => [],
|
||||||
|
'sport' => 'karate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = $this->get(route('videos.show', $video))->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('channel-row', $html,
|
||||||
|
'Match watch page must render the shared channel row component.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Comment reply: nested handle button present ──
|
||||||
|
|
||||||
|
public function test_reply_to_reply_button_carries_data_handle(): void
|
||||||
|
{
|
||||||
|
$owner = $this->makeUser('replyowner');
|
||||||
|
$this->actingAs($owner);
|
||||||
|
$video = Video::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'title' => 'Vid',
|
||||||
|
'description'=> '',
|
||||||
|
'filename' => 'users/x/videos/y/video.mp4',
|
||||||
|
'path' => 'users/x/videos/y/video.mp4',
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token'=> bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
$parent = Comment::create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'body' => 'top',
|
||||||
|
]);
|
||||||
|
$replier = $this->makeUser('replier');
|
||||||
|
Comment::create([
|
||||||
|
'user_id' => $replier->id,
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'parent_id' => $parent->id,
|
||||||
|
'body' => 'nested',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = view('components.video-comments', ['video' => $video])->render();
|
||||||
|
|
||||||
|
// The nested reply trigger must carry the reply author's handle so the
|
||||||
|
// JS can prefill "@handle " when the parent's reply form opens.
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/_comment-reply-nested-trigger[^"]*"\s+data-parent-id="' . $parent->id . '"\s+data-handle="' . preg_quote($replier->username, '/') . '"/',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Liked feed: cursor pagination ────────────────
|
||||||
|
|
||||||
|
public function test_liked_controller_returns_a_cursor_paginator(): void
|
||||||
|
{
|
||||||
|
// The controller introspection is enough to confirm the pagination
|
||||||
|
// mode — the full HTML path depends on view rendering that costs
|
||||||
|
// 400 KB per test and isn't the behaviour we're asserting on.
|
||||||
|
$user = $this->makeUser('liker');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$route = app('router')->getRoutes()->getByName('liked');
|
||||||
|
[$class, $method] = explode('@', $route->getActionName());
|
||||||
|
$controller = app($class);
|
||||||
|
|
||||||
|
$response = $controller->$method();
|
||||||
|
$videos = $response->getData()['videos'] ?? null;
|
||||||
|
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
\Illuminate\Pagination\CursorPaginator::class,
|
||||||
|
$videos,
|
||||||
|
'liked() must return a CursorPaginator instance.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Player hotkey code is present ────────────────
|
||||||
|
|
||||||
|
public function test_player_component_handles_shift_dot_hotkey(): void
|
||||||
|
{
|
||||||
|
// The player component accepts a $video context — pass a bare
|
||||||
|
// record so the view renders.
|
||||||
|
$u = $this->makeUser('p');
|
||||||
|
$v = Video::create([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 't',
|
||||||
|
'description'=> '',
|
||||||
|
'filename' => 'users/x/videos/y/video.mp4',
|
||||||
|
'path' => 'users/x/videos/y/video.mp4',
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token'=> bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString("case '>':", $html);
|
||||||
|
$this->assertStringContainsString("case '<':", $html);
|
||||||
|
$this->assertStringContainsString('video.playbackRate', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(string $prefix): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => $prefix.'-'.substr(bin2hex(random_bytes(4)), 0, 6),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorts: single reused <video> element + 1-2 next preloaded.
|
||||||
|
*
|
||||||
|
* Validates that the shorts feed markup matches the architecture:
|
||||||
|
* - a single #svGlobal <video> rendered once at the end of the forelse,
|
||||||
|
* - two preloader placeholders (#svPre1 / #svPre2),
|
||||||
|
* - no per-short <video> elements inside .short-player-wrap,
|
||||||
|
* - each slot carries its own data-src + data-poster so the shared player
|
||||||
|
* can swap into position without a full DOM rebuild.
|
||||||
|
*/
|
||||||
|
class ShortsReusedVideoTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shorts_feed_renders_a_single_shared_video_element(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create([
|
||||||
|
'username' => 'sh-'.substr(bin2hex(random_bytes(3)), 0, 6),
|
||||||
|
]);
|
||||||
|
// Three shorts so we can prove only ONE <video id="svGlobal"> is
|
||||||
|
// emitted no matter how many shorts there are.
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
Video::create([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'Short '.$i,
|
||||||
|
'description' => '',
|
||||||
|
'filename' => "users/x/videos/y{$i}/video.mp4",
|
||||||
|
'path' => "users/x/videos/y{$i}/video.mp4",
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'is_shorts' => true,
|
||||||
|
'duration' => 45,
|
||||||
|
'orientation' => 'portrait',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = $this->get(route('videos.shorts'))->assertOk()->getContent();
|
||||||
|
|
||||||
|
// Exactly one shared video element and two preloaders.
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
substr_count($html, 'id="svGlobal"'),
|
||||||
|
'Shorts feed must emit exactly one shared <video id="svGlobal">.'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString('id="svPre1"', $html);
|
||||||
|
$this->assertStringContainsString('id="svPre2"', $html);
|
||||||
|
|
||||||
|
// No per-short video elements inside the slot wraps.
|
||||||
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
|
'/short-player-wrap[^>]*>[^<]*<video id="sv-\d+/',
|
||||||
|
$html,
|
||||||
|
'Per-short <video id="sv-{idx}"> elements should be gone.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each slot still carries its own src + poster for the shared
|
||||||
|
// player to pick up on dock.
|
||||||
|
$this->assertStringContainsString('data-src=', $html);
|
||||||
|
$this->assertStringContainsString('data-poster=', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
use CreatesApplication;
|
use CreatesApplication;
|
||||||
|
|
||||||
|
/** Output-buffer depth captured before each test, so tearDown can
|
||||||
|
* rewind back to it instead of leaving dangling buffers that PHPUnit
|
||||||
|
* reports as "Test code or tested code did not close its own output
|
||||||
|
* buffers". The depth leaks when a view throws mid-render — common
|
||||||
|
* with Blade's compiled files when a feature test exercises an
|
||||||
|
* error path. */
|
||||||
|
private int $preTestObLevel = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Belt-and-braces guard against a repeat of the 2026-10-21 incident, where
|
* Belt-and-braces guard against a repeat of the 2026-10-21 incident, where
|
||||||
* a test with the RefreshDatabase trait ran migrate:fresh against the
|
* a test with the RefreshDatabase trait ran migrate:fresh against the
|
||||||
@@ -21,6 +29,20 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$this->assertSafeTestDatabase();
|
$this->assertSafeTestDatabase();
|
||||||
|
$this->preTestObLevel = ob_get_level();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Collapse any output buffers the test (or Blade view it rendered)
|
||||||
|
// left open. Without this, each view()->render() call that touches
|
||||||
|
// a Blade partial with its own @once / @push block can leave the
|
||||||
|
// buffer stack one level deep — PHPUnit reports every one of those
|
||||||
|
// tests as "risky".
|
||||||
|
while (ob_get_level() > $this->preTestObLevel) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertSafeTestDatabase(): void
|
private function assertSafeTestDatabase(): void
|
||||||
|
|||||||
Reference in new issue
Block a user