Compare commits
88
Commits
8ea63a6720
..
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 | ||
|
|
8e719be105 | ||
|
|
7dd92aaeb2 | ||
|
|
1b2377f20f | ||
|
|
e939dd3a53 | ||
|
|
d5d35ab85e | ||
|
|
8812aaf336 | ||
|
|
dd6091d0e4 | ||
|
|
c3955c4a7c | ||
|
|
9e613c05d2 | ||
|
|
646d3e9d2a | ||
|
|
36295fcd6a | ||
|
|
9eb9535c83 | ||
|
|
4581870517 | ||
|
|
36061d1561 | ||
|
|
d1fd76c278 | ||
|
|
a2752643e9 | ||
|
|
45b5f586ba | ||
|
|
6b2a67c4cd | ||
|
|
4b21886cb9 | ||
|
|
133ab6e623 | ||
|
|
a25af30700 | ||
|
|
d9f97a672e | ||
|
|
fa4e09ad62 | ||
|
|
8aa08e705a | ||
|
|
8fdac4b483 | ||
|
|
73620a0f24 | ||
|
|
4718888de0 | ||
|
|
c7d2f25ef6 | ||
|
|
48c947d171 | ||
|
|
c9123bc7b8 | ||
|
|
3122420a3d | ||
|
|
76584651a6 | ||
|
|
1cefc6ad2d | ||
|
|
a1493462f7 | ||
|
|
56b6a83b34 | ||
|
|
5058eaff2f | ||
|
|
468820c9fe | ||
|
|
2d33e39b9b | ||
|
|
20c8fdcdf3 | ||
|
|
9f82d3cf31 | ||
|
|
3e18bd2150 | ||
|
|
665bad76bf | ||
|
|
04d63cccdb | ||
|
|
5b6bd86d70 | ||
|
|
b05ff14b3b | ||
|
|
5d038488fb | ||
|
|
2b5e480c9a | ||
|
|
19741f489b | ||
|
|
ed921a4ccf | ||
|
|
1c4e4986b5 | ||
|
|
55247828e1 | ||
|
|
cba8e01d56 | ||
|
|
716693a74f | ||
|
|
057ce87564 | ||
|
|
402c6dc89f | ||
|
|
ad6cbaf742 | ||
|
|
76f5ef0c84 | ||
|
|
e0aa6e4817 | ||
|
|
3d8d7b8efe | ||
|
|
da714958c6 | ||
|
|
847020fd02 | ||
|
|
fcb752dd76 | ||
|
|
8d27520bdb | ||
|
|
ba8ab11ce4 | ||
|
|
af276f2cd1 |
No files matched your search
@@ -144,6 +144,9 @@ Stored value: ISO2 code (e.g. `"BH"`).
|
||||
|---|---|---|
|
||||
| `resources/views/user/profile.blade.php` | `nationality` | Edit Profile form |
|
||||
| `resources/views/auth/register.blade.php` | `nationality` | Registration form — mandatory |
|
||||
| `resources/views/layouts/partials/sports-match-modal.blade.php` | `participants[p1_country]` | Fighter 1 country → header flag |
|
||||
| `resources/views/layouts/partials/sports-match-modal.blade.php` | `participants[p2_country]` | Fighter 2 country → header flag |
|
||||
| `resources/views/layouts/partials/sports-match-modal.blade.php` | `participants[referee_country]` | Referee country → header flag |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
APP_ENV=testing
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_KEY=base64:Zm7kR6vDfsQQmCeWTR2Mk2WQGu6fGP1UHEcZVk/hIt0=
|
||||
|
||||
# ── Hard rule — tests must never touch the shared SQLite file. ───────────
|
||||
# phpunit.xml also sets these via <env>, but having them here means Laravel
|
||||
# picks them up during the earliest bootstrap step (before any trait or
|
||||
# setUp hook can run), closing the window that let migrate:fresh wipe the
|
||||
# dev DB on 2026-10-21. If you change these values, read tests/TestCase.php's
|
||||
# assertSafeTestDatabase() guard first.
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=:memory:
|
||||
|
||||
BCRYPT_ROUNDS=4
|
||||
CACHE_DRIVER=array
|
||||
MAIL_MAILER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=array
|
||||
|
||||
TELESCOPE_ENABLED=false
|
||||
PULSE_ENABLED=false
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
|
||||
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
|
||||
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
|
||||
* the rest. --dry-run shows what would happen without touching the DB.
|
||||
*/
|
||||
class CommentsDedupeCommand extends Command
|
||||
{
|
||||
protected $signature = 'comments:dedupe
|
||||
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
|
||||
{--dry-run : Print what would be removed; do not delete}';
|
||||
|
||||
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$window = max(1, (int) $this->option('window'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Fetch (user_id, video_id, parent_id, body) groups that appear >1 time.
|
||||
// We ignore the window at the SQL layer and apply it per-group in PHP:
|
||||
// that avoids a correlated subquery and is fast enough for a one-shot
|
||||
// cleanup. For very large tables, chunk by user_id.
|
||||
$groups = DB::table('comments')
|
||||
->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt')
|
||||
->groupBy('user_id', 'video_id', 'parent_id', 'body')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
$this->info('No duplicate (user, video, parent, body) groups found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRemoved = 0;
|
||||
$groupsTouched = 0;
|
||||
|
||||
foreach ($groups as $g) {
|
||||
$rows = Comment::where('user_id', $g->user_id)
|
||||
->where('video_id', $g->video_id)
|
||||
->where(function ($q) use ($g) {
|
||||
$g->parent_id === null
|
||||
? $q->whereNull('parent_id')
|
||||
: $q->where('parent_id', $g->parent_id);
|
||||
})
|
||||
->where('body', $g->body)
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keeper = $rows->first();
|
||||
$cluster = collect([$keeper]);
|
||||
$previous = $keeper;
|
||||
|
||||
foreach ($rows->slice(1) as $row) {
|
||||
// Same cluster if row is within window of the previous row —
|
||||
// which is the behavior the server guard enforces today.
|
||||
if ($row->created_at->diffInSeconds($previous->created_at) <= $window) {
|
||||
$cluster->push($row);
|
||||
$previous = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cluster->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toDelete = $cluster->skip(1);
|
||||
$groupsTouched++;
|
||||
$totalRemoved += $toDelete->count();
|
||||
|
||||
$this->line(sprintf(
|
||||
' user=%d video=%d parent=%s keep=%d remove=%s body="%s"',
|
||||
$g->user_id,
|
||||
$g->video_id,
|
||||
$g->parent_id ?? 'null',
|
||||
$keeper->id,
|
||||
$toDelete->pluck('id')->join(','),
|
||||
mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($keeper, $toDelete) {
|
||||
$ids = $toDelete->pluck('id')->all();
|
||||
|
||||
// Reparent any replies that were nested under a doomed row.
|
||||
Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]);
|
||||
|
||||
// Move likes to the keeper, dropping dupes.
|
||||
DB::table('comment_likes')
|
||||
->whereIn('comment_id', $ids)
|
||||
->update(['comment_id' => $keeper->id]);
|
||||
|
||||
Comment::whereIn('id', $ids)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s %d duplicate rows across %d groups.',
|
||||
$dryRun ? 'Would remove' : 'Removed',
|
||||
$totalRemoved,
|
||||
$groupsTouched,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NasAutoSync extends Command
|
||||
{
|
||||
@@ -19,6 +20,39 @@ class NasAutoSync extends Command
|
||||
return 0; // NAS down or disabled — nothing to do
|
||||
}
|
||||
|
||||
// Drain queued NAS file/folder moves recorded while NAS was down
|
||||
// (e.g. music-video merges). Best-effort per row; failures stay
|
||||
// in the table with attempts/last_error for later retries.
|
||||
$drained = 0; $failed = 0;
|
||||
DB::table('pending_nas_moves')->orderBy('id')->get()->each(
|
||||
function ($row) use ($nas, &$drained, &$failed) {
|
||||
try {
|
||||
$nas->renameNasPath($row->from_path, $row->to_path);
|
||||
// Best-effort prune of the now-empty source parent
|
||||
// (e.g. the source video folder after all its track
|
||||
// subfolders were adopted by the target).
|
||||
$parent = dirname($row->from_path);
|
||||
if ($parent && $parent !== '.') {
|
||||
$nas->deleteFile("{$parent}/meta.json");
|
||||
$nas->deleteFolder($parent);
|
||||
}
|
||||
DB::table('pending_nas_moves')->where('id', $row->id)->delete();
|
||||
$drained++;
|
||||
} catch (\Throwable $e) {
|
||||
DB::table('pending_nas_moves')->where('id', $row->id)->update([
|
||||
'attempts' => (int) $row->attempts + 1,
|
||||
'last_attempt_at' => now(),
|
||||
'last_error' => mb_substr($e->getMessage(), 0, 500),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
);
|
||||
if ($drained || $failed) {
|
||||
$this->info("Drained {$drained} pending NAS move(s)" . ($failed ? " (${failed} failed)" : ''));
|
||||
}
|
||||
|
||||
// Videos whose file OR thumbnail/slides are still on local disk
|
||||
$synced = 0;
|
||||
Video::with(['user', 'slides'])
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Renders the 1200×630 VS composition PNG for a match video's social-share
|
||||
* preview. Runs the same helper used by VideoController@ogImage but from a
|
||||
* CLI process context, because Chromium reliably fails to launch under
|
||||
* PHP-FPM ("Failed to launch the browser process: Code: null") on this
|
||||
* host. Invoke detached via `nohup` from either the ogImage endpoint (on
|
||||
* cache miss) or SportsMatchController on save.
|
||||
*/
|
||||
class RenderMatchOgImage extends Command
|
||||
{
|
||||
protected $signature = 'og:render-match {video}';
|
||||
protected $description = 'Render the OG social-share PNG for a match video';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$video = Video::find($this->argument('video'));
|
||||
if (! $video) {
|
||||
$this->error("Video {$this->argument('video')} not found.");
|
||||
return 1;
|
||||
}
|
||||
if ($video->type !== 'match' || ! $video->sportsMatch) {
|
||||
$this->error("Video {$video->id} is not a match with a sports record.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$controller = new VideoController();
|
||||
$r = new \ReflectionMethod($controller, 'renderMatchOgImage');
|
||||
$r->setAccessible(true);
|
||||
$png = $r->invoke($controller, $video);
|
||||
|
||||
if ($png === null) {
|
||||
$this->error("Render failed. See laravel.log.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Rendered " . strlen($png) . " bytes for video {$video->id}.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RepairMergedVideo extends Command
|
||||
{
|
||||
protected $signature = 'videos:repair-merged {target : Target video encoded id (from the URL) or numeric id} {--source-id= : Force a specific source id from video_redirects} {--source-folder= : Override the source folder path (use when the redirect row has none — pre-repair-column merges)} {--dry-run : Show what would happen, do not modify anything}';
|
||||
|
||||
protected $description = 'After a music-video merge, adopt any track subfolders still stuck in the source folder on NAS as new audio tracks on the target video. Safe to re-run.';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
if (!$nas->isEnabled()) {
|
||||
$this->error('NAS is not reachable — cannot repair. Try again when NAS is back online.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targetArg = $this->argument('target');
|
||||
$target = is_numeric($targetArg)
|
||||
? Video::find((int) $targetArg)
|
||||
: Video::find(Video::decodeId((string) $targetArg));
|
||||
if (!$target) {
|
||||
$this->error("Target video not found: {$targetArg}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info("Target: [{$target->id}] {$target->title}");
|
||||
|
||||
$redirects = DB::table('video_redirects')->where('target_id', $target->id);
|
||||
if ($sid = $this->option('source-id')) {
|
||||
$redirects->where('source_id', (int) $sid);
|
||||
}
|
||||
$redirects = $redirects->get();
|
||||
|
||||
if ($redirects->isEmpty()) {
|
||||
$this->warn('No video_redirects rows found for this target.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$adopted = 0;
|
||||
|
||||
$folderOverride = $this->option('source-folder');
|
||||
|
||||
foreach ($redirects as $r) {
|
||||
$this->line('');
|
||||
$sourceFolder = $folderOverride ?: $r->source_folder;
|
||||
$this->info("Source id {$r->source_id} → folder: " . ($sourceFolder ?: '(unknown)'));
|
||||
|
||||
if (!$sourceFolder) {
|
||||
$this->warn(' Skipping — no source_folder recorded. Pass --source-folder="users/…/music/…" to specify.');
|
||||
continue;
|
||||
}
|
||||
// Backfill the redirect row so future runs don't need the override.
|
||||
if (!$r->source_folder && !$dryRun && $folderOverride) {
|
||||
DB::table('video_redirects')->where('source_id', $r->source_id)
|
||||
->update(['source_folder' => $folderOverride]);
|
||||
$r->source_folder = $folderOverride;
|
||||
} else {
|
||||
$r->source_folder = $sourceFolder;
|
||||
}
|
||||
|
||||
$tracksDir = $r->source_folder . '/tracks';
|
||||
$subdirs = $nas->listNasDirs($tracksDir);
|
||||
if (empty($subdirs)) {
|
||||
$this->line(' No leftover track subfolders. Cleaning up empty source folder.');
|
||||
if (!$dryRun) {
|
||||
$nas->deleteFile($r->source_folder . '/meta.json');
|
||||
$nas->deleteFolder($tracksDir);
|
||||
$nas->deleteFolder($r->source_folder);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetDir = $nas->resolveVideoDir($target);
|
||||
$nas->mkdirp("{$targetDir}/tracks");
|
||||
|
||||
foreach ($subdirs as $sub) {
|
||||
// Parse "{lang}-{origId}" to recover the language for the new track.
|
||||
$lang = 'xx';
|
||||
if (preg_match('/^([a-z]{2,10})-\d+$/i', $sub, $m)) {
|
||||
$lang = strtolower($m[1]);
|
||||
}
|
||||
|
||||
$this->line(" Adopting track folder: {$sub} (lang={$lang})");
|
||||
|
||||
if ($dryRun) { $adopted++; continue; }
|
||||
|
||||
$newTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
'language' => $lang,
|
||||
'label' => "Recovered from merge #{$r->source_id}",
|
||||
'title' => null,
|
||||
'filename' => 'audio.mp3',
|
||||
'path' => '',
|
||||
]);
|
||||
$newBasename = $nas->trackFolderName($target, $newTrack);
|
||||
$oldRel = "{$tracksDir}/{$sub}";
|
||||
$newRel = "{$targetDir}/tracks/{$newBasename}";
|
||||
|
||||
$nas->renameNasPath($oldRel, $newRel);
|
||||
|
||||
// Local mirror (if the folder happens to exist locally too).
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
if (is_dir($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
@rename($oldLocal, $newLocal);
|
||||
}
|
||||
|
||||
// Detect audio filename from local mirror if present, else default.
|
||||
$audioName = 'audio.mp3';
|
||||
foreach (['audio.mp3', 'audio.m4a', 'audio.aac', 'audio.ogg', 'audio.wav', 'audio.opus'] as $cand) {
|
||||
if (file_exists("{$newLocal}/{$cand}")) { $audioName = $cand; break; }
|
||||
}
|
||||
$newTrack->update([
|
||||
'filename' => $audioName,
|
||||
'path' => "{$newRel}/{$audioName}",
|
||||
]);
|
||||
|
||||
$adopted++;
|
||||
}
|
||||
|
||||
// Clean up now-empty source folder.
|
||||
if (!$dryRun) {
|
||||
$nas->deleteFile($r->source_folder . '/meta.json');
|
||||
$nas->deleteFolder($tracksDir);
|
||||
$nas->deleteFolder($r->source_folder);
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info(($dryRun ? '[dry-run] Would adopt ' : 'Adopted ') . $adopted . ' track folder(s).');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Resend the email-verification mail to one user or to every unverified user.
|
||||
*
|
||||
* Useful after an SMTP outage: the account was created and the user was told
|
||||
* "we sent you a link", but the mail never left because Gmail 535'd us. Once
|
||||
* the mail config is fixed, this flushes the backlog.
|
||||
*/
|
||||
class ResendVerificationCommand extends Command
|
||||
{
|
||||
protected $signature = 'app:resend-verification
|
||||
{--user= : A specific user id OR email to resend to}
|
||||
{--all-unverified : Resend to every user with a null email_verified_at}
|
||||
{--dry-run : Print who would be mailed; do not actually send}';
|
||||
|
||||
protected $description = 'Resend the email-verification link to one user or all unverified users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$one = (string) $this->option('user');
|
||||
$all = (bool) $this->option('all-unverified');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($one === '' && ! $all) {
|
||||
$this->error('Pass either --user=<id-or-email> or --all-unverified.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($one !== '') {
|
||||
$query = ctype_digit($one)
|
||||
? User::where('id', (int) $one)
|
||||
: User::where('email', $one);
|
||||
$users = $query->get();
|
||||
} else {
|
||||
$users = User::whereNull('email_verified_at')->get();
|
||||
}
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->warn('No matching users.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
foreach ($users as $user) {
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$skipped++;
|
||||
$this->line(" skip #{$user->id} <{$user->email}> — already verified");
|
||||
continue;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$this->line(" would #{$user->id} <{$user->email}>");
|
||||
continue;
|
||||
}
|
||||
// sendEmailVerificationNotification is already SafeNotifier-wrapped
|
||||
// and will log instead of throwing on SMTP errors.
|
||||
$user->sendEmailVerificationNotification();
|
||||
$sent++;
|
||||
$this->line(" sent #{$user->id} <{$user->email}>");
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "Dry run complete — {$users->count()} candidates, {$skipped} already verified."
|
||||
: "Done — {$sent} sent, {$skipped} skipped.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
|
||||
->weeklyOn(1, '09:00')
|
||||
->withoutOverlapping()
|
||||
->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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,18 @@ class Countries
|
||||
* All countries keyed by ISO2 code.
|
||||
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
|
||||
*/
|
||||
/**
|
||||
* Resolve any ISO2 code (case-insensitive) to its full country name.
|
||||
* Falls back to the uppercased code, then to null for empty input.
|
||||
* Use this instead of printing raw two-letter codes to end users.
|
||||
*/
|
||||
public static function name(?string $iso2): ?string
|
||||
{
|
||||
$code = strtoupper(trim((string) $iso2));
|
||||
if ($code === '') return null;
|
||||
return self::all()[$code]['name'] ?? $code;
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class CountryLanguages
|
||||
{
|
||||
private const MAP = [
|
||||
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
|
||||
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
|
||||
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
|
||||
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
|
||||
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
|
||||
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
|
||||
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
|
||||
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
|
||||
'FI' => 'fi', 'FR' => 'fr',
|
||||
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
|
||||
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
|
||||
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
|
||||
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
|
||||
'JO' => 'ar', 'JP' => 'ja',
|
||||
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
|
||||
'KZ' => 'kk',
|
||||
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
|
||||
'LV' => 'lv', 'LY' => 'ar',
|
||||
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
|
||||
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
|
||||
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
|
||||
'NZ' => 'en',
|
||||
'OM' => 'ar',
|
||||
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
|
||||
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
|
||||
'QA' => 'ar',
|
||||
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
|
||||
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
|
||||
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
|
||||
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
|
||||
'TZ' => 'sw',
|
||||
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
|
||||
'VE' => 'es', 'VN' => 'vi',
|
||||
'YE' => 'ar',
|
||||
'ZA' => 'en', 'ZW' => 'en',
|
||||
];
|
||||
|
||||
public static function primary(?string $iso2): ?string
|
||||
{
|
||||
if (! $iso2) return null;
|
||||
return self::MAP[strtoupper($iso2)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BruteForceLoginAlert;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
|
||||
'details' => ['email' => $credentials['email']],
|
||||
]);
|
||||
|
||||
$this->detectBruteForce($request, $credentials['email']);
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one alert to super admins when the same email has racked up
|
||||
* BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache
|
||||
* key with the same TTL prevents the alert from re-firing on every
|
||||
* subsequent failed attempt within the same burst.
|
||||
*/
|
||||
private const BRUTE_FORCE_THRESHOLD = 5;
|
||||
private const BRUTE_FORCE_WINDOW_MINUTES = 10;
|
||||
|
||||
private function detectBruteForce(Request $request, string $email): void
|
||||
{
|
||||
$window = self::BRUTE_FORCE_WINDOW_MINUTES;
|
||||
$threshold = self::BRUTE_FORCE_THRESHOLD;
|
||||
|
||||
// SQLite stores JSON-cast `details` as text; a LIKE-based match is
|
||||
// database-portable and avoids per-driver JSON operator syntax.
|
||||
$pattern = '%"email":"' . addslashes($email) . '"%';
|
||||
|
||||
$recent = AuditLog::where('action', 'user.login.failed')
|
||||
->where('created_at', '>=', now()->subMinutes($window))
|
||||
->where('details', 'like', $pattern)
|
||||
->count();
|
||||
|
||||
if ($recent < $threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = 'brute_force_alert:' . sha1($email);
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
Cache::put($cacheKey, 1, now()->addMinutes($window));
|
||||
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Forwarded-For')
|
||||
?? $request->ip()
|
||||
?? 'unknown';
|
||||
|
||||
$notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window);
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($notification) {
|
||||
SafeNotifier::send($admin, $notification);
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewUserRegistered;
|
||||
use App\Rules\NotDisposableEmail;
|
||||
use App\Rules\ReservedIdentifier;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
|
||||
'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'birthday' => ['required', 'date', 'before:today'],
|
||||
// Age gate: must be at least 13 on sign-up day. The front-end
|
||||
// date picker already caps the year dropdown, but the server
|
||||
// must enforce it so a client bypass doesn't let a 10-year-old
|
||||
// through.
|
||||
'birthday' => ['required', 'date', 'before:' . now()->subYears(13)->toDateString()],
|
||||
'gender' => ['required', 'in:male,female'],
|
||||
'nationality' => ['required', 'string', 'size:2'],
|
||||
]);
|
||||
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
|
||||
|
||||
// Notify all super admins of the new registration
|
||||
User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
|
||||
$admin->notify(new NewUserRegistered($user));
|
||||
SafeNotifier::send($admin, new NewUserRegistered($user));
|
||||
});
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use App\Services\LlmLyricsService;
|
||||
use App\Support\SafeNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -15,7 +17,7 @@ class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
$this->middleware('auth')->except(['index', 'translate']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
@@ -34,12 +36,66 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after
|
||||
// an SPA swap, fast double-click, flaky connection) can POST the same
|
||||
// comment twice. We dedupe on two signals:
|
||||
//
|
||||
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
|
||||
// UUID; the browser's own retry logic reuses the same value.
|
||||
// This catches the deliberate-retry case before body comparison.
|
||||
//
|
||||
// 2. Identical (user, video, parent, body) within 60s — covers
|
||||
// the duplicate-listener case where two independent fetch()es
|
||||
// fire with different idempotency keys for the same user intent.
|
||||
//
|
||||
// Either match returns the first-written comment unchanged.
|
||||
$idemKey = (string) $request->header('X-Idempotency-Key', '');
|
||||
$cacheKey = $idemKey !== ''
|
||||
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
|
||||
: null;
|
||||
|
||||
if ($cacheKey) {
|
||||
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
if ($existingId && $existing = Comment::find($existingId)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
->where(function ($q) use ($request) {
|
||||
$request->parent_id
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(60))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
|
||||
}
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
if ($cacheKey) {
|
||||
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
@@ -47,12 +103,12 @@ class CommentController extends Controller
|
||||
// Reply — notify the parent comment's author
|
||||
$parent = Comment::find($request->parent_id);
|
||||
if ($parent && $parent->user_id !== $commenter->id) {
|
||||
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter));
|
||||
}
|
||||
} else {
|
||||
// Top-level comment — notify the video owner
|
||||
if ($video->user_id !== $commenter->id) {
|
||||
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
|
||||
SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +145,37 @@ class CommentController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Derive a source-language hint from the comment author's most-frequent
|
||||
// access country (video_views + profile_visits, falling back to their
|
||||
// nationality setting). Never trust the client for this — it's used
|
||||
// to disambiguate scripts the LLM might otherwise misidentify.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
|
||||
$sourceCountry = $topCountries[$comment->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
|
||||
|
||||
if ($translated === '') {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'translated' => $translated,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country'=> $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
@@ -107,7 +194,8 @@ class CommentController extends Controller
|
||||
if ($comment->user_id !== $userId) {
|
||||
$video = $comment->video;
|
||||
if ($video) {
|
||||
$comment->user->notify(
|
||||
SafeNotifier::send(
|
||||
$comment->user,
|
||||
new NewCommentLikeNotification($video, $comment, Auth::user())
|
||||
);
|
||||
}
|
||||
@@ -118,4 +206,56 @@ class CommentController extends Controller
|
||||
|
||||
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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class MatchEventController extends Controller
|
||||
$request->validate([
|
||||
'round_number' => 'required|integer|min:1',
|
||||
'name' => 'nullable|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -45,7 +45,7 @@ class MatchEventController extends Controller
|
||||
$request->validate([
|
||||
'round_number' => 'sometimes|integer|min:1',
|
||||
'name' => 'required|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -87,7 +87,7 @@ class MatchEventController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'round_id' => 'required|exists:match_rounds,id',
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'timestamp_seconds' => 'required|numeric|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
@@ -99,31 +99,10 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Get ALL previous points in this round (ordered by timestamp)
|
||||
$previousPoints = MatchPoint::where('match_round_id', $request->round_id)
|
||||
->where('timestamp_seconds', '<', $request->timestamp_seconds)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->pluck('points', 'competitor')
|
||||
->toArray();
|
||||
|
||||
// Calculate cumulative scores by summing each point value
|
||||
$scoreBlue = 0;
|
||||
$scoreRed = 0;
|
||||
|
||||
if (isset($previousPoints['blue'])) {
|
||||
$scoreBlue += $previousPoints['blue'];
|
||||
}
|
||||
if (isset($previousPoints['red'])) {
|
||||
$scoreRed += $previousPoints['red'];
|
||||
}
|
||||
|
||||
// Add current point
|
||||
if ($request->competitor === 'blue') {
|
||||
$scoreBlue += $request->points;
|
||||
} else {
|
||||
$scoreRed += $request->points;
|
||||
}
|
||||
|
||||
// Create the row first with placeholder scores, then recompute the whole
|
||||
// round's running scores. This is the only correct approach when two
|
||||
// points can share a timestamp (Blue + Red at the same moment) or when
|
||||
// a point is inserted between existing ones.
|
||||
$point = MatchPoint::create([
|
||||
'video_id' => $video->id,
|
||||
'match_round_id' => $request->round_id,
|
||||
@@ -132,21 +111,46 @@ class MatchEventController extends Controller
|
||||
'points' => $request->points,
|
||||
'competitor' => $request->competitor,
|
||||
'notes' => $request->notes,
|
||||
'score_blue' => $scoreBlue,
|
||||
'score_red' => $scoreRed,
|
||||
'score_blue' => 0,
|
||||
'score_red' => 0,
|
||||
]);
|
||||
|
||||
$this->recomputeRoundScores($request->round_id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'point' => $point->fresh(),
|
||||
'message' => 'Point added successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute cumulative score_blue/score_red for every point in a round,
|
||||
* ordered by (timestamp_seconds, id). Handles same-timestamp ties by
|
||||
* insertion order, so Blue and Red saved at the same moment both end up
|
||||
* reflecting the running score after that moment.
|
||||
*/
|
||||
private function recomputeRoundScores(int $roundId): void
|
||||
{
|
||||
$blue = 0;
|
||||
$red = 0;
|
||||
MatchPoint::where('match_round_id', $roundId)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->get()
|
||||
->each(function (MatchPoint $p) use (&$blue, &$red) {
|
||||
if ($p->competitor === 'blue') $blue += (int) $p->points;
|
||||
else $red += (int) $p->points;
|
||||
if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) {
|
||||
$p->update(['score_blue' => $blue, 'score_red' => $red]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function updatePoint(Request $request, MatchPoint $point)
|
||||
{
|
||||
$request->validate([
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'timestamp_seconds' => 'required|numeric|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
@@ -158,6 +162,7 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$roundId = $point->match_round_id;
|
||||
$point->update([
|
||||
'timestamp_seconds' => $request->timestamp_seconds,
|
||||
'action' => $request->action,
|
||||
@@ -166,9 +171,11 @@ class MatchEventController extends Controller
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
$this->recomputeRoundScores($roundId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'point' => $point->fresh(),
|
||||
'message' => 'Point updated successfully!',
|
||||
]);
|
||||
}
|
||||
@@ -180,7 +187,9 @@ class MatchEventController extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$roundId = $point->match_round_id;
|
||||
$point->delete();
|
||||
$this->recomputeRoundScores($roundId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -193,11 +202,13 @@ class MatchEventController extends Controller
|
||||
public function storeReview(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'required|numeric|min:0',
|
||||
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
'position_x' => 'nullable|numeric|between:0,1',
|
||||
'position_y' => 'nullable|numeric|between:0,1',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -213,6 +224,8 @@ class MatchEventController extends Controller
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji ?? '🔥',
|
||||
'position_x' => $request->position_x,
|
||||
'position_y' => $request->position_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -225,11 +238,13 @@ class MatchEventController extends Controller
|
||||
public function updateReview(Request $request, CoachReview $review)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'start_time_seconds' => 'required|numeric|min:0',
|
||||
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
'position_x' => 'nullable|numeric|between:0,1',
|
||||
'position_y' => 'nullable|numeric|between:0,1',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
@@ -243,6 +258,8 @@ class MatchEventController extends Controller
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji,
|
||||
'position_x' => $request->position_x,
|
||||
'position_y' => $request->position_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -323,7 +323,8 @@ class PlaylistController extends Controller
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
'audio_track_id' => 'nullable|integer|exists:video_audio_tracks,id',
|
||||
]);
|
||||
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
@@ -333,7 +334,13 @@ class PlaylistController extends Controller
|
||||
abort(403, 'You cannot add this video to your playlist.');
|
||||
}
|
||||
|
||||
$added = $playlist->addVideo($video);
|
||||
// Track must actually belong to this video (avoid arbitrary FK pinning).
|
||||
$audioTrackId = $request->input('audio_track_id');
|
||||
if ($audioTrackId && ! $video->audioTracks()->whereKey($audioTrackId)->exists()) {
|
||||
$audioTrackId = null;
|
||||
}
|
||||
|
||||
$added = $playlist->addVideo($video, $audioTrackId ? (int) $audioTrackId : null);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
@@ -353,9 +360,30 @@ class PlaylistController extends Controller
|
||||
*/
|
||||
public function removeVideoByBody(Request $request, Playlist $playlist)
|
||||
{
|
||||
$request->validate(['video_id' => 'required|exists:videos,id']);
|
||||
$request->validate([
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
'audio_track_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
return $this->removeVideo($request, $playlist, $video);
|
||||
$trackId = $request->filled('audio_track_id')
|
||||
? (int) $request->input('audio_track_id')
|
||||
: null;
|
||||
|
||||
$removed = $playlist->removeVideoWithTrack($video, $trackId);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $removed ? 'Video removed from playlist.' : 'Video was not in the playlist.',
|
||||
'video_count' => $playlist->video_count,
|
||||
]);
|
||||
}
|
||||
return back()->with('success', 'Video removed from playlist.');
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
@@ -431,6 +459,17 @@ class PlaylistController extends Controller
|
||||
'visibility' => $p->visibility,
|
||||
'thumbnail_url' => $p->thumbnail_url,
|
||||
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
|
||||
// Track-aware entries: each row = {video_id, audio_track_id or null}.
|
||||
// Consumers that need per-track granularity use this; older callers
|
||||
// can still rely on video_ids above.
|
||||
'entries' => \DB::table('playlist_videos')
|
||||
->where('playlist_id', $p->id)
|
||||
->select('video_id', 'audio_track_id')
|
||||
->get()
|
||||
->map(fn ($r) => [
|
||||
'video_id' => (int) $r->video_id,
|
||||
'audio_track_id' => $r->audio_track_id ? (int) $r->audio_track_id : null,
|
||||
])->all(),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Site-wide search across videos, channels and playlists.
|
||||
*
|
||||
* Lives on /search?q=… so the URL reads like what you'd expect from a modern
|
||||
* site. The old /videos/search endpoint still works but redirects here, so
|
||||
* nothing bookmarked earlier breaks.
|
||||
*
|
||||
* Match surface:
|
||||
* - Video title, description, tag list, primary language and the language
|
||||
* of any secondary audio track attached to it.
|
||||
* - User name, username (handle), and short bio.
|
||||
* - Playlist name and description.
|
||||
*
|
||||
* We render the existing `videos.index` view so every search result lands in
|
||||
* the same card component the browse feeds use — one place to style, one place
|
||||
* to fix bugs.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = trim((string) $request->get('q', ''));
|
||||
|
||||
if ($query === '') {
|
||||
// No query → send the user back to the browse feed instead of an
|
||||
// empty "no results" page.
|
||||
return redirect()->route('videos.index');
|
||||
}
|
||||
|
||||
$like = '%' . $query . '%';
|
||||
$qLower = mb_strtolower($query);
|
||||
|
||||
// ── Videos ────────────────────────────────────────────────
|
||||
// Match on text columns AND on any audio-track language that aligns
|
||||
// with the query (e.g. searching "arabic" surfaces videos that carry
|
||||
// an Arabic track even if the primary language is English).
|
||||
$trackVideoIds = VideoAudioTrack::whereRaw('LOWER(language) = ?', [$qLower])
|
||||
->pluck('video_id');
|
||||
|
||||
$videoHits = Video::public()
|
||||
->where(function ($q) use ($like, $qLower, $trackVideoIds) {
|
||||
$q->where('title', 'like', $like)
|
||||
->orWhere('description','like', $like)
|
||||
->orWhere('tags', 'like', $like)
|
||||
->orWhereRaw('LOWER(language) = ?', [$qLower])
|
||||
->orWhereIn('id', $trackVideoIds);
|
||||
})
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
$videos = $videoHits->where('is_shorts', false)->values();
|
||||
$shorts = $videoHits->where('is_shorts', true)->values();
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────
|
||||
$channels = User::where(function ($q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('username','like', $like)
|
||||
->orWhere('bio', 'like', $like);
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
// ── Playlists ─────────────────────────────────────────────
|
||||
$playlists = Playlist::where('visibility', 'public')
|
||||
->where(function ($q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('description','like', $like);
|
||||
})
|
||||
->withCount('videos')
|
||||
->with(['user', 'videos' => fn ($q) => $q->orderBy('playlist_videos.position')->limit(1)])
|
||||
->latest()
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return view('videos.index', [
|
||||
'videos' => $videos,
|
||||
'shorts' => $shorts,
|
||||
'playlists' => $playlists,
|
||||
'channels' => $channels,
|
||||
'query' => $query,
|
||||
'langChips' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\SportsMatch;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -34,6 +35,23 @@ class SportsMatchController extends Controller
|
||||
$this->handleImages($match, $request, $nas);
|
||||
$match->save();
|
||||
|
||||
if ($match->video && $match->video->title !== $match->title) {
|
||||
$match->video->title = $match->title;
|
||||
$match->video->save();
|
||||
}
|
||||
|
||||
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
|
||||
// get the composed VS card on first share, not the fallback frame.
|
||||
if ($match->video) {
|
||||
// Blocks the response by ~3–5s while the VS PNG renders. That
|
||||
// wait is intentional: the user typically shares the URL right
|
||||
// after saving, and WhatsApp/Facebook cache whatever they see
|
||||
// on first crawl. If the render hasn't finished when the
|
||||
// crawler hits, we'd be stuck on the thumbnail fallback in
|
||||
// every future share.
|
||||
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match saved as draft.',
|
||||
@@ -51,6 +69,21 @@ class SportsMatchController extends Controller
|
||||
$this->handleImages($sportsMatch, $request, $nas);
|
||||
$sportsMatch->save();
|
||||
|
||||
// Keep the parent Video's display title in sync with the match title
|
||||
// so the match page header, share metadata, and search results all
|
||||
// reflect the edited value.
|
||||
if ($sportsMatch->video && $sportsMatch->video->title !== $sportsMatch->title) {
|
||||
$sportsMatch->video->title = $sportsMatch->title;
|
||||
$sportsMatch->video->save();
|
||||
}
|
||||
|
||||
if ($sportsMatch->video) {
|
||||
// Synchronous re-render on edit: guarantees the fresh VS card
|
||||
// exists at the new versioned URL before the JSON response
|
||||
// returns and the user can share the link. See renderMatchOgSync.
|
||||
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match updated.',
|
||||
@@ -166,7 +199,9 @@ class SportsMatchController extends Controller
|
||||
// Media text fields (caption/alt/credit/public). Preserve existing image
|
||||
// paths already on the record; handleImages() overwrites any replaced ones.
|
||||
$existingMedia = $match->media ?? [];
|
||||
$mediaText = $this->clean($request->input('media', []));
|
||||
// clean() returns null when the array is entirely empty — coerce back to []
|
||||
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
|
||||
$mediaText = $this->clean($request->input('media', [])) ?? [];
|
||||
if (isset($mediaText['public'])) {
|
||||
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
// ── 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');
|
||||
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
|
||||
$sizeAvatars = $this->dirSize($disk, 'avatars');
|
||||
$sizeImages = $this->dirSize($disk, 'images');
|
||||
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
|
||||
// Images are variable but ~120 KB is a solid conservative estimate
|
||||
// for a web-sized JPEG/PNG upload.
|
||||
$sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
|
||||
$sizeThumbnails = $thumbnailCount * 120 * 1024;
|
||||
$sizeAvatars = $avatarCount * 120 * 1024;
|
||||
$totalPublicSize = $sizeVideos + $sizeImages;
|
||||
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
|
||||
|
||||
// Convert to MB
|
||||
$toMb = fn($b) => round($b / 1024 / 1024, 1);
|
||||
$storage = [
|
||||
'videos' => $toMb($sizeVideos),
|
||||
@@ -506,7 +517,7 @@ class SuperAdminController extends Controller
|
||||
foreach ($uniqueViewers as $viewer) {
|
||||
if (!$viewer->country) continue;
|
||||
if (!isset($countryMap[$viewer->country])) {
|
||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
|
||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name ?: \App\Data\Countries::name($viewer->country), 'total' => 0];
|
||||
}
|
||||
$countryMap[$viewer->country]['total']++;
|
||||
}
|
||||
@@ -772,7 +783,7 @@ class SuperAdminController extends Controller
|
||||
->groupBy('users.id', 'users.name', 'users.avatar')
|
||||
->orderByDesc('video_count')->take($limit)->get();
|
||||
return response()->json(['type' => 'uploaders', 'items' => $items->map(fn($u) => [
|
||||
'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : 'https://i.pravatar.cc/40?u='.$u->id,
|
||||
'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : asset('images/default-avatar.svg'),
|
||||
'name' => $u->name,
|
||||
'videos' => $u->video_count,
|
||||
'views' => $u->total_views,
|
||||
|
||||
@@ -18,7 +18,7 @@ class UserController extends Controller
|
||||
{
|
||||
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)
|
||||
@@ -246,7 +246,14 @@ class UserController extends Controller
|
||||
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
|
||||
// Views first, likes as the tiebreak, created_at as the final
|
||||
// tiebreak so two videos with the same view+like counts don't
|
||||
// flip order between page loads.
|
||||
$baseQuery
|
||||
->withCount(['viewers as viewers_count', 'likes as likes_count'])
|
||||
->orderByDesc('viewers_count')
|
||||
->orderByDesc('likes_count')
|
||||
->latest();
|
||||
break;
|
||||
case 'oldest':
|
||||
$baseQuery->oldest();
|
||||
@@ -262,8 +269,16 @@ class UserController extends Controller
|
||||
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
|
||||
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
|
||||
|
||||
// Header stats must not leak unlisted/private video view counts to
|
||||
// visitors. Owners (and ?preview=1 is handled above by setting
|
||||
// $isOwner=false) see totals across every video they uploaded;
|
||||
// visitors see totals across public videos only — matching what
|
||||
// the videos count row already shows.
|
||||
$viewVideoIds = $isOwner
|
||||
? $user->videos()->pluck('id')
|
||||
: Video::public()->where('user_id', $user->id)->pluck('id');
|
||||
$totalViews = \DB::table('video_views')
|
||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
||||
->whereIn('video_id', $viewVideoIds)
|
||||
->count();
|
||||
|
||||
// Filter social links by visibility for the current viewer
|
||||
@@ -310,29 +325,41 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
// Watch history
|
||||
public function history()
|
||||
public function history(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get videos the user has watched, ordered by most recently watched
|
||||
// Include private videos since they are the user's own
|
||||
$videoIds = \DB::table('video_views')
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('watched_at', 'desc')
|
||||
->pluck('video_id')
|
||||
->unique();
|
||||
|
||||
$videos = Video::whereIn('id', $videoIds)
|
||||
// Latest-first by watched_at. Paginate at the join so a user who
|
||||
// has watched tens of thousands of videos doesn't pull every row
|
||||
// into memory on each page load. Fetch the videos keyed by latest
|
||||
// view so sort order is deterministic even when the same video has
|
||||
// been watched more than once.
|
||||
//
|
||||
// Each history row is one (video_views + videos) pair, with the
|
||||
// uniqueness constraint deferred to presentation: duplicates per
|
||||
// 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) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
$q->where('videos.visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortBy(function ($video) use ($videoIds) {
|
||||
return $videoIds->search($video->id);
|
||||
});
|
||||
->orderBy('video_views.watched_at', 'desc')
|
||||
->select('videos.id as id', 'video_views.watched_at')
|
||||
->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
|
||||
@@ -345,18 +372,82 @@ class UserController extends Controller
|
||||
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
|
||||
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
||||
* the sidebar's "Subscriptions" item used to point at. Thin on purpose:
|
||||
* reuses the existing video-card component via the videos/index view.
|
||||
*/
|
||||
public function subscriptionsFeed()
|
||||
{
|
||||
$me = Auth::user();
|
||||
$channelIds = $me->subscriptions()->pluck('users.id');
|
||||
|
||||
$videos = Video::public()
|
||||
->whereIn('user_id', $channelIds)
|
||||
->notShorts()
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
return view('videos.index', [
|
||||
'videos' => $videos,
|
||||
'filter' => 'subscriptions',
|
||||
'langChips' => [],
|
||||
'shorts' => collect(),
|
||||
'matches' => collect(),
|
||||
'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Liked videos
|
||||
public function liked()
|
||||
{
|
||||
$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()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
->latest('videos.id')
|
||||
->cursorPaginate(24);
|
||||
|
||||
return view('user.liked', compact('videos'));
|
||||
}
|
||||
@@ -394,14 +485,42 @@ class UserController extends Controller
|
||||
} else {
|
||||
$video->likes()->attach($user->id);
|
||||
$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) {
|
||||
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'liked' => $liked,
|
||||
'like_count' => $video->like_count,
|
||||
'liked' => $liked,
|
||||
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Services\VideoMergeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class VideoMergeController extends Controller
|
||||
{
|
||||
public function __construct(private VideoMergeService $merger) {}
|
||||
|
||||
/**
|
||||
* List the current user's other music videos that $target can be merged with.
|
||||
*/
|
||||
public function candidates(Request $request, Video $target)
|
||||
{
|
||||
$this->authorizeOwner($target);
|
||||
if ($target->type !== 'music') {
|
||||
return response()->json(['error' => 'Only music videos can be merged.'], 422);
|
||||
}
|
||||
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
$query = Video::where('user_id', Auth::id())
|
||||
->where('type', 'music')
|
||||
->where('id', '!=', $target->id)
|
||||
->orderByDesc('created_at');
|
||||
if ($q !== '') {
|
||||
$query->where('title', 'like', '%' . $q . '%');
|
||||
}
|
||||
$items = $query->limit(30)->get(['id', 'title', 'thumbnail', 'created_at'])->map(function ($v) {
|
||||
return [
|
||||
'id' => $v->getRouteKey(),
|
||||
'title' => $v->title,
|
||||
'thumbnail' => $v->thumbnail_url,
|
||||
'created' => $v->created_at?->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['items' => $items]);
|
||||
}
|
||||
|
||||
public function preview(Video $target, Video $source)
|
||||
{
|
||||
$this->authorizeOwner($target);
|
||||
$this->authorizeOwner($source);
|
||||
if ($target->type !== 'music' || $source->type !== 'music') {
|
||||
return response()->json(['error' => 'Only music videos can be merged.'], 422);
|
||||
}
|
||||
if ($target->id === $source->id) {
|
||||
return response()->json(['error' => 'Cannot merge a video with itself.'], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'source' => [
|
||||
'id' => $source->getRouteKey(),
|
||||
'title' => $source->title,
|
||||
'thumb' => $source->thumbnail_url,
|
||||
],
|
||||
'target' => [
|
||||
'id' => $target->getRouteKey(),
|
||||
'title' => $target->title,
|
||||
'thumb' => $target->thumbnail_url,
|
||||
],
|
||||
'counts' => $this->merger->preview($source, $target),
|
||||
]);
|
||||
}
|
||||
|
||||
public function merge(Request $request, Video $target, Video $source)
|
||||
{
|
||||
$this->authorizeOwner($target);
|
||||
$this->authorizeOwner($source);
|
||||
|
||||
try {
|
||||
$this->merger->merge($source, $target);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Video merge failed', [
|
||||
'source' => $source->id,
|
||||
'target' => $target->id,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['error' => 'Merge failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'redirect' => route('videos.show', $target),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeOwner(Video $video): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) abort(401);
|
||||
if ($user->id !== $video->user_id && !$user->isSuperAdmin()) abort(403);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\Format\Video\X264;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -13,7 +11,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CompressVideoJob implements ShouldQueue
|
||||
{
|
||||
@@ -30,7 +27,6 @@ class CompressVideoJob implements ShouldQueue
|
||||
{
|
||||
$video = $this->video;
|
||||
|
||||
// Get original file path
|
||||
$originalPath = storage_path('app/' . $video->path);
|
||||
|
||||
if (!file_exists($originalPath)) {
|
||||
@@ -38,98 +34,114 @@ class CompressVideoJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Create compressed file alongside the original
|
||||
$compressedFilename = 'compressed_' . $video->filename;
|
||||
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
|
||||
|
||||
try {
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$ffmpeg = FFMpeg::create([
|
||||
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
||||
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
||||
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
||||
]);
|
||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||
$ffmpegBin = $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg';
|
||||
$ffprobeBin = $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe';
|
||||
|
||||
// Detect the source video codec. Browsers on Chrome/Firefox/Android can't
|
||||
// decode HEVC (H.265/hev1/hvc1) — those sources must be replaced with H.264
|
||||
// even if the transcode is larger, so downloads and MP4 fallback stay playable.
|
||||
$srcCodec = '';
|
||||
try {
|
||||
$probeOut = [];
|
||||
exec(escapeshellcmd($ffprobeBin) . ' -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 ' . escapeshellarg($originalPath), $probeOut);
|
||||
$srcCodec = strtolower(trim($probeOut[0] ?? ''));
|
||||
} catch (\Throwable $e) {}
|
||||
$forceReplace = in_array($srcCodec, ['hevc', 'h265', 'hev1', 'hvc1'], true);
|
||||
|
||||
// Verify the GPU is actually reachable and able to encode before sending
|
||||
// the file to it; otherwise fall back to CPU so the job never hangs.
|
||||
$gpuEnabled = Setting::gpuUsable();
|
||||
$encoder = Setting::gpuEncoder();
|
||||
$preset = Setting::gpuPreset();
|
||||
$encoder = Setting::gpuEncoder(); // h264_nvenc / libx264
|
||||
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||
$device = Setting::gpuDevice();
|
||||
$hwaccel = Setting::gpuHwaccel();
|
||||
|
||||
if ($gpuEnabled) {
|
||||
$videoPasses = [
|
||||
"-c:v {$encoder}",
|
||||
"-preset {$preset}",
|
||||
'-rc vbr',
|
||||
'-cq 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
"-gpu {$device}",
|
||||
];
|
||||
// Build ffmpeg command directly — php-ffmpeg's X264 format validator
|
||||
// only accepts 'libx264' and throws on 'h264_nvenc'.
|
||||
$cmd = [escapeshellcmd($ffmpegBin), '-y'];
|
||||
|
||||
if ($gpuEnabled && $hwaccel !== 'none') {
|
||||
$cmd[] = "-hwaccel {$hwaccel}";
|
||||
$cmd[] = "-hwaccel_device {$device}";
|
||||
}
|
||||
|
||||
$cmd[] = '-i ' . escapeshellarg($originalPath);
|
||||
$cmd[] = "-c:v {$encoder}";
|
||||
|
||||
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-rc vbr';
|
||||
$cmd[] = '-cq 23';
|
||||
$cmd[] = "-gpu {$device}";
|
||||
} else {
|
||||
$videoPasses = [
|
||||
'-c:v libx264',
|
||||
'-preset fast',
|
||||
'-crf 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
];
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-crf 23';
|
||||
}
|
||||
$audioPasses = ['-c:a aac', '-b:a 192k'];
|
||||
|
||||
$format = new X264('aac', $encoder);
|
||||
foreach ($videoPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
foreach ($audioPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
$ffmpegVideo->save($format, $compressedPath);
|
||||
$cmd[] = '-profile:v high';
|
||||
$cmd[] = '-pix_fmt yuv420p';
|
||||
$cmd[] = '-c:a aac';
|
||||
$cmd[] = '-b:a 192k';
|
||||
$cmd[] = '-movflags +faststart';
|
||||
$cmd[] = escapeshellarg($compressedPath);
|
||||
|
||||
$fullCmd = implode(' ', $cmd) . ' 2>&1';
|
||||
|
||||
Log::info('CompressVideoJob: Starting', [
|
||||
'video_id' => $video->id,
|
||||
'src_codec' => $srcCodec,
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
]);
|
||||
|
||||
$output = [];
|
||||
exec($fullCmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$tail = implode("\n", array_slice($output, -30));
|
||||
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
|
||||
}
|
||||
|
||||
// Check if compressed file was created and is smaller
|
||||
if (file_exists($compressedPath)) {
|
||||
$originalSize = filesize($originalPath);
|
||||
$originalSize = filesize($originalPath);
|
||||
$compressedSize = filesize($compressedPath);
|
||||
|
||||
// Only use compressed file if it's smaller
|
||||
if ($compressedSize < $originalSize) {
|
||||
// Delete original and rename compressed
|
||||
if ($compressedSize < $originalSize || $forceReplace) {
|
||||
unlink($originalPath);
|
||||
rename($compressedPath, $originalPath);
|
||||
|
||||
// Update video record
|
||||
$video->update([
|
||||
'size' => $compressedSize,
|
||||
'filename' => $video->filename, // Keep same filename
|
||||
'size' => $compressedSize,
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
Log::info('CompressVideoJob: Video compressed', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
'saved' => round(($originalSize - $compressedSize) / max(1, $originalSize) * 100) . '%',
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
'src_codec' => $srcCodec,
|
||||
'forced' => $forceReplace && $compressedSize >= $originalSize,
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
unlink($compressedPath);
|
||||
Log::info('CompressVideoJob: Compression made file larger, keeping original');
|
||||
Log::info('CompressVideoJob: Compression made file larger, keeping original', ['src_codec' => $srcCodec]);
|
||||
}
|
||||
}
|
||||
|
||||
$video->update(['status' => 'ready']);
|
||||
|
||||
// Chain to HLS generation for GPU-accelerated adaptive playback
|
||||
\App\Jobs\GenerateHlsJob::dispatch($video);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
||||
$video->update(['status' => 'ready']); // Mark as ready anyway
|
||||
if (file_exists($compressedPath)) @unlink($compressedPath);
|
||||
$video->update(['status' => 'ready']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ class CoachReview extends Model
|
||||
'note',
|
||||
'coach_name',
|
||||
'emoji',
|
||||
'position_x',
|
||||
'position_y',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'position_x' => 'float',
|
||||
'position_y' => 'float',
|
||||
'start_time_seconds' => 'float',
|
||||
'end_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
@@ -11,6 +11,13 @@ class Comment extends Model
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'body',
|
||||
'hearted',
|
||||
'pinned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hearted' => 'boolean',
|
||||
'pinned' => 'boolean',
|
||||
];
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ class MatchPoint extends Model
|
||||
'score_red',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'timestamp_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
|
||||
@@ -19,7 +19,7 @@ class MatchRound extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_time_seconds' => 'integer',
|
||||
'start_time_seconds' => 'float',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
|
||||
+46
-7
@@ -34,7 +34,7 @@ class Playlist extends Model
|
||||
public function videos()
|
||||
{
|
||||
return $this->belongsToMany(Video::class, 'playlist_videos')
|
||||
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
|
||||
->withPivot('position', 'audio_track_id', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
@@ -296,24 +296,48 @@ class Playlist extends Model
|
||||
}
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Video $video)
|
||||
// Add video to playlist. When $audioTrackId is null the primary track
|
||||
// plays; otherwise the specified language track is pinned to this row.
|
||||
// Same video with a *different* track becomes a separate row.
|
||||
public function addVideo(Video $video, ?int $audioTrackId = null)
|
||||
{
|
||||
if ($this->hasVideo($video)) {
|
||||
if ($this->hasVideoWithTrack($video, $audioTrackId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPosition = $this->videos()->max('position') ?? -1;
|
||||
|
||||
$this->videos()->attach($video->id, [
|
||||
'position' => $maxPosition + 1,
|
||||
'added_at' => now(),
|
||||
'position' => $maxPosition + 1,
|
||||
'audio_track_id' => $audioTrackId,
|
||||
'added_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
// Remove a specific (video, track) row from the playlist. When
|
||||
// $audioTrackId is null, only the "primary track" row is removed —
|
||||
// language-pinned rows for the same video are left in place.
|
||||
public function removeVideoWithTrack(Video $video, ?int $audioTrackId = null)
|
||||
{
|
||||
$query = \DB::table('playlist_videos')
|
||||
->where('playlist_id', $this->id)
|
||||
->where('video_id', $video->id);
|
||||
if ($audioTrackId === null) {
|
||||
$query->whereNull('audio_track_id');
|
||||
} else {
|
||||
$query->where('audio_track_id', $audioTrackId);
|
||||
}
|
||||
$deleted = $query->delete();
|
||||
|
||||
if ($deleted) $this->reorderPositions();
|
||||
|
||||
return (bool) $deleted;
|
||||
}
|
||||
|
||||
// Remove *all* rows for a given video (any track). Preserved for
|
||||
// callers that don't care about the track distinction.
|
||||
public function removeVideo(Video $video)
|
||||
{
|
||||
if (! $this->hasVideo($video)) {
|
||||
@@ -328,6 +352,21 @@ class Playlist extends Model
|
||||
return true;
|
||||
}
|
||||
|
||||
// Does this playlist contain (video, track)? track=null matches the
|
||||
// "primary track" row specifically, not any row for the video.
|
||||
public function hasVideoWithTrack(Video $video, ?int $audioTrackId = null): bool
|
||||
{
|
||||
$query = \DB::table('playlist_videos')
|
||||
->where('playlist_id', $this->id)
|
||||
->where('video_id', $video->id);
|
||||
if ($audioTrackId === null) {
|
||||
$query->whereNull('audio_track_id');
|
||||
} else {
|
||||
$query->where('audio_track_id', $audioTrackId);
|
||||
}
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
// Reorder positions after removal
|
||||
protected function reorderPositions()
|
||||
{
|
||||
|
||||
@@ -34,6 +34,90 @@ class SportsMatch extends Model
|
||||
'reviews' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Shape the record into exactly what the match page header renders. Every value
|
||||
* is null-safe so a video with no/partial match data degrades to just its title
|
||||
* instead of falling back to hard-coded demo content.
|
||||
*/
|
||||
public function headerData(): array
|
||||
{
|
||||
$p = $this->participants ?? [];
|
||||
$c = $this->competition ?? [];
|
||||
$v = $this->venue ?? [];
|
||||
$m = $this->media ?? [];
|
||||
$trim = fn($x) => (is_string($x) && trim($x) !== '') ? trim($x) : null;
|
||||
|
||||
return [
|
||||
'sport' => $trim($this->sport),
|
||||
'blue' => [
|
||||
'name' => $trim($this->participant1_name),
|
||||
'club' => $trim($p['p1_club'] ?? null),
|
||||
'flag' => $this->flagFor($p['p1_country'] ?? null),
|
||||
// Uploader form (sports-match modal) stores these under the
|
||||
// canonical media.* keys — mirror them to headerData() so the
|
||||
// scoreboard/VS partials get real image paths.
|
||||
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
|
||||
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
|
||||
],
|
||||
'red' => [
|
||||
'name' => $trim($this->participant2_name),
|
||||
'club' => $trim($p['p2_club'] ?? null),
|
||||
'flag' => $this->flagFor($p['p2_country'] ?? null),
|
||||
'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
|
||||
'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
|
||||
],
|
||||
'weight_category' => $trim($p['weight_class'] ?? null),
|
||||
'division' => $trim($c['division'] ?? null),
|
||||
// Sub-header "stage" shown between the two yellow lines above
|
||||
// the weight class (e.g. "Finals", "SEMI FINAL"). Sourced from
|
||||
// the SportsMatch->title column — the form's "Match title" input.
|
||||
'stage' => $trim($this->title),
|
||||
// "Event title" = the championship / tournament name shown above the
|
||||
// fighters. Prefer the SportsMatch->event_name column (the field the
|
||||
// uploader labels "Event title"), fall back to competition.championship_name.
|
||||
'championship' => $trim($this->event_name) ?? $trim($c['championship_name'] ?? null),
|
||||
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
|
||||
'court' => $trim($c['court'] ?? null),
|
||||
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
|
||||
'match_date' => $this->match_date?->format('Y-m-d'),
|
||||
'referee' => [
|
||||
'name' => $trim($this->referee_name),
|
||||
'flag' => $this->flagFor($p['referee_country'] ?? null),
|
||||
],
|
||||
'venue' => [
|
||||
'name' => $trim($this->venue_name) ?? $trim($v['name'] ?? null),
|
||||
'map_link' => $trim($v['map_link'] ?? null),
|
||||
],
|
||||
'event_logo' => $trim($m['event_poster'] ?? null),
|
||||
/*
|
||||
* Per-video overlay defaults set by the uploader. Shape:
|
||||
* ['scorebar'=>bool, 'feed'=>bool, 'matchInfo'=>bool, 'timeline'=>bool,
|
||||
* 'clubs'=>bool, 'flags'=>bool, 'penalties'=>bool, 'cornerLabels'=>'auto|redblue|karate|taekwondo']
|
||||
* Anything not set falls back to the app defaults in the JS.
|
||||
*/
|
||||
'scoreboard_defaults' => is_array($c['scoreboard'] ?? null) ? $c['scoreboard'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
/** Resolve a country name or ISO2 code to a lowercase flag-icons code (or null). */
|
||||
private function flagFor(?string $country): ?string
|
||||
{
|
||||
$country = trim((string) $country);
|
||||
if ($country === '') {
|
||||
return null;
|
||||
}
|
||||
$countries = \App\Data\Countries::all();
|
||||
if (preg_match('/^[A-Za-z]{2}$/', $country) && isset($countries[strtoupper($country)])) {
|
||||
return strtolower($country);
|
||||
}
|
||||
foreach ($countries as $iso2 => $meta) {
|
||||
if (strcasecmp($meta['name'], $country) === 0) {
|
||||
return strtolower($iso2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
|
||||
+114
-2
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
'notification_preferences' => 'array',
|
||||
// TAKEONE account link. The token is a live credential for that
|
||||
// platform, so it is encrypted at rest and never sent back to a browser.
|
||||
'takeone_token' => 'encrypted',
|
||||
'takeone_connected_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Can this user run TAKEONE lookups?
|
||||
*
|
||||
* A super admin always can: the platform holds a TAKEONE super-admin token
|
||||
* on their behalf, so they are connected with full reach without ever
|
||||
* pasting anything. Everyone else links their own account, and sees only
|
||||
* what that account may see.
|
||||
*/
|
||||
public function takeoneLinked(): bool
|
||||
{
|
||||
return $this->takeoneToken() !== null;
|
||||
}
|
||||
|
||||
/** True when the connection is the platform's, not one this user pasted. */
|
||||
public function takeoneManagedLink(): bool
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
return ($own === null || $own === '')
|
||||
&& $this->isSuperAdmin()
|
||||
&& ! empty(config('services.takeone.admin_token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to talk to TAKEONE with. A personally linked account always
|
||||
* wins, so a super admin who deliberately links their own identity is not
|
||||
* silently upgraded back to the platform credential.
|
||||
*/
|
||||
public function takeoneToken(): ?string
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
if ($own !== null && $own !== '') {
|
||||
return $own;
|
||||
}
|
||||
|
||||
if ($this->isSuperAdmin()) {
|
||||
$platform = config('services.takeone.admin_token');
|
||||
|
||||
return ! empty($platform) ? $platform : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defaults for each preference key (true = on, false = off)
|
||||
private const NOTIF_DEFAULTS = [
|
||||
'notif_new_comment' => true,
|
||||
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-frequent ISO2 country for each user id, aggregating
|
||||
* video_views + profile_visits and falling back to users.nationality.
|
||||
* Returns [user_id => 'BH', …]; keys without data are omitted.
|
||||
*/
|
||||
public static function topCountriesFor(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
|
||||
if (! $userIds) return [];
|
||||
|
||||
$tally = [];
|
||||
$rows = DB::table('video_views')
|
||||
->selectRaw('user_id, country, COUNT(*) as c')
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
if (Schema::hasTable('profile_visits')) {
|
||||
$rows = DB::table('profile_visits')
|
||||
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
|
||||
->whereIn('visitor_user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('visitor_user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($tally as $uid => $counts) {
|
||||
arsort($counts);
|
||||
$out[$uid] = array_key_first($counts);
|
||||
}
|
||||
|
||||
// Fallback to users.nationality for anyone still unresolved.
|
||||
$missing = array_values(array_diff($userIds, array_keys($out)));
|
||||
if ($missing) {
|
||||
$nats = static::whereIn('id', $missing)
|
||||
->whereNotNull('nationality')->where('nationality', '!=', '')
|
||||
->pluck('nationality', 'id');
|
||||
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
@@ -168,7 +268,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return route('media.avatar', $this->avatar).'?v='.($this->updated_at?->timestamp ?? '0');
|
||||
}
|
||||
|
||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||
return asset('images/default-avatar.svg');
|
||||
}
|
||||
|
||||
public function getBannerUrlAttribute(): ?string
|
||||
@@ -181,7 +281,19 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
$this->notify(new VerifyEmail);
|
||||
// Mail failures must never bubble up as a 500 to the user — the account
|
||||
// exists, the HTTP request was fine, only the delivery pipe is broken.
|
||||
// We log the failure (ops picks it up) and move on. Re-sending via
|
||||
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
|
||||
try {
|
||||
$this->notify(new VerifyEmail);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
|
||||
'user_id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
|
||||
+51
-2
@@ -53,6 +53,11 @@ class Video extends Model
|
||||
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function dislikes()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
|
||||
}
|
||||
|
||||
public function viewers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_views')
|
||||
@@ -67,7 +72,9 @@ class Video extends Model
|
||||
|
||||
public function audioTracks()
|
||||
{
|
||||
return $this->hasMany(\App\Models\VideoAudioTrack::class)->orderBy('id');
|
||||
return $this->hasMany(\App\Models\VideoAudioTrack::class)
|
||||
->orderBy('position')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function hasSlideshow(): bool
|
||||
@@ -175,6 +182,20 @@ class Video extends Model
|
||||
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
|
||||
public function getViewCountAttribute()
|
||||
{
|
||||
@@ -252,7 +273,29 @@ class Video extends Model
|
||||
if ($realId === null || $realId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
return $this->where('id', $realId)->firstOrFail();
|
||||
$video = $this->where('id', $realId)->first();
|
||||
if ($video) {
|
||||
return $video;
|
||||
}
|
||||
|
||||
// Video not found — check the merge-redirect table so old links still work.
|
||||
$redirect = \DB::table('video_redirects')->where('source_id', $realId)->first();
|
||||
if ($redirect) {
|
||||
$target = static::find($redirect->target_id);
|
||||
if ($target) {
|
||||
$newValue = self::encodeId($target->id);
|
||||
$newUrl = preg_replace(
|
||||
'#/'.preg_quote((string) $value, '#').'(?=/|\?|$)#',
|
||||
'/'.$newValue,
|
||||
request()->fullUrl(),
|
||||
1
|
||||
);
|
||||
throw new \Illuminate\Http\Exceptions\HttpResponseException(
|
||||
redirect()->to($newUrl, 302)
|
||||
);
|
||||
}
|
||||
}
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// All share URLs use the unguessable token route
|
||||
@@ -434,6 +477,12 @@ class Video extends Model
|
||||
return $this->hasMany(CoachReview::class)->orderBy('start_time_seconds');
|
||||
}
|
||||
|
||||
// The sports match record backing a type=match video (fighters, referee, etc.)
|
||||
public function sportsMatch()
|
||||
{
|
||||
return $this->hasOne(SportsMatch::class);
|
||||
}
|
||||
|
||||
// Get recent views count (within hours)
|
||||
public function getRecentViews($hours = 48)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Fires to every super admin when the same email or IP has produced several
|
||||
* failed login attempts in a short window. Lets ops decide whether to lock
|
||||
* the account, change the password, or just raise the global throttle.
|
||||
*/
|
||||
class BruteForceLoginAlert extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $email,
|
||||
public readonly string $ip,
|
||||
public readonly int $failures,
|
||||
public readonly int $windowMinutes,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'kind' => 'brute_force_login_alert',
|
||||
'email' => $this->email,
|
||||
'ip' => $this->ip,
|
||||
'failures' => $this->failures,
|
||||
'window_minutes' => $this->windowMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('TAKEONE — suspicious login attempts')
|
||||
->greeting('Possible brute-force login attempt')
|
||||
->line("The address {$this->email} has had {$this->failures} failed login attempts in the last {$this->windowMinutes} minutes.")
|
||||
->line("Most recent attempt came from {$this->ip}.")
|
||||
->line('Review the audit log and consider forcing a password reset if this account looks real.')
|
||||
->action('Open Admin', url('/admin/audit-logs'));
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
|
||||
);
|
||||
|
||||
// Force HTTPS
|
||||
URL::forceScheme('https');
|
||||
// Force HTTPS unless we're running under phpunit — the test client
|
||||
// speaks plain http://localhost; forcing https would make every POST
|
||||
// in a feature test resolve to a 302 instead of hitting the controller.
|
||||
if (! $this->app->environment('testing')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Universal pagination view — used everywhere by default
|
||||
Paginator::defaultView('partials.pagination');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Blocks impersonation of staff/system accounts.
|
||||
*
|
||||
* Applied to both `name` and the email local-part at registration so a user
|
||||
* cannot sign up as "admin", "superadmin", "takeone", etc. — words that would
|
||||
* let them pass themselves off as the platform or its operators to other
|
||||
* users (comments, DMs, channel pages).
|
||||
*/
|
||||
class ReservedIdentifier implements ValidationRule
|
||||
{
|
||||
private const RESERVED = [
|
||||
'admin', 'administrator', 'superadmin', 'super-admin', 'super_admin',
|
||||
'root', 'sysadmin', 'system', 'owner', 'webmaster', 'postmaster',
|
||||
'moderator', 'mod', 'staff', 'official',
|
||||
'support', 'help', 'helpdesk', 'contact', 'info', 'noreply', 'no-reply',
|
||||
'security', 'abuse', 'billing',
|
||||
'takeone', 'take-one', 'take_one', 'takeonebh', 'takeone-bh',
|
||||
'play', 'playbh', 'playtakeone',
|
||||
'anonymous', 'guest', 'null', 'undefined',
|
||||
];
|
||||
|
||||
public function __construct(private readonly string $kind = 'name')
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = $this->kind === 'email'
|
||||
? strtolower(trim(strstr($value, '@', true) ?: $value))
|
||||
: strtolower(trim($value));
|
||||
|
||||
// Collapse separators so "super_admin", "super-admin" and "superadmin" all match.
|
||||
$normalized = preg_replace('/[\s._\-]+/', '', $candidate) ?? $candidate;
|
||||
|
||||
foreach (self::RESERVED as $word) {
|
||||
$target = preg_replace('/[\s._\-]+/', '', $word) ?? $word;
|
||||
if ($normalized === $target) {
|
||||
$fail($this->kind === 'email'
|
||||
? 'That email address is reserved. Please use a different one.'
|
||||
: 'That name is reserved. Please choose a different one.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,16 +191,176 @@ class LlmLyricsService
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate arbitrary text (e.g. a user comment) to a target language.
|
||||
* Returns the translated string, or '' on any failure / when disabled.
|
||||
*/
|
||||
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! $this->isEnabled()) return '';
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_string($cached) && $cached !== '') return $cached;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
|
||||
. " - Translate EVERY word of the source language, including short exclamations,\n"
|
||||
. " repeated words, and words that happen to also look like a name in the target\n"
|
||||
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
|
||||
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
|
||||
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
|
||||
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
|
||||
. " it is almost certainly a regular word — translate it.\n"
|
||||
. " - If the text is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
|
||||
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
|
||||
. " - Keep the same tone and line breaks.\n\n"
|
||||
. "TEXT:\n" . $text;
|
||||
|
||||
// Translation is deterministic — temp 0. If the model echoes the input
|
||||
// (common when a word happens to also exist as a name in the target
|
||||
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
|
||||
// with a stronger directive. If the retry also echoes, we return it.
|
||||
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
|
||||
// keeps the model from drifting into long completions on tiny inputs.
|
||||
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
|
||||
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
|
||||
$needsRetry = $sourceLangHint
|
||||
&& $sourceLangHint !== $targetLang
|
||||
&& self::looksLikeEcho($text, $raw);
|
||||
if ($needsRetry) {
|
||||
$retryPrompt = $prompt
|
||||
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
|
||||
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
|
||||
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
|
||||
}
|
||||
if ($raw === '') return '';
|
||||
|
||||
Cache::put($cacheKey, $raw, now()->addDays(30));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function postProcess(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
|
||||
private static function looksLikeEcho(string $src, string $out): bool
|
||||
{
|
||||
if ($out === '') return false;
|
||||
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
|
||||
$a = $norm($src); $b = $norm($out);
|
||||
return $a !== '' && $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a sequence of lines, preserving the one-to-one mapping so the
|
||||
* caller can render them side-by-side with the originals. Empty entries
|
||||
* are kept as empty strings. Returns an array of the same length as $lines
|
||||
* (or [] on any failure / when disabled).
|
||||
*/
|
||||
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
|
||||
{
|
||||
if (! $lines || ! $this->isEnabled()) return [];
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && count($cached) === count($lines)) return $cached;
|
||||
|
||||
// Index non-empty lines so empty separators are preserved verbatim.
|
||||
$toTranslate = [];
|
||||
foreach ($lines as $i => $l) {
|
||||
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
|
||||
}
|
||||
if (! $toTranslate) {
|
||||
Cache::put($cacheKey, $lines, now()->addDays(30));
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$numbered = [];
|
||||
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
|
||||
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
|
||||
. " - Translate every word including short exclamations and repeated words that happen\n"
|
||||
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
|
||||
. " - If a line is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
|
||||
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
|
||||
. "LINES (index TAB text):\n" . implode("\n", $numbered);
|
||||
|
||||
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
|
||||
$obj = json_decode($raw, true);
|
||||
if (! is_array($obj)) return [];
|
||||
|
||||
$out = $lines;
|
||||
$echoCount = 0;
|
||||
$translatedCount = 0;
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '') {
|
||||
$out[$i] = $v;
|
||||
$translatedCount++;
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
|
||||
$echoCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If > 30% of the translated lines just echo the source, nudge the model.
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang
|
||||
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
|
||||
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
|
||||
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
|
||||
$obj2 = json_decode($raw2, true);
|
||||
if (is_array($obj2)) {
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
|
||||
$out[$i] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cache::put($cacheKey, $out, now()->addDays(30));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Dispatch to the active provider's adapter. */
|
||||
private function call(string $prompt, int $maxTokens): string
|
||||
private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$p = $this->activeProvider();
|
||||
if (! $p) return '';
|
||||
try {
|
||||
return match ($p['kind']) {
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens),
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
|
||||
@@ -208,24 +368,37 @@ class LlmLyricsService
|
||||
}
|
||||
}
|
||||
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
|
||||
// Qwen3-style "thinking" models respect /no_think to skip the hidden
|
||||
// reasoning phase. Without it they can exhaust num_predict inside
|
||||
// the thinking block and emit empty content.
|
||||
$taggedPrompt = $prompt . "\n\n/no_think";
|
||||
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'stream' => false,
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
|
||||
'stream' => false,
|
||||
'think' => false,
|
||||
// Keep the model resident for an hour so follow-up requests skip
|
||||
// the cold-start reload (default is 5 minutes). Dramatically
|
||||
// reduces per-click latency for comment/description translation.
|
||||
'keep_alive' => '1h',
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
return '';
|
||||
}
|
||||
$j = $resp->json();
|
||||
return (string) ($j['message']['content'] ?? '');
|
||||
$content = (string) ($j['message']['content'] ?? '');
|
||||
// Some thinking models still ignore the flag and leak <think>…</think>
|
||||
// tags into content. Strip them.
|
||||
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
|
||||
$resp = Http::timeout(120)->withHeaders([
|
||||
@@ -233,9 +406,10 @@ class LlmLyricsService
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
])->post($endpoint . '/v1/messages', [
|
||||
'model' => $p['model'],
|
||||
'max_tokens' => $maxTokens,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'model' => $p['model'],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
@@ -245,7 +419,7 @@ class LlmLyricsService
|
||||
return (string) ($j['content'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
|
||||
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
|
||||
@@ -254,7 +428,7 @@ class LlmLyricsService
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => 0.2,
|
||||
'temperature' => $temperature,
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
|
||||
@@ -1235,6 +1235,7 @@ class NasSyncService
|
||||
|
||||
public function deleteFolder(string $nasRelPath): void
|
||||
{
|
||||
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
@@ -1333,8 +1334,9 @@ class NasSyncService
|
||||
/**
|
||||
* Rename a path on the NAS share (works for both files and directories).
|
||||
*/
|
||||
private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
|
||||
public function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
|
||||
{
|
||||
if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
|
||||
$cfg = $this->cfg();
|
||||
$target = escapeshellarg($this->smbTarget($cfg));
|
||||
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoAudioTrack;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VideoMergeService
|
||||
{
|
||||
public function __construct(private NasSyncService $nas) {}
|
||||
|
||||
/**
|
||||
* Preview counts for merging $source into $target. Read-only.
|
||||
*/
|
||||
public function preview(Video $source, Video $target): array
|
||||
{
|
||||
$tracks = DB::table('video_audio_tracks')->where('video_id', $source->id)->count();
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->count();
|
||||
$comments = DB::table('comments')->where('video_id', $source->id)->count();
|
||||
$views = DB::table('video_views')->where('video_id', $source->id)->count();
|
||||
$downloads = DB::table('video_downloads')->where('video_id', $source->id)->count();
|
||||
$shares = DB::table('video_shares')->where('video_id', $source->id)->count();
|
||||
|
||||
$srcLikeUsers = DB::table('video_likes')->where('video_id', $source->id)->pluck('user_id');
|
||||
$tgtLikeUsers = DB::table('video_likes')->where('video_id', $target->id)->pluck('user_id')->flip();
|
||||
$newLikes = $srcLikeUsers->reject(fn ($uid) => $tgtLikeUsers->has($uid))->count();
|
||||
|
||||
$srcPlaylists = DB::table('playlist_videos')->where('video_id', $source->id)->pluck('playlist_id');
|
||||
$tgtPlaylists = DB::table('playlist_videos')->where('video_id', $target->id)->pluck('playlist_id')->flip();
|
||||
$plMerged = $srcPlaylists->reject(fn ($pid) => $tgtPlaylists->has($pid))->count();
|
||||
|
||||
return [
|
||||
'tracks' => $tracks + 1, // +1 = source's primary becomes a new track on target
|
||||
'slides' => $slides,
|
||||
'comments' => $comments,
|
||||
'views' => $views,
|
||||
'downloads' => $downloads,
|
||||
'shares' => $shares,
|
||||
'likes_source' => $srcLikeUsers->count(),
|
||||
'likes_after_dedupe' => $newLikes,
|
||||
'playlists_merged' => $plMerged,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge $source video into $target. Both must be music-type.
|
||||
*
|
||||
* Simplified model: everything under sourceDir moves to targetDir, filename
|
||||
* prefixes are rewritten in one pass, and the user rearranges tracks/slides
|
||||
* in the edit UI afterwards. No special "primary vs extra" bookkeeping.
|
||||
*/
|
||||
public function merge(Video $source, Video $target): void
|
||||
{
|
||||
if ($source->id === $target->id) {
|
||||
throw new \InvalidArgumentException('Cannot merge a video with itself.');
|
||||
}
|
||||
if ($source->type !== 'music' || $target->type !== 'music') {
|
||||
throw new \InvalidArgumentException('Only music-type videos can be merged.');
|
||||
}
|
||||
$source->loadMissing(['audioTracks']);
|
||||
|
||||
$sourceDir = $this->nas->resolveVideoDir($source);
|
||||
$targetDir = $this->nas->resolveVideoDir($target);
|
||||
|
||||
if ($sourceDir === $targetDir) {
|
||||
throw new \RuntimeException('Source and target resolve to the same folder — refusing to merge.');
|
||||
}
|
||||
|
||||
// Merge is DB-first: the schema changes always run, and file moves
|
||||
// are best-effort on both sides. When NAS is unreachable, per-track
|
||||
// NAS renames get queued in `pending_nas_moves` and drained by
|
||||
// `nas:auto-sync` once NAS returns.
|
||||
$nasReachable = $this->nas->isEnabled();
|
||||
|
||||
Log::info('Merge: begin', [
|
||||
'source_id' => $source->id,
|
||||
'target_id' => $target->id,
|
||||
'source_dir' => $sourceDir,
|
||||
'target_dir' => $targetDir,
|
||||
'nas_reachable' => $nasReachable,
|
||||
]);
|
||||
|
||||
// Ensure target has a tracks/ subfolder — always locally; on NAS
|
||||
// only when it's reachable (mkdirp itself no-ops otherwise).
|
||||
$this->nas->mkdirp("{$targetDir}/tracks");
|
||||
@mkdir(storage_path("app/{$targetDir}/tracks"), 0755, true);
|
||||
|
||||
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
|
||||
|
||||
// ── 1. Promote source's primary audio to a new track on target ─
|
||||
// Layout-aware: the source may be stored either as the modern
|
||||
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
|
||||
// or as the legacy flat layout ({sourceDir}/audio.ext,
|
||||
// {sourceDir}/slides/, …). We detect which from source->path
|
||||
// and move only what actually exists in each case.
|
||||
$srcPrimaryPath = (string) $source->path;
|
||||
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
|
||||
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
|
||||
|
||||
$newPrimaryTrack = VideoAudioTrack::create([
|
||||
'video_id' => $target->id,
|
||||
'language' => $source->language ?: 'xx',
|
||||
'label' => $source->title ?: null,
|
||||
'title' => $source->title ?: null,
|
||||
'description' => $source->description,
|
||||
'filename' => $srcPrimaryFilename,
|
||||
'path' => '',
|
||||
]);
|
||||
if (!$newPrimaryTrack || !$newPrimaryTrack->id) {
|
||||
throw new \RuntimeException('Failed to create promoted primary track record.');
|
||||
}
|
||||
|
||||
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
|
||||
// Case A: primary audio sits in a dedicated per-track folder → one
|
||||
// folder move brings the audio, its slides and its thumb along.
|
||||
// Case B: primary audio sits directly at the video root (legacy) →
|
||||
// move the individual files, since the video root also holds the
|
||||
// extra tracks' subfolders which stay put until step 2.
|
||||
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
|
||||
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
|
||||
} else {
|
||||
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
|
||||
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
|
||||
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
|
||||
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
|
||||
}
|
||||
// Opportunistically move thumb.{ext} if present.
|
||||
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
|
||||
$thumb = "{$sourceDir}/thumb.{$ext}";
|
||||
if (file_exists(storage_path('app/' . $thumb))) {
|
||||
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
|
||||
}
|
||||
}
|
||||
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
|
||||
// The new per-track layout stores lyrics at
|
||||
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
|
||||
// on the target resolves it immediately.
|
||||
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
|
||||
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
|
||||
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
|
||||
|
||||
// ── 2. Move each source extra track's folder into target/tracks/
|
||||
// The folder basename ({lang}-{track_id}) is globally unique
|
||||
// because track ids are globally unique — no collisions with
|
||||
// target's own tracks. We derive the actual old folder from
|
||||
// the track's stored path so unusual layouts still work.
|
||||
foreach ($source->audioTracks as $track) {
|
||||
$oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
$newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
|
||||
if ($oldFolder !== $newFolder) {
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
}
|
||||
|
||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||
: "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
|
||||
|
||||
$track->video_id = $target->id;
|
||||
$track->path = $newTrackPath;
|
||||
$track->save();
|
||||
|
||||
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
|
||||
// In the new layout they belong inside the moved track folder.
|
||||
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
|
||||
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
|
||||
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Rewrite every source slide's filename to its new on-disk
|
||||
// location, derived from the track it belongs to AFTER the
|
||||
// per-track folder moves above. This is layout-agnostic —
|
||||
// it works for both new (/tracks/{lang-id}/slides/N) and
|
||||
// legacy (/{sourceDir}/slides/N) source layouts.
|
||||
$updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||
foreach ($slides as $s) {
|
||||
$base = $s->filename ? basename($s->filename) : null;
|
||||
if (!$base) continue; // nothing we can safely rewrite
|
||||
|
||||
$owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
|
||||
$owningTrack = $updatedTracks->get($owningTrackId);
|
||||
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
|
||||
|
||||
$updates = [
|
||||
'video_id' => $target->id,
|
||||
'filename' => "{$owningFolder}/slides/{$base}",
|
||||
];
|
||||
if ($s->audio_track_id === null) {
|
||||
$updates['audio_track_id'] = $newPrimaryTrack->id;
|
||||
}
|
||||
DB::table('video_slides')->where('id', $s->id)->update($updates);
|
||||
}
|
||||
|
||||
// ── 4. Re-parent comments / views / downloads / shares ─────────
|
||||
DB::table('comments')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
DB::table('video_views')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
DB::table('video_downloads')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
DB::table('video_shares')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
|
||||
// ── 5. Likes — dedupe by user_id ────────────────────────────────
|
||||
$existingLikeUsers = DB::table('video_likes')->where('video_id', $target->id)->pluck('user_id')->all();
|
||||
if (!empty($existingLikeUsers)) {
|
||||
DB::table('video_likes')
|
||||
->where('video_id', $source->id)
|
||||
->whereIn('user_id', $existingLikeUsers)
|
||||
->delete();
|
||||
}
|
||||
DB::table('video_likes')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
|
||||
// ── 6. Playlist pivots — dedupe by playlist_id ─────────────────
|
||||
$existingPlaylists = DB::table('playlist_videos')->where('video_id', $target->id)->pluck('playlist_id')->all();
|
||||
if (!empty($existingPlaylists)) {
|
||||
DB::table('playlist_videos')
|
||||
->where('video_id', $source->id)
|
||||
->whereIn('playlist_id', $existingPlaylists)
|
||||
->delete();
|
||||
}
|
||||
DB::table('playlist_videos')->where('video_id', $source->id)->update(['video_id' => $target->id]);
|
||||
|
||||
// ── 7. Counters ────────────────────────────────────────────────
|
||||
DB::table('videos')->where('id', $target->id)->update([
|
||||
'download_count' => DB::raw('COALESCE(download_count,0) + ' . (int) $source->download_count),
|
||||
'share_count' => DB::raw('COALESCE(share_count,0) + ' . (int) $source->share_count),
|
||||
]);
|
||||
|
||||
// ── 8. Redirect row (remember source folder for later repair) ──
|
||||
DB::table('video_redirects')->insert([
|
||||
'source_id' => $source->id,
|
||||
'target_id' => $target->id,
|
||||
'source_folder' => $sourceDir,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// ── 9. Delete source video row ─────────────────────────────────
|
||||
DB::table('videos')->where('id', $source->id)->delete();
|
||||
});
|
||||
|
||||
// ── 10. Best-effort cleanup of the emptied source folder ───────────
|
||||
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
|
||||
// rmdir on sourceDir can succeed.
|
||||
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
|
||||
$this->pruneEmptyFolder("{$sourceDir}/tracks");
|
||||
$this->pruneEmptyFolder($sourceDir);
|
||||
|
||||
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an individual file (not a directory) on NAS + local. Needed for
|
||||
* legacy-layout sources where the primary audio and thumb live directly
|
||||
* in the video root rather than inside a per-track subfolder.
|
||||
*/
|
||||
private function moveFile(string $oldRel, string $newRel): void
|
||||
{
|
||||
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
|
||||
|
||||
$nasMoved = false;
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->mkdirp(dirname($newRel));
|
||||
$this->nas->renameNasPath($oldRel, $newRel);
|
||||
$nasMoved = true;
|
||||
} else {
|
||||
DB::table('pending_nas_moves')->insert([
|
||||
'from_path' => $oldRel,
|
||||
'to_path' => $newRel,
|
||||
'kind' => 'file',
|
||||
'origin' => 'merge',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
$localMoved = false;
|
||||
if (is_file($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
$localMoved = @rename($oldLocal, $newLocal);
|
||||
}
|
||||
Log::info('Merge: moveFile', [
|
||||
'old' => $oldRel, 'new' => $newRel,
|
||||
'nas' => $nasMoved, 'local' => $localMoved,
|
||||
'queued' => !$nasMoved,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a folder on both NAS and local disk. NAS ops are queued in
|
||||
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
|
||||
*/
|
||||
private function moveFolder(string $oldRel, string $newRel): void
|
||||
{
|
||||
if ($oldRel === $newRel) return;
|
||||
|
||||
$nasMoved = false;
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->renameNasPath($oldRel, $newRel);
|
||||
$nasMoved = true;
|
||||
} else {
|
||||
// Queue for later — nas:auto-sync will do the rename when NAS is back.
|
||||
DB::table('pending_nas_moves')->insert([
|
||||
'from_path' => $oldRel,
|
||||
'to_path' => $newRel,
|
||||
'kind' => 'folder',
|
||||
'origin' => 'merge',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$oldLocal = storage_path('app/' . $oldRel);
|
||||
$newLocal = storage_path('app/' . $newRel);
|
||||
$localMoved = false;
|
||||
if (is_dir($oldLocal)) {
|
||||
@mkdir(dirname($newLocal), 0755, true);
|
||||
$localMoved = @rename($oldLocal, $newLocal);
|
||||
}
|
||||
Log::info('Merge: moveFolder', [
|
||||
'old' => $oldRel, 'new' => $newRel,
|
||||
'nas' => $nasMoved, 'local' => $localMoved,
|
||||
'queued' => !$nasMoved,
|
||||
]);
|
||||
}
|
||||
|
||||
private function pruneEmptyFolder(string $rel): void
|
||||
{
|
||||
if ($this->nas->isEnabled()) {
|
||||
$this->nas->deleteFile("{$rel}/meta.json");
|
||||
$this->nas->deleteFolder($rel);
|
||||
}
|
||||
// If NAS was unreachable we DON'T queue a delete: after auto-sync
|
||||
// drains the pending moves, the leftover source folder will be
|
||||
// empty and can be reaped by a subsequent housekeeping pass (or by
|
||||
// the videos:repair-merged command). Deleting a folder we haven't
|
||||
// yet emptied would blow away data.
|
||||
|
||||
$local = storage_path('app/' . $rel);
|
||||
if (is_dir($local)) {
|
||||
@unlink($local . '/meta.json');
|
||||
@rmdir($local);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,11 @@ class HtmlSanitizer
|
||||
/**
|
||||
* Produce safe display HTML. Legacy plain-text values (no tags) are
|
||||
* escaped and converted to <br>; rich values are run through clean().
|
||||
*
|
||||
* Also detects a handful of common markdown-ish prefixes that leaked
|
||||
* in through AI-generated descriptions ("## Letra", "### Chorus") and
|
||||
* turns them into real <h2>/<h3> tags before sanitising. Without this
|
||||
* step a reader saw literal "## Letra" instead of a heading.
|
||||
*/
|
||||
public static function render(?string $value): string
|
||||
{
|
||||
@@ -85,6 +90,8 @@ class HtmlSanitizer
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = self::promoteLeakedMarkdown($value);
|
||||
|
||||
if (strip_tags($value) === $value) {
|
||||
return nl2br(e($value), false);
|
||||
}
|
||||
@@ -92,6 +99,24 @@ class HtmlSanitizer
|
||||
return self::clean($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown-style heading prefixes at the start of a line into
|
||||
* real HTML tags, so the sanitiser can keep them. Only touches lines
|
||||
* that start with ##, ###, or **…**; everything else is untouched.
|
||||
*/
|
||||
private static function promoteLeakedMarkdown(string $value): string
|
||||
{
|
||||
$lines = preg_split("/\r?\n/", $value);
|
||||
foreach ($lines as $i => $line) {
|
||||
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
|
||||
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
|
||||
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private static function sanitizeChildren(DOMNode $node): void
|
||||
{
|
||||
foreach (iterator_to_array($node->childNodes) as $child) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Notification failures (SMTP down, Gmail 535, Mailgun 4xx, etc.) must never
|
||||
* crash a user-facing request. Everywhere we previously did
|
||||
*
|
||||
* $user->notify(new SomeNotification(...));
|
||||
*
|
||||
* we now do
|
||||
*
|
||||
* SafeNotifier::send($user, new SomeNotification(...));
|
||||
*
|
||||
* which runs the same code, catches any Throwable, and writes a warning line
|
||||
* so ops still has the signal.
|
||||
*/
|
||||
class SafeNotifier
|
||||
{
|
||||
/**
|
||||
* @param mixed $notifiable Anything with ->notify(), usually a User.
|
||||
*/
|
||||
public static function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
if (! $notifiable || ! is_object($notifiable) || ! method_exists($notifiable, 'notify')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$notifiable->notify($notification);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('notification send failed', [
|
||||
'notification' => $notification::class,
|
||||
'notifiable' => $notifiable::class,
|
||||
'notifiable_id'=> $notifiable->getKey() ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wraps `node` with a raised file-descriptor limit. PHP-FPM starts with
|
||||
# rlimit_files=1024; Chromium opens ~2000 fds on startup and dies before
|
||||
# Puppeteer can attach, surfacing as "Failed to launch the browser
|
||||
# process: Code: null". Bumping just this subprocess tree avoids
|
||||
# restarting FPM to raise the pool-wide limit.
|
||||
exec /usr/bin/prlimit --nofile=65536 /usr/bin/node "$@"
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"laravel/tinker": "^2.8",
|
||||
"p7h/nas-file-manager": "dev-main",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.4",
|
||||
"pragmarx/google2fa-laravel": "^3.0"
|
||||
"pragmarx/google2fa-laravel": "^3.0",
|
||||
"spatie/browsershot": "^3.61"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
|
||||
Generated
+340
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
|
||||
"content-hash": "bdb64417cd2491642a0712fe40bade22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1457,6 +1457,90 @@
|
||||
],
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "2.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"guzzlehttp/psr7": "~1.1 || ^2.0",
|
||||
"php": ">=5.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~0.9.2",
|
||||
"phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "to use GD library based image processing.",
|
||||
"ext-imagick": "to use Imagick based image processing.",
|
||||
"intervention/imagecache": "Caching extension for the Intervention Image library"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Image": "Intervention\\Image\\Facades\\Image"
|
||||
},
|
||||
"providers": [
|
||||
"Intervention\\Image\\ImageServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "2.4-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src/Intervention/Image"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Image handling and manipulation library with support for Laravel integration",
|
||||
"homepage": "http://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"laravel",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/2.7.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2022-05-21T17:30:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "10.50.2",
|
||||
@@ -2237,6 +2321,71 @@
|
||||
},
|
||||
"time": "2026-01-23T15:30:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/glide",
|
||||
"version": "2.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/glide.git",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/glide/zipball/b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"intervention/image": "^2.7",
|
||||
"league/flysystem": "^2.0|^3.0",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/php-token-stream": "^3.1|^4.0",
|
||||
"phpunit/phpunit": "^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Glide\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jonathan Reinink",
|
||||
"email": "[email protected]",
|
||||
"homepage": "http://reinink.ca"
|
||||
},
|
||||
{
|
||||
"name": "Titouan Galopin",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://titouangalopin.com"
|
||||
}
|
||||
],
|
||||
"description": "Wonderfully easy on-demand image manipulation library with an HTTP based API.",
|
||||
"homepage": "http://glide.thephpleague.com",
|
||||
"keywords": [
|
||||
"ImageMagick",
|
||||
"editing",
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"league",
|
||||
"manipulation",
|
||||
"processing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/glide/issues",
|
||||
"source": "https://github.com/thephpleague/glide/tree/2.3.2"
|
||||
},
|
||||
"time": "2025-03-21T13:48:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
@@ -4021,6 +4170,196 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/browsershot",
|
||||
"version": "3.61.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/browsershot.git",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/browsershot/zipball/14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": "^8.0",
|
||||
"spatie/image": "^1.5.3|^2.0",
|
||||
"spatie/temporary-directory": "^1.1|^2.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.20",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Browsershot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://github.com/freekmurze",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Convert a webpage to an image or pdf using headless Chrome",
|
||||
"homepage": "https://github.com/spatie/browsershot",
|
||||
"keywords": [
|
||||
"chrome",
|
||||
"convert",
|
||||
"headless",
|
||||
"image",
|
||||
"pdf",
|
||||
"puppeteer",
|
||||
"screenshot",
|
||||
"webpage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/browsershot/tree/3.61.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-12-21T10:00:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image",
|
||||
"version": "2.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image.git",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image/zipball/2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-exif": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"league/glide": "^2.2.2",
|
||||
"php": "^8.0",
|
||||
"spatie/image-optimizer": "^1.7",
|
||||
"spatie/temporary-directory": "^1.0|^2.0",
|
||||
"symfony/process": "^3.0|^4.0|^5.0|^6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.22",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/var-dumper": "^4.0|^5.0|^6.0",
|
||||
"vimeo/psalm": "^4.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Manipulate images with an expressive API",
|
||||
"homepage": "https://github.com/spatie/image",
|
||||
"keywords": [
|
||||
"image",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/image/tree/2.2.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-07-24T13:54:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image-optimizer",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image-optimizer.git",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"php": "^7.4|^8.0",
|
||||
"psr/log": "^1.0 | ^2.0 | ^3.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
|
||||
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
|
||||
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\ImageOptimizer\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily optimize images using PHP",
|
||||
"homepage": "https://github.com/spatie/image-optimizer",
|
||||
"keywords": [
|
||||
"image-optimizer",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/image-optimizer/issues",
|
||||
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
|
||||
},
|
||||
"time": "2026-06-29T08:28:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -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,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('video_redirects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('source_id')->unique();
|
||||
$table->foreignId('target_id')->constrained('videos')->cascadeOnDelete();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->index('target_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('video_redirects');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('video_redirects', function (Blueprint $table) {
|
||||
$table->string('source_folder', 500)->nullable()->after('target_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('video_redirects', function (Blueprint $table) {
|
||||
$table->dropColumn('source_folder');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||
$table->unsignedInteger('position')->default(0)->after('label');
|
||||
$table->index(['video_id', 'position']);
|
||||
});
|
||||
|
||||
// Backfill existing rows so their current id-order becomes the
|
||||
// canonical position. New rows start at 0 and get placed at the
|
||||
// end by the update handler.
|
||||
DB::statement('UPDATE video_audio_tracks SET position = id');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('video_audio_tracks', function (Blueprint $table) {
|
||||
$table->dropIndex(['video_id', 'position']);
|
||||
$table->dropColumn('position');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('playlist_videos', function (Blueprint $table) {
|
||||
// Nullable — null means "play the primary track".
|
||||
$table->unsignedBigInteger('audio_track_id')
|
||||
->nullable()
|
||||
->after('video_id');
|
||||
|
||||
// Drop the old (playlist_id, video_id) uniqueness so the same
|
||||
// video can appear multiple times with different tracks.
|
||||
$table->dropUnique(['playlist_id', 'video_id']);
|
||||
|
||||
$table->index(['playlist_id', 'video_id', 'audio_track_id'], 'playlist_video_track_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('playlist_videos', function (Blueprint $table) {
|
||||
$table->dropIndex('playlist_video_track_idx');
|
||||
$table->unique(['playlist_id', 'video_id']);
|
||||
$table->dropColumn('audio_track_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Records file/folder moves that were queued while NAS was
|
||||
// unreachable. `nas:auto-sync` drains rows when NAS is back:
|
||||
// for each row it renames from → to on NAS and deletes the row.
|
||||
Schema::create('pending_nas_moves', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('from_path', 500);
|
||||
$table->string('to_path', 500);
|
||||
$table->string('kind', 24)->default('folder'); // 'folder' or 'file'
|
||||
$table->string('origin', 40)->default('merge'); // where it came from
|
||||
$table->unsignedInteger('attempts')->default(0);
|
||||
$table->timestamp('last_attempt_at')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['origin', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pending_nas_moves');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add drag-and-dropped overlay position for coach notes. Stored as normalized
|
||||
* 0..1 coordinates of the overlay's CENTER within the video area so it
|
||||
* survives any viewport / fullscreen resize.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->decimal('position_x', 6, 4)->nullable()->after('emoji');
|
||||
$table->decimal('position_y', 6, 4)->nullable()->after('position_x');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['position_x', 'position_y']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Frame-accurate timestamps: the match highlights scrubber lands on
|
||||
* frame boundaries (1/fps seconds). Storing these as INTEGER seconds
|
||||
* throws away sub-second precision, so we promote the columns to
|
||||
* decimal(10,3) — 3 decimals cover up to 1000 fps and centuries of
|
||||
* runtime, and existing integer values migrate losslessly.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// MatchRound.start_time_seconds is used to jump to a round's opening
|
||||
// frame too, so bring it along.
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
$table->decimal('start_time_seconds', 10, 3)->nullable()->change();
|
||||
});
|
||||
|
||||
Schema::table('match_points', function (Blueprint $table) {
|
||||
$table->decimal('timestamp_seconds', 10, 3)->change();
|
||||
});
|
||||
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->decimal('start_time_seconds', 10, 3)->change();
|
||||
$table->decimal('end_time_seconds', 10, 3)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Round back to integer on rollback — sub-second data would be
|
||||
// truncated but nothing catastrophic happens.
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
$table->integer('start_time_seconds')->nullable()->change();
|
||||
});
|
||||
Schema::table('match_points', function (Blueprint $table) {
|
||||
$table->integer('timestamp_seconds')->change();
|
||||
});
|
||||
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||
$table->integer('start_time_seconds')->change();
|
||||
$table->integer('end_time_seconds')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
# YouTube-parity audit remediation plan
|
||||
|
||||
Branch per phase. Feature tests per fix. STOP for approval between phases.
|
||||
|
||||
**Safety tag:** `backup/pre-audit-2026-10-21` at commit `5058eaf` on `full-screen-data`.
|
||||
**Stashed WIP:** `stash@{0}` — pre-audit WIP on `full-screen-data` (recover with `git stash pop` on that branch).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Security & data integrity
|
||||
Branch: `fix/phase-1-security`
|
||||
|
||||
- [ ] Wrap `sendEmailVerificationNotification` + `NewCommentNotification` mail in try/catch; move to a queued notification channel; log failures. Document `.env` for Gmail app password + Mailgun/Postmark fallback.
|
||||
- [ ] `php artisan app:resend-verification {--user=}` command.
|
||||
- [ ] Reserved-name blocklist (`admin`, `superadmin`, `takeone`, `support`, `root`, …) via `App\Rules\ReservedIdentifier` wired into `RegisteredUserController`.
|
||||
- [ ] Harden login throttling to `5,1` with lockout message; log repeated failures and alert super admins.
|
||||
- [ ] Confirm `email_verified_at` is not mass-assignable; only the signed-URL verification route flips it.
|
||||
- [ ] Comment dedupe: raise 5s → 60s window, add client idempotency key header, server reject on duplicate. Disable submit button on first click.
|
||||
- [ ] `php artisan comments:dedupe --dry-run` cleanup command.
|
||||
- [ ] Owner-view exclusion in `VideoController@trackProgress`; dedupe by `device_hash` within 30 min.
|
||||
- [ ] Feature tests for each item.
|
||||
|
||||
## Phase 2 — Performance & DOM
|
||||
Branch: `fix/phase-2-perf`
|
||||
|
||||
- [ ] One shared `#globalEditModal` + `#globalCardMenu` per page; populated via `GET /videos/{id}/edit-payload`. Owner-only items gated server-side.
|
||||
- [ ] Lazy-render reply/edit boxes (template clone on open).
|
||||
- [ ] Match highlights panel rendered once.
|
||||
- [ ] `href="javascript:void(0)"` → real `<button>` elements. Hide "Not interested" / "Don't recommend" / "Report" on own videos.
|
||||
- [ ] Cursor pagination (`?cursor=`) across Home, `/videos` filters, channel Videos, search, history, liked, comments.
|
||||
- [ ] IntersectionObserver infinite scroll + "Load more" fallback.
|
||||
- [ ] `loading="lazy"`, explicit `width`/`height`, skeleton placeholders on thumbnails.
|
||||
|
||||
## Phase 3 — Feeds, search, trending
|
||||
Branch: `fix/phase-3-feeds`
|
||||
|
||||
- [ ] Music + filter feeds switch to `latest()`.
|
||||
- [ ] Trending windows: 24 / 168 / 720 h; rank by in-window views.
|
||||
- [ ] Channel Popular: order by views, tiebreak by likes.
|
||||
- [ ] Duration badge on card thumbnail. Scoreboard thumb → `<img>` or `aria-hidden`. Strip `CLUBLOGO` / `FLAG` placeholders.
|
||||
- [ ] Filter tabs: fetch + `pushState`, sticky, `aria-current`.
|
||||
- [ ] Normalize naming (DB stays `match`, display label "Sports" everywhere).
|
||||
- [ ] "New to You" → "Recent" (or make personalized).
|
||||
- [ ] New `SearchController` with `/search?q=` matching title/description/tags/language/channel; include channels + playlists. `/videos/search` becomes 302 redirect. Keep query in input. Merge the two duplicate search forms.
|
||||
- [ ] `/feed/subscriptions` route backed by `subscriptions` table.
|
||||
|
||||
## Phase 4 — Watch page
|
||||
Branch: `fix/phase-4-watch`
|
||||
|
||||
- [ ] Up Next: real `<a href>` cards. Recommender by type/tags/language, cross-channel. Tabs All / From this channel / Same type. Show more.
|
||||
- [ ] Unified player wrapper for match/music/generic: theater (t), mini (i), PiP, volume slider, captions=lyrics (c), 2x speed, end screen, autoplay countdown. Autoplay-ON forces Loop-OFF.
|
||||
- [ ] Like count visible, dislike button, Subscribe for guests, viewer `⋯` → Report. Channel row on match videos.
|
||||
- [ ] `/videos/{id}/download/{format}` signed route; enforce per-video Downloads setting (Off/Everyone/Members/Subscribers) server-side for MP4 **and** MP3.
|
||||
- [ ] Comments: `?lc=` permalink with scroll + highlight. Heart + pin (creator). "Read more" clamp. @handle prefill on nested reply. Singular/plural counts. Top-level vs reply counts separate.
|
||||
- [ ] Markdown rendered (or stripped) in descriptions.
|
||||
- [ ] Dedupe audio Language menu.
|
||||
- [ ] Hide "Generating lyrics…" overlay unless job active.
|
||||
- [ ] Edit form prefilled with real values (fix "Generic" / "Loading…" defaults).
|
||||
- [ ] Streaming: HLS exists (`GenerateHlsJob`, `/videos/{video}/hls/{file}`). Switch player to hls.js when `hls_path` set. Verify `-movflags +faststart` on mp4 encode in `CompressVideoJob`. Add `Cache-Control` + Range on stream route.
|
||||
|
||||
## Phase 5 — Shorts
|
||||
Branch: `fix/phase-5-shorts`
|
||||
|
||||
- [ ] Upload flag + auto-detect (vertical, ≤60s).
|
||||
- [ ] `/shorts/{id}` route + branded 404 at `resources/views/errors/404.blade.php`.
|
||||
- [ ] `/shorts` feed: 100dvh snap container, single reused `<video>` moved between slots, next 1–2 preloaded, URL via `history.replaceState`, wheel + arrows + swipe + ▲▼. Loop. Muted autoplay + "Tap to unmute"; unmuted state in `localStorage`.
|
||||
- [ ] Right action column (like/dislike/comments/share). Bottom overlay (avatar/@handle/Subscribe/title). Comments side panel desktop / bottom sheet mobile.
|
||||
- [ ] Home Shorts shelf. Channel Shorts tab (fix "My Shorts").
|
||||
|
||||
## Phase 6 — Channel / Studio / Live
|
||||
Branch: `fix/phase-6-channel-studio-live`
|
||||
|
||||
- [ ] `users.handle` migration (unique, nullable → backfill → required). `/@handle` routes. Tabs `/@handle/{videos,shorts,playlists,posts,about}`. "Preview as visitor" toggle. Header shows description + links. Age/gender/horoscope private by default. Pluralize "1 video"; drop stray "· 0.0" on playlist cards.
|
||||
- [ ] `/studio/*`: content table + bulk edit, per-video analytics, two-step upload (Details → Visibility: tags + playlist + schedule), file-size + duration + processing-status surfaced.
|
||||
- [ ] Live: ended state → "Stream ended" + duration + recording (never "Waiting"). Live chat. Like / share / subscribe on viewer page. Channel name link. Private option on `/live/new` matching upload wording.
|
||||
|
||||
## Phase 7 — Admin, a11y, consistency
|
||||
Branch: `fix/phase-7-admin-a11y`
|
||||
|
||||
- [ ] Rename "Live Videos" → "Ready". Storage counts NAS (via `NasSyncService`). Fix KPI WoW %. Strip HTML in description excerpts. Full titles with tooltip. Next/Last pagination on Audit Logs, Users, Videos. Error log viewer groups by stack trace in natural order.
|
||||
- [ ] A11y: `aria-label` on every icon button (menu, search, voice, avatar, ⋯, Like-active, comment menus, seek slider with value text). Skip-to-content link. One `<h1>` per page. `label[for]` on all inputs. Remove duplicate "Prefer not to say". Birth year 13+ cap.
|
||||
- [ ] Title helper `{page} | Play`. Brand: **Play** displayed; `TAKEONE` stays as `config('app.name')` per CLAUDE.md.
|
||||
- [ ] Cleanup commands (all `--dry-run` first): `comments:dedupe`, `live:cleanup-test`, `tracks:dedupe`.
|
||||
|
||||
## Final deliverables
|
||||
- Checklist: done / partial / skipped (with reason).
|
||||
- `.env` keys the user must set.
|
||||
- Deploy steps: migrations, queue restart, cache clear, asset build.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Mail setup
|
||||
|
||||
Mail failures are now non-fatal — a down SMTP will log a warning in
|
||||
`data/logs/laravel.log` but will NOT 500 the user. Still, delivery is a
|
||||
feature; here's how to fix the current Gmail 535 auth errors.
|
||||
|
||||
## Option A — Gmail App Password (quickest, free, ~500 sends/day)
|
||||
|
||||
1. On `[email protected]`:
|
||||
- Enable 2-Step Verification (Google Account → Security).
|
||||
- Generate an **App Password** (Security → App passwords → "Mail", "Other: TAKEONE").
|
||||
- Copy the 16-character password Google shows (no spaces).
|
||||
|
||||
2. `.env`:
|
||||
```env
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=platformtakeone@gmail.com
|
||||
MAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx # the app password, no quotes needed
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=platformtakeone@gmail.com
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
3. `php artisan config:clear && php artisan queue:restart`
|
||||
|
||||
4. Smoke test:
|
||||
```bash
|
||||
php artisan app:resend-verification --user=<id-of-an-unverified-user>
|
||||
```
|
||||
|
||||
## Option B — Mailgun (recommended for production)
|
||||
|
||||
```env
|
||||
MAIL_MAILER=mailgun
|
||||
MAILGUN_DOMAIN=mg.takeone.bh
|
||||
MAILGUN_SECRET=key-...
|
||||
MAILGUN_ENDPOINT=api.eu.mailgun.net # or api.mailgun.net for US region
|
||||
MAIL_FROM_ADDRESS=no-reply@takeone.bh
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
## Option C — Postmark (also fine)
|
||||
|
||||
```env
|
||||
MAIL_MAILER=postmark
|
||||
POSTMARK_TOKEN=...
|
||||
MAIL_FROM_ADDRESS=no-reply@takeone.bh
|
||||
MAIL_FROM_NAME="TAKEONE"
|
||||
```
|
||||
|
||||
## Resend verification to users whose first email failed
|
||||
|
||||
```bash
|
||||
# One specific user
|
||||
php artisan app:resend-verification --user=42
|
||||
|
||||
# Everyone still unverified (dry-run first)
|
||||
php artisan app:resend-verification --all-unverified --dry-run
|
||||
php artisan app:resend-verification --all-unverified
|
||||
```
|
||||
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bundled Page</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_thumbnail">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
|
||||
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
|
||||
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"634cd639-9aa5-4e99-93cf-a35ae77b2724":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"8f819462-17b1-46f6-8aa3-2de374e257f7"},{"id":"flagBr","uuid":"cc3aaea8-173f-4338-b7fa-2bae75a84d2b"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"dc229245-056d-4703-a7d0-641172e38b84"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"96676beb-9588-43e1-aef2-459578e9a205"}]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"efd1862c-8a2a-4b74-9741-9eb4a7b79af6\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"786f769a-eb68-482a-90e4-5f4c6341055c\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e50aced0-6710-4914-b239-a64494fce97d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"66dcdbee-b198-4b92-90c2-93c3b6f9111b\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"0be5c901-33ac-4507-bb55-5dafed4159e7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"2f8f7fe2-ce99-4a10-b583-ecf8c782a45d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"754fb47d-ebf2-4909-b5a0-f9abb2ed5c4d\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"7b059d00-08e1-424c-9715-c5b65f9a9411\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"faa370e3-1d36-48e0-b7c4-1a760ef73c77\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"dcfb04b8-57f1-4948-b81e-dcad7d26a879\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"eb64d5f4-973e-4865-8cd1-89be941b16be\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"11b56c5f-a515-42d7-bf11-6aa8bed5ecf6\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"3cf32598-e7d4-4db2-8d36-ceee8f725195\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bundled Page</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
|
||||
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
|
||||
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
|
||||
#__bundler_placeholder { color: #999; font-size: 14px; }
|
||||
</style>
|
||||
<noscript>
|
||||
<style>#__bundler_loading { display: none; }</style>
|
||||
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
|
||||
This page requires JavaScript to display.
|
||||
</div>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__bundler_thumbnail">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
|
||||
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
|
||||
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="__bundler_loading">Unpacking...</div>
|
||||
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const loading = document.getElementById('__bundler_loading');
|
||||
function setStatus(msg) { if (loading) loading.textContent = msg; }
|
||||
|
||||
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
|
||||
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
|
||||
function toBase64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Error sink persists across replaceWith since it's on window, not the DOM.
|
||||
window.addEventListener('error', function(e) {
|
||||
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
|
||||
// events at the element — warn only; real JS errors carry message/error.
|
||||
if (!e.message && !e.error && e.target && e.target !== window) {
|
||||
console.warn('[bundle] resource failed to load:',
|
||||
e.target.tagName, String(e.target.src || e.target.href || ''));
|
||||
return;
|
||||
}
|
||||
var p = document.body || document.documentElement;
|
||||
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
|
||||
d.id = '__bundler_err';
|
||||
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
|
||||
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
|
||||
'[bundle] ' + (e.message || e.type) +
|
||||
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
|
||||
}, true);
|
||||
|
||||
try {
|
||||
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
|
||||
const templateEl = document.querySelector('script[type="__bundler/template"]');
|
||||
if (!manifestEl || !templateEl) {
|
||||
setStatus('Error: missing bundle data');
|
||||
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestEl.textContent);
|
||||
let template = JSON.parse(templateEl.textContent);
|
||||
|
||||
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
|
||||
// document's manifest; frame srcs carry an about:blank#<uuid> marker
|
||||
// instead of a substitutable uuid. Every document mints the blobs for
|
||||
// its OWN frames — from local text at the root, or text obtained from
|
||||
// its DIRECT parent over the relay protocol below — so each frame
|
||||
// navigation stays same-origin with its initiator and a deduped page
|
||||
// loads at any depth on any host, including a file:// download's
|
||||
// opaque origins (where a blob minted by one document cannot be
|
||||
// navigated to by another).
|
||||
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
|
||||
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
|
||||
const pageSet = new Set(pageOrder);
|
||||
const pageTexts = {};
|
||||
|
||||
// ── Nested-page text protocol (parent-chain relay) ──
|
||||
// Page bundles ship once, in the ROOT document's manifest. Every
|
||||
// document mints the blobs for its OWN frames, obtaining page text
|
||||
// through a strict parent-chain relay: a document only ever SENDS to
|
||||
// its direct parent (which, for every document the publisher's own
|
||||
// runtime minted, is another publisher document) and only ACCEPTS
|
||||
// text from that direct parent; requests from below are honored only
|
||||
// for the document's own child frames, and the ROOT never sends
|
||||
// upward at all. That last rule is load-bearing: the artifact host's
|
||||
// frame-ancestors allows claude.ai (and friends) to frame an
|
||||
// artifact, so window.top can be a FOREIGN document — a top-addressed
|
||||
// protocol would hand page uuids to that ancestor and accept forged
|
||||
// "page text" from it, minting attacker HTML as an artifact-origin
|
||||
// blob document. The chain never touches the ancestor: requests stop
|
||||
// at the root, replies are origin-addressed per hop (never '*' when
|
||||
// an origin exists), and both directions are origin-gated (on https
|
||||
// a foreign sender's origin can never equal this document's; in
|
||||
// opaque contexts — file:// — everything serializes as 'null', where
|
||||
// an embedded EXTERNAL frame still fails the gate by carrying its
|
||||
// own real origin, and a sandboxed child of such a frame is excluded
|
||||
// by the own-child-frames check plus uuid unguessability). Shape is
|
||||
// deny-by-default: one request form, one reply form, no error
|
||||
// replies.
|
||||
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
|
||||
// contexts such as a sandboxed preview iframe) — location.origin is
|
||||
// computed from the URL and stays 'https://…' even when the document
|
||||
// is sandboxed, which would misgate every opaque chain.
|
||||
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
|
||||
? window.origin
|
||||
: '*';
|
||||
function trustedOrigin(e) {
|
||||
if (e.origin === window.origin) return true; // https chains; opaque==opaque
|
||||
// file:// trees: Chrome serializes the file document's origin as
|
||||
// 'file://' while the blob: documents it mints serialize as 'null',
|
||||
// so the two legitimate shapes can never string-match — accept the
|
||||
// pair in both directions. https origins never serialize as either,
|
||||
// and sender identity (own child frame / direct parent) is checked
|
||||
// separately.
|
||||
return (
|
||||
(e.origin === 'null' &&
|
||||
(window.origin === 'file://' ||
|
||||
window.location.protocol === 'file:')) ||
|
||||
(/^file:/.test(e.origin) && window.origin === 'null')
|
||||
);
|
||||
}
|
||||
// Non-empty only in the root document (nested bundles ship an empty
|
||||
// island), so it doubles as the root marker: the root answers from
|
||||
// local text and treats a miss as authoritative — never relaying to
|
||||
// its (possibly foreign) parent.
|
||||
const isPageRoot = pageOrder.length > 0;
|
||||
const blobUrls = {};
|
||||
const resourceBlobs = {};
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
|
||||
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
|
||||
function mountPage(el, frag, text) {
|
||||
const pageBlob = new Blob([text], { type: 'text/html' });
|
||||
const u = URL.createObjectURL(pageBlob);
|
||||
resourceBlobs[u] = pageBlob;
|
||||
el.src = u + frag;
|
||||
}
|
||||
function deliverPage(uuid, text) {
|
||||
const frames = pendingFrames[uuid];
|
||||
delete pendingFrames[uuid];
|
||||
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
|
||||
const relays = pendingRelays[uuid];
|
||||
delete pendingRelays[uuid];
|
||||
if (relays) {
|
||||
for (const cw of relays) {
|
||||
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
function ownFrameWindows() {
|
||||
const out = [];
|
||||
for (const f of Array.from(document.querySelectorAll('iframe'))) {
|
||||
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
const d = e.data;
|
||||
if (!d || typeof d !== 'object' || !e.source) return;
|
||||
// Origin gate first; the structural checks below (own-child-frames
|
||||
// for requests, direct-parent + solicited-uuid for replies, the
|
||||
// root's never-relay rule) are what hold in fully opaque chains
|
||||
// where origin strings can't discriminate — a sandboxed root's
|
||||
// FOREIGN wrapper still fails all three even though both serialize
|
||||
// as real-vs-'null' rather than matching.
|
||||
if (!trustedOrigin(e)) return;
|
||||
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
|
||||
// Requests are honored only for this document's own child frames.
|
||||
if (ownFrameWindows().indexOf(e.source) < 0) return;
|
||||
const uuid = d.__bundler_need;
|
||||
const text = pageTexts[uuid];
|
||||
if (typeof text === 'string') {
|
||||
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
|
||||
return;
|
||||
}
|
||||
if (isPageRoot || window.parent === window) return; // authoritative miss
|
||||
const waiting = pendingRelays[uuid];
|
||||
if (waiting) {
|
||||
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
|
||||
return;
|
||||
}
|
||||
// Never-answered uuids would otherwise accumulate forever under a
|
||||
// request spray; past the cap (far above any real page count) new
|
||||
// relays are dropped rather than remembered.
|
||||
if (Object.keys(pendingRelays).length >= 128) return;
|
||||
pendingRelays[uuid] = [e.source];
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
} else if (
|
||||
typeof d.__bundler_page === 'string' &&
|
||||
UUID_SHAPE.test(d.__bundler_page) &&
|
||||
typeof d.__bundler_text === 'string'
|
||||
) {
|
||||
// The shape test is load-bearing beyond tidiness: the solicited
|
||||
// check below uses the in operator, which matches prototype keys
|
||||
// ('__proto__', 'constructor') — an unshaped key would reach
|
||||
// deliverPage and throw, painting the error banner.
|
||||
// Text is accepted only from the direct parent — the document
|
||||
// that minted this one.
|
||||
if (e.source !== window.parent || window.parent === window) return;
|
||||
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
|
||||
deliverPage(d.__bundler_page, d.__bundler_text);
|
||||
}
|
||||
});
|
||||
|
||||
const uuids = Object.keys(manifest);
|
||||
setStatus('Unpacking ' + uuids.length + ' assets...');
|
||||
|
||||
await Promise.all(uuids.map(async (uuid) => {
|
||||
const entry = manifest[uuid];
|
||||
try {
|
||||
const binaryStr = atob(entry.data);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
||||
|
||||
let finalBytes = bytes;
|
||||
if (entry.compressed) {
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const chunks = [];
|
||||
let totalLen = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
finalBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
|
||||
} else {
|
||||
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSet.has(uuid)) {
|
||||
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
|
||||
return;
|
||||
}
|
||||
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
|
||||
// Strict artifact hosts allow font-src data: but not blob:.
|
||||
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
|
||||
(entry.compressed ? toBase64(finalBytes) : entry.data);
|
||||
} else {
|
||||
const blob = new Blob([finalBytes], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode asset ' + uuid + ':', err);
|
||||
const blob = new Blob([], { type: entry.mime });
|
||||
blobUrls[uuid] = URL.createObjectURL(blob);
|
||||
resourceBlobs[blobUrls[uuid]] = blob;
|
||||
}
|
||||
}));
|
||||
|
||||
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
|
||||
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
|
||||
const resourceMap = {};
|
||||
for (const entry of extResources) {
|
||||
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
|
||||
}
|
||||
|
||||
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
|
||||
// consumers read these Blobs directly. Survives the swap like the error sink.
|
||||
window.__resourceBlobs = resourceBlobs;
|
||||
|
||||
setStatus('Rendering...');
|
||||
// Page uuids have no blob here — they ride inside about:blank#<uuid>
|
||||
// frame markers the pass below resolves after the swap.
|
||||
for (const uuid of uuids) {
|
||||
if (pageSet.has(uuid)) continue;
|
||||
template = template.split(uuid).join(blobUrls[uuid]);
|
||||
}
|
||||
|
||||
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
|
||||
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
|
||||
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
|
||||
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
|
||||
|
||||
const resourceScript = '<script>window.__resources = ' +
|
||||
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
|
||||
';</' + 'script>';
|
||||
// Inject after <head> so the DOCTYPE stays first; prepending the script
|
||||
// would push the parser into quirks mode. DOMParser always emits a <head>
|
||||
// (synthesizing one if the source HTML omitted it) but may carry
|
||||
// attributes through, so match the full opening tag. slice() rather than
|
||||
// replace() keeps us clear of $-pattern substitution in resourceScript.
|
||||
const headOpen = template.match(/<head[^>]*>/i);
|
||||
if (headOpen) {
|
||||
const i = headOpen.index + headOpen[0].length;
|
||||
template = template.slice(0, i) + resourceScript + template.slice(i);
|
||||
}
|
||||
|
||||
// Parse the template and swap the root element. Scripts inserted via
|
||||
// DOMParser/replaceWith are inert per spec — re-create each with
|
||||
// createElement so they execute, awaiting onload for src scripts to
|
||||
// preserve ordering (React before ReactDOM before Babel before text/babel).
|
||||
const doc = new DOMParser().parseFromString(template, 'text/html');
|
||||
document.documentElement.replaceWith(doc.documentElement);
|
||||
|
||||
// Resolve page-frame markers: mint this document's own blob for each
|
||||
// framed page — from local text when this is the root, otherwise by
|
||||
// asking the direct parent over the relay protocol above. An
|
||||
// unanswered request leaves the frame on about:blank, the same
|
||||
// degraded state as a refused embed.
|
||||
{
|
||||
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
|
||||
const needed = [];
|
||||
for (const el of Array.from(document.querySelectorAll('iframe'))) {
|
||||
const m = MARKER.exec(el.getAttribute('src') || '');
|
||||
if (!m) continue;
|
||||
const text = pageTexts[m[1]];
|
||||
if (typeof text === 'string') {
|
||||
mountPage(el, m[2] || '', text);
|
||||
} else if (window.parent !== window && !isPageRoot) {
|
||||
const had = pendingFrames[m[1]];
|
||||
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
|
||||
if (!had) needed.push(m[1]);
|
||||
}
|
||||
}
|
||||
for (const uuid of needed) {
|
||||
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
|
||||
}
|
||||
}
|
||||
|
||||
const dead = Array.from(document.scripts);
|
||||
for (const old of dead) {
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
// text/babel scripts with a src: read and inline. transformScriptTags
|
||||
// does XHR against the src, but blob:null/ from a file:// origin is
|
||||
// silently dropped. Inlining makes it a plain inline babel script,
|
||||
// which transformScriptTags handles unconditionally.
|
||||
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
|
||||
const pre = resourceBlobs[s.src.split('#')[0]];
|
||||
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
|
||||
s.removeAttribute('src');
|
||||
}
|
||||
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
|
||||
old.replaceWith(s);
|
||||
if (p) await p;
|
||||
}
|
||||
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
|
||||
// which fired before we swapped the document. Trigger manually if present.
|
||||
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
|
||||
window.Babel.transformScriptTags();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error unpacking: ' + err.message);
|
||||
console.error('Bundle unpack error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script type="__bundler/manifest">
|
||||
{"ace74a53-87ed-4d99-ad8b-261f3f162016":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
|
||||
</script>
|
||||
|
||||
<script type="__bundler/ext_resources">
|
||||
[{"id":"flagJp","uuid":"00166f10-cf88-4a70-bbd1-280330a31a6b"},{"id":"flagBr","uuid":"10064871-2703-4007-829b-9fe2ea8c9830"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"02eff75b-1dc5-4564-a819-548848bc3ea6"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"13d22153-cc4e-43c7-b6eb-12374e56dc44"}]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/page_order">
|
||||
[]
|
||||
</script>
|
||||
|
||||
<script type="__bundler/template">
|
||||
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"290e25e2-2bac-49af-b3d1-10cdd1ae8fbb\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e49d5281-88ab-4404-9f97-f101a278fd68\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"41d613c6-fe3e-4f74-a5ee-c82165b70e04\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"73180845-6d9d-47f1-b736-31e7c919b715\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"024f77f2-3778-4e20-945a-dfc5de912f19\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"20b18051-9da7-42dd-a265-bdcef03ef454\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"a141991d-2d31-4e13-9ba3-a7e1903cb3a4\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"d996ac61-9dd4-4628-92ec-575a6a8ebab7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"4c129e83-dcd6-40f3-9b2f-75eb52c2570f\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"b0420d9b-b5d9-4e9b-a506-be5ec972d317\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"643b5739-aeb8-4056-81ee-78f1669802f5\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"1c70acd1-9588-4697-b346-56c7675f7674\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"fc1721cb-bce6-4bcc-8311-e4be3be85090\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Score bar — exact markup</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@700&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;background:#111;font-family:'Barlow Condensed',sans-serif}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!--
|
||||
SCORE BAR — copy this markup verbatim. Do not restructure it.
|
||||
Placement: inside the player container, which must be position:relative.
|
||||
Data holes are marked with {{ }} — replace ONLY those, nothing else.
|
||||
Hide a part by setting its element's display to none (never remove the element's siblings' flex sizing).
|
||||
|
||||
Rules that break the design if changed:
|
||||
- the panel has transform:skewX(-9deg); ONE inner row has transform:skewX(9deg). Never counter-skew individual children.
|
||||
- every text span keeps flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis
|
||||
- the logo (46px) and score (60px) boxes keep flex:none
|
||||
- bottom:56px keeps the bar above the 48px control band
|
||||
-->
|
||||
|
||||
<div style="position:relative;width:1150px;height:200px;background:#1b1b1e">
|
||||
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
|
||||
|
||||
<!-- RED / AKA -->
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ redName }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ redClub }}</span>
|
||||
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">{{ redLabel }}</span>
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ redScore }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: round + clock -->
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">{{ roundLabel }}</div>
|
||||
<div style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">{{ clock }}</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BLUE / AO — exact mirror -->
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">{{ blueLabel }}</span>
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ blueScore }}</span>
|
||||
</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ blueName }}</span>
|
||||
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ blueClub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty -->
|
||||
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#e8534a"></div>
|
||||
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#2a2724"></div>
|
||||
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2537
File diff suppressed because it is too large.
Load diff
@@ -9,5 +9,8 @@
|
||||
"axios": "^1.6.4",
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.43.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coach Review — Design Mockups</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--bg-panel: #181818;
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaa;
|
||||
--brand-red: #e61e1e;
|
||||
--border: #262626;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
@media (max-width: 900px) { .page { grid-template-columns: 1fr; } }
|
||||
|
||||
.mockup {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.mockup h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mockup h2 .letter {
|
||||
display: inline-block;
|
||||
background: var(--brand-red);
|
||||
color: #fff;
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.mockup .vibe { font-size: 12px; color: var(--text-secondary); margin: 0 0 18px; }
|
||||
.mockup .stage {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #202020;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
min-height: 260px;
|
||||
}
|
||||
.mockup .stage > * + * { margin-top: 8px; }
|
||||
|
||||
.pill { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
|
||||
|
||||
/* ─── Option A: Sticky-note ─────────────────────────── */
|
||||
.a-card {
|
||||
display: flex; gap: 14px;
|
||||
background: rgba(234, 179, 8, .05);
|
||||
border: 1px solid rgba(234, 179, 8, .18);
|
||||
border-left: 4px solid rgba(234, 179, 8, .7);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.a-card .emoji { font-size: 26px; line-height: 1; flex-shrink: 0; }
|
||||
.a-card .body { flex: 1; min-width: 0; }
|
||||
.a-card .note { font-size: 15px; font-weight: 600; color: #f1f1f1; margin: 0 0 6px; }
|
||||
.a-card .divider { height: 1px; background: rgba(255,255,255,.08); margin: 8px 0; }
|
||||
.a-card .meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.a-card .meta-left { font-size: 12px; color: var(--text-secondary); }
|
||||
.a-card .meta-left b { color: #f1f1f1; font-weight: 500; }
|
||||
.a-card .meta-left .dot { margin: 0 6px; opacity: .5; }
|
||||
.a-card .tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Option B: Timeline dot ────────────────────────── */
|
||||
.b-timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
}
|
||||
.b-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px; top: 0; bottom: 0;
|
||||
width: 2px; background: rgba(255,255,255,.08);
|
||||
}
|
||||
.b-item { position: relative; margin-bottom: 22px; }
|
||||
.b-item:last-child { margin-bottom: 0; }
|
||||
.b-dot {
|
||||
position: absolute; left: -26px; top: 2px;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--brand-red);
|
||||
box-shadow: 0 0 0 3px rgba(230,30,30,.2), 0 0 0 6px var(--bg-panel);
|
||||
}
|
||||
.b-time {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
color: var(--brand-red); text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.b-card {
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid rgba(255,255,255,.06);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
}
|
||||
.b-card .emoji { font-size: 18px; line-height: 1.2; flex-shrink: 0; }
|
||||
.b-card .body { flex: 1; min-width: 0; }
|
||||
.b-card .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 4px; }
|
||||
.b-card .author { font-size: 11.5px; color: var(--text-secondary); }
|
||||
.b-card .tools { display: inline-flex; gap: 4px; align-self: center; }
|
||||
|
||||
/* ─── Option C: Media card ──────────────────────────── */
|
||||
.c-card {
|
||||
display: flex; gap: 14px;
|
||||
background: #141414;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.c-thumb {
|
||||
width: 72px; height: 72px; flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(230,30,30,.28), rgba(230,30,30,.08));
|
||||
border: 1px solid rgba(230,30,30,.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
.c-body { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.c-head { display: flex; align-items: center; gap: 8px; }
|
||||
.c-time {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .04em;
|
||||
color: #3ea6ff;
|
||||
background: rgba(62, 166, 255, .12);
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.c-note { font-size: 14px; font-weight: 600; color: #f1f1f1; margin: 4px 0; line-height: 1.3; }
|
||||
.c-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.c-author { font-size: 11.5px; color: var(--text-secondary); }
|
||||
.c-tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Option D: Chat-bubble ─────────────────────────── */
|
||||
.d-row { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.d-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #1f2937, #111827);
|
||||
border: 2px solid var(--brand-red);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; line-height: 1;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
.d-bubble {
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
padding: 10px 14px;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.d-bubble .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 6px; line-height: 1.35; }
|
||||
.d-bubble .meta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding-top: 6px; border-top: 1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.d-bubble .meta-left { font-size: 11px; color: var(--text-secondary); }
|
||||
.d-bubble .meta-left b { color: #f1f1f1; font-weight: 500; }
|
||||
.d-bubble .meta-left .dot { margin: 0 6px; opacity: .5; }
|
||||
.d-tools { display: inline-flex; gap: 4px; }
|
||||
|
||||
/* ─── Shared button styling for mockups ─────────────── */
|
||||
.btn-icon {
|
||||
width: 26px; height: 26px; padding: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 5px; border: none;
|
||||
background: rgba(255,255,255,.06);
|
||||
color: var(--text-primary); cursor: pointer;
|
||||
font-size: 12px; line-height: 1;
|
||||
transition: background .12s;
|
||||
}
|
||||
.btn-icon:hover { background: rgba(255,255,255,.14); }
|
||||
.btn-slowmo {
|
||||
width: auto; padding: 0 10px; gap: 4px;
|
||||
background: rgba(230,30,30,.16); color: var(--brand-red);
|
||||
font-size: 11px;
|
||||
}
|
||||
.btn-slowmo:hover { background: rgba(230,30,30,.28); color: #fff; }
|
||||
|
||||
/* header */
|
||||
.header {
|
||||
text-align: center; margin-bottom: 32px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Coach Review Card — 4 design directions</h1>
|
||||
<p>Same content, four different visual approaches. Pick one and I'll build it.</p>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ═══ Option A: Sticky-note ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">A</span> Sticky-note</h2>
|
||||
<p class="vibe">Warm amber tint, chunky emoji, feels like a coach's Post-it on a locker.</p>
|
||||
<div class="stage">
|
||||
<div class="a-card">
|
||||
<span class="emoji">👀</span>
|
||||
<div class="body">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">
|
||||
<div class="meta-left">
|
||||
<b>Ghassan</b><span class="dot">·</span>00:48 – 00:52
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-card">
|
||||
<span class="emoji">🤔</span>
|
||||
<div class="body">
|
||||
<p class="note">Missed the counter opportunity — great angle but no follow-up</p>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">
|
||||
<div class="meta-left">
|
||||
<b>Coach Sara</b><span class="dot">·</span>02:05 – 02:20
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option B: Timeline dot ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">B</span> Timeline dot</h2>
|
||||
<p class="vibe">Vertical spine with red dots. Reads like a session log — nice for many notes.</p>
|
||||
<div class="stage">
|
||||
<div class="b-timeline">
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">00:48 — 00:52</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">👀</span>
|
||||
<div class="body">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="author">Ghassan</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">02:05 — 02:20</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">🤔</span>
|
||||
<div class="body">
|
||||
<p class="note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="author">Coach Sara</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b-item">
|
||||
<span class="b-dot"></span>
|
||||
<div class="b-time">03:25</div>
|
||||
<div class="b-card">
|
||||
<span class="emoji">😄</span>
|
||||
<div class="body">
|
||||
<p class="note">Excellent exit and re-entry — perfect execution</p>
|
||||
<div class="author">Ghassan</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option C: Media card ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">C</span> Media card</h2>
|
||||
<p class="vibe">Big emoji tile on the left, blue time badge — feels like a video-platform "content card".</p>
|
||||
<div class="stage">
|
||||
<div class="c-card">
|
||||
<div class="c-thumb">👀</div>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-time">00:48 → 00:52</span></div>
|
||||
<p class="c-note">Keep Your Hand Up</p>
|
||||
<div class="c-foot">
|
||||
<span class="c-author">Ghassan</span>
|
||||
<div class="c-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c-card">
|
||||
<div class="c-thumb">🤔</div>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-time">02:05 → 02:20</span></div>
|
||||
<p class="c-note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="c-foot">
|
||||
<span class="c-author">Coach Sara</span>
|
||||
<div class="c-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Option D: Chat-bubble ═══════════════════════════ -->
|
||||
<div class="mockup">
|
||||
<h2><span class="letter">D</span> Chat-bubble</h2>
|
||||
<p class="vibe">Emoji as coach's avatar + message bubble. Feels like WhatsApp — casual + dense.</p>
|
||||
<div class="stage">
|
||||
<div class="d-row">
|
||||
<div class="d-avatar">👀</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Keep Your Hand Up</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>00:48–00:52</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-row" style="margin-top: 14px;">
|
||||
<div class="d-avatar">🤔</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Missed the counter — no follow-up after the angle change</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Coach Sara</b><span class="dot">·</span>02:05–02:20</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-row" style="margin-top: 14px;">
|
||||
<div class="d-avatar">😄</div>
|
||||
<div class="d-bubble">
|
||||
<p class="note">Excellent exit and re-entry — perfect execution</p>
|
||||
<div class="meta">
|
||||
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>03:25</div>
|
||||
<div class="d-tools">
|
||||
<button class="btn-icon">✏️</button>
|
||||
<button class="btn-icon">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150" width="150" height="150">
|
||||
<rect width="150" height="150" fill="#2a2a2a"/>
|
||||
<circle cx="75" cy="60" r="26" fill="#6a6a6a"/>
|
||||
<path d="M25,150 C25,110 50,92 75,92 C100,92 125,110 125,150 Z" fill="#6a6a6a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
@@ -351,24 +351,40 @@
|
||||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||||
</div>
|
||||
<div class="al-page-links">
|
||||
{{-- First --}}
|
||||
@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
|
||||
<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
|
||||
|
||||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||||
@if($page == $logs->currentPage())
|
||||
<span class="active">{{ $page }}</span>
|
||||
<span class="active" aria-current="page">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next --}}
|
||||
@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
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,13 +292,16 @@
|
||||
|
||||
{{-- Ready Videos --}}
|
||||
<div class="col-6 col-lg-4 col-xl-2">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos">
|
||||
<div class="stat-card clickable-card" onclick="openDashModal('Ready','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view ready videos">
|
||||
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
|
||||
<div class="stat-label">Live Videos</div>
|
||||
{{-- "Ready" matches the DB status column; "Live Videos" was misleading
|
||||
— nothing streams live from this card, it just counts videos that
|
||||
finished processing successfully. --}}
|
||||
<div class="stat-label">Ready</div>
|
||||
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
|
||||
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
|
||||
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed
|
||||
@@ -479,10 +482,10 @@
|
||||
@else
|
||||
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
||||
@foreach($viewsByCountry as $i => $row)
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-rank">{{ $i + 1 }}</div>
|
||||
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div>
|
||||
<div class="country-flag" title="{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
||||
</div>
|
||||
@@ -741,8 +744,21 @@ new Chart(document.getElementById('typeChart'), {
|
||||
|
||||
// ── Country Chart ───────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
@php
|
||||
// Blade's @json() parser can't handle a multi-line arrow function with
|
||||
// a `[` array literal inside its body — the directive reads until the
|
||||
// closing paren and sees the inner `[` as an unmatched bracket. Build
|
||||
// the array in PHP first, then hand the plain variable to @json().
|
||||
$countryChartData = $viewsByCountry->map(function ($r) {
|
||||
return [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
'total' => $r->total,
|
||||
];
|
||||
})->values();
|
||||
@endphp
|
||||
(function() {
|
||||
const countryData = @json($viewsByCountry);
|
||||
const countryData = @json($countryChartData);
|
||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||
const values = countryData.map(r => r.total);
|
||||
const maxVal = Math.max(...values);
|
||||
@@ -1015,7 +1031,10 @@ if (typeChartInst) {
|
||||
// Country bar chart click
|
||||
const countryChartInst = Chart.getChart('countryChart');
|
||||
if (countryChartInst) {
|
||||
const countryData = @json($viewsByCountry ?? collect());
|
||||
const countryData = @json(($viewsByCountry ?? collect())->map(fn($r) => [
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||
])->values());
|
||||
countryChartInst.options.onClick = function(evt, elements) {
|
||||
if (!elements.length) return;
|
||||
const row = countryData[elements[0].index];
|
||||
|
||||
@@ -538,7 +538,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="adm-user-avatar" alt="">
|
||||
@endif
|
||||
<div class="adm-user-info">
|
||||
<div class="adm-user-name">{{ Str::limit(Auth::user()->name, 18) }}</div>
|
||||
@@ -637,7 +637,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" alt="">
|
||||
@endif
|
||||
<div class="adm-sidebar-user-info">
|
||||
<div class="adm-sidebar-user-name">{{ Str::limit(Auth::user()->name, 20) }}</div>
|
||||
|
||||
@@ -64,25 +64,73 @@
|
||||
</button>
|
||||
</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))
|
||||
<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' : '' }}.
|
||||
</div>
|
||||
@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;">
|
||||
@foreach($lines as $line)
|
||||
@foreach($groups as $gi => $g)
|
||||
@php
|
||||
$color = '#aaa';
|
||||
if (str_contains($line, '.ERROR:')) $color = '#f87171';
|
||||
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
|
||||
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
|
||||
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
|
||||
if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
|
||||
elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
|
||||
elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
|
||||
elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
|
||||
$hasTrace = !empty($g['trace']);
|
||||
@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
|
||||
</div>
|
||||
@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>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'OG Previews')
|
||||
|
||||
@section('content')
|
||||
<div style="padding: 24px;">
|
||||
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
|
||||
<p style="color: #aaa; margin: 0 0 20px;">
|
||||
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
|
||||
when a sports match video is shared. Generated on save; regenerated on
|
||||
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
|
||||
</p>
|
||||
|
||||
@if($files->isEmpty())
|
||||
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
|
||||
@else
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
|
||||
@foreach($files as $f)
|
||||
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
|
||||
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
|
||||
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
|
||||
alt="{{ $f['name'] }}"
|
||||
style="display: block; width: 100%; height: auto;">
|
||||
</a>
|
||||
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
|
||||
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
|
||||
{{ $f['name'] }}
|
||||
</div>
|
||||
<div style="color: #888; margin-top: 4px;">
|
||||
{{ number_format($f['size'] / 1024, 1) }} KB ·
|
||||
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
|
||||
<div class="country-row">
|
||||
<span class="country-rank">{{ $i + 1 }}</span>
|
||||
<span class="country-flag">{!! flagEmoji($row->country) !!}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) }}</span>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
|
||||
<td>
|
||||
@if($view->country)
|
||||
{!! flagEmoji($view->country) !!}
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? \App\Data\Countries::name($view->country) }}</span>
|
||||
@else
|
||||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||||
@endif
|
||||
@@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
|
||||
|
||||
// ── Country chart ───────────────────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
|
||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? \App\Data\Countries::name($r->country)))->values()) !!};
|
||||
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
|
||||
const countryMax = Math.max(...countryData);
|
||||
|
||||
|
||||
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||
</div>
|
||||
@endif
|
||||
<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 }}
|
||||
</div>
|
||||
@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;">
|
||||
{{ Str::limit($video->description, 55) }}
|
||||
@php
|
||||
// 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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required />
|
||||
{{-- COPPA / platform minimum age: 13 at sign-up. maxYear caps
|
||||
the year dropdown at (this year − 13). --}}
|
||||
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')"
|
||||
:max-year="(int) date('Y') - 13" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -122,7 +122,12 @@
|
||||
|
||||
/* ── Open / close ── */
|
||||
function openModal() {
|
||||
document.getElementById('tcOverlay_' + id).classList.add('open');
|
||||
var ov = document.getElementById('tcOverlay_' + id);
|
||||
// Reparent to <body> at open time — a transformed ancestor (e.g. an
|
||||
// opening Bootstrap modal) would otherwise constrain our position:fixed
|
||||
// overlay to its own stacking context and clip Cropme's canvas.
|
||||
if (ov && ov.parentNode !== document.body) document.body.appendChild(ov);
|
||||
ov.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
window['openCropperModal_' + id] = openModal;
|
||||
@@ -135,42 +140,161 @@
|
||||
if (e.target === this) window.closeCropperModal(id);
|
||||
});
|
||||
|
||||
/* ── Preload a File into the cropper ── */
|
||||
/* ── Preload a File into the cropper ─────────────────────────────────
|
||||
* Uses URL.createObjectURL (fast, memory-friendly) instead of FileReader
|
||||
* data URLs, which are slow / can silently fail with large images.
|
||||
*/
|
||||
function preloadFile(file) {
|
||||
if (!file) return;
|
||||
originalFile = file;
|
||||
document.getElementById('tcFileName_' + id).textContent = file.name;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) { initCropper(e.target.result); };
|
||||
reader.readAsDataURL(file);
|
||||
var url = URL.createObjectURL(file);
|
||||
initCropper(url);
|
||||
}
|
||||
window['tcPreload_' + id] = preloadFile;
|
||||
|
||||
function initCropper(dataUrl) {
|
||||
/*
|
||||
* ───────────────────────────────────────────────────────────────────
|
||||
* DIY cropper — no library. Renders the loaded image in the canvas
|
||||
* with a viewport frame of the desired vw:vh aspect (dashed red
|
||||
* border, dark scrim outside via box-shadow). Pan by dragging the
|
||||
* image, zoom via the slider (or wheel), rotate via the slider.
|
||||
* Save clips the visible viewport region to an offscreen canvas.
|
||||
* ───────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
var _st = null; // cropper state — replaces Cropme's `cropperInst`
|
||||
|
||||
function initCropper(imageUrl) {
|
||||
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
|
||||
document.getElementById('tcSaveBtn_' + id).disabled = false;
|
||||
document.getElementById('tcAsIsBtn_' + id).disabled = false;
|
||||
|
||||
var canvas = document.getElementById('tcCanvas_' + id);
|
||||
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
|
||||
canvas.innerHTML = '';
|
||||
canvas.className = 'tc-canvas tc-canvas-active';
|
||||
|
||||
cropperInst = new Cropme(canvas, {
|
||||
container: { width: '100%', height: 320 },
|
||||
viewport: {
|
||||
width: vw, height: vh,
|
||||
type: shape,
|
||||
border: { enable: true, width: 2, color: '#ef4444' }
|
||||
},
|
||||
transformOrigin: 'viewport',
|
||||
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
|
||||
rotation: { enable: true, slider: false }
|
||||
});
|
||||
cropperInst.bind({ url: dataUrl }).then(function () {
|
||||
document.getElementById('tcZoom_' + id).value = 0;
|
||||
document.getElementById('tcRot_' + id).value = 0;
|
||||
});
|
||||
// ── layout: image element + viewport frame (frame drawn via box-shadow scrim) ──
|
||||
var img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.style.cssText =
|
||||
'position:absolute;top:50%;left:50%;transform-origin:center center;' +
|
||||
'user-select:none;-webkit-user-drag:none;pointer-events:none;max-width:none;';
|
||||
var frame = document.createElement('div');
|
||||
frame.style.cssText =
|
||||
'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
|
||||
'border:2px dashed #ef4444;box-sizing:border-box;pointer-events:none;' +
|
||||
'box-shadow:0 0 0 2000px rgba(0,0,0,.55);' +
|
||||
(shape === 'circle' ? 'border-radius:50%;' : '');
|
||||
canvas.appendChild(img);
|
||||
canvas.appendChild(frame);
|
||||
|
||||
// Reset sliders
|
||||
var zoomEl = document.getElementById('tcZoom_' + id);
|
||||
var rotEl = document.getElementById('tcRot_' + id);
|
||||
zoomEl.value = 0; rotEl.value = 0;
|
||||
|
||||
// Init state
|
||||
_st = {
|
||||
img: img, frame: frame, canvas: canvas,
|
||||
imgW: 0, imgH: 0,
|
||||
canvasW: 0, canvasH: 0,
|
||||
vpW: 0, vpH: 0, // viewport display size (px inside canvas)
|
||||
baseScale: 1, // scale that makes image "cover" the viewport
|
||||
scale: 1, deg: 0, tx: 0, ty: 0,
|
||||
ready: false,
|
||||
imageUrl: imageUrl,
|
||||
};
|
||||
|
||||
img.onload = function () {
|
||||
_st.imgW = img.naturalWidth || 1;
|
||||
_st.imgH = img.naturalHeight || 1;
|
||||
layoutCropper();
|
||||
_st.ready = true;
|
||||
};
|
||||
img.onerror = function (e) {
|
||||
console.error('[image-cropper]', id, 'image failed to load', e);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
// If already cached, onload may have fired synchronously — no-op if not.
|
||||
if (img.complete && img.naturalWidth > 0) img.onload();
|
||||
}
|
||||
|
||||
// Fit the viewport inside the canvas, then fit the image to "cover" the viewport.
|
||||
function layoutCropper() {
|
||||
var s = _st; if (!s) return;
|
||||
s.canvasW = s.canvas.clientWidth || 320;
|
||||
s.canvasH = s.canvas.clientHeight || 320;
|
||||
|
||||
// Viewport: same aspect as vw:vh, fits inside canvas with ~10% padding.
|
||||
var pad = 20;
|
||||
var maxW = s.canvasW - pad * 2;
|
||||
var maxH = s.canvasH - pad * 2;
|
||||
var vAspect = vw / vh;
|
||||
if (maxW / vAspect <= maxH) { s.vpW = maxW; s.vpH = Math.round(maxW / vAspect); }
|
||||
else { s.vpH = maxH; s.vpW = Math.round(maxH * vAspect); }
|
||||
s.frame.style.width = s.vpW + 'px';
|
||||
s.frame.style.height = s.vpH + 'px';
|
||||
|
||||
// Base scale: image must at least cover the viewport.
|
||||
s.baseScale = Math.max(s.vpW / s.imgW, s.vpH / s.imgH);
|
||||
s.scale = s.baseScale;
|
||||
s.tx = 0; s.ty = 0; s.deg = 0;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
var s = _st; if (!s) return;
|
||||
// Image is centered via top/left 50% translate + own translate + scale + rotate.
|
||||
// Set explicit width so scale multiplies pixels rather than object-fit.
|
||||
s.img.style.width = s.imgW + 'px';
|
||||
s.img.style.height = s.imgH + 'px';
|
||||
s.img.style.marginLeft = (-s.imgW / 2) + 'px';
|
||||
s.img.style.marginTop = (-s.imgH / 2) + 'px';
|
||||
s.img.style.transform =
|
||||
'translate(' + s.tx + 'px,' + s.ty + 'px)' +
|
||||
' rotate(' + s.deg + 'deg)' +
|
||||
' scale(' + s.scale + ')';
|
||||
}
|
||||
|
||||
// Drag to pan (mouse + touch)
|
||||
(function bindPan() {
|
||||
var canvas = document.getElementById('tcCanvas_' + id);
|
||||
var dragging = false, sx = 0, sy = 0, otx = 0, oty = 0;
|
||||
function down(e) {
|
||||
if (!_st || !_st.ready) return;
|
||||
dragging = true;
|
||||
var p = e.touches ? e.touches[0] : e;
|
||||
sx = p.clientX; sy = p.clientY; otx = _st.tx; oty = _st.ty;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}
|
||||
function move(e) {
|
||||
if (!dragging || !_st) return;
|
||||
var p = e.touches ? e.touches[0] : e;
|
||||
_st.tx = otx + (p.clientX - sx);
|
||||
_st.ty = oty + (p.clientY - sy);
|
||||
applyTransform();
|
||||
}
|
||||
function up() { dragging = false; canvas.style.cursor = 'grab'; }
|
||||
canvas.addEventListener('mousedown', down);
|
||||
canvas.addEventListener('touchstart', down, { passive: false });
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('touchmove', move, { passive: false });
|
||||
window.addEventListener('mouseup', up);
|
||||
window.addEventListener('touchend', up);
|
||||
// Wheel zoom
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
if (!_st || !_st.ready) return;
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
_st.scale = Math.max(_st.baseScale, Math.min(_st.baseScale * 6, _st.scale * delta));
|
||||
var pct = Math.round(((_st.scale - _st.baseScale) / (_st.baseScale * 5)) * 100);
|
||||
document.getElementById('tcZoom_' + id).value = pct;
|
||||
applyTransform();
|
||||
}, { passive: false });
|
||||
canvas.style.cursor = 'grab';
|
||||
})();
|
||||
|
||||
/* ── Internal file input (the "Choose image" button inside the modal) ── */
|
||||
document.getElementById('tcInput_' + id).addEventListener('change', function () {
|
||||
if (this.files && this.files[0]) preloadFile(this.files[0]);
|
||||
@@ -182,14 +306,16 @@
|
||||
|
||||
/* ── Zoom / rotate sliders ── */
|
||||
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
|
||||
if (!cropperInst || !cropperInst.properties.image) return;
|
||||
if (!_st || !_st.ready) return;
|
||||
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
|
||||
var p = parseFloat(this.value) / 100;
|
||||
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
|
||||
var s = cropperInst.properties;
|
||||
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
|
||||
_st.scale = _st.baseScale + (_st.baseScale * 5) * p;
|
||||
applyTransform();
|
||||
});
|
||||
document.getElementById('tcRot_' + id).addEventListener('input', function () {
|
||||
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
|
||||
if (!_st || !_st.ready) return;
|
||||
_st.deg = parseInt(this.value, 10) || 0;
|
||||
applyTransform();
|
||||
});
|
||||
|
||||
/* ── Helpers ── */
|
||||
@@ -250,43 +376,82 @@
|
||||
.catch(onFail);
|
||||
}
|
||||
|
||||
/* ── Crop & Save ── */
|
||||
/* ── Crop & Save ─────────────────────────────────────────────────────
|
||||
* Renders the visible viewport region to an offscreen canvas at the
|
||||
* output size, then returns the result as a JPEG data URL.
|
||||
* The image is transformed by (translate → rotate → scale) — the
|
||||
* offscreen canvas replays those same transforms scaled up to the
|
||||
* output resolution so nothing is lost.
|
||||
*/
|
||||
function renderCroppedBase64() {
|
||||
var s = _st; if (!s || !s.ready) return null;
|
||||
var outW = outputWidth > 0 ? outputWidth : vw;
|
||||
var outH = Math.round(outW * (vh / vw));
|
||||
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = outW;
|
||||
cv.height = outH;
|
||||
var ctx = cv.getContext('2d');
|
||||
|
||||
// Circle crop mask (matches the round viewport look on save)
|
||||
if (shape === 'circle') {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
// 1 display px inside the viewport == (outW / s.vpW) output px
|
||||
var pxRatio = outW / s.vpW;
|
||||
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.scale(pxRatio, pxRatio);
|
||||
ctx.translate(s.tx, s.ty);
|
||||
ctx.rotate(s.deg * Math.PI / 180);
|
||||
ctx.scale(s.scale, s.scale);
|
||||
ctx.drawImage(s.img, -s.imgW / 2, -s.imgH / 2);
|
||||
|
||||
if (shape === 'circle') ctx.restore();
|
||||
|
||||
return cv.toDataURL('image/jpeg', 0.92);
|
||||
}
|
||||
|
||||
window['tcSave_' + id] = function () {
|
||||
if (!cropperInst) return;
|
||||
if (!_st || !_st.ready) return;
|
||||
var btn = document.getElementById('tcSaveBtn_' + id);
|
||||
var txt = document.getElementById('tcSaveBtnText_' + id);
|
||||
btn.disabled = true;
|
||||
txt.textContent = 'Saving…';
|
||||
|
||||
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
||||
var base64 = renderCroppedBase64();
|
||||
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
|
||||
|
||||
cropperInst.crop(cropOpts).then(function (base64) {
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.png';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.jpg';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.jpg';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
} else {
|
||||
uploadToServer(base64, function (res) {
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
||||
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
||||
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
} else {
|
||||
uploadToServer(base64, function (res) {
|
||||
window.closeCropperModal(id);
|
||||
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
||||
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
}, function (err) {
|
||||
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
});
|
||||
}
|
||||
});
|
||||
}, function (err) {
|
||||
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
||||
btn.disabled = false;
|
||||
txt.textContent = 'Crop & Save';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Upload as-is ── */
|
||||
|
||||
@@ -3,9 +3,38 @@
|
||||
/videos?filter=playlists. Wrapped in @once so it emits only once. --}}
|
||||
@once
|
||||
<style>
|
||||
/* Base styles for video card */
|
||||
/* Base styles for video card — every card in the grid renders at the same
|
||||
overall size regardless of type (video / shorts / match / playlist). The
|
||||
grid handles equal widths; height parity is enforced here by making the
|
||||
card a column-flex container, the thumb a strict 16/9 box, and every
|
||||
text row a fixed line-height so missing channel/meta rows never shrink
|
||||
the card. */
|
||||
.yt-video-card {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* When the 3-dot menu is open, promote this card above its siblings so
|
||||
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
|
||||
covered by later cards in the grid. Cards are position:static by
|
||||
default, so siblings later in the DOM naturally paint on top even
|
||||
though the dropdown itself has a high z-index — the stacking is
|
||||
compared at the card level, not the dropdown level. `:has()` triggers
|
||||
only while a menu is `.show`ing, so this doesn't affect the normal
|
||||
grid layout. */
|
||||
.yt-video-card:has(.dropdown-menu.show) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Same fix for the ⋮ menu container — .yt-video-info has overflow:hidden
|
||||
(to hard-cap card height for grid parity), which was clipping the
|
||||
dropdown's bottom edge. Allow the dropdown to escape by giving its
|
||||
wrapper a stacking context and letting overflow leak upward from that
|
||||
ancestor only when a menu is open. */
|
||||
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb {
|
||||
@@ -61,7 +90,11 @@
|
||||
.yt-video-card .yt-video-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* Match the still-image thumbnail: always cover the 16/9 box so
|
||||
portrait and landscape sources render at the same visible size,
|
||||
instead of letterboxing (which made portrait previews look
|
||||
smaller than landscape ones). */
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -157,6 +190,31 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-track-lang-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
letter-spacing: 0.3px;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.yt-video-card .yt-track-lang-badge .fi {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-visibility-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
@@ -187,6 +245,22 @@
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
/* Reserve a floor for the info block so every card lands at the same
|
||||
height when titles are short. Two-line titles are allowed to grow
|
||||
the block vertically — overflow:visible so no row is hidden. */
|
||||
min-height: 76px;
|
||||
}
|
||||
/* On channel pages the avatar + channel-name row is suppressed by the
|
||||
component's hideChannelInfo prop; drop the floor so the card doesn't
|
||||
leave empty space where the row used to be. */
|
||||
.yt-video-card .yt-video-info-no-channel { min-height: 60px; }
|
||||
.yt-video-card .yt-video-details {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-icon {
|
||||
@@ -212,20 +286,32 @@
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.3;
|
||||
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
|
||||
words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動")
|
||||
break mid-word so they can't widen the card beyond its grid track.
|
||||
Line-clamp replaces the single-line ellipsis the old rule used. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-height: calc(1.3em * 2);
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Inline children (flags, corner-colored fighter spans) must not break
|
||||
the single-line layout. */
|
||||
.yt-video-card .yt-video-title a > * { vertical-align: middle; }
|
||||
.yt-video-card .yt-video-title a .fi { display: inline-block; }
|
||||
.vc-lang-flag {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
@@ -249,6 +335,8 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.yt-video-card a.yt-channel-name {
|
||||
@@ -365,305 +453,5 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Cute Edit Modal Styles */
|
||||
.cute-edit-modal {
|
||||
max-width: 380px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cute-edit-content {
|
||||
background: linear-gradient(145deg, #1f1f1f 0%, #2a2a2a 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cute-edit-header {
|
||||
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cute-edit-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cute-edit-header h5 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close-cute {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-close-cute:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.cute-edit-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cute-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cute-form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cute-form-group label i {
|
||||
color: #ff6b8a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cute-input, .cute-textarea {
|
||||
width: 100%;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cute-input:focus, .cute-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b8a;
|
||||
box-shadow: 0 0 0 3px rgba(255, 107, 138, 0.15);
|
||||
}
|
||||
|
||||
.cute-input::placeholder, .cute-textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Type Options */
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cute-type-option, .cute-privacy-option {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cute-type-option input, .cute-privacy-option input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cute-type-option span, .cute-privacy-option span {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
background: #151515;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cute-type-option:hover span, .cute-privacy-option:hover span {
|
||||
border-color: #555;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.cute-type-option.active span, .cute-privacy-option.active span {
|
||||
background: rgba(255, 107, 138, 0.15);
|
||||
border-color: #ff6b8a;
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
/* Shorts Toggle in Edit Modal */
|
||||
.cute-shorts-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cute-shorts-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: #333;
|
||||
border-radius: 12px;
|
||||
transition: 0.3s;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.cute-shorts-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider {
|
||||
background-color: #e63030;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.cute-shorts-label {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Thumbnail Upload */
|
||||
.cute-thumbnail-upload {
|
||||
border: 2px dashed #444;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.cute-thumbnail-upload:hover {
|
||||
border-color: #ff6b8a;
|
||||
background: rgba(255, 107, 138, 0.05);
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview i {
|
||||
font-size: 24px;
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
.cute-thumbnail-preview span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.cute-status {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cute-status.success {
|
||||
display: block;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.cute-status.error {
|
||||
display: block;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.cute-edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cute-btn-cancel, .cute-btn-save {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cute-btn-cancel {
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.cute-btn-cancel:hover {
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cute-btn-save {
|
||||
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 138, 0.3);
|
||||
}
|
||||
|
||||
.cute-btn-save:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 138, 0.4);
|
||||
}
|
||||
|
||||
.cute-btn-save:disabled {
|
||||
background: #444;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.cute-edit-modal {
|
||||
max-width: 320px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@@ -0,0 +1,377 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
SCOREBAR MINI — the same match scorebar shown on the full player,
|
||||
rendered inside a video-card thumbnail during hover playback.
|
||||
|
||||
Uses the design's fixed 1150 px canvas (identical to the main player's
|
||||
msb-scale block). Every card scales its own canvas via ResizeObserver
|
||||
so the scorebar keeps its pixel-perfect proportions at any card size.
|
||||
|
||||
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
|
||||
and 0-0 / 0:00. No live-scoring script — hover previews cover the
|
||||
first few seconds of the match where those values are always the
|
||||
baseline anyway.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
@php
|
||||
$sbm_hd = $video->sportsMatch?->headerData();
|
||||
$sbm_red = $sbm_hd['red'] ?? null;
|
||||
$sbm_blue = $sbm_hd['blue'] ?? null;
|
||||
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
|
||||
@endphp
|
||||
|
||||
@if ($sbm_show)
|
||||
@php
|
||||
$sbm_redName = $sbm_red['name'] ?? 'RED';
|
||||
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
|
||||
$sbm_redClub = $sbm_red['club'] ?? '';
|
||||
$sbm_blueClub = $sbm_blue['club'] ?? '';
|
||||
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
|
||||
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
|
||||
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
|
||||
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
|
||||
|
||||
// Live-scoring payload — same shape as the full-player scoreboard uses.
|
||||
// Rounds drive the round label + per-round clock. Points give us the
|
||||
// score at any t (last point ≤ t defines the score).
|
||||
$sbm_rounds = $video->matchRounds()
|
||||
->orderBy('round_number')
|
||||
->get(['round_number', 'name', 'start_time_seconds'])
|
||||
->map(fn ($r) => [
|
||||
'n' => (int) $r->round_number,
|
||||
'name' => (string) ($r->name ?? ''),
|
||||
'start' => (float) ($r->start_time_seconds ?? 0),
|
||||
])->values()->all();
|
||||
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
|
||||
|
||||
$sbm_points = $video->matchPoints()
|
||||
->orderBy('timestamp_seconds')
|
||||
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
||||
->flatMap(function ($p) {
|
||||
// Same rule the full-player uses: a "both" event emits two feed rows
|
||||
// — one per corner — so the ticker shows both fighters scoring.
|
||||
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
|
||||
$rows = [[
|
||||
't' => (float) $p->timestamp_seconds,
|
||||
'action' => (string) ($p->action ?? 'Point'),
|
||||
'pts' => (int) $p->points,
|
||||
'side' => $side,
|
||||
'sr' => (int) ($p->score_red ?? 0),
|
||||
'sb' => (int) ($p->score_blue ?? 0),
|
||||
]];
|
||||
if ($p->competitor === 'both') {
|
||||
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
|
||||
}
|
||||
return $rows;
|
||||
})->values()->all();
|
||||
|
||||
$sbm_sport = $sbm_hd['sport'] ?? null;
|
||||
|
||||
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
@endphp
|
||||
|
||||
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
|
||||
{{-- Live scoring ticker — sits in its OWN scaled canvas at the
|
||||
top-right of the thumb. Populated by the sbm live-sync JS. --}}
|
||||
<div class="sbm-feed-scale">
|
||||
<div class="sbm-feed" data-sbm-feed>
|
||||
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
|
||||
<div class="sbm-feed-list" data-sbm-feed-list></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sbm-scale">
|
||||
{{-- ══ Scorebar markup — same design + inline styles as the
|
||||
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
|
||||
of the live-sync data-msb hooks. ══ --}}
|
||||
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
|
||||
|
||||
{{-- RED / AKA --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
@if ($sbm_redFlag !== '')
|
||||
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||
@endif
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redName }}</span>
|
||||
</div>
|
||||
@if ($sbm_redClub !== '')
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CENTER: round + clock --}}
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE / AO --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_blueName }}</span>
|
||||
@if ($sbm_blueFlag !== '')
|
||||
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($sbm_blueClub !== '')
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
|
||||
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right">{{ $sbm_blueClub }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@once
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* ══ SCOREBAR MINI — same 1150 px canvas as the full player scorebar.
|
||||
Sits above the hover-preview <video> (z-index 6, one above the
|
||||
video at 5) and only appears while the video is active. ══ */
|
||||
.yt-video-thumb .sbm {
|
||||
position: absolute; inset: 0;
|
||||
z-index: 6;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #efe9e0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .25s ease .05s;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--sbm-scale: 0.4;
|
||||
}
|
||||
.sbm * { box-sizing: border-box; }
|
||||
|
||||
/* Show only while the hover video is playing (playVideo() adds .active) */
|
||||
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
|
||||
|
||||
.sbm .sbm-scale {
|
||||
position: absolute; bottom: 0; left: 0;
|
||||
width: 1150px; height: 100px;
|
||||
transform-origin: bottom left;
|
||||
transform: scale(var(--sbm-scale));
|
||||
}
|
||||
|
||||
/* ── Live scoring feed (top-right) — SAME 1150 px design canvas as the
|
||||
full-player msb-feed, so the sizes and positions are pixel-identical.
|
||||
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
|
||||
shrinks/grows in lockstep with the rest of the overlay. ── */
|
||||
.sbm .sbm-feed-scale {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 1150px; height: 100%;
|
||||
transform-origin: top left;
|
||||
transform: scale(var(--sbm-scale));
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
|
||||
videos/partials/match/scoreboard/styles.blade.php — only the class
|
||||
names are prefixed .sbm- to avoid colliding with the full player
|
||||
when a card is opened as a sub-page. */
|
||||
.sbm .sbm-feed {
|
||||
position: absolute; top: 22px; right: 24px;
|
||||
width: 262px;
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
}
|
||||
.sbm .sbm-feed-hdr {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
||||
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
||||
}
|
||||
.sbm .sbm-feed-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #e8534a; animation: sbmPulse 1.6s infinite;
|
||||
}
|
||||
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbm .sbm-feed-entry {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||
padding: 7px 10px;
|
||||
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
|
||||
border-right: 3px solid transparent;
|
||||
animation: sbmRiseIn .45s ease both;
|
||||
}
|
||||
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
|
||||
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
|
||||
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
||||
|
||||
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
|
||||
down for cards — below ~340 px it becomes illegible). */
|
||||
.sbm.sbm-small .sbm-feed { display: none; }
|
||||
|
||||
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window._sbmBound) return;
|
||||
window._sbmBound = true;
|
||||
|
||||
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
|
||||
function fit(el) {
|
||||
const host = el.parentElement;
|
||||
if (!host) return;
|
||||
const w = host.clientWidth;
|
||||
if (!w) return;
|
||||
el.style.setProperty('--sbm-scale', (w / 1150));
|
||||
// Below ~340 px thumb width the ticker would shrink under legibility
|
||||
// (matches the full player's `msb-small` breakpoint at 700 px, halved
|
||||
// for card contexts). Hide the feed on those tiny cards.
|
||||
el.classList.toggle('sbm-small', w < 340);
|
||||
}
|
||||
|
||||
// ── Live scoring sync ─────────────────────────────────────────
|
||||
// Each .sbm carries its match state as a data attribute. Bind the
|
||||
// sibling <video>'s timeupdate to updates on THIS card's scorebar
|
||||
// only — no globals, no ID collisions with other cards on the page.
|
||||
function fmtClock(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||
}
|
||||
function currentRound(rounds, t) {
|
||||
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
|
||||
let cur = rounds[0];
|
||||
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
|
||||
return cur;
|
||||
}
|
||||
function lastPoint(points, t) {
|
||||
if (!points || !points.length) return null;
|
||||
let last = null;
|
||||
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
|
||||
return last;
|
||||
}
|
||||
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
|
||||
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
|
||||
function pointLabel(p, sport) {
|
||||
sport = (sport || '').toLowerCase();
|
||||
if (sport.startsWith('taekwondo')) {
|
||||
if (p.pts >= 4) return 'Turning body';
|
||||
if (p.pts === 3) return 'Head kick';
|
||||
if (p.pts === 2) return 'Body kick';
|
||||
if (p.pts === 1) return 'Punch';
|
||||
}
|
||||
if (p.pts === 3) return 'Ippon';
|
||||
if (p.pts === 2) return 'Waza-ari';
|
||||
if (p.pts === 1) return 'Yuko';
|
||||
return p.action || 'Point';
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
||||
|
||||
function bindLive(sbm) {
|
||||
let state = null;
|
||||
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
|
||||
catch (e) { return; }
|
||||
if (!state) return;
|
||||
|
||||
const thumb = sbm.parentElement;
|
||||
const video = thumb && thumb.querySelector(':scope > video');
|
||||
if (!video) return;
|
||||
|
||||
const redEl = sbm.querySelector('[data-sbm-red-score]');
|
||||
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
|
||||
const roundEl = sbm.querySelector('[data-sbm-round]');
|
||||
const clockEl = sbm.querySelector('[data-sbm-clock]');
|
||||
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
|
||||
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
|
||||
|
||||
const tick = () => {
|
||||
const t = video.currentTime || 0;
|
||||
const p = lastPoint(state.points, t);
|
||||
const r = currentRound(state.rounds, t);
|
||||
|
||||
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
|
||||
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
|
||||
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
|
||||
|
||||
const roundTxt = r.name || ('Round ' + r.n);
|
||||
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
|
||||
|
||||
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
|
||||
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
|
||||
|
||||
// Live scoring ticker — newest 3 point events, newest on top.
|
||||
if (feedEl) {
|
||||
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
|
||||
const last4 = seen.slice(-4).reverse();
|
||||
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
|
||||
if (sig !== lastFeedSig) {
|
||||
feedEl.innerHTML = last4.map(pt => {
|
||||
const col = feedColor(pt.side);
|
||||
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
|
||||
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
|
||||
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
|
||||
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
lastFeedSig = sig;
|
||||
}
|
||||
}
|
||||
};
|
||||
video.addEventListener('timeupdate', tick);
|
||||
video.addEventListener('seeked', tick);
|
||||
video.addEventListener('loadedmetadata', tick);
|
||||
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
|
||||
video.addEventListener('pause', tick);
|
||||
video.addEventListener('emptied', tick);
|
||||
tick();
|
||||
}
|
||||
|
||||
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const el = e.target.querySelector(':scope > .sbm');
|
||||
if (el) fit(el);
|
||||
}
|
||||
}) : null;
|
||||
|
||||
function register(el) {
|
||||
fit(el);
|
||||
if (ro) ro.observe(el.parentElement);
|
||||
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sbm').forEach(register);
|
||||
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const n of m.addedNodes) {
|
||||
if (!(n instanceof Element)) continue;
|
||||
if (n.matches?.('.sbm')) register(n);
|
||||
n.querySelectorAll?.('.sbm').forEach(register);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -0,0 +1,604 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
VS MINI — the arena intro card rendered INSIDE a video-card thumbnail.
|
||||
No countdown / no skip button; identical layout to the full-page VS
|
||||
intro (fixed 1920×1080 stage, transform-scaled to fit the thumb).
|
||||
|
||||
Behavior:
|
||||
- Sits at z-index 1 (above the still image, BELOW the hover video
|
||||
which uses z-index 2 + .active { opacity: 1 }).
|
||||
- On mouseleave from the thumb, animations restart so the intro
|
||||
plays again the next time the user's mouse is off the card.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
@php
|
||||
$vsm_hd = $video->sportsMatch?->headerData();
|
||||
$vsm_red = $vsm_hd['red'] ?? null;
|
||||
$vsm_blue = $vsm_hd['blue'] ?? null;
|
||||
$vsm_show = $vsm_hd && (($vsm_red['name'] ?? null) || ($vsm_blue['name'] ?? null));
|
||||
@endphp
|
||||
|
||||
@if ($vsm_show)
|
||||
@php
|
||||
$vsm_flag = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
|
||||
$vsm_cname = fn (?string $c) => \App\Data\Countries::name($c);
|
||||
$vsm_img = fn(?string $p) => $p ? route('media.thumbnail', $p) : null;
|
||||
$vsm_str = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
|
||||
|
||||
// Every placeholder from the design is always in the DOM; empty ones
|
||||
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
|
||||
// CSS below. Same treatment as the full-page VS.
|
||||
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
|
||||
$vsm_stage = $vsm_str($vsm_hd['stage'] ?? '');
|
||||
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
|
||||
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
|
||||
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
|
||||
$vsm_ref = $vsm_str($vsm_hd['referee']['name'] ?? '');
|
||||
|
||||
// Per-fighter chips (Record / Rank / Stats) — fields aren't in
|
||||
// headerData() today, so fall back to raw participants JSON.
|
||||
$vsm_parts = $video->sportsMatch?->participants ?? [];
|
||||
$vsm_redRecord = $vsm_str($vsm_parts['p2_record'] ?? '');
|
||||
$vsm_redRank = $vsm_str($vsm_parts['p2_rank'] ?? '');
|
||||
$vsm_redStats = $vsm_str($vsm_parts['p2_stats'] ?? '');
|
||||
$vsm_blueRecord = $vsm_str($vsm_parts['p1_record'] ?? '');
|
||||
$vsm_blueRank = $vsm_str($vsm_parts['p1_rank'] ?? '');
|
||||
$vsm_blueStats = $vsm_str($vsm_parts['p1_stats'] ?? '');
|
||||
|
||||
$vsm_redName = $vsm_str($vsm_red['name'] ?? '');
|
||||
$vsm_blueName = $vsm_str($vsm_blue['name'] ?? '');
|
||||
$vsm_redClub = $vsm_str($vsm_red['club'] ?? '');
|
||||
$vsm_blueClub = $vsm_str($vsm_blue['club'] ?? '');
|
||||
$vsm_redFlag = $vsm_str($vsm_red['flag'] ?? '');
|
||||
$vsm_blueFlag = $vsm_str($vsm_blue['flag'] ?? '');
|
||||
$vsm_redLogo = $vsm_img($vsm_red['club_logo'] ?? null);
|
||||
$vsm_blueLogo = $vsm_img($vsm_blue['club_logo'] ?? null);
|
||||
@endphp
|
||||
|
||||
<div class="vs-mini vs-mini-run" data-vs-mini>
|
||||
<div class="vs-mini-stage">
|
||||
{{-- RED panel --}}
|
||||
<div class="vs-mini-panel vs-mini-panel-red">
|
||||
<div class="vs-mini-photo"
|
||||
@if($vsm_img($vsm_red['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_red['headshot']) }}')"@endif></div>
|
||||
<div class="vs-mini-scrim vs-mini-scrim-red"></div>
|
||||
<div class="vs-mini-fade"></div>
|
||||
</div>
|
||||
{{-- BLUE panel --}}
|
||||
<div class="vs-mini-panel vs-mini-panel-blue">
|
||||
<div class="vs-mini-photo vs-mini-photo-rev"
|
||||
@if($vsm_img($vsm_blue['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_blue['headshot']) }}')"@endif></div>
|
||||
<div class="vs-mini-scrim vs-mini-scrim-blue"></div>
|
||||
<div class="vs-mini-fade"></div>
|
||||
</div>
|
||||
|
||||
<div class="vs-mini-divider"></div>
|
||||
|
||||
{{-- RED identity (left) — every placeholder always in DOM ── --}}
|
||||
<div class="vs-mini-info vs-mini-info-red">
|
||||
<div class="vs-mini-tag vs-mini-tag-red">AKA · RED</div>
|
||||
<div class="vs-mini-flag-row">
|
||||
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_redFlag) }} {{ $vsm_redFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
|
||||
<div class="vs-mini-country vs-mini-country-red vsm-nullable">{{ $vsm_redFlag !== '' ? strtoupper($vsm_cname($vsm_redFlag) ?? $vsm_redFlag) : '' }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-name vsm-nullable">{{ $vsm_redName }}</div>
|
||||
<div class="vs-mini-club-row">
|
||||
<div class="vs-mini-logo {{ $vsm_redLogo ? '' : 'vsm-nullable-hide' }}"
|
||||
@if($vsm_redLogo) style="background-image:url('{{ $vsm_redLogo }}')"@endif></div>
|
||||
<div class="vs-mini-club vsm-nullable">{{ $vsm_redClub }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-chips-row">
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redRecord }}</div>
|
||||
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_redRank }}</div>
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redStats }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE identity (right) ── --}}
|
||||
<div class="vs-mini-info vs-mini-info-blue">
|
||||
<div class="vs-mini-tag vs-mini-tag-blue">AO · BLUE</div>
|
||||
<div class="vs-mini-flag-row vs-mini-flag-row-r">
|
||||
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_blueFlag) }} {{ $vsm_blueFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
|
||||
<div class="vs-mini-country vs-mini-country-blue vsm-nullable">{{ $vsm_blueFlag !== '' ? strtoupper($vsm_cname($vsm_blueFlag) ?? $vsm_blueFlag) : '' }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-name vsm-nullable">{{ $vsm_blueName }}</div>
|
||||
<div class="vs-mini-club-row vs-mini-club-row-r">
|
||||
<div class="vs-mini-logo {{ $vsm_blueLogo ? '' : 'vsm-nullable-hide' }}"
|
||||
@if($vsm_blueLogo) style="background-image:url('{{ $vsm_blueLogo }}')"@endif></div>
|
||||
<div class="vs-mini-club vsm-nullable">{{ $vsm_blueClub }}</div>
|
||||
</div>
|
||||
<div class="vs-mini-chips-row">
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueRecord }}</div>
|
||||
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_blueRank }}</div>
|
||||
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueStats }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Top: event + stage + weight (all in DOM) --}}
|
||||
<div class="vs-mini-top">
|
||||
<div class="vs-mini-top-event vsm-nullable">{{ $vsm_event }}</div>
|
||||
<div class="vs-mini-top-stage-row {{ $vsm_stage === '' ? 'vsm-nullable-hide' : '' }}">
|
||||
<div class="vs-mini-top-line vs-mini-top-line-l"></div>
|
||||
<div class="vs-mini-top-stage">{{ $vsm_stage }}</div>
|
||||
<div class="vs-mini-top-line vs-mini-top-line-r"></div>
|
||||
</div>
|
||||
<div class="vs-mini-top-weight vsm-nullable">{{ $vsm_weight }}</div>
|
||||
</div>
|
||||
|
||||
{{-- Center VS --}}
|
||||
<div class="vs-mini-center">
|
||||
<div class="vs-mini-word">VS
|
||||
<div class="vs-mini-shine-wrap"><div class="vs-mini-shine"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- .vs-mini-flash removed — the intro's full-thumb white flash at
|
||||
t=1.5s was firing on every mouseleave-triggered replay, which
|
||||
read as strobing on the left edge of the card. --}}
|
||||
|
||||
{{-- Bottom chips — Match / Court / Referee always in DOM; each hides
|
||||
via data-vsm-empty and the flanking diamonds collapse via :has(). --}}
|
||||
<div class="vs-mini-bottom">
|
||||
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_matchNo === '' ? '1' : '0' }}">
|
||||
<span class="vs-mini-chip-lbl">Match</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_matchNo }}</span>
|
||||
</div>
|
||||
<div class="vs-mini-diamond vs-mini-diamond-mc"></div>
|
||||
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_court === '' ? '1' : '0' }}">
|
||||
<span class="vs-mini-chip-lbl">Court</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_court }}</span>
|
||||
</div>
|
||||
<div class="vs-mini-diamond vs-mini-diamond-cr"></div>
|
||||
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_ref === '' ? '1' : '0' }}">
|
||||
<span class="vs-mini-chip-lbl">Referee</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_ref }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@once
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
|
||||
|
||||
<style>
|
||||
/* ══ VS MINI — arena intro inside a video-card thumbnail.
|
||||
Same 1920×1080 canvas as the full-page overlay, transform-scaled
|
||||
to fit the thumb. Every child in ABSOLUTE PIXELS on that canvas.
|
||||
z-index 1: below the hover <video> (which we bump to 5 for match
|
||||
cards), above the still <img>. ══ */
|
||||
.yt-video-thumb .vs-mini {
|
||||
position: absolute; inset: 0; z-index: 1;
|
||||
background: #050507;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
color: #e8e6e0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
--vs-mini-scale: 0.5;
|
||||
--vs-mini-w: 1920px;
|
||||
--vs-mini-h: 1080px;
|
||||
}
|
||||
|
||||
/* When a match card is hovered, the VS mini EXPLODES outward — the
|
||||
inverse of its implode entrance. Each part flies away in the direction
|
||||
opposite to where it came from, clearing the stage for the video.
|
||||
The whole overlay fades to fully hidden by the time the panels are
|
||||
off-screen (~0.6s), so the video takes over cleanly. */
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini {
|
||||
animation: vsmFadeOut .55s .25s ease forwards;
|
||||
}
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-red { animation: vsmExplodeL .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-blue { animation: vsmExplodeR .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-red { animation: vsmExplodeInfoL .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-blue { animation: vsmExplodeInfoR .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-top { animation: vsmExplodeTop .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-bottom { animation: vsmExplodeBot .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-center { animation: vsmExplodeVS .6s cubic-bezier(.34,1.56,.64,1) forwards !important; }
|
||||
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-divider { animation: vsmDividerOut .35s ease forwards !important; }
|
||||
|
||||
/* Force the hover-preview video above the VS mini's stacking context.
|
||||
The default rule sets video { z-index: 2 } but the VS mini itself
|
||||
is z-index 1 + a stacking context, so we bump the video to be safe. */
|
||||
.yt-video-thumb:has(.vs-mini) video { z-index: 5 !important; background: transparent !important; }
|
||||
.vs-mini .vs-mini-stage {
|
||||
position: absolute;
|
||||
left: 50%; top: 50%;
|
||||
width: var(--vs-mini-w); height: var(--vs-mini-h);
|
||||
transform: translate(-50%, -50%) scale(var(--vs-mini-scale));
|
||||
transform-origin: center center;
|
||||
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
/* Hidden until JS's fit() has measured the parent and set the real
|
||||
--vs-mini-scale. Without this, a card whose parent had 0 height at
|
||||
registration time (grid still resolving on mobile) would paint the
|
||||
stage at the fallback 0.5 scale — larger than the ~640×360 mobile
|
||||
thumb — clipping to just the center (looks like a broken card). */
|
||||
visibility: hidden;
|
||||
}
|
||||
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
|
||||
|
||||
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
|
||||
render every element in its final state. The intro relies on ~1.3s
|
||||
of CSS animation-delays; on mobile it was fragile — anything that
|
||||
interrupted timing (late layout resolve, ResizeObserver refires
|
||||
when the browser toolbar hides/shows, portrait class toggles that
|
||||
swap `vsmPanelL` ↔ `vsmPanelT`) left cards frozen mid-animation.
|
||||
On mobile users are scrolling, not hovering to watch a preview,
|
||||
so the intro adds no value — show the final composition instead. ══ */
|
||||
/* Same "no-animation, final-state" treatment when the parent explicitly
|
||||
requests it — used by the OG-image render route so Browsershot
|
||||
screenshots the composed final frame regardless of what Chromium
|
||||
thinks its hover capability is. */
|
||||
.vs-mini-static,
|
||||
.vs-mini-static .vs-mini-panel,
|
||||
.vs-mini-static .vs-mini-info,
|
||||
.vs-mini-static .vs-mini-top,
|
||||
.vs-mini-static .vs-mini-center,
|
||||
.vs-mini-static .vs-mini-bottom {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
|
||||
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
|
||||
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
|
||||
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
|
||||
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
|
||||
.vs-mini-static .vs-mini-photo,
|
||||
.vs-mini-static .vs-mini-word,
|
||||
.vs-mini-static .vs-mini-shine { animation: none !important; }
|
||||
|
||||
@media (hover: none) {
|
||||
.vs-mini,
|
||||
.vs-mini .vs-mini-panel,
|
||||
.vs-mini .vs-mini-info,
|
||||
.vs-mini .vs-mini-top,
|
||||
.vs-mini .vs-mini-center,
|
||||
.vs-mini .vs-mini-bottom {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
|
||||
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
|
||||
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
|
||||
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
|
||||
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
|
||||
/* Also skip the ambient photo drift + VS pulse/shine — they were
|
||||
running fine, but there's no need to burn cycles on animation
|
||||
loops for a scrolling grid on mobile. */
|
||||
.vs-mini .vs-mini-photo,
|
||||
.vs-mini .vs-mini-word,
|
||||
.vs-mini .vs-mini-shine { animation: none !important; }
|
||||
}
|
||||
|
||||
/* Animations only run while the .vs-mini-run class is set (removed +
|
||||
re-added on mouseleave to force a replay from the intro). */
|
||||
|
||||
/* ── Panels ── */
|
||||
.vs-mini .vs-mini-panel { position: absolute; overflow: hidden; opacity: 0; }
|
||||
.vs-mini .vs-mini-panel-red {
|
||||
inset: 0 auto 0 0; width: 56%;
|
||||
background: oklch(0.28 0.09 25);
|
||||
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
|
||||
}
|
||||
.vs-mini .vs-mini-panel-blue {
|
||||
inset: 0 0 0 auto; width: 56%;
|
||||
background: oklch(0.28 0.09 255);
|
||||
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-panel-red { animation: vsmPanelL .9s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini.vs-mini-run .vs-mini-panel-blue { animation: vsmPanelR .9s cubic-bezier(.22,1,.36,1) both; }
|
||||
|
||||
.vs-mini .vs-mini-photo {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
animation: vsmDrift 18s ease-in-out infinite;
|
||||
}
|
||||
.vs-mini .vs-mini-photo-rev { animation-direction: reverse; }
|
||||
.vs-mini .vs-mini-scrim-red { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
|
||||
.vs-mini .vs-mini-scrim-blue { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
|
||||
.vs-mini .vs-mini-fade {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
|
||||
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
|
||||
}
|
||||
|
||||
/* ── Divider ── */
|
||||
.vs-mini .vs-mini-divider {
|
||||
position: absolute; top: -6%; bottom: -6%; left: 50%;
|
||||
width: 3px; margin-left: -1.5px;
|
||||
transform: rotate(10.15deg);
|
||||
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
|
||||
pointer-events: none; filter: blur(1px);
|
||||
}
|
||||
|
||||
/* ── Fighter identity (fixed px on 1920×1080 canvas) ── */
|
||||
.vs-mini .vs-mini-info {
|
||||
position: absolute; z-index: 6; max-width: 44%;
|
||||
display: flex; flex-direction: column; gap: 13px;
|
||||
opacity: 0;
|
||||
}
|
||||
.vs-mini .vs-mini-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; }
|
||||
.vs-mini .vs-mini-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; }
|
||||
.vs-mini.vs-mini-run .vs-mini-info-red { animation: vsmRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini.vs-mini-run .vs-mini-info-blue { animation: vsmRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
|
||||
|
||||
.vs-mini .vs-mini-tag {
|
||||
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .35em; color: #fff;
|
||||
padding: 5.4px 15.1px 5.4px 18.9px;
|
||||
}
|
||||
.vs-mini .vs-mini-tag-red { background: oklch(0.55 0.20 25); }
|
||||
.vs-mini .vs-mini-tag-blue { background: oklch(0.50 0.16 255); }
|
||||
|
||||
.vs-mini .vs-mini-flag-row { display: flex; align-items: center; gap: 15.1px; }
|
||||
.vs-mini .vs-mini-flag-row-r { flex-direction: row-reverse; }
|
||||
.vs-mini .vs-mini-flag {
|
||||
width: 56.2px; height: auto; aspect-ratio: 4/3;
|
||||
background-size: cover !important; background-position: center !important;
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,.6);
|
||||
display: inline-block; line-height: 0;
|
||||
}
|
||||
.vs-mini .vs-mini-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
|
||||
.vs-mini .vs-mini-country-red { color: oklch(0.85 0.05 25); }
|
||||
.vs-mini .vs-mini-country-blue { color: oklch(0.85 0.05 255); }
|
||||
|
||||
.vs-mini .vs-mini-name {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 71.3px; line-height: .95;
|
||||
text-transform: uppercase; color: #fff;
|
||||
text-shadow: 0 6px 30px rgba(0,0,0,.8);
|
||||
}
|
||||
|
||||
.vs-mini .vs-mini-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
|
||||
.vs-mini .vs-mini-club-row-r { flex-direction: row-reverse; }
|
||||
.vs-mini .vs-mini-logo {
|
||||
width: 69.1px; height: 69.1px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.06);
|
||||
background-size: cover; background-position: center;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
}
|
||||
.vs-mini .vs-mini-club {
|
||||
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.9);
|
||||
}
|
||||
|
||||
/* Fighter chips row (Record / Rank / Stats) — matches full-page VS. */
|
||||
.vs-mini .vs-mini-chips-row { display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px; }
|
||||
.vs-mini .vs-mini-info-blue .vs-mini-chips-row { justify-content: flex-end; }
|
||||
.vs-mini .vs-mini-chip-fighter {
|
||||
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
padding: 5.4px 14px;
|
||||
background: rgba(10,10,14,.62);
|
||||
border: 1px solid rgba(255,255,255,.25);
|
||||
color: #fff;
|
||||
}
|
||||
.vs-mini .vs-mini-chip-fighter-gold {
|
||||
border-color: oklch(0.85 0.16 85 / .6);
|
||||
color: oklch(0.87 0.14 85);
|
||||
}
|
||||
|
||||
/* ══ Placeholder hiding — parity with the full-page VS. ══ */
|
||||
.vs-mini .vsm-nullable:empty { display: none; }
|
||||
.vs-mini .vsm-nullable-hide { display: none; }
|
||||
.vs-mini .vs-mini-chip.vsm-nullable-chip[data-vsm-empty="1"] { display: none; }
|
||||
.vs-mini .vs-mini-bottom .vs-mini-diamond-mc:has(+ .vs-mini-chip[data-vsm-empty="1"]),
|
||||
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-mc,
|
||||
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-cr,
|
||||
.vs-mini .vs-mini-bottom .vs-mini-diamond-cr:has(+ .vs-mini-chip[data-vsm-empty="1"]) { display: none; }
|
||||
|
||||
/* ── Top block ── */
|
||||
.vs-mini .vs-mini-top {
|
||||
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
|
||||
z-index: 8; width: 92%; pointer-events: none; opacity: 0;
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-top { animation: vsmDropIn .8s .5s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini .vs-mini-top-event {
|
||||
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .42em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.92); text-align: center;
|
||||
text-shadow: 0 2px 14px rgba(0,0,0,.9);
|
||||
}
|
||||
.vs-mini .vs-mini-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
|
||||
.vs-mini .vs-mini-top-line { height: 2px; width: 64.8px; }
|
||||
.vs-mini .vs-mini-top-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
|
||||
.vs-mini .vs-mini-top-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
|
||||
.vs-mini .vs-mini-top-stage {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 38.9px;
|
||||
letter-spacing: .30em; padding-left: .3em;
|
||||
color: oklch(0.85 0.16 85); text-transform: uppercase;
|
||||
}
|
||||
.vs-mini .vs-mini-top-weight {
|
||||
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
|
||||
color: #fff; text-shadow: 0 2px 14px rgba(0,0,0,.9);
|
||||
}
|
||||
|
||||
/* ── Center VS ── */
|
||||
.vs-mini .vs-mini-center {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
|
||||
z-index: 7; pointer-events: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-center { animation: vsmSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini .vs-mini-word {
|
||||
position: relative;
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 183.6px; font-style: italic;
|
||||
color: #fffdf5; line-height: 1;
|
||||
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
|
||||
animation: vsmPulse 2.4s ease-in-out infinite;
|
||||
overflow: visible;
|
||||
}
|
||||
.vs-mini .vs-mini-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
|
||||
.vs-mini .vs-mini-shine {
|
||||
position: absolute; top: 0; bottom: 0; width: 34%;
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
|
||||
animation: vsmShine 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* .vs-mini-flash rules removed with the element itself. */
|
||||
|
||||
/* ── Bottom chips ── */
|
||||
.vs-mini .vs-mini-bottom {
|
||||
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 17.3px; z-index: 8; align-items: center;
|
||||
flex-wrap: wrap; justify-content: center; max-width: 94%; opacity: 0;
|
||||
}
|
||||
.vs-mini.vs-mini-run .vs-mini-bottom { animation: vsmRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both; }
|
||||
.vs-mini .vs-mini-chip {
|
||||
display: flex; align-items: baseline; gap: 8.6px;
|
||||
background: rgba(10,10,14,.72);
|
||||
border: 1px solid oklch(0.85 0.16 85 / .45);
|
||||
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
|
||||
}
|
||||
.vs-mini .vs-mini-chip-lbl {
|
||||
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
|
||||
letter-spacing: .30em; text-transform: uppercase;
|
||||
color: rgba(232,230,224,.65);
|
||||
}
|
||||
.vs-mini .vs-mini-chip-val {
|
||||
font-family: 'Anton', 'Barlow Condensed', sans-serif;
|
||||
font-size: 34.6px; color: #fff;
|
||||
}
|
||||
.vs-mini .vs-mini-diamond {
|
||||
width: 6px; height: 6px; transform: rotate(45deg);
|
||||
background: oklch(0.85 0.16 85);
|
||||
}
|
||||
|
||||
/* ── Portrait canvas (rare — vertical match cards) ── */
|
||||
.vs-mini.vs-mini-portrait { --vs-mini-w: 1080px; --vs-mini-h: 1920px; }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
|
||||
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-divider {
|
||||
left: -4%; right: -4%; top: 50%; bottom: auto;
|
||||
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
|
||||
transform: rotate(-4.48deg);
|
||||
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
|
||||
}
|
||||
.vs-mini.vs-mini-portrait .vs-mini-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
|
||||
.vs-mini.vs-mini-portrait .vs-mini-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
|
||||
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-red { animation-name: vsmPanelT; }
|
||||
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-blue { animation-name: vsmPanelB; }
|
||||
|
||||
/* Hide the shimmer skeleton on match cards — the VS mini fills the thumb. */
|
||||
.yt-video-thumb:has(.vs-mini)::before { display: none; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes vsmPulse {
|
||||
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
|
||||
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
|
||||
}
|
||||
@keyframes vsmShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
|
||||
@keyframes vsmDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
|
||||
@keyframes vsmPanelL { from { opacity: 1; transform: translateX(-105%); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes vsmPanelR { from { opacity: 1; transform: translateX( 105%); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes vsmPanelT { from { opacity: 1; transform: translateY(-105%); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsmPanelB { from { opacity: 1; transform: translateY( 105%); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsmRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes vsmRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes vsmDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes vsmSlam {
|
||||
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
|
||||
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
|
||||
80% { transform: translate(-50%,-52%) scale(1.06); }
|
||||
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ══ EXPLODE — the inverse of the implode entrance. Each piece flies
|
||||
OUT in the direction opposite to where it came from, then the
|
||||
whole mini fades to zero opacity while the video takes the stage. ══ */
|
||||
@keyframes vsmFadeOut { to { opacity: 0; } }
|
||||
@keyframes vsmExplodeL { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX(-140%) scale(1.05); } }
|
||||
@keyframes vsmExplodeR { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX( 140%) scale(1.05); } }
|
||||
@keyframes vsmExplodeInfoL { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate(-70%, 40%) scale(.85); } }
|
||||
@keyframes vsmExplodeInfoR { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate( 70%, 40%) scale(.85); } }
|
||||
@keyframes vsmExplodeTop { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%,-120%) scale(.9); } }
|
||||
@keyframes vsmExplodeBot { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%, 120%) scale(.9); } }
|
||||
@keyframes vsmExplodeVS {
|
||||
0% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); filter: blur(0); }
|
||||
40% { opacity: 1; transform: translate(-50%,-52%) scale(1.3) rotate(-2deg); filter: blur(1px); }
|
||||
100% { opacity: 0; transform: translate(-50%,-52%) scale(4) rotate(6deg); filter: blur(8px); }
|
||||
}
|
||||
@keyframes vsmDividerOut { to { opacity: 0; transform: rotate(10.15deg) scaleY(0); } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window._vsMiniBound) return;
|
||||
window._vsMiniBound = true;
|
||||
|
||||
// Fit the 1920×1080 (or 1080×1920) stage to whatever thumb size the
|
||||
// card resolves to. One ResizeObserver watches every current + future
|
||||
// thumb — cheap because the callback is a couple of arithmetic ops.
|
||||
function fit(el) {
|
||||
const host = el.parentElement;
|
||||
if (!host) return;
|
||||
const w = host.clientWidth, h = host.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const portrait = h > w * 1.05;
|
||||
el.classList.toggle('vs-mini-portrait', portrait);
|
||||
const sw = portrait ? 1080 : 1920;
|
||||
const sh = portrait ? 1920 : 1080;
|
||||
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
|
||||
// Reveal the stage — hidden by default so the CSS's fallback
|
||||
// scale never paints a pre-fit oversized/clipped stage.
|
||||
el.classList.add('vs-mini-fit');
|
||||
}
|
||||
|
||||
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const el = e.target.querySelector(':scope > .vs-mini');
|
||||
if (el) fit(el);
|
||||
}
|
||||
}) : null;
|
||||
|
||||
function register(el) {
|
||||
fit(el);
|
||||
if (ro) ro.observe(el.parentElement);
|
||||
}
|
||||
|
||||
// Register everything already in the DOM.
|
||||
document.querySelectorAll('.vs-mini').forEach(register);
|
||||
|
||||
// Register anything added later (SPA nav, infinite-scroll loads).
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const n of m.addedNodes) {
|
||||
if (!(n instanceof Element)) continue;
|
||||
if (n.matches?.('.vs-mini')) register(n);
|
||||
n.querySelectorAll?.('.vs-mini').forEach(register);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Restart entrance animations whenever the mouse leaves a card thumb
|
||||
// that has a VS mini in it. Only bind on real hover devices — on touch,
|
||||
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
|
||||
// kept restarting animations and leaving cards frozen mid-intro
|
||||
// (elements at opacity:0 during the 0.5–1.3s animation-delay window).
|
||||
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
|
||||
if (hasHover) {
|
||||
document.addEventListener('mouseleave', (e) => {
|
||||
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
|
||||
if (!thumb) return;
|
||||
const vsm = thumb.querySelector(':scope > .vs-mini');
|
||||
if (!vsm) return;
|
||||
vsm.classList.remove('vs-mini-run');
|
||||
void vsm.offsetWidth;
|
||||
vsm.classList.add('vs-mini-run');
|
||||
}, true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -306,7 +306,7 @@
|
||||
@if($embedAudioInput)
|
||||
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'">
|
||||
onchange="editHandleTrackAudio(this, '{{ $p }}', '{{ $titleId }}')">
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,10 +71,14 @@
|
||||
color: var(--brand-red) !important;
|
||||
}
|
||||
|
||||
/* Single Action dropdown for every viewport — the individual
|
||||
.desktop-action buttons are collapsed into this one menu. */
|
||||
.mobile-action-dropdown {
|
||||
display: none;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
.video-actions > .desktop-action { display: none !important; }
|
||||
|
||||
.mobile-action-dropdown .dropdown-menu {
|
||||
right: 0;
|
||||
@@ -128,6 +132,12 @@
|
||||
<i class="bi bi-pencil"></i>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button class="action-btn desktop-action" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i>
|
||||
<span>Merge</span>
|
||||
</button>
|
||||
@endif
|
||||
{{-- Lyrics generate/regenerate + edit now live inside the player's gear menu
|
||||
so they're always reachable on both mobile and desktop. --}}
|
||||
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
|
||||
@@ -148,25 +158,44 @@
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
||||
{{-- Guest Subscribe: showToast first so the user understands what's
|
||||
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>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
|
||||
one clears the other via the controllers' detach() call. --}}
|
||||
<button type="button"
|
||||
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
||||
data-video-id="{{ $video->id }}"
|
||||
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
|
||||
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>
|
||||
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
|
||||
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>
|
||||
</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
|
||||
<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' }}
|
||||
</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
|
||||
|
||||
@if ($video->isShareable())
|
||||
@@ -250,6 +279,11 @@
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button type="button" class="dropdown-item" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i> Merge
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
|
||||
@@ -436,6 +470,46 @@
|
||||
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 ──────────────────────────────
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
@props(['video' => null, 'size' => 'medium'])
|
||||
@props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
|
||||
|
||||
@php
|
||||
use App\Data\Languages;
|
||||
|
||||
$videoUrl = $video ? route('videos.stream', $video) : null;
|
||||
// When a specific audio track is being surfaced (e.g. via the language
|
||||
// filter chips), the hover-preview should play that language, not the
|
||||
// primary. audio-track route serves the track file directly.
|
||||
$forceTrackIdEarly = $video ? $video->getAttribute('_force_track_id') : null;
|
||||
$videoUrl = $video
|
||||
? ($forceTrackIdEarly
|
||||
? route('videos.audio-track', ['video' => $video, 'track' => $forceTrackIdEarly])
|
||||
: route('videos.stream', $video))
|
||||
: null;
|
||||
$thumbnailUrl = $video && $video->thumbnail
|
||||
? route('media.thumbnail', $video->thumbnail)
|
||||
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
|
||||
@@ -20,26 +28,87 @@ $isShorts = $video && $video->isShorts();
|
||||
// Check if current user is the owner of the video
|
||||
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
|
||||
|
||||
// Language flag code (null when no language set)
|
||||
$langFlag = $video ? Languages::flag($video->language) : null;
|
||||
// When surfacing a video via a secondary audio track (e.g. from the
|
||||
// language filter chips) the controller tags it with these runtime
|
||||
// attributes so the card links straight to that track and badges the
|
||||
// thumbnail with its flag.
|
||||
$forceTrackId = $video ? $video->getAttribute('_force_track_id') : null;
|
||||
$forceTrackFlag = $video ? $video->getAttribute('_force_track_flag') : null;
|
||||
|
||||
// Language flag code (null when no language set). Prefer the forced
|
||||
// track flag when set — it's the language the card is being surfaced for.
|
||||
$langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : null);
|
||||
|
||||
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
|
||||
|
||||
// Composed display title for match videos — matches the header on the
|
||||
// match page: "{match title} – {blue flag+name} vs {red flag+name}
|
||||
// ({weight})", with blue/red corner colours and inline flags. Falls
|
||||
// back to plain $video->title for non-match cards.
|
||||
$displayTitle = $video?->title;
|
||||
$displayTitleHtml = null;
|
||||
if ($video && $video->type === 'match' && $video->sportsMatch) {
|
||||
$hdCard = $video->sportsMatch->headerData();
|
||||
$blueN = $hdCard['blue']['name'] ?? null;
|
||||
$redN = $hdCard['red']['name'] ?? null;
|
||||
$blueF = $hdCard['blue']['flag'] ?? null;
|
||||
$redF = $hdCard['red']['flag'] ?? null;
|
||||
if ($blueN || $redN) {
|
||||
$flagStyle = 'width:16px;height:12px;border-radius:2px;display:inline-block;vertical-align:middle;margin-right:4px;';
|
||||
$mkSide = function ($name, $flag, $color) use ($flagStyle) {
|
||||
if (!$name) return '';
|
||||
$flagHtml = $flag ? '<span class="fi fi-'.e($flag).'" style="'.$flagStyle.'"></span>' : '';
|
||||
return '<span style="color:'.$color.';font-weight:600;">'.$flagHtml.e($name).'</span>';
|
||||
};
|
||||
$blueHtml = $mkSide($blueN, $blueF, '#2563eb');
|
||||
$redHtml = $mkSide($redN, $redF, '#ef4444');
|
||||
|
||||
$parts = [];
|
||||
if (!empty($video->title)) $parts[] = e(strtoupper($video->title)) . ' –';
|
||||
if ($blueHtml && $redHtml) $parts[] = $blueHtml . ' <span>vs</span> ' . $redHtml;
|
||||
elseif ($blueHtml) $parts[] = $blueHtml;
|
||||
elseif ($redHtml) $parts[] = $redHtml;
|
||||
if (!empty($hdCard['weight_category'])) $parts[] = '(' . e($hdCard['weight_category']) . ')';
|
||||
$displayTitleHtml = implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
// Size classes
|
||||
$sizeClasses = match($size) {
|
||||
'small' => 'yt-video-card-sm',
|
||||
default => '',
|
||||
};
|
||||
|
||||
@endphp
|
||||
|
||||
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
|
||||
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
|
||||
<a href="{{ $showUrl }}">
|
||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
{{-- width/height let the browser reserve the card's thumb box
|
||||
BEFORE the image decodes, so a grid of 60 cards doesn't shift
|
||||
as each thumbnail finishes loading. The ratio is honored by
|
||||
.yt-video-thumb's aspect-ratio: 16/9 regardless of actual px. --}}
|
||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}"
|
||||
width="320" height="180"
|
||||
loading="lazy" decoding="async"
|
||||
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
@if($videoUrl)
|
||||
<video preload="none">
|
||||
{{-- Match cards preload metadata so the first frame is ready the
|
||||
instant the user hovers (otherwise the browser fetches, buffers
|
||||
then paints — showing a black frame behind the fade). Other
|
||||
card types keep preload="none" to save bandwidth. --}}
|
||||
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
|
||||
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||
</video>
|
||||
@endif
|
||||
{{-- Match cards used to inline the full VS portrait + the live-scoring
|
||||
scorebar as thumbnail overlays. Those partials leaked fighter
|
||||
names, "AKA / AO", "ROUND 1", "0:00" and club text into the
|
||||
card DOM — showing stale 0-0 for finished matches, breaking
|
||||
the title layout at narrow widths, and inflating card HTML.
|
||||
The regular <img> above is the thumbnail now; the VS intro
|
||||
and live scorebar live on the full match player page only. --}}
|
||||
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
||||
<div class="audio-preview-overlay">
|
||||
<div class="audio-eq">
|
||||
@@ -54,6 +123,12 @@ $sizeClasses = match($size) {
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
@if($forceTrackId && $forceTrackFlag)
|
||||
<span class="yt-track-lang-badge" title="Plays the {{ strtoupper($video->language ?? '') }} track's language variant">
|
||||
<span class="fi fi-{{ $forceTrackFlag }}"></span>
|
||||
<span>Track</span>
|
||||
</span>
|
||||
@endif
|
||||
@if($isOwner && $video->visibility === 'private')
|
||||
<span class="yt-visibility-badge yt-visibility-private">
|
||||
<i class="bi bi-lock-fill"></i> Private
|
||||
@@ -65,23 +140,29 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="yt-video-info">
|
||||
<div class="yt-video-info {{ $hideChannelInfo ? 'yt-video-info-no-channel' : '' }}">
|
||||
@unless($hideChannelInfo)
|
||||
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
|
||||
class="yt-channel-icon" onclick="event.stopPropagation();">
|
||||
@if($video && $video->user && $video->user->avatar_url)
|
||||
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
|
||||
@endif
|
||||
</a>
|
||||
@endunless
|
||||
<div class="yt-video-details">
|
||||
<h3 class="yt-video-title">
|
||||
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
|
||||
<a href="{{ $showUrl }}">
|
||||
@if($langFlag)
|
||||
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
|
||||
@endif
|
||||
{{ $video->title ?? 'Untitled Video' }}
|
||||
@if($displayTitleHtml)
|
||||
{!! $displayTitleHtml !!}
|
||||
@else
|
||||
{{ $displayTitle ?? 'Untitled Video' }}
|
||||
@endif
|
||||
</a>
|
||||
</h3>
|
||||
@if($video && $video->user)
|
||||
@if($video && $video->user && ! $hideChannelInfo)
|
||||
<a href="{{ route('channel', $video->user->channel) }}"
|
||||
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
|
||||
@endif
|
||||
@@ -100,15 +181,15 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
@if($video)
|
||||
<div class="position-relative">
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More actions">
|
||||
<i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if($isOwner)
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
|
||||
@@ -118,17 +199,17 @@ $sizeClasses = match($size) {
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@endif
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-list-nested"></i> Add to queue
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-clock"></i> Save to Watch later
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
|
||||
<x-save-to-playlist-button :video="$video" tag="button" class="dropdown-item">
|
||||
<i class="bi bi-bookmark"></i> Save to playlist
|
||||
</x-save-to-playlist-button>
|
||||
</li>
|
||||
@@ -141,133 +222,45 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
@if($video->isShareable())
|
||||
<li>
|
||||
<x-share-button :video="$video" tag="a" class="dropdown-item">
|
||||
<x-share-button :video="$video" tag="button" class="dropdown-item">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</x-share-button>
|
||||
</li>
|
||||
@endif
|
||||
{{-- "Not interested", "Don't recommend channel" and "Report"
|
||||
only make sense for someone else's video. On your own, the
|
||||
Edit/Delete items above are what you want instead. --}}
|
||||
@unless($isOwner)
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-dash-circle"></i> Not interested
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||
<button type="button" class="dropdown-item" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||
<i class="bi bi-x-circle"></i> Don't recommend channel
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||
<button type="button" class="dropdown-item" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-flag"></i> Report
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
@endunless
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $vk = $video->getRouteKey() ?? ''; @endphp
|
||||
<!-- Cute Edit Video Modal -->
|
||||
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
|
||||
<div class="modal-content cute-edit-content">
|
||||
<div class="cute-edit-header">
|
||||
<span class="cute-edit-icon">✏️</span>
|
||||
<h5>Edit Video</h5>
|
||||
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
|
||||
</div>
|
||||
<div class="cute-edit-body">
|
||||
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- Title -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-card-heading"></i> Title</label>
|
||||
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-text-paragraph"></i> Description</label>
|
||||
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Video Type -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-collection-play"></i> Type</label>
|
||||
<div class="cute-type-options">
|
||||
<label class="cute-type-option active" data-type="generic">
|
||||
<input type="radio" name="type" value="generic" checked>
|
||||
<span>🎬 Generic</span>
|
||||
</label>
|
||||
<label class="cute-type-option" data-type="music">
|
||||
<input type="radio" name="type" value="music">
|
||||
<span>🎵 Music</span>
|
||||
</label>
|
||||
<label class="cute-type-option" data-type="match">
|
||||
<input type="radio" name="type" value="match">
|
||||
<span>🏆 Match</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Toggle -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
|
||||
<label class="cute-shorts-toggle">
|
||||
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
|
||||
<span class="cute-shorts-slider"></span>
|
||||
<span class="cute-shorts-label">Mark as Short</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-image"></i> Thumbnail</label>
|
||||
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
|
||||
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
|
||||
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
|
||||
<i class="bi bi-camera"></i>
|
||||
<span>Click to change</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-shield-lock"></i> Privacy</label>
|
||||
<div class="cute-privacy-options">
|
||||
<label class="cute-privacy-option active" data-privacy="public">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
<span>🌐 Public</span>
|
||||
</label>
|
||||
<label class="cute-privacy-option" data-privacy="unlisted">
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
<span>🔗 Unlisted</span>
|
||||
</label>
|
||||
<label class="cute-privacy-option" data-privacy="private">
|
||||
<input type="radio" name="visibility" value="private">
|
||||
<span>🔒 Private</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="cute-edit-actions">
|
||||
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
|
||||
<button type="submit" class="cute-btn-save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{--
|
||||
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
|
||||
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
|
||||
defines a global window.openEditVideoModal that always wins the last-definition race,
|
||||
so clicking "Edit" on a card has always been going through the singleton. Removing the
|
||||
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
|
||||
--}}
|
||||
|
||||
@include('components.partials.card-styles')
|
||||
|
||||
@@ -322,155 +315,8 @@ function stopVideo(element) {
|
||||
element.classList.remove('audio-playing');
|
||||
}
|
||||
|
||||
// Edit Modal Functions
|
||||
let currentEditVideoId = null;
|
||||
|
||||
function openEditVideoModal(videoId) {
|
||||
currentEditVideoId = videoId;
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
||||
modal.show();
|
||||
|
||||
// Fetch video data
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const video = data.video;
|
||||
document.getElementById('edit-title-' + videoId).value = video.title || '';
|
||||
document.getElementById('edit-description-' + videoId).value = video.description || '';
|
||||
|
||||
// Set type
|
||||
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
|
||||
typeOptions.forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
if (opt.dataset.type === (video.type || 'generic')) {
|
||||
opt.classList.add('active');
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Set privacy
|
||||
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
|
||||
privacyOptions.forEach(opt => {
|
||||
opt.classList.remove('active');
|
||||
if (opt.dataset.privacy === (video.visibility || 'public')) {
|
||||
opt.classList.add('active');
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Set shorts toggle
|
||||
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
|
||||
if (shortsCheckbox) {
|
||||
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
|
||||
}
|
||||
|
||||
// Clear status
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
statusEl.className = 'cute-status';
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function closeEditVideoModal(videoId) {
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modalEl = document.getElementById(modalId);
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// SPA navigation re-executes this once-protected script on every page swap.
|
||||
// Guard the document-level listeners so they don't stack up across navs.
|
||||
if (!window._videoCardListenersBound) {
|
||||
window._videoCardListenersBound = true;
|
||||
|
||||
// Type option click handlers
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.cute-type-option')) {
|
||||
const option = e.target.closest('.cute-type-option');
|
||||
const parent = option.parentElement;
|
||||
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
option.querySelector('input').checked = true;
|
||||
}
|
||||
if (e.target.closest('.cute-privacy-option')) {
|
||||
const option = e.target.closest('.cute-privacy-option');
|
||||
const parent = option.parentElement;
|
||||
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
option.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Thumbnail preview
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const videoId = e.target.id.replace('edit-thumbnail-', '');
|
||||
const preview = document.getElementById('thumbnail-preview-' + videoId);
|
||||
preview.innerHTML = `<span>${file.name}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Form submission
|
||||
document.addEventListener('submit', function(e) {
|
||||
const form = e.target;
|
||||
if (form.id && form.id.startsWith('edit-video-form-')) {
|
||||
e.preventDefault();
|
||||
const videoId = form.id.replace('edit-video-form-', '');
|
||||
const formData = new FormData(form);
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
const submitBtn = form.querySelector('.cute-btn-save');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
statusEl.className = 'cute-status success';
|
||||
statusEl.textContent = '✓ Saved successfully!';
|
||||
setTimeout(() => {
|
||||
closeEditVideoModal(videoId);
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(data.message || 'Update failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
statusEl.className = 'cute-status error';
|
||||
statusEl.textContent = '✗ ' + error.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Save';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} // end _videoCardListenersBound guard
|
||||
// Edit-modal open/close + its form handlers live in the singleton at
|
||||
// layouts/partials/edit-video-modal.blade.php (not here) — see the
|
||||
// removed-markup note above the dropdown for why.
|
||||
</script>
|
||||
@endonce
|
||||
@@ -4,8 +4,31 @@
|
||||
use App\Models\CommentLike;
|
||||
|
||||
$currentUserId = Auth::id() ?? 0;
|
||||
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
|
||||
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
|
||||
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||
|
||||
// Flag per commenter: most-frequent access country, falling back to the
|
||||
// user's nationality. Batched so the comments list stays a single query.
|
||||
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
|
||||
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
|
||||
// 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();
|
||||
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
|
||||
|
||||
// Permalink: ?lc=<comment-id> scrolls to and highlights a specific
|
||||
// comment or reply so a shared link lands on the exact message. The
|
||||
// id is validated against the current video's comment ids so the
|
||||
// highlight only applies when the target really belongs here.
|
||||
$permalinkId = (int) request('lc', 0);
|
||||
|
||||
$allCommentIds = $topComments->pluck('id')
|
||||
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
|
||||
@@ -24,8 +47,8 @@
|
||||
|
||||
if (!function_exists('ytcAvatar')) {
|
||||
function ytcAvatar($user) {
|
||||
if (!$user) return 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
|
||||
return $user->avatar_url ?? ('https://ui-avatars.com/api/?name='.urlencode($user->name ?? 'User').'&background=333&color=fff');
|
||||
if (!$user) return asset('images/default-avatar.svg');
|
||||
return $user->avatar_url ?? asset('images/default-avatar.svg');
|
||||
}
|
||||
}
|
||||
if (!function_exists('ytcTime')) {
|
||||
@@ -40,7 +63,17 @@
|
||||
|
||||
{{-- ── Header ── --}}
|
||||
<div class="ytc-header">
|
||||
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span>
|
||||
{{-- Top-level count on the header; reply count surfaces inside each
|
||||
thread's "N replies" expander. "1 Comment" singular when there's
|
||||
exactly one — never "1 Comments". --}}
|
||||
<span class="ytc-count" id="ytcCount">
|
||||
{{ number_format($topLevelCount) }} {{ Str::plural('Comment', $topLevelCount) }}
|
||||
@if($replyCount > 0)
|
||||
<span class="ytc-count-replies" aria-label="reply count">
|
||||
· {{ number_format($replyCount) }} {{ Str::plural('reply', $replyCount) }}
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="ytc-sort-wrap" id="ytcSortWrap">
|
||||
<button class="ytc-sort-btn" id="ytcSortBtn">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
|
||||
@@ -82,18 +115,41 @@
|
||||
{{-- ── Comments list ── --}}
|
||||
<div id="ytcList">
|
||||
@forelse($topComments as $comment)
|
||||
@php $isOwn = $comment->user_id === $currentUserId; @endphp
|
||||
<div class="ytc-comment" id="comment-{{ $comment->id }}">
|
||||
@php
|
||||
$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">
|
||||
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
|
||||
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
|
||||
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
|
||||
<a class="ytc-time ytc-permalink"
|
||||
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
|
||||
title="Copy link to this comment"
|
||||
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 class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
{{-- Edit form --}}
|
||||
@if($isOwn)
|
||||
@@ -118,8 +174,31 @@
|
||||
<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>
|
||||
@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>
|
||||
@endauth
|
||||
@if($llmEnabled)
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->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>
|
||||
<span class="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($isOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn" title="More actions">
|
||||
@@ -139,20 +218,12 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Reply form --}}
|
||||
{{-- Reply form is lazy-cloned from #ytc-reply-form-tpl on
|
||||
first click; the ~13-line markup used to render for
|
||||
every top-level comment (so a 50-comment video shipped
|
||||
~650 lines of hidden form markup to every viewer). --}}
|
||||
@auth
|
||||
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
|
||||
<div class="ytc-input-wrap">
|
||||
<textarea class="ytc-textarea ytc-reply-textarea" id="replyBody{{ $comment->id }}"
|
||||
placeholder="Add a reply..." rows="1"></textarea>
|
||||
<div class="ytc-form-actions">
|
||||
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger" data-comment-id="{{ $comment->id }}">Cancel</button>
|
||||
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger"
|
||||
data-video-id="{{ $video->getRouteKey() }}" data-parent-id="{{ $comment->id }}">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
|
||||
@endauth
|
||||
|
||||
{{-- Replies --}}
|
||||
@@ -172,10 +243,18 @@
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
|
||||
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
|
||||
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
|
||||
<a class="ytc-time ytc-permalink"
|
||||
href="?lc={{ $reply->id }}#comment-{{ $reply->id }}"
|
||||
title="Copy link to this reply"
|
||||
aria-label="Permalink">{{ ytcTime($reply->created_at) }}</a>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
|
||||
@@ -197,6 +276,21 @@
|
||||
<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>
|
||||
</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)
|
||||
<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>
|
||||
<span class="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn">
|
||||
@@ -270,6 +364,66 @@
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ytc-count-replies {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
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
|
||||
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
|
||||
.ytc-comment.ytc-highlight,
|
||||
.ytc-reply.ytc-highlight {
|
||||
animation: ytc-highlight-pulse 2.8s ease-out;
|
||||
}
|
||||
@keyframes ytc-highlight-pulse {
|
||||
0% { background-color: rgba(239, 68, 68, 0.18); }
|
||||
40% { background-color: rgba(239, 68, 68, 0.18); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.ytc-sort-wrap { position: relative; }
|
||||
.ytc-sort-btn {
|
||||
display: flex;
|
||||
@@ -440,7 +594,11 @@
|
||||
.ytc-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The time stamp is now an <a> permalink. Underline on hover so it's
|
||||
discoverable but doesn't scream "link" at rest. */
|
||||
.ytc-time.ytc-permalink:hover { text-decoration: underline; cursor: pointer; }
|
||||
|
||||
/* ── Comment text ── */
|
||||
.ytc-text {
|
||||
@@ -451,6 +609,31 @@
|
||||
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 ── */
|
||||
.ytc-actions {
|
||||
display: flex;
|
||||
@@ -498,6 +681,57 @@
|
||||
}
|
||||
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
|
||||
|
||||
/* ── Translate ── */
|
||||
.ytc-translate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 18px;
|
||||
transition: background .15s, color .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
|
||||
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
||||
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
|
||||
.ytc-translate-btn.active { color: #3ea6ff; }
|
||||
.ytc-flag {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ytc-translation {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(62,166,255,.08);
|
||||
border-left: 2px solid #3ea6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ytc-translation-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
/* ── Three-dot menu ── */
|
||||
.ytc-more-wrap {
|
||||
position: relative;
|
||||
@@ -695,9 +929,16 @@ const YTC = {
|
||||
videoId: '{{ $video->getRouteKey() }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
userId: {{ $currentUserId }},
|
||||
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
|
||||
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
|
||||
deleteId: null,
|
||||
toastTimer: null,
|
||||
};
|
||||
const YTC_TRANSLATE_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>';
|
||||
function targetLang() {
|
||||
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
|
||||
return l || 'en';
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
function esc(s) {
|
||||
@@ -707,8 +948,8 @@ function esc(s) {
|
||||
}
|
||||
function fmt(s) { return s ?? ''; }
|
||||
function avatarUrl(user) {
|
||||
if (!user) return 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
|
||||
return user.avatar_url || ('https://ui-avatars.com/api/?name=' + encodeURIComponent(user.name || 'User') + '&background=333&color=fff');
|
||||
if (!user) return '{{ asset('images/default-avatar.svg') }}';
|
||||
return user.avatar_url || '{{ asset('images/default-avatar.svg') }}';
|
||||
}
|
||||
|
||||
// ── Toast (use global if available, else local) ───────
|
||||
@@ -877,6 +1118,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
||||
const rt = e.target.closest('._comment-reply-trigger');
|
||||
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
|
||||
const et = e.target.closest('._comment-edit-trigger');
|
||||
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
|
||||
@@ -904,12 +1165,148 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
||||
// Dislike btn
|
||||
const db = e.target.closest('.ytc-dislike-btn');
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Reply form ────────────────────────────────────────
|
||||
// Translate — bound at `document` level once, dispatches to the LATEST
|
||||
// IIFE's handler via window.__ytcToggleTranslate. SPA video-to-video swaps
|
||||
// replace #ytcSection and re-run this script; the new IIFE overwrites
|
||||
// window.__ytcToggleTranslate so it keeps using fresh state (YTC.csrf etc.).
|
||||
window.__ytcToggleTranslate = function (btn) { toggleTranslate(btn); };
|
||||
if (!window.__ytcTranslateDelegated) {
|
||||
window.__ytcTranslateDelegated = true;
|
||||
document.addEventListener('click', e => {
|
||||
const tb = e.target.closest('._comment-translate-trigger');
|
||||
if (!tb) return;
|
||||
e.stopPropagation();
|
||||
if (typeof window.__ytcToggleTranslate === 'function') window.__ytcToggleTranslate(tb);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Translate ─────────────────────────────────────────
|
||||
async function toggleTranslate(btn) {
|
||||
const id = btn.dataset.commentId;
|
||||
const panel = document.getElementById('translation-' + id);
|
||||
const label = btn.querySelector('.ytc-translate-label');
|
||||
if (!panel) return;
|
||||
|
||||
// Toggle off if already shown.
|
||||
if (panel.style.display !== 'none' && panel.dataset.loaded === '1') {
|
||||
panel.style.display = 'none';
|
||||
btn.classList.remove('active');
|
||||
if (label) label.textContent = 'Translate';
|
||||
return;
|
||||
}
|
||||
// Already loaded — just reveal.
|
||||
if (panel.dataset.loaded === '1') {
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add('loading');
|
||||
if (label) label.textContent = 'Translating…';
|
||||
try {
|
||||
const r = await fetch('/comments/' + id + '/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ target: targetLang() })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
|
||||
|
||||
panel.innerHTML = '';
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'ytc-translation-meta';
|
||||
meta.textContent = 'Translated → ' + (d.target || targetLang()).toUpperCase();
|
||||
const body = document.createElement('div');
|
||||
body.textContent = d.translated;
|
||||
panel.appendChild(meta);
|
||||
panel.appendChild(body);
|
||||
|
||||
panel.dataset.loaded = '1';
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
} catch (err) {
|
||||
toast(err.message || 'Translation failed', 'error');
|
||||
if (label) label.textContent = 'Translate';
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reply form (lazy-rendered from a <template>) ──────
|
||||
// Replaces the per-comment inline markup that used to live in the DOM
|
||||
// pre-hidden. First click on "Reply" clones the template into the comment's
|
||||
// .ytc-reply-slot and wires its IDs. Subsequent toggles just show/hide.
|
||||
function toggleReplyForm(commentId) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) return;
|
||||
let form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) {
|
||||
const slot = document.querySelector('.ytc-reply-slot[data-comment-id="' + commentId + '"]');
|
||||
const tpl = document.getElementById('ytc-reply-form-tpl');
|
||||
if (!slot || !tpl) return;
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const root = frag.querySelector('.ytc-reply-form');
|
||||
const ta = frag.querySelector('.ytc-reply-textarea');
|
||||
const cancel = frag.querySelector('._comment-cancel-reply-trigger');
|
||||
const submit = frag.querySelector('._comment-submit-reply-trigger');
|
||||
root.id = 'replyForm' + commentId;
|
||||
ta.id = 'replyBody' + commentId;
|
||||
cancel.dataset.commentId = commentId;
|
||||
submit.dataset.parentId = commentId;
|
||||
submit.dataset.videoId = slot.dataset.videoId || YTC.videoId;
|
||||
slot.appendChild(frag);
|
||||
form = root;
|
||||
}
|
||||
const isOpen = form.classList.contains('open');
|
||||
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
|
||||
if (!isOpen) {
|
||||
@@ -970,16 +1367,26 @@ async function saveEdit(commentId) {
|
||||
|
||||
// ── Post comment ──────────────────────────────────────
|
||||
async function postComment() {
|
||||
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
|
||||
const ta = document.getElementById('ytcTextarea');
|
||||
if (!ta) return;
|
||||
const text = ta.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
postComment._busy = true;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Posting…';
|
||||
try {
|
||||
// Idempotency key: random per attempt. On retry the client reuses the
|
||||
// same key so the server returns the original comment instead of
|
||||
// writing a second one.
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1000,6 +1407,7 @@ async function postComment() {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
postComment._busy = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Comment';
|
||||
}
|
||||
@@ -1012,11 +1420,21 @@ async function postReply(vid, parentId) {
|
||||
const text = input.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
|
||||
if (btn) {
|
||||
if (btn.dataset.busy === '1') return; // guard against double-submit
|
||||
btn.dataset.busy = '1';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Posting…';
|
||||
}
|
||||
try {
|
||||
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
|
||||
const r = await fetch('/videos/' + vid + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': YTC.csrf,
|
||||
'X-Idempotency-Key': idemKey,
|
||||
},
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
||||
@@ -1035,7 +1453,7 @@ async function postReply(vid, parentId) {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1551,12 @@ function buildCommentEl(c, isReply) {
|
||||
</div>` : '';
|
||||
|
||||
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
|
||||
const translateBtn = YTC.llmEnabled ? `
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
|
||||
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
|
||||
</button>` : '';
|
||||
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
|
||||
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
|
||||
@@ -1143,10 +1567,12 @@ function buildCommentEl(c, isReply) {
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
${flagEl}
|
||||
<span class="ytc-author">${name}</span>
|
||||
<span class="ytc-time">${time}</span>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
|
||||
${translationPanel}
|
||||
${editForm}
|
||||
<div class="ytc-actions">
|
||||
<button class="ytc-like-btn" data-id="${id}" title="Like">
|
||||
@@ -1157,6 +1583,7 @@ function buildCommentEl(c, isReply) {
|
||||
<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>
|
||||
${replyBtn}
|
||||
${translateBtn}
|
||||
${moreMenu}
|
||||
</div>
|
||||
${replyForm}
|
||||
@@ -1226,6 +1653,32 @@ function toggleDislike(btn) {
|
||||
function initLikeStates() {} // server-rendered state is already in the HTML
|
||||
|
||||
// ── 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) {
|
||||
if (!el || el.dataset._commentEnhanced === '1') return;
|
||||
let text = el.textContent || '';
|
||||
@@ -1255,6 +1708,9 @@ function enhanceBody(el) {
|
||||
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() {
|
||||
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
|
||||
@@ -1309,16 +1765,39 @@ const listObserver = new MutationObserver(() => {
|
||||
const list = document.getElementById('ytcList');
|
||||
if (list) listObserver.observe(list, { childList: true, subtree: true });
|
||||
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
});
|
||||
if (document.readyState !== 'loading') {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
// ── Comment permalink (?lc=<id>) ─────────────────────
|
||||
// Scroll to #comment-<id> and briefly highlight the row so a shared link
|
||||
// lands on the exact message. The reply row is nested inside a .ytc-replies
|
||||
// expander by default — we auto-open the parent thread first so the target
|
||||
// is visible before we scroll.
|
||||
function openCommentPermalink(id) {
|
||||
if (!id) return;
|
||||
const target = document.getElementById('comment-' + id);
|
||||
if (!target) return;
|
||||
// Find and expand the parent replies section, if any.
|
||||
const repliesSection = target.closest('.ytc-replies-section');
|
||||
if (repliesSection) {
|
||||
const toggle = repliesSection.querySelector('.ytc-replies-toggle');
|
||||
if (toggle && toggle.dataset.open !== '1') toggle.click();
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add('ytc-highlight');
|
||||
setTimeout(() => target.classList.remove('ytc-highlight'), 2800);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
function _ytcInit() {
|
||||
enhanceAll();
|
||||
initLikeStates();
|
||||
@if($permalinkId)
|
||||
openCommentPermalink({{ (int) $permalinkId }});
|
||||
@endif
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', _ytcInit);
|
||||
if (document.readyState !== 'loading') _ytcInit();
|
||||
|
||||
// ── Public API (for backward compat) ─────────────────
|
||||
window._comment = {
|
||||
deleteComment: openDeleteModal,
|
||||
@@ -1327,3 +1806,22 @@ window._comment = {
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@auth
|
||||
{{-- Reply-form template. Cloned into .ytc-reply-slot the first time a user
|
||||
clicks Reply on a given comment. Keeping it here (not in the layout)
|
||||
means the markup travels with the comments component — the only place
|
||||
that uses it. --}}
|
||||
<template id="ytc-reply-form-tpl">
|
||||
<div class="ytc-reply-form">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
|
||||
<div class="ytc-input-wrap">
|
||||
<textarea class="ytc-textarea ytc-reply-textarea" placeholder="Add a reply..." rows="1"></textarea>
|
||||
<div class="ytc-form-actions">
|
||||
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger">Cancel</button>
|
||||
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endauth
|
||||
@@ -468,7 +468,7 @@ function renderInsights(d) {
|
||||
</div>`).join('');
|
||||
|
||||
const recentViewerRows = (d.recent_viewers || []).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const avatarSrc = r.user_avatar || '{{ asset('images/default-avatar.svg') }}';
|
||||
const title = `Tap for full activity — ${r.user_name}`;
|
||||
const safeName = (r.user_name || 'Viewer').replace(/'/g, "\\'");
|
||||
return `<div class="ins-dl-user-row" title="${title}"
|
||||
@@ -554,7 +554,7 @@ function renderInsights(d) {
|
||||
: '';
|
||||
|
||||
const recentRows = (d.dl_recent||[]).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const avatarSrc = r.user_avatar || '{{ asset('images/default-avatar.svg') }}';
|
||||
const safeName = (r.user_name || 'Downloader').replace(/'/g, "\\'");
|
||||
const clickAttr = r.user_id
|
||||
? `onclick="openDownloaderHistory(${r.user_id},'${safeName}','${avatarSrc}')" style="cursor:pointer;" title="See full download history"`
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@php
|
||||
// Force 16:9 for every video — orientation classes intentionally disabled
|
||||
$orientationClass = '';
|
||||
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
|
||||
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'playlist.m3u8']) : null;
|
||||
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
|
||||
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
|
||||
$prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||||
@@ -205,12 +205,99 @@
|
||||
</div>{{-- /chrome-controls --}}
|
||||
</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-wrap --}}
|
||||
|
||||
|
||||
@once
|
||||
<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
|
||||
══════════════════════════════════════════════════════════ */
|
||||
@@ -220,19 +307,31 @@
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
/* default aspect ratio; overridden per orientation */
|
||||
/* Always 16:9 — no max-height so the shape is never distorted. */
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 70vh;
|
||||
/* Establish a size container so descendants can size themselves relative
|
||||
to the player width (used by the coach-note overlay to scale). */
|
||||
container-type: inline-size;
|
||||
container-name: ytpwrap;
|
||||
}
|
||||
/* Orientation classes intentionally forced to 16:9 too, so the frame
|
||||
shape never changes with the underlying media orientation. The video
|
||||
element itself uses object-fit: contain and will letterbox as needed. */
|
||||
.ytp-wrap.portrait,
|
||||
.ytp-wrap.square,
|
||||
.ytp-wrap.ultrawide {
|
||||
aspect-ratio: 16/9;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
|
||||
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
|
||||
|
||||
/* Theater mode */
|
||||
/* Theater mode — keep 16:9 shape, just remove rounded corners. */
|
||||
.ytp-wrap.theater {
|
||||
max-height: 80vh;
|
||||
aspect-ratio: unset;
|
||||
height: 80vh;
|
||||
aspect-ratio: 16/9;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -530,10 +629,23 @@
|
||||
background: rgba(28,28,28,.95);
|
||||
border-radius: 12px;
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
/* Cap height so the panel stays inside the player and scrolls when the
|
||||
item list (mini/speed/autoplay/shuffle + injected Scoreboard toggles)
|
||||
grows taller than the available space above the control bar. */
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
||||
z-index: 100;
|
||||
}
|
||||
/* Slim, on-brand scrollbar so it doesn't clash with the dark panel. */
|
||||
.ytp-settings-panel::-webkit-scrollbar { width: 6px; }
|
||||
.ytp-settings-panel::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,.22); border-radius: 3px;
|
||||
}
|
||||
.ytp-settings-panel::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.35); }
|
||||
.ytp-settings-panel::-webkit-scrollbar-track { background: transparent; }
|
||||
.ytp-settings-panel.open { display: block; }
|
||||
.ytp-settings-item {
|
||||
display: flex;
|
||||
@@ -587,10 +699,10 @@
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
|
||||
.ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; }
|
||||
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
|
||||
.ytp-wrap.ultrawide { max-height: 50vw; }
|
||||
.ytp-wrap { border-radius: 0; margin: 0; width: 100%; aspect-ratio: 16/9; max-height: none; }
|
||||
.ytp-wrap.portrait,
|
||||
.ytp-wrap.square,
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 16/9; width: 100%; max-width: none; max-height: none; }
|
||||
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
|
||||
@@ -1279,17 +1391,96 @@ function 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', () => {
|
||||
updatePlayIcon();
|
||||
largePlay.classList.add('visible');
|
||||
clearTimeout(hideTimer);
|
||||
releaseWakeLock();
|
||||
if (window._plOnVideoEnd) {
|
||||
window._plOnVideoEnd();
|
||||
} else if (autoplayOn || isLooping) {
|
||||
navigateNext();
|
||||
if (isLooping) { navigateNext(); return; }
|
||||
if (autoplayOn || window._plOnVideoEnd) {
|
||||
openEndScreen();
|
||||
}
|
||||
});
|
||||
// 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);
|
||||
|
||||
// ── Keyboard shortcuts ────────────────────────────────
|
||||
@@ -1314,10 +1505,39 @@ document.addEventListener('keydown', e => {
|
||||
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
||||
case 'i': case 'I':
|
||||
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':
|
||||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||||
case 'l': case 'L':
|
||||
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':
|
||||
e.preventDefault(); seekRelative(-5); showControls(); break;
|
||||
case 'ArrowRight':
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Page not found | ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.err-404 {
|
||||
max-width: 560px;
|
||||
margin: 80px auto;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.err-404-emoji {
|
||||
font-size: 72px;
|
||||
line-height: 1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.err-404 h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.err-404 p {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.err-404-code {
|
||||
font-size: 11px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.err-404-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.err-404 { margin: 32px 12px; padding: 32px 16px; }
|
||||
.err-404-emoji { font-size: 56px; }
|
||||
.err-404 h1 { font-size: 22px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="err-404">
|
||||
<div class="err-404-emoji" aria-hidden="true">🎞️</div>
|
||||
<div class="err-404-code">Error 404</div>
|
||||
<h1>Can't find that one</h1>
|
||||
<p>
|
||||
The page or video you were looking for isn't here. It might have
|
||||
been removed, renamed, or the link might have a typo.
|
||||
</p>
|
||||
<div class="err-404-actions">
|
||||
<a href="{{ route('home') }}" class="action-btn action-btn-primary">
|
||||
<i class="bi bi-house-door-fill" aria-hidden="true"></i>
|
||||
<span>Back to Home</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.index') }}" class="action-btn">
|
||||
<i class="bi bi-grid-fill" aria-hidden="true"></i>
|
||||
<span>Browse Videos</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.shorts') }}" class="action-btn">
|
||||
<i class="bi bi-lightning-charge-fill" aria-hidden="true"></i>
|
||||
<span>Watch Shorts</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -435,6 +435,14 @@
|
||||
.action-btn.icon-only { padding: 8px; }
|
||||
|
||||
/* Filter chip bar */
|
||||
.yt-filter-bar-wrap {
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
z-index: 90;
|
||||
margin: -24px -24px 20px;
|
||||
background: var(--bg-dark);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.yt-filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -442,15 +450,35 @@
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 12px 24px;
|
||||
margin: -24px -24px 20px;
|
||||
background: var(--bg-dark);
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
z-index: 90;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.yt-filter-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Left / right scroll buttons — visible only when the bar overflows */
|
||||
.yt-filter-nav {
|
||||
position: absolute; top: 0; bottom: 0;
|
||||
display: none; align-items: center;
|
||||
width: 48px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
.yt-filter-nav.visible { display: flex; }
|
||||
.yt-filter-nav.left { left: 0; background: linear-gradient(to right, var(--bg-dark) 40%, transparent); justify-content: flex-start; padding-left: 6px; }
|
||||
.yt-filter-nav.right { right: 0; background: linear-gradient(to left, var(--bg-dark) 40%, transparent); justify-content: flex-end; padding-right: 6px; }
|
||||
.yt-filter-nav i {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
}
|
||||
.yt-filter-nav:hover i { background: #555; }
|
||||
|
||||
.yt-chip {
|
||||
flex-shrink: 0;
|
||||
background: #3f3f3f;
|
||||
@@ -1170,12 +1198,15 @@
|
||||
}
|
||||
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
||||
.yt-main.video-view-page { padding: 0 !important; }
|
||||
.yt-filter-bar {
|
||||
.yt-filter-bar-wrap {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
margin: -16px -16px 16px !important;
|
||||
}
|
||||
.yt-filter-bar {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1187,6 +1218,20 @@
|
||||
<script src="{{ asset('fp.js') }}" async></script>
|
||||
</head>
|
||||
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
|
||||
{{-- Skip link: keyboard users land here first and can jump straight to
|
||||
the main content region, past the header + sidebar nav. Hidden
|
||||
off-screen until focused. --}}
|
||||
<a href="#main" class="skip-to-content">Skip to content</a>
|
||||
<style>
|
||||
.skip-to-content {
|
||||
position: absolute; top: -40px; left: 8px;
|
||||
padding: 8px 14px; z-index: 10000;
|
||||
background: #ef4444; color: #fff; border-radius: 0 0 8px 8px;
|
||||
text-decoration: none; font-weight: 600;
|
||||
transition: top .15s ease;
|
||||
}
|
||||
.skip-to-content:focus { top: 0; outline: 2px solid #fff; outline-offset: 2px; }
|
||||
</style>
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
@@ -1206,10 +1251,11 @@
|
||||
|
||||
<!-- Mobile Search Overlay -->
|
||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
||||
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="mobile-search-submit">
|
||||
<i class="bi bi-search"></i>
|
||||
<form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
|
||||
<label for="mobile-search-input" class="visually-hidden">Search</label>
|
||||
<input type="search" id="mobile-search-input" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||
<button type="submit" class="mobile-search-submit" aria-label="Submit search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1230,6 +1276,7 @@
|
||||
@include('layouts.partials.upload-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
@include('layouts.partials.sports-match-modal')
|
||||
@include('layouts.partials.merge-video-modal')
|
||||
@endauth
|
||||
|
||||
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
||||
@@ -1330,10 +1377,18 @@
|
||||
<i class="bi bi-fire"></i>
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@auth
|
||||
<a href="#" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"
|
||||
onclick="event.preventDefault(); openUploadChooser();">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-bottom-nav-item">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@endauth
|
||||
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>History</span>
|
||||
@@ -1657,11 +1712,24 @@
|
||||
};
|
||||
|
||||
function loadNotifications() {
|
||||
// Switch the pre-rendered "No notifications" placeholder to a
|
||||
// spinner while the fetch is in flight, then let the response
|
||||
// handler replace it with the real list, an empty state, or
|
||||
// an error message. Previously the panel just said "Loading…"
|
||||
// forever if the fetch threw synchronously or returned non-JSON.
|
||||
list.innerHTML = '<div class="yt-notif-empty"><i class="bi bi-arrow-repeat spin"></i> Loading…</div>';
|
||||
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) { loaded = true; renderNotifications(data); })
|
||||
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; });
|
||||
.catch(function () {
|
||||
list.innerHTML = '<div class="yt-notif-empty">Could not load notifications. <button type="button" class="yt-notif-retry" onclick="loadNotifications()">Retry</button></div>';
|
||||
});
|
||||
}
|
||||
// Expose for the inline Retry button above.
|
||||
window.loadNotifications = loadNotifications;
|
||||
|
||||
window.toggleNotifPanel = function () {
|
||||
var btn = document.getElementById('notifBtn');
|
||||
|
||||
@@ -237,9 +237,22 @@
|
||||
.then(data => {
|
||||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||||
let html = '';
|
||||
// Language-track the modal is scoped to (0 = primary).
|
||||
// Read once here so both the "already in?" check and the
|
||||
// toggle call use the same value.
|
||||
const currentTrackId = parseInt(window._ytpTrackId || 0);
|
||||
data.playlists.forEach(function(playlist) {
|
||||
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(
|
||||
videoId));
|
||||
// Prefer entries[] (per-track granularity) when present.
|
||||
// Fall back to video_ids for older API responses.
|
||||
let isInPlaylist = false;
|
||||
if (Array.isArray(playlist.entries)) {
|
||||
isInPlaylist = playlist.entries.some(function (e) {
|
||||
return e.video_id === parseInt(videoId)
|
||||
&& (e.audio_track_id || 0) === currentTrackId;
|
||||
});
|
||||
} else if (playlist.video_ids) {
|
||||
isInPlaylist = playlist.video_ids.includes(parseInt(videoId));
|
||||
}
|
||||
const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
|
||||
const durationText = playlist.formatted_duration || '0m';
|
||||
html += `
|
||||
@@ -328,7 +341,11 @@
|
||||
// of the encoded route key used by GET /videos/{video}.
|
||||
var url = `/playlists/${playlistId}/videos`;
|
||||
var method = currentlyIn ? 'DELETE' : 'POST';
|
||||
var body = JSON.stringify({ video_id: videoId });
|
||||
// Pin the row to the currently-active audio track (0 = primary/null).
|
||||
var trackId = parseInt(window._ytpTrackId || 0);
|
||||
var payload = { video_id: videoId };
|
||||
if (trackId > 0) payload.audio_track_id = trackId;
|
||||
var body = JSON.stringify(payload);
|
||||
|
||||
fetch(url, {
|
||||
method,
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
<div class="um-track-card" id="edit-tc-t1-card">
|
||||
<div class="um-tc-body">
|
||||
<div class="um-tc-left">
|
||||
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
|
||||
<div class="um-tc-num">1</div>
|
||||
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
|
||||
<div class="um-tc-info">
|
||||
@@ -116,12 +117,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="um-tc-right">
|
||||
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('t1-card','up')" title="Move up" style="display:none;">
|
||||
<i class="bi bi-arrow-up"></i>
|
||||
</button>
|
||||
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('t1-card','down')" title="Move down" style="display:none;">
|
||||
<i class="bi bi-arrow-down"></i>
|
||||
</button>
|
||||
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
@@ -183,26 +178,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}}
|
||||
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true">
|
||||
<x-track-editor-form
|
||||
prefix="__TPL__"
|
||||
:is-primary="false"
|
||||
language-name="__LANGNAME__"
|
||||
language-id="csd_v___TPL__"
|
||||
title-name="__TITLENAME__"
|
||||
title-id="edit-__TPL__-title"
|
||||
desc-name="__DESCNAME__"
|
||||
desc-id="edit-__TPL__-desc"
|
||||
:embed-audio-input="true"
|
||||
audio-input-name="__AUDIONAME__"
|
||||
video-file-input-id=""
|
||||
/>
|
||||
</div>
|
||||
{{-- Secondary tracks in this modal are cloned from the SHARED
|
||||
#um-track-form-template defined in layouts.partials.upload-modal. One
|
||||
template → identical UX in both the create (upload) modal and this modal. --}}
|
||||
|
||||
<style>
|
||||
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
||||
#editVideoModal.show .modal-dialog { opacity: 1; }
|
||||
|
||||
/* ── Track drag-and-drop reorder ─────────────────────────────── */
|
||||
#edit-tc-list .um-track-card { position: relative; }
|
||||
#edit-tc-list .um-tc-drag {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; margin-right: 2px;
|
||||
color: #666; cursor: grab; user-select: none;
|
||||
border-radius: 6px; transition: color .15s, background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#edit-tc-list .um-tc-drag:hover { color: #e5e5e5; background: rgba(255,255,255,.05); }
|
||||
#edit-tc-list .um-tc-drag:active { cursor: grabbing; }
|
||||
#edit-tc-list .um-tc-drag i { font-size: 14px; line-height: 1; }
|
||||
#edit-tc-list .um-track-card.dragging {
|
||||
opacity: .4; outline: 2px dashed #e61e1e; outline-offset: -2px;
|
||||
}
|
||||
#edit-tc-list .um-track-card.drop-before { box-shadow: 0 -3px 0 0 #e61e1e inset; }
|
||||
#edit-tc-list .um-track-card.drop-after { box-shadow: 0 3px 0 0 #e61e1e inset; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -220,12 +220,8 @@ window._editCurrentVideoId = null;
|
||||
|
||||
// ── Modal open / close ────────────────────────────────────────────────────────
|
||||
function openEditVideoModal(videoId) {
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = `/videos/${videoId}/edit`;
|
||||
return;
|
||||
}
|
||||
window._editCurrentVideoId = videoId;
|
||||
|
||||
// Always resolve type first so a match video opens the sports-match sheet
|
||||
// instead of the generic video-edit modal — same for mobile.
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
@@ -238,6 +234,25 @@ function openEditVideoModal(videoId) {
|
||||
if (!data.success) { showToast('Failed to load video data', 'error'); return; }
|
||||
const v = data.video;
|
||||
|
||||
// Match videos always route to the sports-match sheet — edit the existing
|
||||
// match if one is linked, otherwise open create-mode with the video already
|
||||
// attached so the user can add the match details.
|
||||
if (v.type === 'match' && typeof window.openSportsMatchModal === 'function') {
|
||||
if (v.sports_match_id) {
|
||||
window.openSportsMatchModal(v.sports_match_id);
|
||||
} else {
|
||||
window.openSportsMatchModal({ videoId: v.id, videoTitle: v.title });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile fallback for non-match videos: full-page edit view
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = `/videos/${videoId}/edit`;
|
||||
return;
|
||||
}
|
||||
window._editCurrentVideoId = videoId;
|
||||
|
||||
document.getElementById('edit-form').action = `/videos/${videoId}`;
|
||||
|
||||
// Header
|
||||
@@ -288,8 +303,9 @@ function openEditVideoModal(videoId) {
|
||||
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
|
||||
_editRenderSlides('t1');
|
||||
|
||||
// Extra tracks
|
||||
if (v.is_audio && v.audio_tracks) {
|
||||
// Extra tracks — show whenever the row is declared music, even if the
|
||||
// primary file is still a video (mid-conversion state).
|
||||
if ((v.type === 'music' || v.is_audio) && v.audio_tracks) {
|
||||
v.audio_tracks.forEach(track => _editAddExistingTrack(track));
|
||||
}
|
||||
|
||||
@@ -446,33 +462,16 @@ function _editFmtSize(bytes) {
|
||||
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// ── Track ordering ────────────────────────────────────────────────────────────
|
||||
function editMoveTrack(cardSuffix, dir) {
|
||||
const card = document.getElementById('edit-tc-' + cardSuffix);
|
||||
if (!card) return;
|
||||
const list = document.getElementById('edit-tc-list');
|
||||
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
||||
const idx = cards.indexOf(card);
|
||||
console.log('%c[EditTrack] Reorder:', 'color:#3b82f6', { card: card.id, trackId: card.dataset.trackId || 'primary', direction: dir, fromPos: idx + 1 });
|
||||
if (dir === 'up' && idx > 0) list.insertBefore(card, cards[idx - 1]);
|
||||
if (dir === 'down' && idx < cards.length - 1) list.insertBefore(cards[idx + 1], card);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
// ── Track ordering (drag-and-drop) ────────────────────────────────────────────
|
||||
function _editUpdateTrackPositions() {
|
||||
const list = document.getElementById('edit-tc-list');
|
||||
if (!list) return;
|
||||
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
||||
const total = cards.length;
|
||||
cards.forEach((card, i) => {
|
||||
const numEl = card.querySelector('.um-tc-num');
|
||||
if (numEl) numEl.textContent = i + 1;
|
||||
const badge = card.querySelector('.um-tc-primary');
|
||||
if (badge) badge.style.display = i === 0 ? '' : 'none';
|
||||
const up = card.querySelector('.edit-tc-arrow-up');
|
||||
const down = card.querySelector('.edit-tc-arrow-down');
|
||||
if (up) up.style.display = (i === 0) ? 'none' : '';
|
||||
if (down) down.style.display = (i === total - 1) ? 'none' : '';
|
||||
});
|
||||
// Set promote_track_id: non-empty only when a secondary is at position 1
|
||||
const first = cards[0];
|
||||
@@ -485,6 +484,93 @@ function _editUpdateTrackPositions() {
|
||||
}
|
||||
}
|
||||
|
||||
// HTML5 drag-and-drop: bind once on the shared parent so newly appended cards
|
||||
// pick it up without extra wiring. The card starts non-draggable so text
|
||||
// selection / button clicks work; grabbing the grip handle flips draggable=on
|
||||
// for the duration of the drag.
|
||||
(function () {
|
||||
const list = document.getElementById('edit-tc-list');
|
||||
if (!list || list.dataset.dndWired) return;
|
||||
list.dataset.dndWired = '1';
|
||||
|
||||
// Reset any draggable="true" set by the initial HTML — we only enable it
|
||||
// on demand via the grip handle.
|
||||
list.querySelectorAll('.um-track-card').forEach(c => c.removeAttribute('draggable'));
|
||||
|
||||
let dragged = null;
|
||||
|
||||
list.addEventListener('mousedown', function (e) {
|
||||
const handle = e.target.closest('.um-tc-drag');
|
||||
if (!handle) return;
|
||||
const card = handle.closest('.um-track-card');
|
||||
if (!card || card.parentElement !== list) return;
|
||||
card.setAttribute('draggable', 'true');
|
||||
});
|
||||
list.addEventListener('mouseup', function () {
|
||||
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
|
||||
});
|
||||
|
||||
list.addEventListener('dragstart', function (e) {
|
||||
const card = e.target.closest('.um-track-card');
|
||||
if (!card || card.parentElement !== list || card.getAttribute('draggable') !== 'true') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
dragged = card;
|
||||
card.classList.add('dragging');
|
||||
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', card.id); } catch (_) {}
|
||||
});
|
||||
|
||||
list.addEventListener('dragend', function () {
|
||||
if (dragged) dragged.classList.remove('dragging');
|
||||
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
||||
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
|
||||
dragged = null;
|
||||
});
|
||||
|
||||
// Always accept drops anywhere inside the list so the browser doesn't
|
||||
// veto the drop when the cursor slips into gaps between cards.
|
||||
list.addEventListener('dragover', function (e) {
|
||||
if (!dragged) return;
|
||||
e.preventDefault();
|
||||
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
|
||||
const over = e.target.closest('.um-track-card');
|
||||
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
||||
if (!over || over === dragged || over.parentElement !== list) return;
|
||||
const r = over.getBoundingClientRect();
|
||||
const after = (e.clientY - r.top) > (r.height / 2);
|
||||
over.classList.add(after ? 'drop-after' : 'drop-before');
|
||||
});
|
||||
|
||||
list.addEventListener('drop', function (e) {
|
||||
if (!dragged) return;
|
||||
e.preventDefault();
|
||||
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
||||
const over = e.target.closest('.um-track-card');
|
||||
let refNode;
|
||||
if (over && over !== dragged && over.parentElement === list) {
|
||||
const r = over.getBoundingClientRect();
|
||||
const after = (e.clientY - r.top) > (r.height / 2);
|
||||
refNode = after ? over.nextSibling : over;
|
||||
} else {
|
||||
// Cursor fell into a gap or outside a card — figure out the closest slot
|
||||
// by comparing cursor Y against each card's midpoint.
|
||||
let insertBefore = null;
|
||||
for (const c of cards) {
|
||||
if (c === dragged) continue;
|
||||
const r = c.getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { insertBefore = c; break; }
|
||||
}
|
||||
refNode = insertBefore; // null → append to end
|
||||
}
|
||||
if (refNode === dragged) refNode = dragged.nextSibling;
|
||||
list.insertBefore(dragged, refNode);
|
||||
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
||||
console.log('%c[EditTrack] Drag-reordered:', 'color:#3b82f6', { moved: dragged.id, refNode: refNode?.id || '(end)' });
|
||||
_editUpdateTrackPositions();
|
||||
});
|
||||
})();
|
||||
|
||||
function _editSetLangCsd(wrap, code) {
|
||||
if (!wrap || !code) return;
|
||||
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
|
||||
@@ -621,6 +707,8 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
|
||||
function editHandleSlides(fileList, tid) {
|
||||
tid = tid || 't1';
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlides);
|
||||
const inp = document.getElementById('edit-' + tid + '-slides-input');
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
@@ -641,8 +729,12 @@ function _editSlidesCropLoadNext() {
|
||||
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
|
||||
const f = _editSlidesCropQueue.shift();
|
||||
if (typeof window.tcPreload_slides_edit === 'function') {
|
||||
window.tcPreload_slides_edit(f);
|
||||
// Open modal first so the cropper canvas has a real layout box when
|
||||
// initCropper measures. Preloading first (prior order) caused the
|
||||
// second batch to open with stale zero-sized measurements and a Save
|
||||
// button that silently did nothing.
|
||||
window.openCropperModal_slides_edit();
|
||||
requestAnimationFrame(() => window.tcPreload_slides_edit(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +820,8 @@ function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getEleme
|
||||
|
||||
function editHandleSlidesForTrack(tid, fileList) {
|
||||
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
|
||||
const inp = document.getElementById('edit-slides-input-' + tid);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
function editClearSlidesForTrack(e, tid) {
|
||||
@@ -786,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
|
||||
// ── Track popup ───────────────────────────────────────────────────────────────
|
||||
function editOpenTrackPopup(trackId) {
|
||||
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
|
||||
const form = document.getElementById('edit-tf-' + trackId);
|
||||
// Primary track form uses the legacy id (edit-tf-t1); extra tracks are
|
||||
// cloned from the SHARED #um-track-form-template and use id um-tf-{id}.
|
||||
const form = document.getElementById('edit-tf-' + trackId) || document.getElementById('um-tf-' + trackId);
|
||||
if (form) form.style.display = 'block';
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
popup.style.opacity = '0';
|
||||
@@ -803,6 +899,10 @@ function editCloseTrackPopup() {
|
||||
}
|
||||
}
|
||||
|
||||
// For t1 the inputs are the primary-track Blade ids. For extra tracks (both
|
||||
// newly-added and existing-track rows) the inputs come from the SHARED
|
||||
// #um-track-form-template, so they use the shared ids track-lang-{id} /
|
||||
// track-title-{id}. One function reads whichever is present.
|
||||
function editUpdateTrackCard(trackId) {
|
||||
let langCode = '', title = '';
|
||||
if (trackId === 't1') {
|
||||
@@ -811,9 +911,8 @@ function editUpdateTrackCard(trackId) {
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
} else {
|
||||
const n = trackId.replace('e','');
|
||||
const li = document.getElementById('csd_v_e' + n);
|
||||
const ti = document.getElementById('edit-e' + n + '-title');
|
||||
const li = document.getElementById('track-lang-' + trackId);
|
||||
const ti = document.getElementById('track-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
}
|
||||
@@ -833,24 +932,26 @@ function editUpdateTrackCard(trackId) {
|
||||
// ── Extra track management ────────────────────────────────────────────────────
|
||||
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||
|
||||
// Clone the SHARED upload-modal track-form partial into the edit popup. Both
|
||||
// "add new track" and "load existing track" go through here, so there is one
|
||||
// source of truth for the form markup. The caller picks the submission names:
|
||||
// - new track → extra_track_*[]
|
||||
// - existing track → track_*_updates[trackId]
|
||||
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
|
||||
const tpl = document.getElementById('edit-extra-track-tpl');
|
||||
if (!tpl) return null;
|
||||
const html = tpl.innerHTML
|
||||
.replaceAll('__TPL__', prefix)
|
||||
.replaceAll('__LANGNAME__', langName)
|
||||
.replaceAll('__TITLENAME__', titleName)
|
||||
.replaceAll('__DESCNAME__', descName)
|
||||
.replaceAll('__AUDIONAME__', audioName);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const formEl = tmp.querySelector('.um-track-form');
|
||||
if (!formEl) return null;
|
||||
formEl.style.display = 'none';
|
||||
document.getElementById('edit-tf-extra').appendChild(formEl);
|
||||
_editSlidesData[prefix] = [];
|
||||
if (window.CSD) new CSD('csd_' + prefix);
|
||||
return formEl;
|
||||
const frag = window.cloneTrackFormTemplate({
|
||||
key: prefix, langName, titleName, descName, audioName,
|
||||
});
|
||||
if (!frag) return null;
|
||||
const host = document.getElementById('edit-tf-extra');
|
||||
host.appendChild(frag);
|
||||
// _slidesData is the shared state used by the shared slides-zone handlers.
|
||||
// Mirror the handle under _editSlidesData so the edit submit code stays
|
||||
// oblivious to the migration (one array, two aliases).
|
||||
window._slidesData = window._slidesData || {};
|
||||
_slidesData[prefix] = [];
|
||||
_editSlidesData[prefix] = _slidesData[prefix];
|
||||
window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
|
||||
return document.getElementById('um-tf-' + prefix);
|
||||
}
|
||||
|
||||
function _editTrackCard(n, trackId, isExisting) {
|
||||
@@ -863,6 +964,7 @@ function _editTrackCard(n, trackId, isExisting) {
|
||||
card.innerHTML = `
|
||||
<div class="um-tc-body">
|
||||
<div class="um-tc-left">
|
||||
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
|
||||
<div class="um-tc-num">${n + 1}</div>
|
||||
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
|
||||
<div class="um-tc-info">
|
||||
@@ -871,8 +973,6 @@ function _editTrackCard(n, trackId, isExisting) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="um-tc-right">
|
||||
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('e${n}','up')" title="Move up" style="display:none;"><i class="bi bi-arrow-up"></i></button>
|
||||
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('e${n}','down')" title="Move down" style="display:none;"><i class="bi bi-arrow-down"></i></button>
|
||||
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
@@ -892,34 +992,47 @@ function _editAddExistingTrack(track) {
|
||||
'track_description_updates[' + track.id + ']',
|
||||
'track_file_updates[' + track.id + ']'
|
||||
);
|
||||
const titleEl = document.getElementById('edit-' + pfx + '-title');
|
||||
// Shared partial field ids: track-title-{pfx} / track-desc-{pfx} / track-lang-{pfx}
|
||||
const titleEl = document.getElementById('track-title-' + pfx);
|
||||
if (titleEl) titleEl.value = track.title || '';
|
||||
const descEl = document.getElementById('edit-' + pfx + '-desc');
|
||||
const descEl = document.getElementById('track-desc-' + pfx);
|
||||
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
|
||||
if (track.language && window.CSD) {
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
const wrap = document.getElementById('csd_' + pfx);
|
||||
const hi = document.getElementById('track-lang-' + pfx);
|
||||
const wrap = document.getElementById('lsd_track_' + pfx);
|
||||
if (hi) hi.value = track.language;
|
||||
if (wrap) _editSetLangCsd(wrap, track.language);
|
||||
}
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
||||
editUpdateTrackCard(pfx);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
function editDeleteTrack(trackId, n) {
|
||||
_editDeleteTrackIds.push(trackId);
|
||||
document.getElementById('edit-tc-e'+n)?.remove();
|
||||
document.getElementById('edit-tf-e'+n)?.remove();
|
||||
function _editRemoveTrackForms(n) {
|
||||
const pfx = 'e' + n;
|
||||
document.getElementById('edit-tc-' + pfx)?.remove();
|
||||
// Form id depends on which cloner built it: shared template → um-tf-{pfx},
|
||||
// legacy edit template → edit-tf-{pfx}. Clean up whichever exists.
|
||||
document.getElementById('um-tf-' + pfx)?.remove();
|
||||
document.getElementById('edit-tf-' + pfx)?.remove();
|
||||
delete _editSlidesData[pfx];
|
||||
delete (window._slidesData || {})[pfx];
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
if (popup.style.display !== 'none') {
|
||||
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
|
||||
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
|
||||
if (!vis || vis.id === 'edit-tf-' + pfx || vis.id === 'um-tf-' + pfx) popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function editDeleteTrack(trackId, n) {
|
||||
_editDeleteTrackIds.push(trackId);
|
||||
_editRemoveTrackForms(n);
|
||||
_editUpdateTrackPositions();
|
||||
}
|
||||
|
||||
function editRemoveNewTrack(n) {
|
||||
_editRemoveTrackForms(n);
|
||||
}
|
||||
|
||||
function editAddExtraTrack() {
|
||||
const n = ++_editExtraCount;
|
||||
const pfx = 'e' + n;
|
||||
@@ -931,23 +1044,10 @@ function editAddExtraTrack() {
|
||||
'extra_track_descriptions[]',
|
||||
'extra_track_files[]'
|
||||
);
|
||||
const hi = document.getElementById('csd_v_' + pfx);
|
||||
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
|
||||
_editUpdateTrackPositions();
|
||||
editOpenTrackPopup(pfx);
|
||||
}
|
||||
|
||||
function editRemoveNewTrack(n) {
|
||||
document.getElementById('edit-tc-e'+n)?.remove();
|
||||
document.getElementById('edit-tf-e'+n)?.remove();
|
||||
delete _editSlidesData['e'+n];
|
||||
const popup = document.getElementById('edit-track-popup');
|
||||
if (popup.style.display !== 'none') {
|
||||
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
|
||||
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Wire primary language → update card
|
||||
;(function() {
|
||||
const li = document.getElementById('edit_primary_language');
|
||||
@@ -980,6 +1080,14 @@ document.getElementById('edit-form').addEventListener('submit', function(e) {
|
||||
// Append delete IDs
|
||||
_editDeleteTrackIds.forEach(id => formData.append('delete_track_ids[]', id));
|
||||
|
||||
// Ordered list of track IDs as displayed. Index 0 is the primary slot;
|
||||
// "primary" is the sentinel for the original primary track row, since
|
||||
// the primary isn't a video_audio_tracks row of its own.
|
||||
Array.from(document.querySelectorAll('#edit-tc-list > .um-track-card')).forEach(card => {
|
||||
const val = (card.id === 'edit-tc-t1-card') ? 'primary' : (card.dataset.trackId || '');
|
||||
if (val) formData.append('track_order[]', val);
|
||||
});
|
||||
|
||||
// ── Log tracker: dump the exact track-related payload being submitted ──────
|
||||
(function () {
|
||||
const promoteId = document.getElementById('edit-promote-track-id')?.value || '(none)';
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<!-- Header -->
|
||||
<header class="yt-header">
|
||||
<div class="yt-header-left">
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()">
|
||||
<i class="bi bi-list fs-5"></i>
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar menu" aria-controls="sidebar">
|
||||
<i class="bi bi-list fs-5" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a href="{{ route('home') }}" class="yt-logo">
|
||||
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;">
|
||||
{{-- Two responsive logos. The visible one at a given viewport
|
||||
carries the app name; its sibling is display:none and gets
|
||||
alt="" so a screen reader doesn't announce "Play Play". --}}
|
||||
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="" class="d-none d-md-block" style="height:30px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-center d-none d-md-flex">
|
||||
<div class="yt-search">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form">
|
||||
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button>
|
||||
<form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
|
||||
<label for="header-search-input" class="visually-hidden">Search</label>
|
||||
<input type="search" id="header-search-input" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
|
||||
<button type="submit" class="yt-search-btn" aria-label="Submit search"><i class="bi bi-search" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-right">
|
||||
<!-- Mobile search -->
|
||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()">
|
||||
<i class="bi bi-search"></i>
|
||||
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()" aria-label="Open search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@auth
|
||||
@@ -48,7 +52,7 @@
|
||||
onclick="markAllRead()" style="display:none;">Mark all read</button>
|
||||
</div>
|
||||
<div class="yt-notif-list" id="notifList">
|
||||
<div class="yt-notif-empty">Loading…</div>
|
||||
<div class="yt-notif-empty" id="notifInitialState">No notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,9 +60,10 @@
|
||||
<!-- User dropdown -->
|
||||
<div class="yt-user-dropdown" id="headerUserDropdown">
|
||||
<button class="yt-icon-btn" id="headerUserBtn"
|
||||
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true">
|
||||
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true"
|
||||
aria-label="Account menu for {{ Auth::user()->name }}">
|
||||
<img src="{{ Auth::user()->avatar_url }}"
|
||||
class="yt-user-avatar" alt="{{ Auth::user()->name }}">
|
||||
class="yt-user-avatar" alt="">
|
||||
</button>
|
||||
|
||||
<div class="yt-user-panel" id="headerUserPanel" role="menu">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
{{-- Music-video merge modal. Opened via openMergeModal(targetId).
|
||||
Target = the video currently being viewed (survives).
|
||||
Source = the picked video (folded in, then deleted, then 302s to target). --}}
|
||||
<div class="modal fade" id="mergeVideoModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content" style="background:#181818; color:#eee; border:1px solid #2a2a2a;">
|
||||
<div class="modal-header" style="border-bottom:1px solid #2a2a2a;">
|
||||
<h5 class="modal-title"><i class="bi bi-signpost-split"></i> Merge songs</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color:#aaa; font-size:14px;">
|
||||
The song you pick below will be <strong>merged into this song</strong>.
|
||||
Its audio tracks, slides, comments, views, likes, downloads and shares
|
||||
all move here. The old link will redirect to this song.
|
||||
</p>
|
||||
<p style="color:#f5b544; font-size:13px;"><i class="bi bi-exclamation-triangle"></i> This cannot be undone.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<input id="mergeSearchInput" type="text" class="form-control"
|
||||
placeholder="Search your music songs…"
|
||||
style="background:#0f0f0f; color:#eee; border:1px solid #2a2a2a;">
|
||||
</div>
|
||||
|
||||
<div id="mergeCandidateList" style="max-height:320px; overflow-y:auto; border:1px solid #2a2a2a; border-radius:8px;">
|
||||
<div class="text-center text-secondary p-4">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div id="mergePreviewBox" style="display:none; margin-top:16px; padding:12px; background:#0f0f0f; border:1px solid #2a2a2a; border-radius:8px;">
|
||||
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px;">
|
||||
<img id="mergePreviewThumb" src="" style="width:80px; height:45px; object-fit:cover; border-radius:4px;">
|
||||
<div>
|
||||
<div style="font-size:13px; color:#aaa;">Merging in:</div>
|
||||
<div id="mergePreviewTitle" style="font-weight:600;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mergePreviewCounts" style="font-size:13px; color:#ccc; line-height:1.8;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top:1px solid #2a2a2a;">
|
||||
<button type="button" class="action-btn" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" id="mergeConfirmBtn" class="action-btn action-btn-primary" disabled onclick="doMerge()">
|
||||
<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let targetId = null;
|
||||
let sourceId = null;
|
||||
let searchTimer = null;
|
||||
|
||||
window.openMergeModal = function (id) {
|
||||
targetId = id;
|
||||
sourceId = null;
|
||||
document.getElementById('mergePreviewBox').style.display = 'none';
|
||||
document.getElementById('mergeConfirmBtn').disabled = true;
|
||||
document.getElementById('mergeSearchInput').value = '';
|
||||
loadCandidates('');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mergeVideoModal'));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
document.getElementById('mergeSearchInput').addEventListener('input', function (e) {
|
||||
clearTimeout(searchTimer);
|
||||
const q = e.target.value;
|
||||
searchTimer = setTimeout(() => loadCandidates(q), 250);
|
||||
});
|
||||
|
||||
function loadCandidates(q) {
|
||||
const list = document.getElementById('mergeCandidateList');
|
||||
list.innerHTML = '<div class="text-center text-secondary p-4">Loading…</div>';
|
||||
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/candidates?q=' + encodeURIComponent(q);
|
||||
fetch(url, { headers: { 'Accept': 'application/json' }})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!d.items || d.items.length === 0) {
|
||||
list.innerHTML = '<div class="text-center text-secondary p-4">No other music songs to merge.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = d.items.map(it => `
|
||||
<div class="merge-cand" data-id="${it.id}"
|
||||
style="display:flex; gap:10px; align-items:center; padding:8px 10px; cursor:pointer; border-bottom:1px solid #1e1e1e;">
|
||||
<img src="${it.thumbnail}" style="width:64px; height:36px; object-fit:cover; border-radius:4px; flex:none;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(it.title)}</div>
|
||||
<div style="font-size:12px; color:#888;">${it.created || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('.merge-cand').forEach(el => {
|
||||
el.addEventListener('click', () => pickSource(el.dataset.id));
|
||||
el.addEventListener('mouseenter', () => el.style.background = '#242424');
|
||||
el.addEventListener('mouseleave', () => el.style.background = '');
|
||||
});
|
||||
})
|
||||
.catch(() => { list.innerHTML = '<div class="text-center text-danger p-4">Failed to load.</div>'; });
|
||||
}
|
||||
|
||||
function pickSource(id) {
|
||||
sourceId = id;
|
||||
const box = document.getElementById('mergePreviewBox');
|
||||
const counts = document.getElementById('mergePreviewCounts');
|
||||
counts.innerHTML = '<div class="text-secondary">Calculating…</div>';
|
||||
box.style.display = 'block';
|
||||
document.getElementById('mergeConfirmBtn').disabled = true;
|
||||
|
||||
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/' + encodeURIComponent(sourceId) + '/preview';
|
||||
fetch(url, { headers: { 'Accept': 'application/json' }})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.error) {
|
||||
counts.innerHTML = '<div class="text-danger">' + escapeHtml(d.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('mergePreviewThumb').src = d.source.thumb;
|
||||
document.getElementById('mergePreviewTitle').textContent = d.source.title;
|
||||
const c = d.counts;
|
||||
counts.innerHTML = `
|
||||
<div><i class="bi bi-music-note-list"></i> ${c.tracks} audio track(s) will be added</div>
|
||||
<div><i class="bi bi-images"></i> ${c.slides} slide(s) will move</div>
|
||||
<div><i class="bi bi-chat-left-text"></i> ${c.comments} comment(s) will move</div>
|
||||
<div><i class="bi bi-eye"></i> ${c.views} view(s) will merge</div>
|
||||
<div><i class="bi bi-hand-thumbs-up"></i> ${c.likes_source} like(s) → ${c.likes_after_dedupe} new (deduped by user)</div>
|
||||
<div><i class="bi bi-download"></i> ${c.downloads} download(s) will merge</div>
|
||||
<div><i class="bi bi-share"></i> ${c.shares} share(s) will merge</div>
|
||||
<div><i class="bi bi-collection-play"></i> ${c.playlists_merged} playlist(s) will re-point (dedupe)</div>
|
||||
`;
|
||||
document.getElementById('mergeConfirmBtn').disabled = false;
|
||||
})
|
||||
.catch(() => { counts.innerHTML = '<div class="text-danger">Preview failed.</div>'; });
|
||||
}
|
||||
|
||||
window.doMerge = function () {
|
||||
if (!sourceId || !targetId) return;
|
||||
const btn = document.getElementById('mergeConfirmBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Merging…</span>';
|
||||
|
||||
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/' + encodeURIComponent(sourceId);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
if (window.showToast) window.showToast('Songs merged successfully.', 'success');
|
||||
setTimeout(() => window.location.href = d.redirect, 400);
|
||||
} else {
|
||||
if (window.showToast) window.showToast(d.error || 'Merge failed.', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (window.showToast) window.showToast('Merge failed.', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>';
|
||||
});
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -16,7 +16,8 @@
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
@auth
|
||||
<a href="#" class="yt-sidebar-link">
|
||||
<a href="{{ route('subscriptions.feed') }}"
|
||||
class="yt-sidebar-link {{ request()->routeIs('subscriptions.feed') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>Subscriptions</span>
|
||||
</a>
|
||||
@@ -56,6 +57,10 @@
|
||||
<i class="bi bi-hand-thumbs-up-fill"></i>
|
||||
<span>Liked Videos</span>
|
||||
</a>
|
||||
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
|
||||
<i class="bi bi-sliders"></i>
|
||||
<span>Studio</span>
|
||||
</a>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
|
||||
@@ -91,6 +91,16 @@
|
||||
<div class="invalid-feedback" data-field="match_time"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-0">
|
||||
<div class="col-sm-6">
|
||||
<label for="sm-match-number" class="form-label">Match #</label>
|
||||
<input type="text" class="form-control" name="competition[match_number]" id="sm-match-number" placeholder="e.g. 103">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="sm-court" class="form-label">Court / field</label>
|
||||
<input type="text" class="form-control" name="competition[court]" id="sm-court" placeholder="e.g. 2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Participants --}}
|
||||
@@ -126,8 +136,16 @@
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<x-sports-image name="media_referee_photo" label="Photo" class="sm-img-avatar" />
|
||||
<div class="flex-grow-1">
|
||||
<label for="sm-referee" class="form-label">Referee name</label>
|
||||
<input type="text" class="form-control" name="referee_name" id="sm-referee" placeholder="Referee name">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-7">
|
||||
<label for="sm-referee" class="form-label">Referee name</label>
|
||||
<input type="text" class="form-control" name="referee_name" id="sm-referee" placeholder="Referee name">
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Country</label>
|
||||
<x-country-select name="participants[referee_country]" placeholder="Country" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +214,7 @@
|
||||
<div class="col-sm-6"><label class="form-label">Type</label>
|
||||
<input type="text" class="form-control" name="participants[p1_type]" placeholder="Individual / Team / Pair"></div>
|
||||
<div class="col-sm-6"><label class="form-label">Country</label>
|
||||
<input type="text" class="form-control" name="participants[p1_country]"></div>
|
||||
<x-country-select name="participants[p1_country]" placeholder="Select country" /></div>
|
||||
<div class="col-sm-6"><label class="form-label">Role</label>
|
||||
<input type="text" class="form-control" name="participants[p1_role]" placeholder="home, away, red, blue…"></div>
|
||||
</div>
|
||||
@@ -207,7 +225,7 @@
|
||||
<div class="col-sm-6"><label class="form-label">Type</label>
|
||||
<input type="text" class="form-control" name="participants[p2_type]" placeholder="Individual / Team / Pair"></div>
|
||||
<div class="col-sm-6"><label class="form-label">Country</label>
|
||||
<input type="text" class="form-control" name="participants[p2_country]"></div>
|
||||
<x-country-select name="participants[p2_country]" placeholder="Select country" /></div>
|
||||
<div class="col-sm-6"><label class="form-label">Role</label>
|
||||
<input type="text" class="form-control" name="participants[p2_role]" placeholder="home, away, red, blue…"></div>
|
||||
</div>
|
||||
@@ -399,8 +417,10 @@
|
||||
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
|
||||
Six form-mode croppers write the cropped file straight onto each hidden input;
|
||||
one callback-mode cropper serves all dynamic official rows. --}}
|
||||
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
|
||||
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
|
||||
{{-- Fighter photos: 3:4 portrait crop to match the VS card frame (228×304).
|
||||
Club logos + referee: 1:1 square. --}}
|
||||
<x-image-cropper id="smc_media_participant1_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
|
||||
<x-image-cropper id="smc_media_participant2_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
|
||||
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
|
||||
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
|
||||
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
|
||||
@@ -506,6 +526,72 @@
|
||||
.sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; }
|
||||
.sm-img-avatar .sm-img-ph i { font-size: 16px; }
|
||||
.sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; }
|
||||
|
||||
/* ── Mobile: render as a bottom sheet ─────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
#sportsMatchModal .modal-dialog {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
#sportsMatchModal .sm-content {
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
|
||||
transform: translateY(100%);
|
||||
transition: transform .3s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
#sportsMatchModal.show .sm-content { transform: translateY(0); }
|
||||
|
||||
/* Grabber handle at the top */
|
||||
#sportsMatchModal .sm-header {
|
||||
position: relative;
|
||||
padding-top: 18px;
|
||||
}
|
||||
#sportsMatchModal .sm-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px; height: 4px;
|
||||
background: #333; border-radius: 2px;
|
||||
}
|
||||
|
||||
#sportsMatchModal .modal-body {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#sportsMatchModal .sm-footer {
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#sportsMatchModal .sm-footer .btn {
|
||||
flex: 1 1 auto;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* Slide-up animation on the modal fade class as well */
|
||||
#sportsMatchModal.fade .modal-dialog { transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#sportsMatchModal .sm-header h5 { font-size: 15px; }
|
||||
#sportsMatchModal .sm-header-icon { width: 34px; height: 34px; font-size: 16px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -537,12 +623,28 @@
|
||||
function token() { return form.querySelector('[name="_token"]').value; }
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
window.openSportsMatchModal = function (matchId) {
|
||||
// - openSportsMatchModal(matchId) → edit existing match
|
||||
// - openSportsMatchModal({videoId, videoTitle}) → create against an existing video
|
||||
// - openSportsMatchModal() → fresh create (upload a new video)
|
||||
window.openSportsMatchModal = function (arg) {
|
||||
resetForm();
|
||||
if (matchId) loadMatch(matchId);
|
||||
if (typeof arg === 'number' || typeof arg === 'string') {
|
||||
loadMatch(arg);
|
||||
} else if (arg && typeof arg === 'object' && arg.videoId) {
|
||||
attachExistingVideo(arg.videoId, arg.videoTitle || '');
|
||||
}
|
||||
getModal().show();
|
||||
};
|
||||
|
||||
function attachExistingVideo(videoId, videoTitle) {
|
||||
videoIdInp.value = videoId;
|
||||
document.getElementById('sm-video-create').classList.add('d-none');
|
||||
document.getElementById('sm-video-attached').classList.remove('d-none');
|
||||
document.getElementById('sm-video-attached-title').textContent = videoTitle || ('Video #' + videoId);
|
||||
// Advanced fields are also useful when the video already exists.
|
||||
document.getElementById('sm-advanced').classList.remove('d-none');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
idInput.value = '';
|
||||
@@ -777,8 +879,12 @@
|
||||
clearErrors();
|
||||
lockButtons(true);
|
||||
|
||||
// Edit mode → just update the record. Create mode → upload the video first.
|
||||
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
|
||||
// Skip the video-upload step when:
|
||||
// - editing an existing match (matchId present), OR
|
||||
// - creating a match against an already-attached video (videoId present via
|
||||
// attachExistingVideo, e.g. clicking "Edit" on a match-type video that has
|
||||
// no SportsMatch record yet — the video is real, we're just adding match data).
|
||||
const chain = (idInput.value || videoIdInp.value) ? Promise.resolve() : uploadVideoFirst();
|
||||
chain
|
||||
.then(() => postMatch(intent))
|
||||
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
|
||||
|
||||
@@ -338,9 +338,31 @@
|
||||
</div>
|
||||
</div>{{-- /um-tf-t1 --}}
|
||||
|
||||
{{-- Extra track forms appended by JS --}}
|
||||
{{-- Extra track forms appended by JS (cloned from the template below) --}}
|
||||
<div id="um-tf-extra"></div>
|
||||
|
||||
{{-- SHARED track form template.
|
||||
This is the single source of truth for the "Add Language Track"
|
||||
dialog used by BOTH the create (upload) modal AND the edit modal.
|
||||
Any CSS/markup/behaviour change here automatically applies to both.
|
||||
Cloners replace the placeholders at clone time:
|
||||
__N__ → track key (e.g. "e1" for create, "editE1" for edit)
|
||||
__LANGNAME__ → form field name for the language select
|
||||
__TITLENAME__ → form field name for the title input
|
||||
__DESCNAME__ → form field name for the description textarea
|
||||
__AUDIONAME__ → form field name for the audio file input --}}
|
||||
<template id="um-track-form-template">
|
||||
@include('layouts.partials.upload-modal.track-form', [
|
||||
'key' => '__N__',
|
||||
'showPrimaryNote' => false,
|
||||
'langName' => '__LANGNAME__',
|
||||
'titleName' => '__TITLENAME__',
|
||||
'descName' => '__DESCNAME__',
|
||||
'audioName' => '__AUDIONAME__',
|
||||
'langRequired' => true,
|
||||
])
|
||||
</template>
|
||||
|
||||
</div>{{-- /um-tp-body --}}
|
||||
<div class="um-tp-footer">
|
||||
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
|
||||
@@ -711,6 +733,55 @@
|
||||
}
|
||||
.utc-card:hover .utc-card-arr { opacity: 1; transform: translateX(0); }
|
||||
|
||||
/* ── Mobile: render the chooser as a bottom sheet ───────────── */
|
||||
@media (max-width: 768px) {
|
||||
.utc-overlay {
|
||||
align-items: flex-end; justify-content: stretch;
|
||||
padding: 0; background: rgba(0,0,0,.55);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.utc-box {
|
||||
max-width: none; width: 100%;
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-left: none; border-right: none; border-bottom: none;
|
||||
padding: 10px 18px calc(22px + env(safe-area-inset-bottom));
|
||||
transform: translateY(100%); transition: transform .28s cubic-bezier(.22,1,.36,1);
|
||||
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
|
||||
max-height: 88vh; overflow-y: auto;
|
||||
}
|
||||
.utc-overlay.show .utc-box { transform: translateY(0); }
|
||||
|
||||
/* Grabber handle at the top */
|
||||
.utc-box::before {
|
||||
content: ''; display: block; width: 40px; height: 4px;
|
||||
background: #333; border-radius: 2px; margin: 4px auto 14px;
|
||||
}
|
||||
|
||||
.utc-close { top: 12px; right: 12px; }
|
||||
.utc-head { margin-bottom: 16px; }
|
||||
.utc-head-icon { width: 44px; height: 44px; font-size: 20px; margin-bottom: 10px; }
|
||||
.utc-title { font-size: 17px; }
|
||||
.utc-sub { font-size: 12px; }
|
||||
|
||||
/* Row of compact tiles instead of full stacked cards */
|
||||
.utc-grid { grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.utc-card {
|
||||
padding: 16px 8px 14px; border-radius: 14px; border-width: 1px;
|
||||
}
|
||||
.utc-card-ico {
|
||||
width: 44px; height: 44px; margin-bottom: 8px;
|
||||
font-size: 20px; border-radius: 12px;
|
||||
}
|
||||
.utc-card-title { font-size: 13px; }
|
||||
.utc-card-desc { display: none; }
|
||||
.utc-card-arr { display: none; }
|
||||
.utc-card:active {
|
||||
transform: scale(.96);
|
||||
background: color-mix(in srgb, var(--accent) 10%, #111);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Unified control system — one consistent look for every field,
|
||||
dropdown and picker in the upload modal. Scoped to #uploadModal
|
||||
@@ -829,19 +900,28 @@ const _UTC_META = {
|
||||
};
|
||||
|
||||
function openUploadChooser() {
|
||||
// Mobile keeps the existing full-page create flow (with its own type picker)
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = '{{ route("videos.create") }}';
|
||||
return;
|
||||
}
|
||||
// Chooser is shown on every viewport — on mobile chooseUploadType() routes
|
||||
// straight to the standalone create page with the picked ?type= preserved.
|
||||
document.getElementById('upload-type-chooser').classList.add('show');
|
||||
}
|
||||
|
||||
// Called from the desktop chooser cards on mobile — carries the picked type
|
||||
// through to the standalone create page via ?type=.
|
||||
function chooseUploadTypeMobile(type) {
|
||||
const t = ['generic', 'music', 'match'].includes(type) ? type : 'generic';
|
||||
window.location.href = '{{ route("videos.create") }}?type=' + encodeURIComponent(t);
|
||||
}
|
||||
|
||||
function closeUploadChooser() {
|
||||
document.getElementById('upload-type-chooser').classList.remove('show');
|
||||
}
|
||||
|
||||
function chooseUploadType(type) {
|
||||
// Mobile has no modal — route straight to the create page with the type preserved.
|
||||
if (window.innerWidth < 992) {
|
||||
chooseUploadTypeMobile(type);
|
||||
return;
|
||||
}
|
||||
const meta = _UTC_META[type] || _UTC_META.generic;
|
||||
closeUploadChooser();
|
||||
openUploadModal();
|
||||
@@ -1314,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
|
||||
|
||||
function handleSlidesForTrack(tid, fileList) {
|
||||
_slidesCropStart(fileList, tid);
|
||||
// Reset the file input so picking the SAME filename a second time still
|
||||
// fires change (browsers suppress change when FileList is identical).
|
||||
const inp = document.getElementById('slides-input-' + tid);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
// ── Slides crop queue: every added image is cropped before it enters the strip ──
|
||||
@@ -1332,8 +1416,12 @@ function _slidesCropLoadNext() {
|
||||
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
|
||||
const f = _slidesCropQueue.shift();
|
||||
if (typeof window.tcPreload_slides_upload === 'function') {
|
||||
window.tcPreload_slides_upload(f);
|
||||
// Open the modal FIRST so the canvas has a non-zero layout box when
|
||||
// initCropper measures clientWidth/clientHeight. Preloading first
|
||||
// (the previous order) caused the second batch to open with stale
|
||||
// zero-sized measurements and a Save button that silently did nothing.
|
||||
window.openCropperModal_slides_upload();
|
||||
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1342,7 +1430,13 @@ function uploadSlidesCropDone(file) {
|
||||
if (tid && file) {
|
||||
if (!_slidesData[tid]) _slidesData[tid] = [];
|
||||
if (_slidesData[tid].length < 10) {
|
||||
_slidesData[tid].push(file);
|
||||
// Cache a per-file object URL once so renderSlidesStrip can reuse it
|
||||
// on every reorder without spawning a fresh FileReader per slide.
|
||||
// The old approach (new FileReader per file per render) ran dozens
|
||||
// of readers after a reorder and silently dropped some under memory
|
||||
// pressure, leaving the strip with broken thumbnails.
|
||||
const url = URL.createObjectURL(file);
|
||||
_slidesData[tid].push({ file, url });
|
||||
renderSlidesStrip(tid);
|
||||
}
|
||||
}
|
||||
@@ -1364,7 +1458,10 @@ function renderSlidesStrip(tid) {
|
||||
if (zone) zone.style.borderColor = '';
|
||||
return;
|
||||
}
|
||||
files.forEach((f, index) => {
|
||||
files.forEach((item, index) => {
|
||||
// Entries are {file, url}; cached url is stable across reorders.
|
||||
const url = item.url || (item.file ? URL.createObjectURL(item.file) : '');
|
||||
if (item.file && !item.url) item.url = url;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
|
||||
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
|
||||
@@ -1383,10 +1480,15 @@ function renderSlidesStrip(tid) {
|
||||
const rb = document.createElement('button');
|
||||
rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
|
||||
rb.innerHTML='<i class="bi bi-x"></i>';
|
||||
rb.addEventListener('click', function(ev) { ev.stopPropagation(); _slidesData[tid].splice(index,1); renderSlidesStrip(tid); });
|
||||
rb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
const removed = _slidesData[tid].splice(index,1)[0];
|
||||
if (removed && removed.url) URL.revokeObjectURL(removed.url);
|
||||
renderSlidesStrip(tid);
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt='';
|
||||
const reader = new FileReader(); reader.onload = ev2 => { img.src=ev2.target.result; }; reader.readAsDataURL(f);
|
||||
img.src = url;
|
||||
wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap);
|
||||
});
|
||||
if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback';
|
||||
@@ -1397,6 +1499,7 @@ function renderSlidesStrip(tid) {
|
||||
|
||||
function clearSlidesForTrack(e, tid) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
|
||||
_slidesData[tid] = [];
|
||||
const input = document.getElementById('slides-input-' + tid);
|
||||
if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); }
|
||||
@@ -1436,12 +1539,10 @@ function updateTrackCard(trackId) {
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
} else {
|
||||
const n = trackId.replace('e', '');
|
||||
const uid = 'ltme_' + n;
|
||||
const li = document.getElementById('csd_v_' + uid);
|
||||
const ti = document.getElementById('um-tf-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
const li = document.getElementById('track-lang-' + trackId);
|
||||
const ti = document.getElementById('track-title-' + trackId);
|
||||
langCode = li ? li.value : '';
|
||||
title = ti ? ti.value.trim() : '';
|
||||
}
|
||||
const lang = LANG_OPTIONS_MODAL.find(o => o.value === langCode);
|
||||
const hasContent = !!(lang || title);
|
||||
@@ -1454,49 +1555,111 @@ function updateTrackCard(trackId) {
|
||||
if (card) card.style.display = hasContent ? '' : 'none';
|
||||
}
|
||||
|
||||
const flagEl = document.getElementById('um-tc-flag-' + trackId);
|
||||
const titleEl = document.getElementById('um-tc-title-' + trackId);
|
||||
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
||||
if (titleEl) {
|
||||
if (lang && title) titleEl.textContent = title + ' — ' + lang.label;
|
||||
else if (lang) titleEl.textContent = lang.label;
|
||||
else if (title) titleEl.textContent = title;
|
||||
else titleEl.textContent = '';
|
||||
}
|
||||
// Update WHICHEVER card id convention exists — the create modal uses
|
||||
// `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
|
||||
// both keeps this helper a single source of truth for any shared template.
|
||||
const flagEls = [
|
||||
document.getElementById('um-tc-flag-' + trackId),
|
||||
document.getElementById('edit-tc-flag-' + trackId),
|
||||
];
|
||||
const titleEls = [
|
||||
document.getElementById('um-tc-title-' + trackId),
|
||||
document.getElementById('edit-tc-title-' + trackId),
|
||||
];
|
||||
const flagClass = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
|
||||
let display;
|
||||
if (lang && title) display = title + ' — ' + lang.label;
|
||||
else if (lang) display = lang.label;
|
||||
else if (title) display = title;
|
||||
else display = '';
|
||||
flagEls.forEach(el => { if (el) el.className = flagClass; });
|
||||
titleEls.forEach(el => { if (el) el.textContent = display; });
|
||||
}
|
||||
|
||||
// ── Extra track management ────────────────────────────────────
|
||||
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
|
||||
|
||||
function _ltBuildOptsHtml() {
|
||||
return LANG_OPTIONS_MODAL.map(o =>
|
||||
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
|
||||
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
|
||||
<span class="csd-opt-main">${o.label}</span>
|
||||
<span class="csd-opt-sub">${o.native}</span>
|
||||
</li>`
|
||||
).join('');
|
||||
// Audio picker handler shared by every track form (primary + extras) cloned
|
||||
// from the <template id="um-track-form-template"> blade partial.
|
||||
function handleTrackAudio(key, input) {
|
||||
const file = input.files && input.files[0];
|
||||
const fnameEl = document.getElementById('track-audio-fname-' + key);
|
||||
const box = document.getElementById('track-audio-box-' + key);
|
||||
if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
|
||||
if (box) box.style.borderColor = file ? '#22c55e' : '';
|
||||
|
||||
// Auto-fill the track title from the filename (same behavior as primary track)
|
||||
if (file) {
|
||||
const titleEl = document.getElementById('track-title-' + key);
|
||||
if (titleEl && !titleEl.value) {
|
||||
titleEl.value = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
|
||||
updateTrackCard(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared cloner for #um-track-form-template. Both the create modal and the
|
||||
// edit modal call this so there is ONE source for the track-form markup.
|
||||
// Returns a DocumentFragment with every placeholder (__N__, __LANGNAME__, …)
|
||||
// substituted. The caller is responsible for appending it + initialising the
|
||||
// language picker + RTE on the new nodes.
|
||||
window.cloneTrackFormTemplate = window.cloneTrackFormTemplate || function (opts) {
|
||||
const tpl = document.getElementById('um-track-form-template');
|
||||
if (!tpl) { console.error('[track-form] missing #um-track-form-template'); return null; }
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const subs = {
|
||||
'__N__': opts.key,
|
||||
'__LANGNAME__': opts.langName,
|
||||
'__TITLENAME__': opts.titleName,
|
||||
'__DESCNAME__': opts.descName,
|
||||
'__AUDIONAME__': opts.audioName,
|
||||
};
|
||||
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ELEMENT, null);
|
||||
const nodes = [];
|
||||
for (let el = walker.nextNode(); el; el = walker.nextNode()) nodes.push(el);
|
||||
nodes.forEach(node => {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
let v = attr.value;
|
||||
for (const k in subs) if (v.indexOf(k) !== -1) v = v.split(k).join(subs[k]);
|
||||
if (v !== attr.value) node.setAttribute(attr.name, v);
|
||||
}
|
||||
});
|
||||
return frag;
|
||||
};
|
||||
|
||||
// Shared post-clone wiring: CSD language picker + RTE on fresh markup, plus
|
||||
// change listeners that keep whichever card is on-screen in sync with the form.
|
||||
window.initTrackFormNode = window.initTrackFormNode || function (key, onChange) {
|
||||
if (window.CSD) new CSD('lsd_track_' + key);
|
||||
const rteWrap = document.querySelector('#um-tf-' + key + ' .rte-wrap:not([data-rte-ready])');
|
||||
if (rteWrap && window.RTE && typeof window.RTE.init === 'function') window.RTE.init(rteWrap);
|
||||
const langInput = document.getElementById('track-lang-' + key);
|
||||
const titleInput = document.getElementById('track-title-' + key);
|
||||
const cb = onChange || (() => updateTrackCard(key));
|
||||
if (langInput) langInput.addEventListener('change', () => cb(key));
|
||||
if (titleInput) titleInput.addEventListener('input', () => cb(key));
|
||||
};
|
||||
|
||||
function addExtraTrackModal() {
|
||||
const n = ++_umExtraCount;
|
||||
const trackNum = n + 1;
|
||||
const uid = 'ltme_' + n;
|
||||
const key = 'e' + n;
|
||||
|
||||
// Track card (shown in the list on the main modal body)
|
||||
const card = document.createElement('div');
|
||||
card.className = 'um-track-card';
|
||||
card.id = 'um-tc-e' + n;
|
||||
card.id = 'um-tc-' + key;
|
||||
card.innerHTML = `
|
||||
<div class="um-tc-body">
|
||||
<div class="um-tc-left">
|
||||
<div class="um-tc-num">${trackNum}</div>
|
||||
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-e${n}"></span>
|
||||
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-${key}"></span>
|
||||
<div class="um-tc-info">
|
||||
<span class="um-tc-title" id="um-tc-title-e${n}">Track — select language</span>
|
||||
<span class="um-tc-title" id="um-tc-title-${key}">Track — select language</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="um-tc-right">
|
||||
<button type="button" class="action-btn" onclick="openTrackPopup('e${n}')">
|
||||
<button type="button" class="action-btn" onclick="openTrackPopup('${key}')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
|
||||
@@ -1506,100 +1669,24 @@ function addExtraTrackModal() {
|
||||
</div>`;
|
||||
document.getElementById('um-tc-extra').appendChild(card);
|
||||
|
||||
const formEl = document.createElement('div');
|
||||
formEl.className = 'um-track-form';
|
||||
formEl.id = 'um-tf-e' + n;
|
||||
formEl.style.display = 'none';
|
||||
formEl.innerHTML = `
|
||||
<div class="um-tf-row2" style="margin-bottom:14px;">
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label>
|
||||
<div class="csd-wrap" id="${uid}">
|
||||
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
|
||||
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
|
||||
<span class="csd-val ph">Select language</span>
|
||||
<i class="bi bi-chevron-down csd-arr"></i>
|
||||
</button>
|
||||
<div class="csd-panel" hidden role="listbox">
|
||||
<div class="csd-srch"><i class="bi bi-search"></i>
|
||||
<input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<ul class="csd-list">${_ltBuildOptsHtml()}</ul>
|
||||
<p class="csd-empty" hidden>No results</p>
|
||||
</div>
|
||||
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Title <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<input type="text" name="extra_track_titles[]" id="um-tf-title-e${n}" class="um-input"
|
||||
style="font-size:13px;padding:9px 12px;" placeholder="Title in this language…">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Description <span class="um-lbl-hint">optional</span>
|
||||
</label>
|
||||
<div class="rte-wrap" data-min-height="100px">
|
||||
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
|
||||
placeholder="Description in this language…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
<i class="bi bi-music-note-beamed"></i> Audio File
|
||||
</label>
|
||||
<label class="lta-file-label"
|
||||
onmouseenter="this.style.borderColor='#e61e1e'"
|
||||
onmouseleave="this.style.borderColor='#222'">
|
||||
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:15px;flex-shrink:0;"></i>
|
||||
<span class="lta-file-name" id="um-tf-fname-e${n}">Choose audio file…</span>
|
||||
<input type="file" name="extra_track_files[]"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('um-tf-fname-e${n}').textContent=this.files[0]?.name||'Choose audio file…'">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
||||
Cover Slides <span class="um-lbl-hint">optional · drag to reorder</span>
|
||||
</label>
|
||||
<div id="slides-zone-e${n}" class="um-slides-zone"
|
||||
onclick="slidesZoneClick(event,'e${n}')"
|
||||
ondragover="slidesZoneDragover(event,'e${n}')"
|
||||
ondragleave="slidesZoneDragleave(event,'e${n}')"
|
||||
ondrop="slidesZoneDrop(event,'e${n}')">
|
||||
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-e${n}"
|
||||
onchange="handleSlidesForTrack('e${n}',this.files)">
|
||||
<div class="um-slides-ph" id="slides-ph-e${n}">
|
||||
<i class="bi bi-images"></i>
|
||||
<span>Click or drag to add cover images</span>
|
||||
</div>
|
||||
<div id="slides-preview-e${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
|
||||
<div id="slides-strip-e${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span id="slides-count-e${n}" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
<button type="button" onclick="clearSlidesForTrack(event,'e${n}')"
|
||||
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
|
||||
<i class="bi bi-x-lg"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Clone the SHARED track-form template and substitute placeholders. The same
|
||||
// template is cloned by the edit modal (editAddExtraTrack / existing-track
|
||||
// loader), so a change here automatically shows up in both flows.
|
||||
const frag = window.cloneTrackFormTemplate({
|
||||
key: key,
|
||||
langName: 'extra_track_languages[]',
|
||||
titleName: 'extra_track_titles[]',
|
||||
descName: 'extra_track_descriptions[]',
|
||||
audioName: 'extra_track_files[]',
|
||||
});
|
||||
if (!frag) return;
|
||||
|
||||
document.getElementById('um-tf-extra').appendChild(formEl);
|
||||
_slidesData['e' + n] = [];
|
||||
const host = document.getElementById('um-tf-extra');
|
||||
host.appendChild(frag);
|
||||
_slidesData[key] = [];
|
||||
|
||||
if (window.CSD) new CSD(uid);
|
||||
|
||||
const hiddenInput = document.getElementById('csd_v_' + uid);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
|
||||
}
|
||||
|
||||
openTrackPopup('e' + n);
|
||||
window.initTrackFormNode(key);
|
||||
openTrackPopup(key);
|
||||
}
|
||||
|
||||
function removeExtraTrackModal(n) {
|
||||
@@ -1626,6 +1713,57 @@ function removeExtraTrackModal(n) {
|
||||
}
|
||||
}());
|
||||
|
||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
|
||||
async function _chunkedUploadVideo(file, onProgress) {
|
||||
const CHUNK = 2 * 1024 * 1024; // 2 MB per chunk
|
||||
const CONCURRENCY = 3; // parallel in-flight chunks
|
||||
const total = Math.max(1, Math.ceil(file.size / CHUNK));
|
||||
const uploadId = (window.crypto && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
||||
|
||||
let uploadedBytes = 0;
|
||||
const uploadOne = async (i) => {
|
||||
const start = i * CHUNK;
|
||||
const end = Math.min(start + CHUNK, file.size);
|
||||
const blob = file.slice(start, end);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('upload_id', uploadId);
|
||||
fd.append('chunk_index', i);
|
||||
fd.append('chunk_offset', start);
|
||||
fd.append('total_chunks', total);
|
||||
fd.append('filename', file.name);
|
||||
fd.append('chunk', blob, 'chunk');
|
||||
|
||||
const res = await fetch('{{ route("videos.uploadChunk") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = 'Chunk ' + (i + 1) + '/' + total + ' failed (' + res.status + ')';
|
||||
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
uploadedBytes += (end - start);
|
||||
if (onProgress) onProgress({ loaded: Math.min(uploadedBytes, file.size), total: file.size });
|
||||
};
|
||||
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= total) return;
|
||||
await uploadOne(i);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
return { uploadId: uploadId, filename: file.name };
|
||||
}
|
||||
|
||||
// ── Form submission ───────────────────────────────────────────
|
||||
document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -1659,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
}
|
||||
|
||||
const formData = new FormData(this);
|
||||
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
|
||||
if (_isMusicMode) {
|
||||
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
|
||||
(_slidesData['t1'] || []).forEach(s => formData.append('slides[]', _slideFile(s)));
|
||||
// Walk extra-track form sections in DOM order so the slide index lines up
|
||||
// with the positional `extra_track_files[]` the form already submits.
|
||||
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
|
||||
@@ -1669,8 +1808,8 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
if (!m) return;
|
||||
var tid = m[1];
|
||||
var files = _slidesData[tid] || [];
|
||||
files.forEach(function (f) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', f);
|
||||
files.forEach(function (s) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1685,11 +1824,39 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
document.getElementById('submit-btn-modal').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
|
||||
document.getElementById('status-message-modal').className = 'status-message-modal';
|
||||
|
||||
// The video file is the big one — upload it in 8 MB chunks first, then
|
||||
// submit the small form (thumbnails/slides/text) as the final request.
|
||||
const _videoFile = videoInput.files[0];
|
||||
formData.delete('video');
|
||||
|
||||
const _pbar = document.getElementById('progress-bar-modal');
|
||||
const _ptext = document.getElementById('progress-text-modal');
|
||||
|
||||
_pbar.style.width = '0%';
|
||||
_ptext.textContent = 'Uploading… 0%';
|
||||
|
||||
_chunkedUploadVideo(_videoFile, function(p) {
|
||||
// Chunk upload accounts for 0–95% of the visible progress.
|
||||
const pct = Math.round((p.loaded / p.total) * 95);
|
||||
_pbar.style.width = pct + '%';
|
||||
_ptext.textContent = 'Uploading… ' + pct + '%';
|
||||
}).then(function(res) {
|
||||
formData.append('video_upload_id', res.uploadId);
|
||||
formData.append('video_filename', res.filename);
|
||||
_pbar.style.width = '96%';
|
||||
_ptext.textContent = 'Finalising…';
|
||||
xhr.open('POST', '{{ route("videos.store") }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
xhr.send(formData);
|
||||
}).catch(function(err) {
|
||||
_uploadInProgress = false;
|
||||
_showUploadError(err && err.message ? err.message : 'Chunked upload failed');
|
||||
});
|
||||
|
||||
xhr.upload.addEventListener('progress', function(ev) {
|
||||
if (ev.lengthComputable) {
|
||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
||||
document.getElementById('progress-bar-modal').style.width = pct + '%';
|
||||
document.getElementById('progress-text-modal').textContent = 'Uploading… ' + pct + '%';
|
||||
if (ev.lengthComputable && ev.total > 0) {
|
||||
const pct = 96 + Math.round((ev.loaded / ev.total) * 3);
|
||||
_pbar.style.width = pct + '%';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1730,10 +1897,6 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
|
||||
xhr.addEventListener('error', () => { _uploadInProgress = false; _showUploadError('Upload failed. Please check your connection.'); });
|
||||
xhr.addEventListener('timeout', () => { _uploadInProgress = false; _showUploadError('Upload timed out.'); });
|
||||
xhr.addEventListener('abort', () => { _uploadInProgress = false; _showUploadError('Upload was cancelled.'); });
|
||||
|
||||
xhr.open('POST', '{{ route("videos.store") }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
function _showUploadError(message) {
|
||||
|
||||
@@ -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
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user