diff --git a/app/Http/Controllers/MatchEventController.php b/app/Http/Controllers/MatchEventController.php index 0471a27..c2e7c33 100644 --- a/app/Http/Controllers/MatchEventController.php +++ b/app/Http/Controllers/MatchEventController.php @@ -99,31 +99,10 @@ class MatchEventController extends Controller return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); } - // Get ALL previous points in this round (ordered by timestamp) - $previousPoints = MatchPoint::where('match_round_id', $request->round_id) - ->where('timestamp_seconds', '<', $request->timestamp_seconds) - ->orderBy('timestamp_seconds', 'asc') - ->pluck('points', 'competitor') - ->toArray(); - - // Calculate cumulative scores by summing each point value - $scoreBlue = 0; - $scoreRed = 0; - - if (isset($previousPoints['blue'])) { - $scoreBlue += $previousPoints['blue']; - } - if (isset($previousPoints['red'])) { - $scoreRed += $previousPoints['red']; - } - - // Add current point - if ($request->competitor === 'blue') { - $scoreBlue += $request->points; - } else { - $scoreRed += $request->points; - } - + // Create the row first with placeholder scores, then recompute the whole + // round's running scores. This is the only correct approach when two + // points can share a timestamp (Blue + Red at the same moment) or when + // a point is inserted between existing ones. $point = MatchPoint::create([ 'video_id' => $video->id, 'match_round_id' => $request->round_id, @@ -132,17 +111,42 @@ class MatchEventController extends Controller 'points' => $request->points, 'competitor' => $request->competitor, 'notes' => $request->notes, - 'score_blue' => $scoreBlue, - 'score_red' => $scoreRed, + 'score_blue' => 0, + 'score_red' => 0, ]); + $this->recomputeRoundScores($request->round_id); + return response()->json([ 'success' => true, - 'point' => $point, + 'point' => $point->fresh(), 'message' => 'Point added successfully!', ]); } + /** + * Recompute cumulative score_blue/score_red for every point in a round, + * ordered by (timestamp_seconds, id). Handles same-timestamp ties by + * insertion order, so Blue and Red saved at the same moment both end up + * reflecting the running score after that moment. + */ + private function recomputeRoundScores(int $roundId): void + { + $blue = 0; + $red = 0; + MatchPoint::where('match_round_id', $roundId) + ->orderBy('timestamp_seconds', 'asc') + ->orderBy('id', 'asc') + ->get() + ->each(function (MatchPoint $p) use (&$blue, &$red) { + if ($p->competitor === 'blue') $blue += (int) $p->points; + else $red += (int) $p->points; + if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) { + $p->update(['score_blue' => $blue, 'score_red' => $red]); + } + }); + } + public function updatePoint(Request $request, MatchPoint $point) { $request->validate([ @@ -158,6 +162,7 @@ class MatchEventController extends Controller return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); } + $roundId = $point->match_round_id; $point->update([ 'timestamp_seconds' => $request->timestamp_seconds, 'action' => $request->action, @@ -166,9 +171,11 @@ class MatchEventController extends Controller 'notes' => $request->notes, ]); + $this->recomputeRoundScores($roundId); + return response()->json([ 'success' => true, - 'point' => $point, + 'point' => $point->fresh(), 'message' => 'Point updated successfully!', ]); } @@ -180,7 +187,9 @@ class MatchEventController extends Controller return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); } + $roundId = $point->match_round_id; $point->delete(); + $this->recomputeRoundScores($roundId); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 378a829..725fc96 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -479,8 +479,29 @@ class VideoController extends Controller } if ($nasUploadSucceeded) { - // For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local) + // For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local). + // GenerateHlsJob no-ops unless status === 'ready', and on the NAS branch the + // upload IS the "done" state (there's no local CompressVideoJob to flip it). if (! $isAudioUpload) { + $duration = 0; + try { + $ffprobeBin = config('ffmpeg.ffprobe.binaries', '/usr/bin/ffprobe'); + $localCopy = app(\App\Services\NasSyncService::class)->ensureLocalCopy($video->fresh()); + if ($localCopy && file_exists($localCopy)) { + $out = []; + exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($localCopy), $out); + $duration = (int) round((float) ($out[0] ?? 0)); + } + } catch (\Throwable $e) { + \Log::warning('store: ffprobe duration failed: ' . $e->getMessage()); + } + + $video->update([ + 'status' => 'ready', + 'duration' => $duration ?: $video->duration, + 'is_shorts' => ($duration ?: $video->duration) <= 60 && $orientation === 'portrait', + ]); + \App\Jobs\GenerateHlsJob::dispatch($video->fresh()) ->onQueue('video-processing') ->onConnection('database'); @@ -831,7 +852,7 @@ class VideoController extends Controller 'key' => $video->getRouteKey(), 'type' => $video->type, 'has_hls' => (bool) $video->has_hls, - 'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null, + 'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'playlist.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 @@ -1112,6 +1133,9 @@ class VideoController extends Controller 'slides' => $slides, 'language' => $video->language, 'audio_tracks' => $audioTracks, + 'sports_match_id'=> $video->type === 'match' + ? \App\Models\SportsMatch::where('video_id', $video->id)->value('id') + : null, ], ]); } diff --git a/app/Jobs/CompressVideoJob.php b/app/Jobs/CompressVideoJob.php index f7c7d5a..da61dd0 100644 --- a/app/Jobs/CompressVideoJob.php +++ b/app/Jobs/CompressVideoJob.php @@ -4,8 +4,6 @@ namespace App\Jobs; use App\Models\Setting; use App\Models\Video; -use FFMpeg\FFMpeg; -use FFMpeg\Format\Video\X264; use Illuminate\Support\Facades\Config; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -13,7 +11,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; class CompressVideoJob implements ShouldQueue { @@ -30,7 +27,6 @@ class CompressVideoJob implements ShouldQueue { $video = $this->video; - // Get original file path $originalPath = storage_path('app/' . $video->path); if (!file_exists($originalPath)) { @@ -38,98 +34,114 @@ class CompressVideoJob implements ShouldQueue return; } - // Create compressed file alongside the original $compressedFilename = 'compressed_' . $video->filename; $compressedPath = dirname($originalPath) . '/' . $compressedFilename; try { $ffmpegConfig = Config::get('ffmpeg'); - $ffmpeg = FFMpeg::create([ - 'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg', - 'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe', - 'timeout' => $ffmpegConfig['timeout'] ?? 3600, - ]); - $ffmpegVideo = $ffmpeg->open($originalPath); + $ffmpegBin = $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg'; + $ffprobeBin = $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe'; + + // Detect the source video codec. Browsers on Chrome/Firefox/Android can't + // decode HEVC (H.265/hev1/hvc1) — those sources must be replaced with H.264 + // even if the transcode is larger, so downloads and MP4 fallback stay playable. + $srcCodec = ''; + try { + $probeOut = []; + exec(escapeshellcmd($ffprobeBin) . ' -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 ' . escapeshellarg($originalPath), $probeOut); + $srcCodec = strtolower(trim($probeOut[0] ?? '')); + } catch (\Throwable $e) {} + $forceReplace = in_array($srcCodec, ['hevc', 'h265', 'hev1', 'hvc1'], true); - // Verify the GPU is actually reachable and able to encode before sending - // the file to it; otherwise fall back to CPU so the job never hangs. $gpuEnabled = Setting::gpuUsable(); - $encoder = Setting::gpuEncoder(); - $preset = Setting::gpuPreset(); + $encoder = Setting::gpuEncoder(); // h264_nvenc / libx264 + $preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264 $device = Setting::gpuDevice(); + $hwaccel = Setting::gpuHwaccel(); - if ($gpuEnabled) { - $videoPasses = [ - "-c:v {$encoder}", - "-preset {$preset}", - '-rc vbr', - '-cq 23', - '-profile:v high', - '-pix_fmt yuv420p', - "-gpu {$device}", - ]; + // Build ffmpeg command directly — php-ffmpeg's X264 format validator + // only accepts 'libx264' and throws on 'h264_nvenc'. + $cmd = [escapeshellcmd($ffmpegBin), '-y']; + + if ($gpuEnabled && $hwaccel !== 'none') { + $cmd[] = "-hwaccel {$hwaccel}"; + $cmd[] = "-hwaccel_device {$device}"; + } + + $cmd[] = '-i ' . escapeshellarg($originalPath); + $cmd[] = "-c:v {$encoder}"; + + if ($gpuEnabled && str_contains($encoder, 'nvenc')) { + $cmd[] = "-preset {$preset}"; + $cmd[] = '-rc vbr'; + $cmd[] = '-cq 23'; + $cmd[] = "-gpu {$device}"; } else { - $videoPasses = [ - '-c:v libx264', - '-preset fast', - '-crf 23', - '-profile:v high', - '-pix_fmt yuv420p', - ]; + $cmd[] = "-preset {$preset}"; + $cmd[] = '-crf 23'; } - $audioPasses = ['-c:a aac', '-b:a 192k']; - $format = new X264('aac', $encoder); - foreach ($videoPasses as $pass) { - $format->addLegacyOption($pass); - } - foreach ($audioPasses as $pass) { - $format->addLegacyOption($pass); - } - $ffmpegVideo->save($format, $compressedPath); + $cmd[] = '-profile:v high'; + $cmd[] = '-pix_fmt yuv420p'; + $cmd[] = '-c:a aac'; + $cmd[] = '-b:a 192k'; + $cmd[] = '-movflags +faststart'; + $cmd[] = escapeshellarg($compressedPath); + + $fullCmd = implode(' ', $cmd) . ' 2>&1'; + + Log::info('CompressVideoJob: Starting', [ + 'video_id' => $video->id, + 'src_codec' => $srcCodec, + 'encoder' => $encoder, + 'gpu' => $gpuEnabled, + ]); + + $output = []; + exec($fullCmd, $output, $exitCode); + + if ($exitCode !== 0) { + $tail = implode("\n", array_slice($output, -30)); + throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}"); + } - // Check if compressed file was created and is smaller if (file_exists($compressedPath)) { - $originalSize = filesize($originalPath); + $originalSize = filesize($originalPath); $compressedSize = filesize($compressedPath); - // Only use compressed file if it's smaller - if ($compressedSize < $originalSize) { - // Delete original and rename compressed + if ($compressedSize < $originalSize || $forceReplace) { unlink($originalPath); rename($compressedPath, $originalPath); - // Update video record $video->update([ - 'size' => $compressedSize, - 'filename' => $video->filename, // Keep same filename + 'size' => $compressedSize, 'mime_type' => 'video/mp4', ]); - Log::info('CompressVideoJob: Video compressed', [ - 'video_id' => $video->id, - 'original_size' => $originalSize, + Log::info('CompressVideoJob: Video compressed', [ + 'video_id' => $video->id, + 'original_size' => $originalSize, 'compressed_size' => $compressedSize, - 'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%', - 'encoder' => $encoder, - 'gpu' => $gpuEnabled, + 'saved' => round(($originalSize - $compressedSize) / max(1, $originalSize) * 100) . '%', + 'encoder' => $encoder, + 'gpu' => $gpuEnabled, + 'src_codec' => $srcCodec, + 'forced' => $forceReplace && $compressedSize >= $originalSize, ]); } else { - // Compressed file is larger, delete it unlink($compressedPath); - Log::info('CompressVideoJob: Compression made file larger, keeping original'); + Log::info('CompressVideoJob: Compression made file larger, keeping original', ['src_codec' => $srcCodec]); } } $video->update(['status' => 'ready']); - // Chain to HLS generation for GPU-accelerated adaptive playback \App\Jobs\GenerateHlsJob::dispatch($video); } catch (\Exception $e) { Log::error('CompressVideoJob failed: ' . $e->getMessage()); - $video->update(['status' => 'ready']); // Mark as ready anyway + if (file_exists($compressedPath)) @unlink($compressedPath); + $video->update(['status' => 'ready']); } } } - diff --git a/resources/views/components/video-player.blade.php b/resources/views/components/video-player.blade.php index fcf1b59..f87e8e2 100644 --- a/resources/views/components/video-player.blade.php +++ b/resources/views/components/video-player.blade.php @@ -9,7 +9,7 @@ @php // Force 16:9 for every video — orientation classes intentionally disabled $orientationClass = ''; - $hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null; + $hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'playlist.m3u8']) : null; $mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp; $nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null; $prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 9e363f9..36575f4 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1362,10 +1362,18 @@ Trending - - - Upload - + @auth + + + Upload + + @else + + + Upload + + @endauth History diff --git a/resources/views/layouts/partials/edit-video-modal.blade.php b/resources/views/layouts/partials/edit-video-modal.blade.php index 8b2f456..7c93933 100644 --- a/resources/views/layouts/partials/edit-video-modal.blade.php +++ b/resources/views/layouts/partials/edit-video-modal.blade.php @@ -233,12 +233,8 @@ window._editCurrentVideoId = null; // ── Modal open / close ──────────────────────────────────────────────────────── function openEditVideoModal(videoId) { - if (window.innerWidth < 992) { - window.location.href = `/videos/${videoId}/edit`; - return; - } - window._editCurrentVideoId = videoId; - + // Always resolve type first so a match video opens the sports-match sheet + // instead of the generic video-edit modal — same for mobile. fetch(`/videos/${videoId}/edit`, { headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', @@ -251,6 +247,25 @@ function openEditVideoModal(videoId) { if (!data.success) { showToast('Failed to load video data', 'error'); return; } const v = data.video; + // Match videos always route to the sports-match sheet — edit the existing + // match if one is linked, otherwise open create-mode with the video already + // attached so the user can add the match details. + if (v.type === 'match' && typeof window.openSportsMatchModal === 'function') { + if (v.sports_match_id) { + window.openSportsMatchModal(v.sports_match_id); + } else { + window.openSportsMatchModal({ videoId: v.id, videoTitle: v.title }); + } + return; + } + + // Mobile fallback for non-match videos: full-page edit view + if (window.innerWidth < 992) { + window.location.href = `/videos/${videoId}/edit`; + return; + } + window._editCurrentVideoId = videoId; + document.getElementById('edit-form').action = `/videos/${videoId}`; // Header diff --git a/resources/views/layouts/partials/sports-match-modal.blade.php b/resources/views/layouts/partials/sports-match-modal.blade.php index 321666a..369b239 100644 --- a/resources/views/layouts/partials/sports-match-modal.blade.php +++ b/resources/views/layouts/partials/sports-match-modal.blade.php @@ -524,6 +524,72 @@ .sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; } .sm-img-avatar .sm-img-ph i { font-size: 16px; } .sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; } + +/* ── Mobile: render as a bottom sheet ─────────────────────────── */ +@media (max-width: 768px) { + #sportsMatchModal .modal-dialog { + margin: 0; + max-width: 100%; + width: 100%; + min-height: 100%; + display: flex; + align-items: flex-end; + } + #sportsMatchModal .sm-content { + border-radius: 20px 20px 0 0; + border-left: none; + border-right: none; + border-bottom: none; + width: 100%; + max-height: 92vh; + display: flex; + flex-direction: column; + box-shadow: 0 -12px 40px rgba(0,0,0,.6); + transform: translateY(100%); + transition: transform .3s cubic-bezier(.22,1,.36,1); + } + #sportsMatchModal.show .sm-content { transform: translateY(0); } + + /* Grabber handle at the top */ + #sportsMatchModal .sm-header { + position: relative; + padding-top: 18px; + } + #sportsMatchModal .sm-header::before { + content: ''; + position: absolute; + top: 6px; left: 50%; + transform: translateX(-50%); + width: 40px; height: 4px; + background: #333; border-radius: 2px; + } + + #sportsMatchModal .modal-body { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + flex: 1 1 auto; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + } + + #sportsMatchModal .sm-footer { + padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); + gap: 6px; + flex-wrap: wrap; + } + #sportsMatchModal .sm-footer .btn { + flex: 1 1 auto; + font-size: 13px; + padding: 10px 12px; + } + + /* Slide-up animation on the modal fade class as well */ + #sportsMatchModal.fade .modal-dialog { transform: none; } +} + +@media (max-width: 480px) { + #sportsMatchModal .sm-header h5 { font-size: 15px; } + #sportsMatchModal .sm-header-icon { width: 34px; height: 34px; font-size: 16px; } +}