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:
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"
|
||||
/>
|
||||
@@ -74,6 +74,11 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
|
||||
var csrfToken = _getLatestCsrf();
|
||||
var shareUrl = videoUrl;
|
||||
|
||||
// Preserve the version selector (?track=) from the requested URL — the server's tracked
|
||||
// share link replaces shareUrl below, so we re-attach it afterwards.
|
||||
var trackParam = '';
|
||||
try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {}
|
||||
|
||||
// Obtain a unique tracked share link from the server
|
||||
if (recordUrl) {
|
||||
try {
|
||||
@@ -92,6 +97,11 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) {
|
||||
} catch (e) { /* fallback to plain URL */ }
|
||||
}
|
||||
|
||||
// Re-attach the version selector so the recipient opens the right language.
|
||||
if (trackParam) {
|
||||
shareUrl += (shareUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + encodeURIComponent(trackParam);
|
||||
}
|
||||
|
||||
// Mobile: use native share sheet with the unique link
|
||||
if (window.innerWidth <= 768 && navigator.share) {
|
||||
navigator.share({ title: videoTitle, url: shareUrl }).catch(function() {});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
Reference in new issue
Block a user