Compare commits

...

2 Commits

Author SHA1 Message Date
ghassan
e0aa6e4817 Drag-and-drop reordering for tracks in the edit modal
Replaces the up/down arrow buttons on each track card with a grip
handle. Dragging a card shows a red insertion indicator on the target
card's top or bottom edge and drops it into the closest slot on
release, including gaps between cards or past the last card.

The card is only marked draggable while the grip handle is being held,
so text selection and button clicks inside the card body behave
normally. Existing promote_track_id logic (secondary at position 1
becomes primary on save) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-02 02:17:24 +03:00
ghassan
3d8d7b8efe Allow converting a video to a music track without losing views/URL
VideoController@update() now handles a replacement media file: if the
new file is audio the row is forced to type=music and, when the on-disk
type folder differs from the declared type (e.g. a video sitting under
users/{u}/videos/ that was later marked music), the media is relocated
into the canonical users/{u}/music/{slug}/tracks/{lang}-{id}/audio.ext
layout. Old primary file and stale meta.json are deleted from NAS; HLS
and slideshow caches are reset. The same relocation is applied by
replaceFile() so the mobile "Replace Media File" flow behaves the same.

Slide- and audio-track-management gates in update() also switched from
isAudioOnlyFile() (extension-based) to the declared type, so secondary
tracks and cover slides added mid-conversion are no longer silently
dropped. The edit modal now renders existing secondary tracks whenever
type=music regardless of the primary file extension.

Also carries an unrelated app.blade.php tweak: the mobile filter bar's
sticky reset moved from .yt-filter-bar to its wrapper, with padding
kept on the inner bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-01 14:39:19 +03:00
3 changed files with 304 additions and 32 deletions

View File

