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) <noreply@anthropic.com>
104 lines
5.7 KiB
PHP
104 lines
5.7 KiB
PHP
{{--
|
|
Tabbed description + insights box.
|
|
Expects: $video (Video model)
|
|
Optionally accepts: $descriptionSlot — raw HTML to show in the About tab body
|
|
--}}
|
|
@php
|
|
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
|
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
|
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
|
|
$showBox = $hasDesc || $isVideoOwner;
|
|
@endphp
|
|
@if ($showBox)
|
|
<style>
|
|
/* ── Description box ─────────────────────────────────── */
|
|
.vdb-wrap { background:var(--bg-secondary); border-radius:12px; margin-top:12px; overflow:hidden; border:1px solid var(--border-color); }
|
|
.vdb-tabs { display:flex; border-bottom:1px solid var(--border-color); padding:0 4px; }
|
|
.vdb-tab { background:none; border:none; color:var(--text-secondary); font-size:13px; font-weight:600; padding:0 16px; height:44px; cursor:pointer; position:relative; transition:color .15s; white-space:nowrap; display:flex; align-items:center; gap:6px; }
|
|
.vdb-tab:hover { color:var(--text-primary); }
|
|
.vdb-tab.active { color:var(--text-primary); }
|
|
.vdb-tab.active::after { content:''; position:absolute; bottom:0; left:0; right:0; height:2px; background:#ef4444; border-radius:2px 2px 0 0; }
|
|
.vdb-panel { display:none; padding:14px 16px 16px; }
|
|
.vdb-panel.active { display:block; }
|
|
.vdb-meta { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:10px; display:flex; gap:10px; flex-wrap:wrap; }
|
|
.vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
|
|
.vdb-desc-text p { margin:0 0 8px; }
|
|
.vdb-desc-text p:last-child { margin-bottom:0; }
|
|
.vdb-desc-text h2 { font-size:19px; font-weight:700; margin:6px 0 8px; }
|
|
.vdb-desc-text h3 { font-size:16px; font-weight:700; margin:6px 0 6px; }
|
|
.vdb-desc-text ul, .vdb-desc-text ol { margin:0 0 8px; padding-left:22px; }
|
|
.vdb-desc-text blockquote { margin:0 0 8px; padding-left:12px; border-left:3px solid var(--border-color); color:var(--text-secondary); }
|
|
.vdb-desc-text a { color:#3ea6ff; }
|
|
.vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; }
|
|
.vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); }
|
|
.vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; }
|
|
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; }
|
|
</style>
|
|
|
|
<div class="vdb-wrap" id="vdbWrap">
|
|
<div class="vdb-tabs">
|
|
<button class="vdb-tab active" data-panel="vdb-about" onclick="switchVdbTab('vdb-about',this)">
|
|
<i class="bi bi-card-text"></i> About
|
|
</button>
|
|
@if($isVideoOwner)
|
|
<button class="vdb-tab" data-panel="vdb-insights" onclick="switchVdbTab('vdb-insights',this)">
|
|
<i class="bi bi-bar-chart-line-fill"></i> Insights
|
|
</button>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="vdb-panel active" id="vdb-about">
|
|
<div class="vdb-meta">
|
|
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
|
|
<span>•</span>
|
|
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
|
@if($video->duration)<span>•</span><span><i class="bi bi-clock" style="margin-right:4px;"></i>{{ $video->formatted_duration }}</span>@endif
|
|
</div>
|
|
@if(isset($descriptionSlot))
|
|
{!! $descriptionSlot !!}
|
|
@elseif($renderedDescription !== '')
|
|
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
|
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)">Show more</button>
|
|
@else
|
|
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
|
@endif
|
|
</div>
|
|
|
|
@if($isVideoOwner)
|
|
<x-video-insights :video="$video" />
|
|
@endif
|
|
</div>
|
|
|
|
<script>
|
|
// ── Tab switching ──────────────────────────────────────
|
|
function switchVdbTab(panelId, btn) {
|
|
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById(panelId).classList.add('active');
|
|
if (panelId === 'vdb-insights') {
|
|
const panel = document.getElementById('vdb-insights');
|
|
const currentUrl = panel && panel.dataset.insightsBase;
|
|
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
|
|
}
|
|
}
|
|
function toggleVdbDesc(btn) {
|
|
const d = document.getElementById('vdbDescShort');
|
|
if (!d) return;
|
|
const expanded = d.classList.toggle('vdb-expanded');
|
|
btn.textContent = expanded ? 'Show less' : 'Show more';
|
|
}
|
|
// Reveal "Show more" only when the description overflows the clamp. Compare the
|
|
// natural content height to the clamp limit (130px) rather than clientHeight,
|
|
// which is unreliable right after a content swap.
|
|
function _vdbCheckOverflow() {
|
|
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
|
|
if (!d || !b) return;
|
|
if (d.classList.contains('vdb-expanded')) { b.style.display = 'block'; return; }
|
|
b.style.display = (d.scrollHeight > 138) ? 'block' : 'none';
|
|
}
|
|
document.addEventListener('DOMContentLoaded', _vdbCheckOverflow);
|
|
window.addEventListener('load', _vdbCheckOverflow);
|
|
</script>
|
|
@endif
|