Compare commits
5 Commits
8ea63a6720
...
847020fd02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847020fd02 | ||
|
|
fcb752dd76 | ||
|
|
8d27520bdb | ||
|
|
ba8ab11ce4 | ||
|
|
af276f2cd1 |
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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'])
|
||||
|
||||
141
app/Console/Commands/RepairMergedVideo.php
Normal file
141
app/Console/Commands/RepairMergedVideo.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@ -772,7 +772,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,
|
||||
|
||||
@ -33,6 +33,46 @@ class VideoController extends Controller
|
||||
{
|
||||
$filter = request('filter', 'all');
|
||||
|
||||
// Chips: distinct languages that actually appear in public content —
|
||||
// either as a video's primary language or as any of its audio tracks.
|
||||
// Sorted by video count so the most-used languages come first, capped
|
||||
// at 12 so the bar doesn't overflow.
|
||||
$langChips = $this->buildLanguageChips();
|
||||
|
||||
// ── Filter by language (?filter=lang:xx) ──────────────────
|
||||
// Matches when the video's primary language is xx OR when any of
|
||||
// its secondary audio tracks is xx. When a card is being shown only
|
||||
// because of a secondary track, we tag it so video-card can link
|
||||
// straight to that track via ?track=ID and badge it with the flag.
|
||||
if (str_starts_with((string) $filter, 'lang:')) {
|
||||
$lang = strtolower(substr($filter, 5));
|
||||
$primaryIds = \App\Models\Video::public()->where('language', $lang)->pluck('id');
|
||||
$secondaryTracks = \App\Models\VideoAudioTrack::where('language', $lang)
|
||||
->whereIn('video_id', \App\Models\Video::public()->pluck('id'))
|
||||
->get();
|
||||
$secondaryVideoIds = $secondaryTracks->pluck('video_id')->unique();
|
||||
// If the video is already primary-matched, no track override.
|
||||
// Otherwise pin the surfaced track.
|
||||
$trackByVideo = $secondaryTracks
|
||||
->filter(fn ($t) => ! $primaryIds->contains($t->video_id))
|
||||
->keyBy('video_id');
|
||||
|
||||
$ids = $primaryIds->concat($secondaryVideoIds)->unique()->values();
|
||||
$videos = \App\Models\Video::public()->whereIn('id', $ids)
|
||||
->latest()->limit(60)->get();
|
||||
|
||||
foreach ($videos as $v) {
|
||||
if ($trackByVideo->has($v->id)) {
|
||||
$v->setAttribute('_force_track_id', $trackByVideo[$v->id]->id);
|
||||
$v->setAttribute('_force_track_flag', \App\Data\Languages::flag($lang));
|
||||
}
|
||||
}
|
||||
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Playlists-only browse ──────────────────────────────────
|
||||
if ($filter === 'playlists') {
|
||||
$playlists = Playlist::where('visibility', 'public')
|
||||
@ -41,7 +81,7 @@ class VideoController extends Controller
|
||||
->latest()
|
||||
->limit(60)
|
||||
->get();
|
||||
return view('videos.index', compact('playlists', 'filter') + [
|
||||
return view('videos.index', compact('playlists', 'filter', 'langChips') + [
|
||||
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(),
|
||||
]);
|
||||
}
|
||||
@ -49,7 +89,7 @@ class VideoController extends Controller
|
||||
// ── Shorts-only browse ────────────────────────────────────
|
||||
if ($filter === 'shorts') {
|
||||
$videos = Video::public()->shorts()->latest()->limit(60)->get();
|
||||
return view('videos.index', compact('videos', 'filter') + [
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
@ -57,7 +97,7 @@ class VideoController extends Controller
|
||||
// ── Sports/Matches-only browse ────────────────────────────
|
||||
if ($filter === 'match') {
|
||||
$videos = Video::public()->where('type', 'match')->notShorts()->latest()->limit(60)->get();
|
||||
return view('videos.index', compact('videos', 'filter') + [
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
@ -68,7 +108,7 @@ class VideoController extends Controller
|
||||
if ($filter === 'music') $query->where('type', 'music');
|
||||
else $query->latest();
|
||||
$videos = $query->limit(50)->get();
|
||||
return view('videos.index', compact('videos', 'filter') + [
|
||||
return view('videos.index', compact('videos', 'filter', 'langChips') + [
|
||||
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
@ -91,11 +131,58 @@ class VideoController extends Controller
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
return view('videos.index', compact('feedItems', 'filter') + [
|
||||
return view('videos.index', compact('feedItems', 'filter', 'langChips') + [
|
||||
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct languages currently present in public content, sorted by how
|
||||
* many videos each covers (primary + secondary tracks combined). Returned
|
||||
* as [ ['code'=>'ja','flag'=>'jp','name'=>'Japanese','count'=>N], ... ]
|
||||
*/
|
||||
protected function buildLanguageChips(int $limit = 12): array
|
||||
{
|
||||
$publicIds = \App\Models\Video::public()->pluck('id');
|
||||
if ($publicIds->isEmpty()) return [];
|
||||
|
||||
// Video-per-language: union of primary language and each track's
|
||||
// language, deduplicated per video so a video with EN primary + EN
|
||||
// extra doesn't inflate the EN count.
|
||||
$primary = \App\Models\Video::public()
|
||||
->whereNotNull('language')->where('language', '!=', '')
|
||||
->pluck('language', 'id');
|
||||
$tracks = \App\Models\VideoAudioTrack::whereIn('video_id', $publicIds)
|
||||
->whereNotNull('language')->where('language', '!=', '')
|
||||
->get(['video_id', 'language']);
|
||||
|
||||
$langsPerVideo = [];
|
||||
foreach ($primary as $vid => $lang) {
|
||||
$langsPerVideo[$vid][strtolower($lang)] = true;
|
||||
}
|
||||
foreach ($tracks as $t) {
|
||||
$langsPerVideo[$t->video_id][strtolower($t->language)] = true;
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($langsPerVideo as $langs) {
|
||||
foreach (array_keys($langs) as $code) {
|
||||
$counts[$code] = ($counts[$code] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
arsort($counts);
|
||||
|
||||
$chips = [];
|
||||
foreach ($counts as $code => $count) {
|
||||
$flag = \App\Data\Languages::flag($code);
|
||||
if (! $flag) continue; // skip unknown codes
|
||||
$name = \App\Data\Languages::all()[$code]['name'] ?? strtoupper($code);
|
||||
$chips[] = ['code' => $code, 'flag' => $flag, 'name' => $name, 'count' => $count];
|
||||
if (count($chips) >= $limit) break;
|
||||
}
|
||||
return $chips;
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = $request->get('q', '');
|
||||
@ -131,9 +218,92 @@ class VideoController extends Controller
|
||||
return view('videos.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive one chunk of a large upload. Client posts chunks of ~8 MB
|
||||
* (well under Cloudflare's 100 MB per-request cap) sequentially; server
|
||||
* appends each to a per-upload buffer under data/app/tmp/uploads/{uid}/.
|
||||
* When the final `store` call is made with `video_upload_id`, the
|
||||
* assembled buffer is promoted to the real UploadedFile.
|
||||
*/
|
||||
public function uploadChunk(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'upload_id' => ['required', 'string', 'regex:/^[A-Za-z0-9\-]{8,64}$/'],
|
||||
'chunk_index' => 'required|integer|min:0',
|
||||
'total_chunks' => 'required|integer|min:1|max:100000',
|
||||
'filename' => 'required|string|max:255',
|
||||
'chunk' => 'required|file',
|
||||
]);
|
||||
|
||||
$uid = Auth::id();
|
||||
$uploadId = $request->input('upload_id');
|
||||
$idx = (int) $request->input('chunk_index');
|
||||
$total = (int) $request->input('total_chunks');
|
||||
$filename = basename($request->input('filename'));
|
||||
|
||||
$dir = storage_path("app/tmp/uploads/{$uid}");
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return response()->json(['success' => false, 'message' => 'Cannot create upload buffer'], 500);
|
||||
}
|
||||
|
||||
$partPath = "{$dir}/{$uploadId}.part";
|
||||
$mode = $idx === 0 ? 'wb' : 'ab';
|
||||
|
||||
$out = @fopen($partPath, $mode);
|
||||
if (!$out) {
|
||||
return response()->json(['success' => false, 'message' => 'Cannot open upload buffer'], 500);
|
||||
}
|
||||
$in = fopen($request->file('chunk')->getRealPath(), 'rb');
|
||||
while (!feof($in)) fwrite($out, fread($in, 1 << 20));
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
|
||||
@file_put_contents("{$dir}/{$uploadId}.meta", json_encode([
|
||||
'filename' => $filename,
|
||||
'total_chunks' => $total,
|
||||
'received' => $idx + 1,
|
||||
'updated_at' => time(),
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'chunk_index' => $idx,
|
||||
'received' => $idx + 1,
|
||||
'total_chunks' => $total,
|
||||
'complete' => ($idx + 1) === $total,
|
||||
'size' => filesize($partPath),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
try {
|
||||
// Reassemble chunked upload (see uploadChunk). Must run BEFORE any
|
||||
// call to $request->file()/hasFile() — Laravel caches convertedFiles
|
||||
// on first access, and a stale cache would ignore our injected file.
|
||||
// Using $request->files->has() (Symfony FileBag) does not trigger
|
||||
// that cache, so it's safe to check here.
|
||||
if ($request->filled('video_upload_id') && !$request->files->has('video')) {
|
||||
$uid = Auth::id();
|
||||
$uploadId = preg_replace('/[^A-Za-z0-9\-]/', '', (string) $request->input('video_upload_id'));
|
||||
$filename = basename((string) $request->input('video_filename', 'upload.mp4'));
|
||||
$partPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.part");
|
||||
$metaPath = storage_path("app/tmp/uploads/{$uid}/{$uploadId}.meta");
|
||||
|
||||
if (!$uploadId || !is_file($partPath)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Upload buffer not found — please retry.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$mime = @mime_content_type($partPath) ?: 'application/octet-stream';
|
||||
$request->files->set('video', new \Illuminate\Http\UploadedFile(
|
||||
$partPath, $filename, $mime, null, true
|
||||
));
|
||||
@unlink($metaPath);
|
||||
}
|
||||
|
||||
$audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
|
||||
$uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? '');
|
||||
$isAudioUpload = in_array($uploadedExt, $audioExtensions);
|
||||
@ -614,6 +784,13 @@ class VideoController extends Controller
|
||||
? route('media.thumbnail', $video->thumbnail)
|
||||
: asset('storage/images/logo.png');
|
||||
|
||||
// Requested language track (0/absent = primary). Only honoured when
|
||||
// the id actually belongs to this video.
|
||||
$requestedTrackId = (int) $request->query('track', 0);
|
||||
$requestedTrack = $requestedTrackId
|
||||
? $video->audioTracks->firstWhere('id', $requestedTrackId)
|
||||
: null;
|
||||
|
||||
// Per-track slide map (key "0" = primary). Each entry already has the
|
||||
// sharing fallback applied by Video::slidesForTrack — a track without its
|
||||
// own slides borrows the primary's (or a sibling's) automatically.
|
||||
@ -623,7 +800,9 @@ class VideoController extends Controller
|
||||
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
|
||||
->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
|
||||
}
|
||||
$slides = $slideMap['0'];
|
||||
$slides = $requestedTrack
|
||||
? ($slideMap[(string) $requestedTrack->id] ?: $slideMap['0'])
|
||||
: $slideMap['0'];
|
||||
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
$audioTracks = $video->audioTracks->map(fn ($t) => [
|
||||
@ -652,6 +831,15 @@ class VideoController extends Controller
|
||||
'has_hls' => (bool) $video->has_hls,
|
||||
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
|
||||
'stream_url' => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
|
||||
// When a specific language track is requested (playlist row pin,
|
||||
// shared link with ?track=), expose it separately. The player uses
|
||||
// this for the actual <audio> src while stream_url stays as the
|
||||
// primary, so the language popup still lists the true primary.
|
||||
'active_track_id' => $requestedTrack->id ?? null,
|
||||
'active_stream_url' => $requestedTrack
|
||||
? route('videos.audio-track', ['video' => $video, 'track' => $requestedTrack->id])
|
||||
. '?v=' . $requestedTrack->updated_at->timestamp
|
||||
: null,
|
||||
'cover_url' => $coverUrl,
|
||||
'slides' => $slides,
|
||||
'slide_map' => $slideMap,
|
||||
@ -959,6 +1147,12 @@ class VideoController extends Controller
|
||||
'promote_track_id' => 'nullable|integer',
|
||||
'delete_track_ids' => 'nullable|array',
|
||||
'delete_track_ids.*' => 'integer',
|
||||
// Ordered list of track IDs as they appear in the edit UI. Index
|
||||
// 0 is the primary; the value at each index is either a numeric
|
||||
// track id (existing secondary) or the literal string "primary"
|
||||
// for the original primary. Missing = no reorder was performed.
|
||||
'track_order' => 'nullable|array',
|
||||
'track_order.*' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
$oldTitle = $video->title;
|
||||
@ -1212,7 +1406,9 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
// Promote a secondary track to primary (swap metadata)
|
||||
if ($promoteId = (int) $request->input('promote_track_id')) {
|
||||
$promoteId = (int) $request->input('promote_track_id');
|
||||
$newSecondaryId = null; // set below if a promote happens
|
||||
if ($promoteId) {
|
||||
$promoteTrack = $video->audioTracks()->find($promoteId);
|
||||
if ($promoteTrack) {
|
||||
\Log::info('Track promote: swapping primary ↔ secondary', [
|
||||
@ -1221,7 +1417,7 @@ class VideoController extends Controller
|
||||
'new_primary' => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
|
||||
]);
|
||||
// Create new secondary track from current primary metadata
|
||||
VideoAudioTrack::create([
|
||||
$newSecondary = VideoAudioTrack::create([
|
||||
'video_id' => $video->id,
|
||||
'language' => $video->language ?? 'en',
|
||||
'label' => strtoupper($video->language ?? 'en'),
|
||||
@ -1230,6 +1426,7 @@ class VideoController extends Controller
|
||||
'path' => $video->path,
|
||||
'filename' => $video->filename,
|
||||
]);
|
||||
$newSecondaryId = $newSecondary->id;
|
||||
// Override $data with the promoted track's values
|
||||
$data['title'] = $promoteTrack->title ?: ($data['title'] ?? $video->title);
|
||||
$data['language'] = $promoteTrack->language;
|
||||
@ -1242,6 +1439,40 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user's reordered track list. `track_order` is the
|
||||
// ordered list of items in the edit UI, index 0 being the primary.
|
||||
// Each entry is either the literal string "primary" (the original
|
||||
// primary track row placeholder) or a numeric secondary-track id.
|
||||
$trackOrder = $request->input('track_order', []);
|
||||
if (is_array($trackOrder) && count($trackOrder) > 0) {
|
||||
foreach ($trackOrder as $i => $val) {
|
||||
if ($i === 0) continue; // slot 0 is the primary, not a track row
|
||||
if ($val === 'primary') {
|
||||
// The old primary was demoted to secondary in the promote
|
||||
// block above; write its new position.
|
||||
if ($newSecondaryId) {
|
||||
VideoAudioTrack::where('id', $newSecondaryId)->update(['position' => $i]);
|
||||
}
|
||||
} else {
|
||||
$tid = (int) $val;
|
||||
if ($tid <= 0 || $tid === $promoteId) continue; // promoted id is now the primary
|
||||
VideoAudioTrack::where('id', $tid)
|
||||
->where('video_id', $video->id)
|
||||
->update(['position' => $i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any tracks that were freshly created in this same request (e.g.
|
||||
// brand-new secondaries uploaded via extra_track_files) still sit at
|
||||
// position=0 because the client couldn't know their DB id in advance.
|
||||
// Append them to the end of the list so the ordering stays stable.
|
||||
$maxPos = (int) $video->audioTracks()->max('position');
|
||||
foreach ($video->audioTracks()->where('position', 0)->orderBy('id')->get() as $t) {
|
||||
$maxPos++;
|
||||
$t->update(['position' => $maxPos]);
|
||||
}
|
||||
|
||||
$video->update($data);
|
||||
|
||||
// If the title changed, rename the NAS folder and update meta.json.
|
||||
@ -2989,7 +3220,7 @@ class VideoController extends Controller
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
'device' => $ua['device'],
|
||||
@ -3044,7 +3275,7 @@ class VideoController extends Controller
|
||||
'user_avatar' => $r->user_id
|
||||
? ($r->user_avatar
|
||||
? route('media.avatar', $r->user_avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $r->country,
|
||||
'device' => $ua['device'],
|
||||
@ -3099,7 +3330,7 @@ class VideoController extends Controller
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
'device' => $ua['device'],
|
||||
@ -3145,7 +3376,7 @@ class VideoController extends Controller
|
||||
'user_avatar'=> $r->user_id
|
||||
? ($r->user_avatar
|
||||
? route('media.avatar', $r->user_avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $r->country,
|
||||
'type' => $r->type,
|
||||
@ -3238,7 +3469,7 @@ class VideoController extends Controller
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
'liked_at' => $u->liked_at,
|
||||
]);
|
||||
|
||||
@ -3276,7 +3507,7 @@ class VideoController extends Controller
|
||||
'avatar' => $user
|
||||
? ($user->avatar
|
||||
? route('media.avatar', $user->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $user->id)
|
||||
: asset('images/default-avatar.svg'))
|
||||
: null,
|
||||
'country' => $topCountry ? $topCountry->country : null,
|
||||
'country_name' => $topCountry ? $topCountry->country_name : null,
|
||||
@ -3401,7 +3632,7 @@ class VideoController extends Controller
|
||||
'id' => $u->id,
|
||||
'channel' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
]);
|
||||
@ -3465,7 +3696,7 @@ class VideoController extends Controller
|
||||
'id' => $u->id,
|
||||
'channel' => $u->username,
|
||||
'name' => $u->name,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : asset('images/default-avatar.svg'),
|
||||
'count' => (int) $u->cnt,
|
||||
'last_at' => $u->last_at,
|
||||
]);
|
||||
@ -3536,7 +3767,7 @@ class VideoController extends Controller
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : asset('images/default-avatar.svg'),
|
||||
],
|
||||
'total' => $records->count(),
|
||||
'records' => $records,
|
||||
@ -3640,7 +3871,7 @@ class VideoController extends Controller
|
||||
'channel' => $u->username,
|
||||
'avatar' => $u->avatar
|
||||
? route('media.avatar', $u->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3743,12 +3974,12 @@ class VideoController extends Controller
|
||||
'channel' => $user->username,
|
||||
'avatar' => $user->avatar
|
||||
? route('media.avatar', $user->avatar)
|
||||
: 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||
: asset('images/default-avatar.svg'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$identity['name'] = 'Guest (' . $key . ')';
|
||||
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
|
||||
$identity['avatar'] = asset('images/default-avatar.svg');
|
||||
}
|
||||
|
||||
// Country aggregation
|
||||
|
||||
98
app/Http/Controllers/VideoMergeController.php
Normal file
98
app/Http/Controllers/VideoMergeController.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,63 @@ 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 ?? [];
|
||||
|
||||
return [
|
||||
'blue' => [
|
||||
'name' => $this->participant1_name ?: null,
|
||||
'club' => $p['p1_club'] ?? null,
|
||||
'flag' => $this->flagFor($p['p1_country'] ?? null),
|
||||
],
|
||||
'red' => [
|
||||
'name' => $this->participant2_name ?: null,
|
||||
'club' => $p['p2_club'] ?? null,
|
||||
'flag' => $this->flagFor($p['p2_country'] ?? null),
|
||||
],
|
||||
'weight_category' => $p['weight_class'] ?? null,
|
||||
'championship' => $c['championship_name'] ?? ($this->event_name ?: null),
|
||||
'match_number' => $c['match_number'] ?? null,
|
||||
'court' => $c['court'] ?? null,
|
||||
'referee' => [
|
||||
'name' => $this->referee_name ?: null,
|
||||
'flag' => $this->flagFor($p['referee_country'] ?? null),
|
||||
],
|
||||
'venue' => [
|
||||
'name' => $this->venue_name ?: ($v['name'] ?? null),
|
||||
'map_link' => $v['map_link'] ?? null,
|
||||
],
|
||||
'event_logo' => $this->media['event_poster'] ?? null, // rel path or null
|
||||
];
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@ -168,7 +168,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
|
||||
|
||||
@ -67,7 +67,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
|
||||
@ -252,7 +254,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 +458,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)
|
||||
{
|
||||
|
||||
@ -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));
|
||||
|
||||
262
app/Services/VideoMergeService.php
Normal file
262
app/Services/VideoMergeService.php
Normal file
@ -0,0 +1,262 @@
|
||||
<?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 ─
|
||||
$srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3';
|
||||
$srcPrimaryFilename = "audio.{$srcPrimaryExt}";
|
||||
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
|
||||
|
||||
$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);
|
||||
$this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder);
|
||||
$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.
|
||||
foreach ($source->audioTracks as $track) {
|
||||
$basename = $this->nas->trackFolderName($source, $track);
|
||||
$oldFolder = "{$sourceDir}/tracks/{$basename}";
|
||||
$newFolder = "{$targetDir}/tracks/{$basename}";
|
||||
$this->moveFolder($oldFolder, $newFolder);
|
||||
|
||||
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
|
||||
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
|
||||
: $track->path;
|
||||
|
||||
$track->video_id = $target->id;
|
||||
$track->path = $newTrackPath;
|
||||
$track->save();
|
||||
}
|
||||
|
||||
// ── 3. Move ALL slides for the source video ────────────────────
|
||||
// One pass: re-parent every source slide, rewrite any filename
|
||||
// that started with sourceDir/... to targetDir/..., and if the
|
||||
// slide had NULL audio_track_id (belonged to source's primary),
|
||||
// point it at the newly-created primary track.
|
||||
$sourcePrefix = $sourceDir . '/';
|
||||
$targetPrefix = $targetDir . '/';
|
||||
$slides = DB::table('video_slides')->where('video_id', $source->id)->get();
|
||||
foreach ($slides as $s) {
|
||||
$newFilename = $s->filename;
|
||||
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) {
|
||||
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
|
||||
}
|
||||
$updates = [
|
||||
'video_id' => $target->id,
|
||||
'filename' => $newFilename,
|
||||
];
|
||||
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 ───────────
|
||||
$this->pruneEmptyFolder("{$sourceDir}/tracks");
|
||||
$this->pruneEmptyFolder($sourceDir);
|
||||
|
||||
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
6
public/images/default-avatar.svg
Normal file
6
public/images/default-avatar.svg
Normal file
@ -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 |
@ -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>
|
||||
|
||||
@ -157,6 +157,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;
|
||||
|
||||
@ -128,6 +128,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) }})"
|
||||
@ -250,6 +256,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') }}'">
|
||||
|
||||
@ -3,7 +3,15 @@
|
||||
@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,8 +28,18 @@ $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) : '') : '#';
|
||||
|
||||
// Size classes
|
||||
$sizeClasses = match($size) {
|
||||
@ -31,7 +49,7 @@ $sizeClasses = match($size) {
|
||||
@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')">
|
||||
@ -54,6 +72,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
|
||||
@ -74,7 +98,7 @@ $sizeClasses = match($size) {
|
||||
</a>
|
||||
<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
|
||||
|
||||
@ -24,8 +24,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')) {
|
||||
@ -707,8 +707,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) ───────
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -1230,6 +1230,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) -->
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -980,6 +980,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)';
|
||||
|
||||
175
resources/views/layouts/partials/merge-video-modal.blade.php
Normal file
175
resources/views/layouts/partials/merge-video-modal.blade.php
Normal file
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -1626,6 +1626,42 @@ function removeExtraTrackModal(n) {
|
||||
}
|
||||
}());
|
||||
|
||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
|
||||
async function _chunkedUploadVideo(file, onProgress) {
|
||||
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit
|
||||
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));
|
||||
|
||||
for (let i = 0; i < total; 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('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);
|
||||
}
|
||||
if (onProgress) onProgress({ loaded: end, total: file.size });
|
||||
}
|
||||
return { uploadId: uploadId, filename: file.name };
|
||||
}
|
||||
|
||||
// ── Form submission ───────────────────────────────────────────
|
||||
document.getElementById('upload-form-modal').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@ -1685,11 +1721,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 +1794,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) {
|
||||
|
||||
@ -330,7 +330,7 @@ if ($oldLinks) {
|
||||
@if($user->avatar)
|
||||
<img src="{{ route('media.avatar', $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -527,7 +527,7 @@ if ($oldLinks) {
|
||||
@if($user->avatar)
|
||||
<img src="{{ route('media.avatar', $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
<img src="{{ $user->avatar_url }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
@endif
|
||||
<button type="button" class="avatar-edit-btn" id="avatarEditBtn" title="Change photo">
|
||||
<i class="bi bi-camera-fill"></i>
|
||||
|
||||
@ -798,6 +798,42 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chunked upload helper (bypasses Cloudflare's 100 MB cap) ─
|
||||
async function _cChunkedUploadVideo(file, onProgress) {
|
||||
const CHUNK = 8 * 1024 * 1024;
|
||||
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));
|
||||
|
||||
for (let i = 0; i < total; 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('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);
|
||||
}
|
||||
if (onProgress) onProgress({ loaded: end, total: file.size });
|
||||
}
|
||||
return { uploadId: uploadId, filename: file.name };
|
||||
}
|
||||
|
||||
// ── Form submission ───────────────────────────────────────
|
||||
document.getElementById('upload-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@ -845,11 +881,34 @@
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
||||
document.getElementById('status-message').className = '';
|
||||
|
||||
// Chunk-upload the (potentially huge) video/audio file first, then
|
||||
// send the small form + upload_id. Keeps every request <100 MB.
|
||||
formData.delete('video');
|
||||
const _pbar = document.getElementById('progress-bar');
|
||||
const _ptext = document.getElementById('progress-text');
|
||||
_pbar.style.width = '0%';
|
||||
_ptext.textContent = 'Uploading... 0%';
|
||||
|
||||
_cChunkedUploadVideo(currentFile, function(p) {
|
||||
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) {
|
||||
showError(err && err.message ? err.message : 'Chunked upload failed');
|
||||
});
|
||||
|
||||
xhr.upload.addEventListener('progress', function(e) {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
document.getElementById('progress-bar').style.width = pct + '%';
|
||||
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
|
||||
if (e.lengthComputable && e.total > 0) {
|
||||
const pct = 96 + Math.round((e.loaded / e.total) * 3);
|
||||
_pbar.style.width = pct + '%';
|
||||
}
|
||||
});
|
||||
|
||||
@ -886,10 +945,6 @@
|
||||
|
||||
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
|
||||
xhr.addEventListener('timeout', () => showError('Upload timed out.'));
|
||||
|
||||
xhr.open('POST', '{{ route("videos.store") }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
|
||||
@ -34,6 +34,10 @@
|
||||
|
||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||
|
||||
/* Language chips */
|
||||
.yt-chip-sep { display:inline-block; width:1px; align-self:stretch; margin:6px 4px; background:var(--border-color); }
|
||||
.yt-chip-lang { display:inline-flex; align-items:center; gap:6px; }
|
||||
.yt-chip-lang .fi { width:18px; height:13px; border-radius:2px; display:inline-block; flex-shrink:0; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@ -57,6 +61,19 @@
|
||||
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
|
||||
<a href="{{ route('videos.index', ['filter' => 'latest']) }}"
|
||||
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
|
||||
|
||||
@if(!empty($langChips ?? []))
|
||||
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
|
||||
<span class="yt-chip-sep" aria-hidden="true"></span>
|
||||
@foreach($langChips as $chip)
|
||||
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
|
||||
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
|
||||
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
|
||||
<span class="fi fi-{{ $chip['flag'] }}"></span>
|
||||
<span>{{ $chip['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
|
||||
@ -62,6 +62,11 @@
|
||||
<button class="action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button class="action-btn" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i> <span>Merge</span>
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="action-btn">
|
||||
|
||||
@ -2014,62 +2014,10 @@
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
// ===== DUMMY DATA FALLBACKS =====
|
||||
$blueFighter =
|
||||
$blueFighter ??
|
||||
(object) [
|
||||
'name' => 'Sami Al Manea',
|
||||
'flag' => 'bh',
|
||||
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_5.png?v=1773157950',
|
||||
'team' => 'Emperor TaeKwonDo Academy',
|
||||
'country' => 'Bahrain',
|
||||
'logo' => 'https://takeone-dev.innovator.bh/storage/clubs/1/branding/logo_1771488666.png',
|
||||
'member_id' => 5,
|
||||
'club_url' => 'https://takeone-dev.innovator.bh/mobile/eta',
|
||||
];
|
||||
$redFighter =
|
||||
$redFighter ??
|
||||
(object) [
|
||||
'name' => 'Hassan Al Shallan',
|
||||
'flag' => 'bh',
|
||||
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_11.png?t=1773268056907',
|
||||
'team' => 'Legend TaeKwonDo Academy',
|
||||
'country' => 'Bahrain',
|
||||
'logo' => 'https://takeone-dev.innovator.bh/storage/clubs/logos/logo_1773267551.png',
|
||||
'member_id' => 11,
|
||||
'club_url' => 'https://takeone-dev.innovator.bh/clubs/legend-taekwondo-academy',
|
||||
];
|
||||
$match =
|
||||
$match ??
|
||||
(object) [
|
||||
'number' => '103',
|
||||
'court' => '2',
|
||||
'weight_category' => 'U18',
|
||||
'score_blue' => 7,
|
||||
'score_red' => 5,
|
||||
'features' =>
|
||||
'Electronic scoring system (PSS & head‑gear) • Video replay available for head kicks and gam-jeom appeals',
|
||||
];
|
||||
$referee =
|
||||
$referee ??
|
||||
(object) [
|
||||
'name' => 'Ghassan Yusuf',
|
||||
'flag' => 'bh',
|
||||
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_8.png?t=1773267305519',
|
||||
'member_id' => 4,
|
||||
];
|
||||
$championship =
|
||||
$championship ??
|
||||
(object) [
|
||||
'name' => 'Championship 2026',
|
||||
'url' => '/championship/2026',
|
||||
];
|
||||
$venue =
|
||||
$venue ??
|
||||
(object) [
|
||||
'name' => 'Manama Sports Hall',
|
||||
'map_link' => 'https://maps.google.com/?q=Manama+Sports+Hall+Bahrain',
|
||||
];
|
||||
// Real match data (fighters, referee, championship, match#/court, venue)
|
||||
// for this video. null when no SportsMatch record exists — the header then
|
||||
// degrades to just the title/views/date instead of showing demo content.
|
||||
$hd = $video->sportsMatch?->headerData();
|
||||
@endphp
|
||||
|
||||
<!-- Video Layout Container -->
|
||||
@ -2096,52 +2044,70 @@
|
||||
<div class="event-header">
|
||||
<div class="event-header-left">
|
||||
<div class="event-logo">
|
||||
<img src="https://picsum.photos/seed/event-logo/200/200" alt="Event logo">
|
||||
@if ($hd && $hd['event_logo'])
|
||||
<img src="{{ route('media.thumbnail', $hd['event_logo']) }}" alt="Event logo">
|
||||
@else
|
||||
<i class="bi bi-trophy" style="font-size: 26px; color: var(--text-secondary);"></i>
|
||||
@endif
|
||||
</div>
|
||||
<div class="event-text">
|
||||
<!-- Title with Trophy Icon BEFORE "Taekwondo Finals" -->
|
||||
@php
|
||||
$flagStyle = 'width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;';
|
||||
$hasBlue = $hd && $hd['blue']['name'];
|
||||
$hasRed = $hd && $hd['red']['name'];
|
||||
// Ordered inline meta pieces — only the ones that actually have data.
|
||||
$metaPieces = [];
|
||||
if ($hd) {
|
||||
if ($hd['championship']) $metaPieces[] = ['type' => 'champ', 'val' => $hd['championship']];
|
||||
if ($hd['match_number']) $metaPieces[] = ['type' => 'num', 'val' => $hd['match_number']];
|
||||
if ($hd['court']) $metaPieces[] = ['type' => 'court', 'val' => $hd['court']];
|
||||
if ($hd['referee']['name']) $metaPieces[] = ['type' => 'ref', 'val' => $hd['referee']];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<h1 class="title">
|
||||
<i class="bi bi-trophy trophy-icon" title="Match Type"></i>
|
||||
<span>{{ $video->title }} –</span>
|
||||
<span style="color: #2563eb; font-weight: bold">
|
||||
<span class="fi fi-{{ $blueFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>
|
||||
<a href="https://takeone-dev.innovator.bh/member/{{ $blueFighter->member_id ?? 5 }}"
|
||||
title="View {{ $blueFighter->name ?? 'Sami Al Manea' }}'s profile">
|
||||
{{ $blueFighter->name ?? 'Sami Al Manea' }}
|
||||
</a>
|
||||
</span>
|
||||
<span>vs</span>
|
||||
<span style="color: #ef4444; font-weight: bold">
|
||||
<span class="fi fi-{{ $redFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>
|
||||
<a href="https://takeone-dev.innovator.bh/member/{{ $redFighter->member_id ?? 11 }}"
|
||||
title="View {{ $redFighter->name ?? 'Hassan Al Shallan' }}'s profile">
|
||||
{{ $redFighter->name ?? 'Hassan Al Shallan' }}
|
||||
</a>
|
||||
</span>
|
||||
<span>({{ $match->weight_category ?? 'U18' }})</span>
|
||||
<i class="bi bi-trophy trophy-icon" title="Sports match"></i>
|
||||
<span>{{ $video->title }}@if ($hasBlue || $hasRed) –@endif</span>
|
||||
@if ($hasBlue)
|
||||
<span style="color: #2563eb; font-weight: bold">
|
||||
@if ($hd['blue']['flag'])<span class="fi fi-{{ $hd['blue']['flag'] }}" style="{{ $flagStyle }}"></span>@endif
|
||||
{{ $hd['blue']['name'] }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($hasBlue && $hasRed)<span>vs</span>@endif
|
||||
@if ($hasRed)
|
||||
<span style="color: #ef4444; font-weight: bold">
|
||||
@if ($hd['red']['flag'])<span class="fi fi-{{ $hd['red']['flag'] }}" style="{{ $flagStyle }}"></span>@endif
|
||||
{{ $hd['red']['name'] }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($hd && $hd['weight_category'])<span>({{ $hd['weight_category'] }})</span>@endif
|
||||
</h1>
|
||||
|
||||
<!-- Meta: Championship Link • Match# • Court • Referee (all inline) -->
|
||||
<a class="meta-link" href="{{ $championship->url ?? '/championship/2026' }}"
|
||||
style="white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; line-height: 1.4;">
|
||||
<span
|
||||
style="color: #ffffff; font-weight: bold; font-size: 0.85rem;">{{ $championship->name ?? 'Championship 2026' }}</span>
|
||||
<span style="color: var(--text-secondary);">•</span>
|
||||
<span style="color: #ff00dd; font-weight: bold; font-size: 0.85rem;">Match#
|
||||
{{ $match->number ?? '103' }}</span>
|
||||
<span style="color: var(--text-secondary);">•</span>
|
||||
<span style="color: #00af09; font-weight: bold; font-size: 0.85rem;">Court
|
||||
{{ $match->court ?? '2' }}</span>
|
||||
<span style="color: var(--text-secondary);">•</span>
|
||||
<span
|
||||
style="color: #ffd900; font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
|
||||
<span class="fi fi-{{ $referee->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span> {{ $referee->name ?? 'Ghassan Yusuf' }}
|
||||
{{-- Inline meta: championship • Match# • Court • referee • views/date.
|
||||
Only non-empty pieces render; views/date always show. --}}
|
||||
<div class="meta-link"
|
||||
style="display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; line-height: 1.4;">
|
||||
@foreach ($metaPieces as $piece)
|
||||
@if (!$loop->first)<span style="color: var(--text-secondary);">•</span>@endif
|
||||
@if ($piece['type'] === 'champ')
|
||||
<span style="color: #ffffff; font-weight: bold; font-size: 0.85rem;">{{ $piece['val'] }}</span>
|
||||
@elseif ($piece['type'] === 'num')
|
||||
<span style="color: #ff00dd; font-weight: bold; font-size: 0.85rem;">Match# {{ $piece['val'] }}</span>
|
||||
@elseif ($piece['type'] === 'court')
|
||||
<span style="color: #00af09; font-weight: bold; font-size: 0.85rem;">Court {{ $piece['val'] }}</span>
|
||||
@elseif ($piece['type'] === 'ref')
|
||||
<span style="color: #ffd900; font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
|
||||
@if ($piece['val']['flag'])<span class="fi fi-{{ $piece['val']['flag'] }}" style="{{ $flagStyle }}"></span>@endif
|
||||
{{ $piece['val']['name'] }}
|
||||
</span>
|
||||
@endif
|
||||
@endforeach
|
||||
@if (count($metaPieces))<span style="color: var(--text-secondary);">•</span>@endif
|
||||
<span style="color: var(--text-secondary); font-weight: bold; font-size: 0.85rem;">
|
||||
{{ number_format($video->view_count) }} views • {{ $video->created_at->format('M d, Y') }}
|
||||
</span>
|
||||
<span
|
||||
style="color: var(--text-secondary); font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
|
||||
- {{ number_format($video->view_count) }} views •
|
||||
{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -576,10 +576,14 @@
|
||||
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
||||
|
||||
// swap audio src (keeps browser autoplay permission)
|
||||
// Playlist rows may pin a specific language track — prefer
|
||||
// active_stream_url when present so we play that one instead
|
||||
// of the primary.
|
||||
var audio = document.getElementById('audioEl');
|
||||
var srcToPlay = d.active_stream_url || d.stream_url;
|
||||
if (audio) {
|
||||
var _savedVol = audio.volume; var _savedMuted = audio.muted;
|
||||
audio.src = d.stream_url; audio.load();
|
||||
audio.src = srcToPlay; audio.load();
|
||||
audio.volume = _savedVol; audio.muted = _savedMuted;
|
||||
}
|
||||
|
||||
@ -587,6 +591,23 @@
|
||||
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
|
||||
catch(e) { console.warn('_audioPlayerUpdate', e); }
|
||||
|
||||
// After the popup is rebuilt, mark the pinned language
|
||||
// option as active so the flag/lyrics/share reflect it.
|
||||
if (d.active_track_id) {
|
||||
try {
|
||||
var pop = document.getElementById('ytpLangPopup');
|
||||
var opt = pop && pop.querySelector('.ytp-lang-option[data-lang-id="' + d.active_track_id + '"]');
|
||||
if (opt) {
|
||||
pop.querySelectorAll('.ytp-lang-option').forEach(function(o){ o.classList.remove('active'); });
|
||||
opt.classList.add('active');
|
||||
window._ytpTrackId = parseInt(d.active_track_id, 10) || 0;
|
||||
var lb = document.getElementById('ytpLangBtn') || document.querySelector('.ytp-lang-btn');
|
||||
var flag = opt.dataset.langFlag;
|
||||
if (lb && flag) lb.innerHTML = '<span class="fi fi-' + flag + '" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>';
|
||||
}
|
||||
} catch(e) { console.warn('active_track_id apply', e); }
|
||||
}
|
||||
|
||||
// update state
|
||||
PL_CURRENT = d.id;
|
||||
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
|
||||
@ -699,6 +720,23 @@
|
||||
if(a&&plLoop==='one') a.loop=true;
|
||||
plRender();
|
||||
plHighlight(PL_CURRENT, false);
|
||||
|
||||
// Honour ?track=ID on initial load — same effect as the
|
||||
// pin logic in plTransitionTo, but applied to the server-
|
||||
// rendered primary track on first paint.
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var initTrack = parseInt(params.get('track') || '0', 10);
|
||||
if (initTrack > 0) {
|
||||
var applyPin = function() {
|
||||
var pop = document.getElementById('ytpLangPopup');
|
||||
var opt = pop && pop.querySelector('.ytp-lang-option[data-lang-id="' + initTrack + '"]');
|
||||
if (opt) { opt.click(); return true; }
|
||||
return false;
|
||||
};
|
||||
if (!applyPin()) setTimeout(applyPin, 300);
|
||||
}
|
||||
} catch(e) { console.warn('initial track pin', e); }
|
||||
}
|
||||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit();
|
||||
})();
|
||||
@ -707,9 +745,14 @@
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||
@php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp
|
||||
@php
|
||||
$pinnedTrackId = $playlistVideo->pivot->audio_track_id ?? null;
|
||||
$trackQs = $pinnedTrackId ? '&track=' . $pinnedTrackId : '';
|
||||
@endphp
|
||||
<div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}"
|
||||
data-pl-id="{{ $playlistVideo->id }}"
|
||||
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}')"
|
||||
data-pl-track-id="{{ $pinnedTrackId ?? '' }}"
|
||||
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}{{ $trackQs }}')"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
|
||||
@ -70,12 +70,18 @@ Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->n
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
|
||||
Route::post('/videos/upload-chunk', [VideoController::class, 'uploadChunk'])->name('videos.uploadChunk');
|
||||
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
||||
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
||||
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
|
||||
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
||||
Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete');
|
||||
|
||||
// Music-video merge — fold {source} into {target}. Owner-only.
|
||||
Route::get('/videos/{target}/merge/candidates', [\App\Http\Controllers\VideoMergeController::class, 'candidates'])->name('videos.merge.candidates');
|
||||
Route::get('/videos/{target}/merge/{source}/preview',[\App\Http\Controllers\VideoMergeController::class, 'preview'])->name('videos.merge.preview');
|
||||
Route::post('/videos/{target}/merge/{source}', [\App\Http\Controllers\VideoMergeController::class, 'merge'])->name('videos.merge');
|
||||
|
||||
// Like/unlike routes
|
||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user