@ -1206,9 +1206,170 @@ class VideoController extends Controller
unset($data['visibility']);
}
// ── Optional main media file replacement + type conversion ──────────
// When the edit form ships a new `video` file, replace the primary
// media. If the new file is audio, force type=music. When the type
// folder changes (generic → music, etc.), relocate the file on NAS
// into the canonical per-type layout.
if ($request->hasFile('video')) {
$request->validate([
'video' => 'file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
]);
$newFile = $request->file('video');
$mimeType = $newFile->getMimeType();
$isAudio = str_starts_with((string) $mimeType, 'audio/');
$newSize = $newFile->getSize();
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
$nas = app(\App\Services\NasSyncService::class);
// Desired type — form value wins, but audio always forces music.
$desiredType = $data['type'] ?? $video->type ?? 'generic';
if ($isAudio) $desiredType = 'music';
$data['type'] = $desiredType;
$oldPath = (string) $video->path;
$oldVideoDirNas = str_starts_with($oldPath, 'users/')
? implode('/', array_slice(explode('/', $oldPath), 0, 4))
: null;
// Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…".
// We must compare against this (not the DB type) because a prior type
// edit can leave a video with type=music but files still under videos/.
$oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null;
// Mutate in-memory so typeFolder / resolveVideoDir react to the new type
$video->type = $desiredType;
// For music, the primary track folder is {lang}-{id} — pick up any
// language the same form is about to save so the folder is right.
if (array_key_exists('language', $data)) {
$video->language = $data['language'];
}
$needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video);
// Clear old HLS (bytes + DB flags reset below)
if ($video->has_hls && $video->hls_path) {
\Storage::deleteDirectory($video->hls_path);
}
$localHlsAbs = storage_path('app/public/hls/' . $video->id);
if (is_dir($localHlsAbs)) @exec('rm -rf ' . escapeshellarg($localHlsAbs));
// Stash the uploaded file to a temp location
$tempFilename = \Str::uuid() . '.' . $newExt;
$newFile->storeAs('public/tmp', $tempFilename);
$tempAbs = storage_path('app/public/tmp/' . $tempFilename);
if (! file_exists($tempAbs)) {
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
}
// ── ffprobe ─────────────────────────────────────────────────────
$width = $height = null;
$orientation = 'landscape';
$duration = 0;
try {
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
$out = [];
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbs), $out);
$duration = (int) round((float) ($out[0] ?? 0));
if (! $isAudio) {
$ffprobe = \FFMpeg\FFProbe::create();
$stream = $ffprobe->streams($tempAbs)->videos()->first();
if ($stream) {
$width = $stream->get('width');
$height = $stream->get('height');
if ($width && $height) {
if ($height > $width) $orientation = 'portrait';
elseif ($width > $height) $orientation = 'landscape';
else $orientation = 'square';
}
}
}
} catch (\Throwable $e) {
\Log::warning('update: FFprobe on replacement file failed: ' . $e->getMessage());
}
// Prep video for uploadDirectToNas — filename drives ext detection,
// clearing path when relocating forces a fresh dir under the new type.
$video->filename = 'placeholder.' . $newExt;
if ($needsRelocate) $video->path = null;
$nasReplaceSucceeded = false;
if ($nas->isEnabled()) {
try {
$nas->uploadDirectToNas($video, $tempAbs, null);
$video->refresh();
// Re-apply the in-memory type mutation lost by refresh()
$video->type = $desiredType;
$nasReplaceSucceeded = true;
} catch (\Throwable $e) {
\Log::error('update: NAS media replace failed: ' . $e->getMessage());
// Fall through — file stays in /tmp; DB state below still lands
$video->update([
'path' => 'public/tmp/' . $tempFilename,
'filename' => $tempFilename,
]);
}
} else {
$video->update([
'path' => 'public/tmp/' . $tempFilename,
'filename' => $tempFilename,
]);
}
// Cleanup: delete OLD primary file + meta.json from NAS (only when
// it's on NAS AND the new path is different). Leave the old thumbnail
// in place so it keeps serving until the user uploads a new one.
if ($nasReplaceSucceeded && $oldPath && str_starts_with($oldPath, 'users/') && $oldPath !== $video->path) {
try { $nas->deleteFile($oldPath); } catch (\Throwable $e) {}
if ($needsRelocate && $oldVideoDirNas) {
$newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4));
if ($oldVideoDirNas !== $newVideoDirNas) {
// Old meta.json must go so resolveVideoDir doesn't get
// confused by two folders claiming the same video id.
try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {}
}
}
}
// Wipe local cache from the OLD dir (regenerable content only)
if ($oldVideoDirNas) {
$oldLocalCache = storage_path('app/' . $oldVideoDirNas . '/cache');
if (is_dir($oldLocalCache)) @exec('rm -rf ' . escapeshellarg($oldLocalCache));
}
// Bake metadata into $data so the trailing $video->update($data) applies it
$data['size'] = $newSize;
$data['mime_type'] = $mimeType;
$data['has_hls'] = false;
$data['hls_path'] = null;
$data['status'] = 'ready';
$data['slideshow_video_path'] = null;
if (! $isAudio) {
$data['duration'] = $duration ?: $video->duration;
$data['width'] = $width ?: $video->width;
$data['height'] = $height ?: $video->height;
$data['orientation'] = $orientation;
$data['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
} else {
$data['duration'] = $duration ?: $video->duration;
$data['width'] = null;
$data['height'] = null;
$data['orientation'] = 'landscape';
$data['is_shorts'] = false;
}
if (! $isAudio) {
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
->onQueue('video-processing')
->onConnection('database');
}
}
// Handle slide reorder / removal / additions for audio tracks
// Gate on declared type, not the file extension — a video row can be
// type=music while its primary file is still an mp4 (mid-conversion).
$slidesChanged = false;
if ($this->isAudioOnlyFile($video)) {
if (($data['type'] ?? $video->type) === 'music') {
// slides_order is a JSON array of kept slide IDs in their new order
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
@ -1295,7 +1456,8 @@ class VideoController extends Controller
}
// ── Audio track management (delete + add) ────────────────────────────
if ($this->isAudioOnlyFile($video)) {
// Gate on declared type, not extension (see slide gate above).
if (($data['type'] ?? $video->type) === 'music') {
$nas = app(\App\Services\NasSyncService::class);
// Delete tracks requested for removal
@ -1588,6 +1750,19 @@ class VideoController extends Controller
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
$nas = app(\App\Services\NasSyncService::class);
// When the replacement is audio, the video must become type=music so the
// NAS layout / player pipeline agree with the file. Force it and relocate.
$desiredType = $isAudio ? 'music' : $video->type;
$oldPath = (string) $video->path;
$oldVideoDirNas = str_starts_with($oldPath, 'users/')
? implode('/', array_slice(explode('/', $oldPath), 0, 4))
: null;
// Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…".
// Compare against this (not DB type) so a stale type edit can still relocate.
$oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null;
$video->type = $desiredType;
$needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video);
// ── 1. Clear old HLS ─────────────────────────────────────────────────
if ($video->has_hls && $video->hls_path) {
\Storage::deleteDirectory($video->hls_path);
@ -1657,13 +1832,24 @@ class VideoController extends Controller
if ($nas->isEnabled()) {
// Point filename at the new file (uploadDirectToNas uses this for ext)
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
$update = ['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize, 'type' => $desiredType];
if ($needsRelocate) $update['path'] = null; // force resolveVideoDir to pick a fresh type-appropriate dir
$video->update($update);
try {
// Pass null for thumb — we don't want to overwrite the existing thumbnail
$nas->uploadDirectToNas($video, $tempAbsPath, null);
$video->refresh();
$nasReplaceSucceeded = true;
// Clean up the old NAS folder's meta.json if we relocated (so
// resolveVideoDir isn't confused by two folders claiming this id).
if ($needsRelocate && $oldVideoDirNas) {
$newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4));
if ($oldVideoDirNas !== $newVideoDirNas) {
try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {}
}
}
} catch (\Throwable $e) {
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
// Temp file still exists — fall through to organizeLocalFiles below

View File

@ -1198,12 +1198,15 @@
}
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
.yt-main.video-view-page { padding: 0 !important; }
.yt-filter-bar {
.yt-filter-bar-wrap {
position: relative !important;
top: auto !important;
z-index: auto !important;
margin: -16px -16px 16px !important;
}
.yt-filter-bar {
padding: 12px 16px !important;
}
}
</style>

View File

@ -108,6 +108,7 @@
<div class="um-track-card" id="edit-tc-t1-card">
<div class="um-tc-body">
<div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">1</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
<div class="um-tc-info">
@ -116,12 +117,6 @@
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('t1-card','up')" title="Move up" style="display:none;">
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('t1-card','down')" title="Move down" style="display:none;">
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
<i class="bi bi-pencil"></i> <span>Edit</span>
</button>
@ -203,6 +198,24 @@
<style>
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
#editVideoModal.show .modal-dialog { opacity: 1; }
/* ── Track drag-and-drop reorder ─────────────────────────────── */
#edit-tc-list .um-track-card { position: relative; }
#edit-tc-list .um-tc-drag {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; margin-right: 2px;
color: #666; cursor: grab; user-select: none;
border-radius: 6px; transition: color .15s, background .15s;
flex-shrink: 0;
}
#edit-tc-list .um-tc-drag:hover { color: #e5e5e5; background: rgba(255,255,255,.05); }
#edit-tc-list .um-tc-drag:active { cursor: grabbing; }
#edit-tc-list .um-tc-drag i { font-size: 14px; line-height: 1; }
#edit-tc-list .um-track-card.dragging {
opacity: .4; outline: 2px dashed #e61e1e; outline-offset: -2px;
}
#edit-tc-list .um-track-card.drop-before { box-shadow: 0 -3px 0 0 #e61e1e inset; }
#edit-tc-list .um-track-card.drop-after { box-shadow: 0 3px 0 0 #e61e1e inset; }
</style>
<script>
@ -288,8 +301,9 @@ function openEditVideoModal(videoId) {
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
_editRenderSlides('t1');
// Extra tracks
if (v.is_audio && v.audio_tracks) {
// Extra tracks — show whenever the row is declared music, even if the
// primary file is still a video (mid-conversion state).
if ((v.type === 'music' || v.is_audio) && v.audio_tracks) {
v.audio_tracks.forEach(track => _editAddExistingTrack(track));
}
@ -446,33 +460,16 @@ function _editFmtSize(bytes) {
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
}
// ── Track ordering ────────────────────────────────────────────────────────────
function editMoveTrack(cardSuffix, dir) {
const card = document.getElementById('edit-tc-' + cardSuffix);
if (!card) return;
const list = document.getElementById('edit-tc-list');
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const idx = cards.indexOf(card);
console.log('%c[EditTrack] Reorder:', 'color:#3b82f6', { card: card.id, trackId: card.dataset.trackId || 'primary', direction: dir, fromPos: idx + 1 });
if (dir === 'up' && idx > 0) list.insertBefore(card, cards[idx - 1]);
if (dir === 'down' && idx < cards.length - 1) list.insertBefore(cards[idx + 1], card);
_editUpdateTrackPositions();
}
// ── Track ordering (drag-and-drop) ────────────────────────────────────────────
function _editUpdateTrackPositions() {
const list = document.getElementById('edit-tc-list');
if (!list) return;
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const total = cards.length;
cards.forEach((card, i) => {
const numEl = card.querySelector('.um-tc-num');
if (numEl) numEl.textContent = i + 1;
const badge = card.querySelector('.um-tc-primary');
if (badge) badge.style.display = i === 0 ? '' : 'none';
const up = card.querySelector('.edit-tc-arrow-up');
const down = card.querySelector('.edit-tc-arrow-down');
if (up) up.style.display = (i === 0) ? 'none' : '';
if (down) down.style.display = (i === total - 1) ? 'none' : '';
});
// Set promote_track_id: non-empty only when a secondary is at position 1
const first = cards[0];
@ -485,6 +482,93 @@ function _editUpdateTrackPositions() {
}
}
// HTML5 drag-and-drop: bind once on the shared parent so newly appended cards
// pick it up without extra wiring. The card starts non-draggable so text
// selection / button clicks work; grabbing the grip handle flips draggable=on
// for the duration of the drag.
(function () {
const list = document.getElementById('edit-tc-list');
if (!list || list.dataset.dndWired) return;
list.dataset.dndWired = '1';
// Reset any draggable="true" set by the initial HTML — we only enable it
// on demand via the grip handle.
list.querySelectorAll('.um-track-card').forEach(c => c.removeAttribute('draggable'));
let dragged = null;
list.addEventListener('mousedown', function (e) {
const handle = e.target.closest('.um-tc-drag');
if (!handle) return;
const card = handle.closest('.um-track-card');
if (!card || card.parentElement !== list) return;
card.setAttribute('draggable', 'true');
});
list.addEventListener('mouseup', function () {
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
});
list.addEventListener('dragstart', function (e) {
const card = e.target.closest('.um-track-card');
if (!card || card.parentElement !== list || card.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
dragged = card;
card.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', card.id); } catch (_) {}
});
list.addEventListener('dragend', function () {
if (dragged) dragged.classList.remove('dragging');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
dragged = null;
});
// Always accept drops anywhere inside the list so the browser doesn't
// veto the drop when the cursor slips into gaps between cards.
list.addEventListener('dragover', function (e) {
if (!dragged) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
const over = e.target.closest('.um-track-card');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
if (!over || over === dragged || over.parentElement !== list) return;
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
over.classList.add(after ? 'drop-after' : 'drop-before');
});
list.addEventListener('drop', function (e) {
if (!dragged) return;
e.preventDefault();
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const over = e.target.closest('.um-track-card');
let refNode;
if (over && over !== dragged && over.parentElement === list) {
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
refNode = after ? over.nextSibling : over;
} else {
// Cursor fell into a gap or outside a card — figure out the closest slot
// by comparing cursor Y against each card's midpoint.
let insertBefore = null;
for (const c of cards) {
if (c === dragged) continue;
const r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { insertBefore = c; break; }
}
refNode = insertBefore; // null → append to end
}
if (refNode === dragged) refNode = dragged.nextSibling;
list.insertBefore(dragged, refNode);
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
console.log('%c[EditTrack] Drag-reordered:', 'color:#3b82f6', { moved: dragged.id, refNode: refNode?.id || '(end)' });
_editUpdateTrackPositions();
});
})();
function _editSetLangCsd(wrap, code) {
if (!wrap || !code) return;
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
@ -863,6 +947,7 @@ function _editTrackCard(n, trackId, isExisting) {
card.innerHTML = `
<div class="um-tc-body">
<div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">${n + 1}</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
<div class="um-tc-info">
@ -871,8 +956,6 @@ function _editTrackCard(n, trackId, isExisting) {
</div>
</div>
<div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('e${n}','up')" title="Move up" style="display:none;"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('e${n}','down')" title="Move down" style="display:none;"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
${deleteBtn}
</div>