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

+55 -15
View File
@@ -196,7 +196,7 @@
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
<x-rich-text-editor name="description" id="video-description" placeholder="Tell viewers about your video…" />
</div>
</div>
@@ -266,9 +266,7 @@
<!-- Description (audio mode) -->
<div style="margin-bottom:14px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Description</label>
<textarea id="lt-track1-desc-create" class="form-textarea"
rows="2" style="font-size:13px;min-height:64px;"
placeholder="Tell viewers about this track…"></textarea>
<x-rich-text-editor name="" id="lt-track1-desc-create" min-height="80px" placeholder="Tell viewers about this track…" />
</div>
<!-- Slides -->
@@ -562,7 +560,10 @@
if (cFname) cFname.textContent = 'Choose audio file…';
document.getElementById('video-title').value = '';
document.getElementById('lt-track1-title-create').value = '';
document.getElementById('lt-track1-desc-create').value = '';
['video-description','lt-track1-desc-create'].forEach(function(id){
const el = document.getElementById(id);
if (el) { el.value = ''; el.dispatchEvent(new Event('rte:refresh')); }
});
setAudioMode(false);
}
@@ -646,15 +647,43 @@
}
function handleCSlidesForTrack(trackId, fileList) {
if (!fileList || !fileList.length) return;
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
for (const f of Array.from(fileList)) {
if (_cSlidesData[trackId].length >= 10) break;
_cSlidesData[trackId].push(f);
}
renderCSlidesStrip(trackId);
_cSlidesCropStart(fileList, trackId);
}
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _cSlidesCropQueue = [];
let _cSlidesCropTid = null;
function _cSlidesCropStart(fileList, trackId) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_cSlidesCropTid = trackId;
_cSlidesCropQueue = imgs;
_cSlidesCropLoadNext();
}
function _cSlidesCropLoadNext() {
if (!_cSlidesCropQueue.length) { window.closeCropperModal('slides_create_mobile'); return; }
const f = _cSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_create_mobile === 'function') {
window.tcPreload_slides_create_mobile(f);
window.openCropperModal_slides_create_mobile();
}
}
function cSlidesCropDone(file) {
const trackId = _cSlidesCropTid;
if (trackId && file) {
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
if (_cSlidesData[trackId].length < 10) {
_cSlidesData[trackId].push(file);
renderCSlidesStrip(trackId);
}
}
_cSlidesCropLoadNext();
}
window.cSlidesCropDone = cSlidesCropDone;
function renderCSlidesStrip(trackId) {
const files = _cSlidesData[trackId] || [];
const strip = document.getElementById('slides-strip-' + trackId);
@@ -945,9 +974,10 @@
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
Localized Description <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span>
</label>
<textarea name="extra_track_descriptions[]" class="form-textarea"
rows="2" style="font-size:13px;min-height:64px;"
placeholder="Description in this language…"></textarea>
<div class="rte-wrap" data-min-height="80px">
<textarea class="rte-source" name="extra_track_descriptions[]" id="lt-desc-ce${n}"
placeholder="Description in this language…"></textarea>
</div>
</div>
<div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
@@ -1038,4 +1068,14 @@
output-width="1280"
title="Crop Thumbnail"
/>
<x-image-cropper
id="slides_create_mobile"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="cSlidesCropDone"
/>
@endsection
+46 -9
View File
@@ -198,8 +198,7 @@
{{-- Description --}}
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="edit-description" rows="4" class="form-textarea"
placeholder="Tell viewers about your video (Markdown supported)">{{ $video->description }}</textarea>
<x-rich-text-editor name="description" id="edit-description" :value="$video->description" placeholder="Tell viewers about your video…" />
</div>
@if(!$video->isAudioOnly())
@@ -279,9 +278,11 @@
value="{{ $track->title ?? '' }}"
placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;">
<textarea name="track_description_updates[{{ $track->id }}]"
placeholder="Description in this language…" rows="2"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;resize:none;margin-bottom:6px;">{{ $track->description ?? '' }}</textarea>
<x-rich-text-editor :name="'track_description_updates[' . $track->id . ']'"
:id="'ep-track-desc-' . $track->id"
:value="$track->description ?? ''"
min-height="72px"
placeholder="Description in this language…" />
<label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;"
onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i>
@@ -542,16 +543,42 @@
}
document.getElementById('ep-slides-add-input').addEventListener('change', function() {
Array.from(this.files).forEach(file => {
epSlidesCropStart(this.files);
this.value = '';
});
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _epSlidesCropQueue = [];
function epSlidesCropStart(fileList) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_epSlidesCropQueue = imgs;
_epSlidesCropLoadNext();
}
function _epSlidesCropLoadNext() {
if (!_epSlidesCropQueue.length) { window.closeCropperModal('slides_edit_mobile'); return; }
const f = _epSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit_mobile === 'function') {
window.tcPreload_slides_edit_mobile(f);
window.openCropperModal_slides_edit_mobile();
}
}
window.epSlidesCropDone = function(file) {
if (file) {
const reader = new FileReader();
reader.onload = e => {
epSlidesData.push({ file, url: e.target.result });
epSlidesRefresh();
_epSlidesCropLoadNext();
};
reader.readAsDataURL(file);
});
this.value = '';
});
return;
}
_epSlidesCropLoadNext();
};
// Initialise strip with existing slides
epSlidesRefresh();
@@ -785,4 +812,14 @@
output-width="1280"
title="Crop Thumbnail"
/>
<x-image-cropper
id="slides_edit_mobile"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="epSlidesCropDone"
/>
@endsection
@@ -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>';
});
@@ -5,11 +5,9 @@
--}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$hasDesc = !empty($video->description) || isset($descriptionSlot);
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner;
$fullDescription = $video->description ?? '';
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
@if ($showBox)
<style>
@@ -23,9 +21,17 @@
.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); white-space:pre-wrap; }
.vdb-desc-text p { margin-bottom:8px; }
.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>
@@ -50,12 +56,9 @@
</div>
@if(isset($descriptionSlot))
{!! $descriptionSlot !!}
@elseif($hasDesc)
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
@endif
@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
@@ -80,10 +83,21 @@ function switchVdbTab(panelId, btn) {
}
}
function toggleVdbDesc(btn) {
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull');
if (!f) return;
if (f.style.display === 'none') { s.style.display='none'; f.style.display='block'; btn.textContent='Show less'; }
else { s.style.display='block'; f.style.display='none'; btn.textContent='Show more'; }
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
@@ -565,7 +565,7 @@
var doc=new DOMParser().parseFromString(html,'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
@@ -725,8 +725,10 @@
</div>
<div class="sidebar-info">
<div class="sidebar-title">
@php $recFlag = \App\Data\Languages::flag($recVideo->language); @endphp
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
@if($recFlag)<span class="fi fi-{{ $recFlag }}" style="display:inline-block;width:16px;height:12px;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>@endif
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
@@ -822,7 +824,7 @@
// description
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
// channel row
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
+4 -2
View File
@@ -2637,7 +2637,7 @@
var doc=new DOMParser().parseFromString(html,'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
@@ -2798,8 +2798,10 @@
</div>
<div class="sidebar-info">
<div class="sidebar-title">
@php $recFlag = \App\Data\Languages::flag($recVideo->language); @endphp
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
@if($recFlag)<span class="fi fi-{{ $recFlag }}" style="display:inline-block;width:16px;height:12px;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>@endif
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
@@ -2890,7 +2892,7 @@
var doc = new DOMParser().parseFromString(html, 'text/html');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
+15 -8
View File
@@ -1,20 +1,25 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name'))
@php
$metaTitle = $shareTitle ?? $video->title;
$metaDesc = $shareDescription ?? $video->description;
$metaUrl = $video->share_url . (!empty($shareTrackId) ? '?track=' . $shareTrackId : '');
@endphp
@section('title', $metaTitle . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:title" content="{{ $metaTitle }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($metaDesc ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:url" content="{{ $metaUrl }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:title" content="{{ $metaTitle }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($metaDesc ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@@ -599,7 +604,7 @@
// swap description box
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
// swap channel row
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
@@ -801,7 +806,7 @@
var newVdb = doc.getElementById('vdbWrap');
var oldVdb = document.getElementById('vdbWrap');
if (newVdb && oldVdb) oldVdb.innerHTML = newVdb.innerHTML;
if (newVdb && oldVdb) { oldVdb.innerHTML = newVdb.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
var newCh = doc.querySelector('.channel-row');
var oldCh = document.querySelector('.channel-row');
@@ -887,8 +892,10 @@
</div>
<div class="sidebar-info">
<div class="sidebar-title">
@php $recFlag = \App\Data\Languages::flag($recVideo->language); @endphp
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
@if($recFlag)<span class="fi fi-{{ $recFlag }}" style="display:inline-block;width:16px;height:12px;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>@endif
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">