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