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
@@ -14,6 +14,7 @@
|
||||
'id' => 0,
|
||||
'language' => $primaryLang,
|
||||
'label' => $video->language ? strtoupper($video->language) : 'Default',
|
||||
'name' => $video->language ? ($allLangData[$primaryLang]['name'] ?? strtoupper($video->language)) : 'Default',
|
||||
'flag' => $primaryFlag,
|
||||
'stream_url' => $audioUrl,
|
||||
'title' => $video->title,
|
||||
@@ -23,6 +24,7 @@
|
||||
'id' => $t->id,
|
||||
'language' => $t->language,
|
||||
'label' => $t->label,
|
||||
'name' => $allLangData[$t->language]['name'] ?? strtoupper($t->language),
|
||||
'flag' => $allLangData[$t->language]['flag'] ?? null,
|
||||
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
|
||||
'title' => $t->title ?? '',
|
||||
@@ -123,12 +125,13 @@
|
||||
data-lang-id="{{ $track['id'] }}"
|
||||
data-lang-url="{{ $track['stream_url'] }}"
|
||||
data-lang-label="{{ $track['label'] }}"
|
||||
data-lang-name="{{ $track['name'] }}"
|
||||
data-lang-flag="{{ $track['flag'] ?? '' }}"
|
||||
data-lang-title="{{ $track['title'] ?? '' }}"
|
||||
data-lang-description="{{ $track['description'] ?? '' }}"
|
||||
data-lang-dl-url="{{ $track['dl_url'] }}">
|
||||
<span class="fi fi-{{ $track['flag'] ?: 'xx' }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;"></span>
|
||||
<span class="ytp-lang-opt-label">{{ $track['label'] }}</span>
|
||||
<span class="ytp-lang-opt-label">{{ $track['name'] }}</span>
|
||||
<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
</div>
|
||||
@endforeach
|
||||
@@ -450,6 +453,7 @@ const barsBtn = document.getElementById('ytpBarsBtn');
|
||||
const animCanvas = document.getElementById('audioAnimCanvas');
|
||||
|
||||
const NEXT_URL = @json($nextUrl ?? null);
|
||||
window._LANG_NAMES = window._LANG_NAMES || @json(collect(\App\Data\Languages::all())->map(fn($l) => $l['name']));
|
||||
let hideTimer = null;
|
||||
let isDragging = false;
|
||||
let userSeeking = false;
|
||||
@@ -586,6 +590,10 @@ const langBtn = document.getElementById('ytpLangBtn');
|
||||
const langPopup = document.getElementById('ytpLangPopup');
|
||||
const langOpts = document.querySelectorAll('.ytp-lang-option');
|
||||
|
||||
// Which version is currently playing (0 = primary). Download + Share read this so they
|
||||
// act on exactly the version the viewer chose.
|
||||
window._ytpTrackId = 0;
|
||||
|
||||
if (langBtn && langPopup) {
|
||||
langBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
@@ -603,6 +611,7 @@ if (langBtn && langPopup) {
|
||||
const url = opt.dataset.langUrl;
|
||||
const flag = opt.dataset.langFlag;
|
||||
if (!url) return;
|
||||
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
|
||||
const relPos = audio.duration ? audio.currentTime / audio.duration : 0;
|
||||
const wasPlaying = !audio.paused;
|
||||
const _vol = audio.volume, _muted = audio.muted;
|
||||
@@ -646,83 +655,79 @@ if (langBtn && langPopup) {
|
||||
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
|
||||
});
|
||||
});
|
||||
|
||||
// Shared / deep links: ?track={id} opens directly on that version so the recipient gets
|
||||
// the same language end-to-end (audio + title + flag + About description). Deferred to
|
||||
// DOMContentLoaded because this player partial is included BEFORE the title/description
|
||||
// elements appear in the page — they must exist before the switch handler can update them.
|
||||
const _ytpAutoSelectTrack = function () {
|
||||
const want = new URLSearchParams(window.location.search).get('track');
|
||||
if (!want || want === '0') return;
|
||||
const opt = [...langOpts].find(o => o.dataset.langId === String(want));
|
||||
if (opt && !opt.classList.contains('active')) opt.click();
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _ytpAutoSelectTrack);
|
||||
} else {
|
||||
_ytpAutoSelectTrack();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Description box updater ──────────────────────────────────
|
||||
function _rteToHtml(s) {
|
||||
s = (s || '').trim();
|
||||
if (!s) return '';
|
||||
if (/<[a-z][\s\S]*>/i.test(s)) return s; // already sanitized HTML (server-side)
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML.replace(/\n/g, '<br>'); // legacy plain text
|
||||
}
|
||||
function _updateDescriptionBox(text) {
|
||||
text = (text || '').trim();
|
||||
const SHORT_LEN = 200;
|
||||
const needsExpand = text.length > SHORT_LEN;
|
||||
const short = needsExpand ? text.slice(0, SHORT_LEN) + '…' : text;
|
||||
|
||||
const html = _rteToHtml(text);
|
||||
const aboutPanel = document.getElementById('vdb-about');
|
||||
let shortEl = document.getElementById('vdbDescShort');
|
||||
const fullEl = document.getElementById('vdbDescFull');
|
||||
|
||||
// If the description elements don't exist yet, inject them into vdb-about
|
||||
if (!shortEl) {
|
||||
const aboutPanel = document.getElementById('vdb-about');
|
||||
if (aboutPanel) {
|
||||
// Remove existing "No description" placeholder if present
|
||||
const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
|
||||
if (ph) ph.style.display = 'none';
|
||||
|
||||
shortEl = document.createElement('div');
|
||||
shortEl.id = 'vdbDescShort';
|
||||
shortEl.className = 'vdb-desc-text';
|
||||
aboutPanel.appendChild(shortEl);
|
||||
}
|
||||
if (!shortEl && aboutPanel) {
|
||||
const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
|
||||
if (ph) ph.style.display = 'none';
|
||||
shortEl = document.createElement('div');
|
||||
shortEl.id = 'vdbDescShort';
|
||||
shortEl.className = 'vdb-desc-text vdb-clamp';
|
||||
aboutPanel.appendChild(shortEl);
|
||||
}
|
||||
|
||||
if (!shortEl) return;
|
||||
|
||||
if (!text) {
|
||||
// Drop the legacy two-element model if a previous build left it behind.
|
||||
const legacyFull = document.getElementById('vdbDescFull');
|
||||
if (legacyFull) legacyFull.remove();
|
||||
|
||||
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
|
||||
|
||||
if (!html) {
|
||||
shortEl.style.display = 'none';
|
||||
if (fullEl) fullEl.style.display = 'none';
|
||||
const moreBtn2 = shortEl.nextElementSibling;
|
||||
if (moreBtn2 && moreBtn2.classList.contains('vdb-show-more')) moreBtn2.style.display = 'none';
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Always reset to the collapsed view on a language switch
|
||||
shortEl.textContent = needsExpand ? short : text;
|
||||
shortEl.style.display = '';
|
||||
shortEl.classList.add('vdb-clamp');
|
||||
shortEl.classList.remove('vdb-expanded');
|
||||
shortEl.innerHTML = html;
|
||||
|
||||
// Ensure the full-text element exists when expansion is needed
|
||||
let full = fullEl;
|
||||
if (needsExpand && !full) {
|
||||
full = document.createElement('div');
|
||||
full.id = 'vdbDescFull';
|
||||
full.className = 'vdb-desc-text';
|
||||
full.style.display = 'none';
|
||||
shortEl.insertAdjacentElement('afterend', full);
|
||||
}
|
||||
if (full) {
|
||||
full.textContent = text;
|
||||
full.style.display = 'none';
|
||||
}
|
||||
|
||||
// Locate (or create) the show-more button — it is NOT necessarily shortEl's
|
||||
// direct sibling because #vdbDescFull sits between them, so search the panel.
|
||||
const aboutPanel = document.getElementById('vdb-about');
|
||||
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
|
||||
if (needsExpand) {
|
||||
if (!moreBtn) {
|
||||
moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'vdb-show-more';
|
||||
moreBtn.onclick = function() {
|
||||
const f = document.getElementById('vdbDescFull');
|
||||
const collapsed = !f || f.style.display === 'none';
|
||||
if (f) f.style.display = collapsed ? 'block' : 'none';
|
||||
shortEl.style.display = collapsed ? 'none' : '';
|
||||
moreBtn.textContent = collapsed ? 'Show less' : 'Show more';
|
||||
};
|
||||
(full || shortEl).insertAdjacentElement('afterend', moreBtn);
|
||||
}
|
||||
moreBtn.textContent = 'Show more';
|
||||
moreBtn.style.display = '';
|
||||
} else if (moreBtn) {
|
||||
moreBtn.style.display = 'none';
|
||||
if (!moreBtn) {
|
||||
moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'vdb-show-more';
|
||||
moreBtn.onclick = function () { if (window.toggleVdbDesc) toggleVdbDesc(moreBtn); };
|
||||
shortEl.insertAdjacentElement('afterend', moreBtn);
|
||||
}
|
||||
moreBtn.textContent = 'Show more';
|
||||
// Reveal "Show more" only when the content overflows the clamp. Compare the
|
||||
// natural content height to the clamp's pixel limit (130px, see .vdb-clamp) —
|
||||
// clientHeight is unreliable right after innerHTML swap / when re-laying out.
|
||||
const _btn = moreBtn, _el = shortEl;
|
||||
requestAnimationFrame(function () {
|
||||
_btn.style.display = (_el.scrollHeight > 138) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fullscreen ───────────────────────────────────────────────
|
||||
@@ -1063,6 +1068,10 @@ window._audioPlayerUpdate = function(d) {
|
||||
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
|
||||
return '<span class="fi fi-' + (flag || 'xx') + '" style="' + s + '"></span>';
|
||||
}
|
||||
function langName(code) {
|
||||
if (!code) return 'Default';
|
||||
return (window._LANG_NAMES && window._LANG_NAMES[String(code).toLowerCase()]) || String(code).toUpperCase();
|
||||
}
|
||||
|
||||
// Build combined track list: primary + deduped extras
|
||||
var primaryLangNew = d.language || null;
|
||||
@@ -1116,12 +1125,13 @@ window._audioPlayerUpdate = function(d) {
|
||||
+ ' data-lang-id="' + t.id + '"'
|
||||
+ ' data-lang-url="' + t.stream_url + '"'
|
||||
+ ' data-lang-label="' + t.label + '"'
|
||||
+ ' data-lang-name="' + langName(t.language) + '"'
|
||||
+ ' data-lang-flag="' + (t.flag || '') + '"'
|
||||
+ ' data-lang-title="' + safeTitle + '"'
|
||||
+ ' data-lang-description="' + safeDesc + '"'
|
||||
+ ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">'
|
||||
+ flagHtml(t.flag, 'sm')
|
||||
+ '<span class="ytp-lang-opt-label">' + t.label + '</span>'
|
||||
+ '<span class="ytp-lang-opt-label">' + langName(t.language) + '</span>'
|
||||
+ '<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>'
|
||||
+ '</div>';
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user