Audio songs: one-folder storage, version-aware download/share, GPU-checked renders

Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-23 14:03:43 +03:00
1 parent 66fd78c10f
commit a4384113c2
24 files changed
+1738 -258

No files matched your search

@@ -188,9 +188,7 @@
<div class="um-field">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description</label>
<textarea id="lt-track1-desc-modal" class="um-input um-textarea"
rows="3" style="font-size:13px;padding:9px 12px;"
placeholder="Tell viewers about this content…"></textarea>
<x-rich-text-editor name="" id="lt-track1-desc-modal" placeholder="Tell viewers about this content…" />
</div>
{{-- Video file + Thumbnail side by side (video/match mode) --}}
@@ -716,7 +714,7 @@ function resetUploadForm() {
const t1Desc = document.getElementById('lt-track1-desc-modal');
const t1Fname = document.getElementById('lt-track1-fname');
if (t1Title) t1Title.value = '';
if (t1Desc) t1Desc.value = '';
if (t1Desc) { t1Desc.value = ''; t1Desc.dispatchEvent(new Event('rte:refresh')); }
if (t1Fname) t1Fname.textContent = 'Click to choose file…';
const audioBoxR = document.getElementById('um-tf-t1-audio-box');
if (audioBoxR) audioBoxR.style.borderColor = '';
@@ -933,7 +931,7 @@ function removeVideoModal(e) {
const t1Title = document.getElementById('lt-track1-title-modal');
const t1Desc = document.getElementById('lt-track1-desc-modal');
if (t1Title) t1Title.value = '';
if (t1Desc) t1Desc.value = '';
if (t1Desc) { t1Desc.value = ''; t1Desc.dispatchEvent(new Event('rte:refresh')); }
if (_currentMode === 'music') {
_applyMode('generic');
_gsSetDefault('type', 'generic', 'bi-film', 'Generic');
@@ -1018,13 +1016,40 @@ function slidesZoneDrop(e, tid) {
}
function handleSlidesForTrack(tid, fileList) {
if (!fileList || !fileList.length) return;
if (!_slidesData[tid]) _slidesData[tid] = [];
for (const f of Array.from(fileList)) {
if (_slidesData[tid].length >= 10) break;
_slidesData[tid].push(f);
_slidesCropStart(fileList, tid);
}
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _slidesCropQueue = [];
let _slidesCropTid = null;
function _slidesCropStart(fileList, tid) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_slidesCropTid = tid;
_slidesCropQueue = imgs;
_slidesCropLoadNext();
}
function _slidesCropLoadNext() {
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
const f = _slidesCropQueue.shift();
if (typeof window.tcPreload_slides_upload === 'function') {
window.tcPreload_slides_upload(f);
window.openCropperModal_slides_upload();
}
renderSlidesStrip(tid);
}
function uploadSlidesCropDone(file) {
const tid = _slidesCropTid;
if (tid && file) {
if (!_slidesData[tid]) _slidesData[tid] = [];
if (_slidesData[tid].length < 10) {
_slidesData[tid].push(file);
renderSlidesStrip(tid);
}
}
_slidesCropLoadNext();
}
function renderSlidesStrip(tid) {
@@ -1214,9 +1239,10 @@ function addExtraTrackModal() {
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Description <span class="um-lbl-hint">optional</span>
</label>
<textarea name="extra_track_descriptions[]" class="um-input um-textarea"
rows="3" style="font-size:13px;padding:9px 12px;"
placeholder="Description in this language…"></textarea>
<div class="rte-wrap" data-min-height="100px">
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
placeholder="Description in this language…"></textarea>
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
@@ -1425,3 +1451,13 @@ _applyMode('generic');
output-width="1280"
title="Crop Thumbnail"
/>
<x-image-cropper
id="slides_upload"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="uploadSlidesCropDone"
/>