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

@@ -269,7 +269,7 @@ function openEditVideoModal(videoId) {
const titleEl = document.getElementById('edit-track1-title');
const descEl = document.getElementById('edit-track1-desc');
if (titleEl) titleEl.value = v.title || '';
if (descEl) descEl.value = v.description || '';
if (descEl) { descEl.value = v.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
_editSetLangSelect(v.language || '');
// Thumbnail
@@ -620,16 +620,50 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
function editHandleSlides(fileList, tid) {
tid = tid || 't1';
if (!fileList || !fileList.length) return;
if (!_editSlidesData[tid]) _editSlidesData[tid] = [];
for (const f of Array.from(fileList)) {
if (_editSlidesData[tid].length >= 10) break;
const reader = new FileReader();
reader.onload = ev => { _editSlidesData[tid].push({ file: f, url: ev.target.result }); _editRenderSlides(tid); };
reader.readAsDataURL(f);
_editSlidesCropStart(fileList, tid, _editRenderSlides);
}
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _editSlidesCropQueue = [];
let _editSlidesCropTid = null;
let _editSlidesCropRender = null;
function _editSlidesCropStart(fileList, tid, renderFn) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_editSlidesCropTid = tid;
_editSlidesCropRender = renderFn;
_editSlidesCropQueue = imgs;
_editSlidesCropLoadNext();
}
function _editSlidesCropLoadNext() {
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
const f = _editSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit === 'function') {
window.tcPreload_slides_edit(f);
window.openCropperModal_slides_edit();
}
}
function editSlidesCropDone(file) {
const tid = _editSlidesCropTid;
if (tid && file) {
if (!_editSlidesData[tid]) _editSlidesData[tid] = [];
if (_editSlidesData[tid].length < 10) {
const reader = new FileReader();
reader.onload = ev => {
_editSlidesData[tid].push({ file, url: ev.target.result });
if (_editSlidesCropRender) _editSlidesCropRender(tid);
_editSlidesCropLoadNext();
};
reader.readAsDataURL(file);
return;
}
}
_editSlidesCropLoadNext();
}
function editClearSlides(e, tid) {
tid = tid || 't1';
if (e) { e.preventDefault(); e.stopPropagation(); }
@@ -693,14 +727,7 @@ function editSlidesZoneDragleaveE(tid) { document.getElementById('edit-slides-
function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getElementById('edit-slides-zone-'+tid).classList.remove('dragover'); if (e.dataTransfer.files.length) editHandleSlidesForTrack(tid, e.dataTransfer.files); }
function editHandleSlidesForTrack(tid, fileList) {
if (!fileList || !fileList.length) return;
if (!_editSlidesData[tid]) _editSlidesData[tid] = [];
for (const f of Array.from(fileList)) {
if (_editSlidesData[tid].length >= 10) break;
const reader = new FileReader();
reader.onload = ev => { _editSlidesData[tid].push({ file: f, url: ev.target.result }); _editRenderSlidesForTrack(tid); };
reader.readAsDataURL(f);
}
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
}
function editClearSlidesForTrack(e, tid) {
@@ -868,7 +895,7 @@ function _editAddExistingTrack(track) {
const titleEl = document.getElementById('edit-' + pfx + '-title');
if (titleEl) titleEl.value = track.title || '';
const descEl = document.getElementById('edit-' + pfx + '-desc');
if (descEl) descEl.value = track.description || '';
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
if (track.language && window.CSD) {
const hi = document.getElementById('csd_v_' + pfx);
const wrap = document.getElementById('csd_' + pfx);
@@ -1030,3 +1057,13 @@ _editApplyMode('generic');
output-width="1280"
title="Crop Thumbnail"
/>
<x-image-cropper
id="slides_edit"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="editSlidesCropDone"
/>