Merge match-video: sports match sheet, in-player point capture, HEVC transcode fix
This commit is contained in:
commit
057ce87564
@ -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,
|
||||
|
||||
@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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,71 +34,87 @@ 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);
|
||||
$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
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
@ -110,26 +122,26 @@ class CompressVideoJob implements ShouldQueue
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1362,10 +1362,18 @@
|
||||
<i class="bi bi-fire"></i>
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
||||
@auth
|
||||
<a href="#" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"
|
||||
onclick="event.preventDefault(); openUploadChooser();">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-bottom-nav-item">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
@endauth
|
||||
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>History</span>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@ -555,12 +621,28 @@
|
||||
function token() { return form.querySelector('[name="_token"]').value; }
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
window.openSportsMatchModal = function (matchId) {
|
||||
// - openSportsMatchModal(matchId) → edit existing match
|
||||
// - openSportsMatchModal({videoId, videoTitle}) → create against an existing video
|
||||
// - openSportsMatchModal() → fresh create (upload a new video)
|
||||
window.openSportsMatchModal = function (arg) {
|
||||
resetForm();
|
||||
if (matchId) loadMatch(matchId);
|
||||
if (typeof arg === 'number' || typeof arg === 'string') {
|
||||
loadMatch(arg);
|
||||
} else if (arg && typeof arg === 'object' && arg.videoId) {
|
||||
attachExistingVideo(arg.videoId, arg.videoTitle || '');
|
||||
}
|
||||
getModal().show();
|
||||
};
|
||||
|
||||
function attachExistingVideo(videoId, videoTitle) {
|
||||
videoIdInp.value = videoId;
|
||||
document.getElementById('sm-video-create').classList.add('d-none');
|
||||
document.getElementById('sm-video-attached').classList.remove('d-none');
|
||||
document.getElementById('sm-video-attached-title').textContent = videoTitle || ('Video #' + videoId);
|
||||
// Advanced fields are also useful when the video already exists.
|
||||
document.getElementById('sm-advanced').classList.remove('d-none');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
idInput.value = '';
|
||||
|
||||
@ -711,6 +711,55 @@
|
||||
}
|
||||
.utc-card:hover .utc-card-arr { opacity: 1; transform: translateX(0); }
|
||||
|
||||
/* ── Mobile: render the chooser as a bottom sheet ───────────── */
|
||||
@media (max-width: 768px) {
|
||||
.utc-overlay {
|
||||
align-items: flex-end; justify-content: stretch;
|
||||
padding: 0; background: rgba(0,0,0,.55);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.utc-box {
|
||||
max-width: none; width: 100%;
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-left: none; border-right: none; border-bottom: none;
|
||||
padding: 10px 18px calc(22px + env(safe-area-inset-bottom));
|
||||
transform: translateY(100%); transition: transform .28s cubic-bezier(.22,1,.36,1);
|
||||
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
|
||||
max-height: 88vh; overflow-y: auto;
|
||||
}
|
||||
.utc-overlay.show .utc-box { transform: translateY(0); }
|
||||
|
||||
/* Grabber handle at the top */
|
||||
.utc-box::before {
|
||||
content: ''; display: block; width: 40px; height: 4px;
|
||||
background: #333; border-radius: 2px; margin: 4px auto 14px;
|
||||
}
|
||||
|
||||
.utc-close { top: 12px; right: 12px; }
|
||||
.utc-head { margin-bottom: 16px; }
|
||||
.utc-head-icon { width: 44px; height: 44px; font-size: 20px; margin-bottom: 10px; }
|
||||
.utc-title { font-size: 17px; }
|
||||
.utc-sub { font-size: 12px; }
|
||||
|
||||
/* Row of compact tiles instead of full stacked cards */
|
||||
.utc-grid { grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.utc-card {
|
||||
padding: 16px 8px 14px; border-radius: 14px; border-width: 1px;
|
||||
}
|
||||
.utc-card-ico {
|
||||
width: 44px; height: 44px; margin-bottom: 8px;
|
||||
font-size: 20px; border-radius: 12px;
|
||||
}
|
||||
.utc-card-title { font-size: 13px; }
|
||||
.utc-card-desc { display: none; }
|
||||
.utc-card-arr { display: none; }
|
||||
.utc-card:active {
|
||||
transform: scale(.96);
|
||||
background: color-mix(in srgb, var(--accent) 10%, #111);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Unified control system — one consistent look for every field,
|
||||
dropdown and picker in the upload modal. Scoped to #uploadModal
|
||||
@ -829,19 +878,28 @@ const _UTC_META = {
|
||||
};
|
||||
|
||||
function openUploadChooser() {
|
||||
// Mobile keeps the existing full-page create flow (with its own type picker)
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = '{{ route("videos.create") }}';
|
||||
return;
|
||||
}
|
||||
// Chooser is shown on every viewport — on mobile chooseUploadType() routes
|
||||
// straight to the standalone create page with the picked ?type= preserved.
|
||||
document.getElementById('upload-type-chooser').classList.add('show');
|
||||
}
|
||||
|
||||
// Called from the desktop chooser cards on mobile — carries the picked type
|
||||
// through to the standalone create page via ?type=.
|
||||
function chooseUploadTypeMobile(type) {
|
||||
const t = ['generic', 'music', 'match'].includes(type) ? type : 'generic';
|
||||
window.location.href = '{{ route("videos.create") }}?type=' + encodeURIComponent(t);
|
||||
}
|
||||
|
||||
function closeUploadChooser() {
|
||||
document.getElementById('upload-type-chooser').classList.remove('show');
|
||||
}
|
||||
|
||||
function chooseUploadType(type) {
|
||||
// Mobile has no modal — route straight to the create page with the type preserved.
|
||||
if (window.innerWidth < 992) {
|
||||
chooseUploadTypeMobile(type);
|
||||
return;
|
||||
}
|
||||
const meta = _UTC_META[type] || _UTC_META.generic;
|
||||
closeUploadChooser();
|
||||
openUploadModal();
|
||||
|
||||
@ -784,6 +784,17 @@
|
||||
setAudioMode(this.dataset.type === 'music');
|
||||
});
|
||||
});
|
||||
|
||||
// Honor ?type=generic|music|match forwarded from the desktop chooser
|
||||
// when routed here on mobile — pre-select the corresponding option.
|
||||
(function preselectTypeFromQuery() {
|
||||
try {
|
||||
const t = new URLSearchParams(window.location.search).get('type');
|
||||
if (!t) return;
|
||||
const opt = document.querySelector('#type-options .option-item[data-type="' + t + '"]');
|
||||
if (opt) opt.click();
|
||||
} catch (_) {}
|
||||
}());
|
||||
document.querySelectorAll('#visibility-options .option-item').forEach(item => {
|
||||
item.querySelector('input').addEventListener('change', () => {
|
||||
document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active'));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user