Compare commits

..

5 Commits

Author SHA1 Message Date
ghassan
847020fd02 Offline-tolerant music-video merge
The merge no longer refuses when NAS is unreachable. DB changes always
run; per-side file operations are best-effort. When the NAS rename can't
happen it's recorded in a new pending_nas_moves table, and nas:auto-sync
drains the queue (rename + best-effort parent prune) once NAS is back,
retaining attempts/last_error on failure. Also plugs missing isEnabled()
guards on renameNasPath and deleteFolder so the merge cleanup path can't
hang on smbclient timeouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-31 03:27:52 +03:00
ghassan
fcb752dd76 Language filter chips on the videos feed
Adds one chip per language currently present in public content —
counting both primary language and any secondary audio track, sorted
by video count, capped at 12. Clicking a chip lists every video whose
primary is that language or that has a matching secondary track; cards
surfaced only via a secondary track link straight to /videos/{key}?track=<id>
so the pinned playback wiring loads the right audio, get a small flag
badge on the thumbnail, and their hover-preview streams the track file
instead of the primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-31 03:27:42 +03:00
ghassan
8d27520bdb Video merge feature, default avatar, video-redirects table, misc UI tweaks
Bundled pre-existing changes on the branch:
- Video merge: controller, service, admin merge modal, and a repair
  console command; new video_redirects table (with source_folder)
  so merged/moved videos keep resolving under their old keys.
- Default avatar SVG for users without a profile picture.
- Assorted view tweaks across profile, video actions/comments/insights,
  admin layout, video-details, and the app layout.
- Small touch-ups in SuperAdminController, User model, NasSyncService.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-31 02:42:43 +03:00
ghassan
ba8ab11ce4 Chunked video upload, persistent track order, per-track playlist entries
Chunked uploads: bypass Cloudflare's 100 MB request cap by slicing the
video/audio into 8 MB chunks and reassembling server-side before the
normal store flow runs.

Track reorder: audio tracks gain a `position` column so a user's edit-
modal ordering survives reload. `Video::audioTracks()` now orders by
position, `promote_track_id` remembers the swapped row, and freshly
uploaded tracks append to the end.

Playlist per-track pinning: `playlist_videos.audio_track_id` remembers
which language the user pinned when adding a song. Same song with a
different track becomes a separate row (Spotify-style duplicates).
Player-data exposes `active_stream_url` + `active_track_id`, sidebar
cards render `?track=` from the pivot, and both `plTransitionTo` and
initial-load code activate the corresponding language option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-31 02:42:25 +03:00
ghassan
af276f2cd1 Wire match header to real SportsMatch data; add Match#/Court + country flags
The match page header rendered hard-coded demo content (Sami Al Manea vs Hassan
Al Shallan, Match# 103, referee Ghassan Yusuf, external takeone-dev links) on
EVERY match video, ignoring the SportsMatch record the create form saves.

- Video::sportsMatch() hasOne relationship.
- SportsMatch::headerData() maps the record into the header's shape (fighters +
  country flags, championship, weight class, referee, venue, match#/court),
  null-safe, with a country-name/ISO2 -> flag-icons resolver.
- match.blade header now renders from headerData(): real names/flags, no
  external links, and degrades gracefully to just title/views/date when a video
  has no match data (instead of the fake card).
- sports-match-modal: add Match # and Court fields (stored in the existing
  competition JSON, no migration) and convert fighter/referee country inputs to
  the mandated <x-country-select> so header flags resolve from ISO2.
- Tracker updated for the new country-select usages.

Verified: empty state (no SportsMatch) shows only title/views/date; a populated
record renders fighters, flags (bh/us), championship, Match# 42, Court 3, U21,
referee; modal compiles with the new fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 01:57:07 +03:00
38 changed files with 1685 additions and 172 deletions

View File

@ -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 |
---

View File

@ -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'])

View 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;
}
}

View File

@ -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(),
];
});

View File

@ -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,

View File

@ -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

View 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);
}
}

View File

@ -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()
{

View File

@ -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);

View File

@ -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

View File

@ -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)
{

View File

@ -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));

View 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);
}
}
}

View File

@ -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');
}
};

View File

@ -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');
});
}
};

View File

@ -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');
});
}
};

View File

@ -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');
});
}
};

View File

@ -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');
}
};

View 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

View File

@ -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>

View File

@ -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;

View File

@ -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') }}'">

View File

@ -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

View File

@ -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) ───────

View File

@ -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"`

View File

@ -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) -->

View File

@ -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,

View File

@ -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)';

View 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
})();
</script>

View File

@ -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>

View File

@ -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 095% 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) {

View File

@ -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>

View File

@ -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) {

View File

@ -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

View File

@ -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">

View File

@ -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 & headgear) • 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>

View File

@ -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)

View File

@ -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');