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
@@ -11,6 +11,7 @@
|
||||
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
|
||||
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
|
||||
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
|
||||
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
|
||||
@endphp
|
||||
|
||||
@once
|
||||
@@ -202,8 +203,22 @@
|
||||
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
|
||||
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
|
||||
var previewImgId = '{{ $previewImg }}';
|
||||
var resultCbName = '{{ $resultCallback }}'; // callback mode: name of a global fn given the cropped File
|
||||
|
||||
var isFormMode = targetInputId !== '';
|
||||
var isFormMode = targetInputId !== '';
|
||||
var isCallbackMode = resultCbName !== '';
|
||||
|
||||
// In callback mode the host function decides when to close / advance, so we
|
||||
// never auto-close here — we just hand the File back and reset the button.
|
||||
function deliverResult(file) {
|
||||
if (typeof window[resultCbName] === 'function') window[resultCbName](file);
|
||||
var sb = document.getElementById('tcSaveBtn_' + id);
|
||||
var st = document.getElementById('tcSaveBtnText_' + id);
|
||||
var ab = document.getElementById('tcAsIsBtn_' + id);
|
||||
if (sb) sb.disabled = false;
|
||||
if (ab) ab.disabled = false;
|
||||
if (st) st.textContent = 'Crop & Save';
|
||||
}
|
||||
|
||||
function getCsrf() {
|
||||
var m = document.querySelector('meta[name="csrf-token"]');
|
||||
@@ -343,6 +358,11 @@
|
||||
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
||||
|
||||
cropperInst.crop(cropOpts).then(function (base64) {
|
||||
if (isCallbackMode) {
|
||||
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
||||
deliverResult(base64ToFile(base64, cbName));
|
||||
return;
|
||||
}
|
||||
if (isFormMode) {
|
||||
var fname = originalFile ? originalFile.name : 'cropped.png';
|
||||
setOnTargetInput(base64ToFile(base64, fname));
|
||||
@@ -370,7 +390,9 @@
|
||||
window['tcUploadAsIs_' + id] = function () {
|
||||
if (!originalFile) { window.closeCropperModal(id); return; }
|
||||
|
||||
if (isFormMode) {
|
||||
if (isCallbackMode) {
|
||||
deliverResult(originalFile);
|
||||
} else if (isFormMode) {
|
||||
// Put the original (un-cropped) file on the target input so the form sees it.
|
||||
setOnTargetInput(originalFile);
|
||||
window.closeCropperModal(id);
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
@props([
|
||||
'name' => '',
|
||||
'id' => null,
|
||||
'value' => '',
|
||||
'placeholder' => 'Tell viewers about this content…',
|
||||
'class' => '',
|
||||
'style' => '',
|
||||
'minHeight' => '110px',
|
||||
])
|
||||
|
||||
@php
|
||||
$rid = $id ?: 'rte-' . \Illuminate\Support\Str::random(6);
|
||||
@endphp
|
||||
|
||||
{{-- ── Shared CSS (once per page) ─────────────────────────────────────────── --}}
|
||||
@once('rte-styles')
|
||||
<style>
|
||||
.rte-wrap { position: relative; }
|
||||
.rte-source { display: none !important; }
|
||||
.rte-toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
|
||||
background: var(--bg-dark, #0f0f0f);
|
||||
border: 1px solid var(--border-color, #303030); border-bottom: none;
|
||||
border-radius: 8px 8px 0 0; padding: 5px 6px;
|
||||
}
|
||||
.rte-tbtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px; border: none; border-radius: 6px;
|
||||
background: none; color: var(--text-secondary, #aaa);
|
||||
font-size: 15px; cursor: pointer; transition: background .12s, color .12s; outline: none;
|
||||
}
|
||||
.rte-tbtn:hover { background: rgba(255,255,255,.08); color: var(--text-primary, #f1f1f1); }
|
||||
.rte-tbtn.active { background: rgba(230,30,30,.18); color: var(--brand-red, #e61e1e); }
|
||||
.rte-sep { width: 1px; height: 18px; background: var(--border-color, #303030); margin: 0 3px; flex-shrink: 0; }
|
||||
.rte-editable {
|
||||
background: var(--bg-dark, #0f0f0f);
|
||||
border: 1px solid var(--border-color, #303030); border-radius: 0 0 8px 8px;
|
||||
padding: 10px 12px; color: var(--text-primary, #f1f1f1);
|
||||
font-size: 13px; line-height: 1.6; font-family: inherit;
|
||||
outline: none; overflow-y: auto; word-break: break-word;
|
||||
}
|
||||
.rte-editable:focus { border-color: var(--brand-red, #e61e1e); }
|
||||
.rte-editable:empty::before { content: attr(data-placeholder); color: var(--text-secondary, #777); pointer-events: none; }
|
||||
.rte-editable p { margin: 0 0 8px; }
|
||||
.rte-editable p:last-child { margin-bottom: 0; }
|
||||
.rte-editable h2 { font-size: 18px; font-weight: 700; margin: 4px 0 8px; }
|
||||
.rte-editable h3 { font-size: 15px; font-weight: 700; margin: 4px 0 6px; }
|
||||
.rte-editable ul, .rte-editable ol { margin: 0 0 8px; padding-left: 22px; }
|
||||
.rte-editable blockquote { margin: 0 0 8px; padding-left: 12px; border-left: 3px solid var(--border-color, #303030); color: var(--text-secondary, #aaa); }
|
||||
.rte-editable a { color: #3ea6ff; }
|
||||
.rte-editable a.action-btn, .rte-editable a.action-btn-primary, .rte-editable a.action-btn-danger {
|
||||
color: inherit; text-decoration: none;
|
||||
}
|
||||
|
||||
/* inline popover (link / button / emoji) */
|
||||
.rte-pop {
|
||||
position: absolute; z-index: 1090; min-width: 240px;
|
||||
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
|
||||
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); padding: 10px;
|
||||
}
|
||||
.rte-pop[hidden] { display: none; }
|
||||
.rte-pop label { display: block; font-size: 11px; color: var(--text-secondary, #aaa); margin: 0 0 4px; }
|
||||
.rte-pop input[type=text] {
|
||||
width: 100%; box-sizing: border-box; background: var(--bg-dark, #0f0f0f);
|
||||
border: 1px solid var(--border-color, #303030); border-radius: 6px;
|
||||
padding: 7px 9px; color: var(--text-primary, #f1f1f1); font-size: 13px; outline: none; margin-bottom: 8px;
|
||||
}
|
||||
.rte-pop input[type=text]:focus { border-color: var(--brand-red, #e61e1e); }
|
||||
.rte-pop-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.rte-pop-btn { border: none; border-radius: 6px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.rte-pop-btn.cancel { background: rgba(255,255,255,.08); color: var(--text-secondary, #aaa); }
|
||||
.rte-pop-btn.ok { background: var(--brand-red, #e61e1e); color: #fff; }
|
||||
.rte-emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; max-height: 180px; overflow-y: auto; }
|
||||
.rte-emoji-grid button { border: none; background: none; font-size: 18px; line-height: 1; padding: 5px; border-radius: 6px; cursor: pointer; }
|
||||
.rte-emoji-grid button:hover { background: rgba(255,255,255,.1); }
|
||||
</style>
|
||||
@endonce
|
||||
|
||||
{{-- ── Engine (once per page) ─────────────────────────────────────────────── --}}
|
||||
@once('rte-script')
|
||||
<script>
|
||||
(function () {
|
||||
if (window.RTE) return;
|
||||
|
||||
var EMOJI = ('😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 😉 😍 🥰 😘 😎 🤩 🥳 🤔 🤨 😐 😴 😭 😡 🥺 😱 '
|
||||
+ '👍 👎 👏 🙌 🙏 💪 🔥 ✨ ⭐ 🌟 💯 ✅ ❌ ❓ ❗ ❤️ 🧡 💛 💚 💙 💜 🖤 💔 💕 💖 '
|
||||
+ '🎉 🎊 🎁 🏆 🥇 ⚽ 🏀 🏈 🎵 🎶 🎬 📺 📱 💻 📷 🎤 🎮 🚀 🌍 ☀️ 🌙 ⚡ 💧 🍀').split(' ');
|
||||
|
||||
function saveSel(ed) {
|
||||
var s = window.getSelection();
|
||||
if (s && s.rangeCount && ed.contains(s.anchorNode)) ed._range = s.getRangeAt(0).cloneRange();
|
||||
}
|
||||
function restoreSel(ed) {
|
||||
ed.focus();
|
||||
if (ed._range) {
|
||||
var s = window.getSelection();
|
||||
s.removeAllRanges();
|
||||
s.addRange(ed._range);
|
||||
}
|
||||
}
|
||||
function exec(ed, cmd, val) { restoreSel(ed); document.execCommand(cmd, false, val || null); sync(ed); }
|
||||
// Alignment must be emitted as CSS text-align (not the legacy align attribute,
|
||||
// which the server sanitizer strips), so toggle styleWithCSS just for these.
|
||||
function execAlign(ed, cmd) {
|
||||
restoreSel(ed);
|
||||
try { document.execCommand('styleWithCSS', false, true); } catch (e) {}
|
||||
document.execCommand(cmd, false, null);
|
||||
try { document.execCommand('styleWithCSS', false, false); } catch (e) {}
|
||||
sync(ed);
|
||||
}
|
||||
function sync(ed) {
|
||||
var src = ed._source;
|
||||
var html = ed.innerHTML.trim();
|
||||
if (html === '<br>' || html === '<div><br></div>' || html === '<p><br></p>') html = '';
|
||||
src.value = html;
|
||||
src.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
src.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function tbtn(icon, title, fn) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'rte-tbtn'; b.title = title;
|
||||
b.innerHTML = '<i class="bi bi-' + icon + '"></i>';
|
||||
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
||||
b.addEventListener('click', function (e) { e.preventDefault(); fn(e, b); });
|
||||
return b;
|
||||
}
|
||||
function sep() { var s = document.createElement('span'); s.className = 'rte-sep'; return s; }
|
||||
|
||||
function buildPopover(wrap, ed) {
|
||||
var pop = document.createElement('div');
|
||||
pop.className = 'rte-pop'; pop.hidden = true;
|
||||
wrap.appendChild(pop);
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!pop.hidden && !pop.contains(e.target) && !wrap.querySelector('.rte-toolbar').contains(e.target)) pop.hidden = true;
|
||||
});
|
||||
return pop;
|
||||
}
|
||||
function openPop(pop, anchorBtn, html) {
|
||||
pop.innerHTML = html;
|
||||
pop.hidden = false;
|
||||
var tb = anchorBtn.closest('.rte-toolbar');
|
||||
pop.style.top = (tb.offsetTop + tb.offsetHeight + 4) + 'px';
|
||||
pop.style.left = Math.min(anchorBtn.offsetLeft, tb.offsetWidth - 250) + 'px';
|
||||
var f = pop.querySelector('input[type=text]');
|
||||
if (f) setTimeout(function () { f.focus(); }, 10);
|
||||
}
|
||||
|
||||
function linkForm(pop, ed, anchorBtn, asButton) {
|
||||
saveSel(ed);
|
||||
var sel = window.getSelection();
|
||||
var selText = (sel && ed.contains(sel.anchorNode)) ? sel.toString() : '';
|
||||
openPop(pop, anchorBtn,
|
||||
'<label>' + (asButton ? 'Button label' : 'Link text') + '</label>'
|
||||
+ '<input type="text" class="rte-f-text" value="' + selText.replace(/"/g, '"') + '" placeholder="' + (asButton ? 'Click here' : 'Text to show') + '">'
|
||||
+ '<label>URL</label>'
|
||||
+ '<input type="text" class="rte-f-url" placeholder="https://…">'
|
||||
+ '<div class="rte-pop-actions"><button type="button" class="rte-pop-btn cancel">Cancel</button>'
|
||||
+ '<button type="button" class="rte-pop-btn ok">Insert</button></div>');
|
||||
pop.querySelector('.cancel').onclick = function () { pop.hidden = true; };
|
||||
pop.querySelector('.ok').onclick = function () {
|
||||
var txt = pop.querySelector('.rte-f-text').value.trim();
|
||||
var url = pop.querySelector('.rte-f-url').value.trim();
|
||||
if (!url) { pop.querySelector('.rte-f-url').focus(); return; }
|
||||
if (!/^([a-z][a-z0-9+.\-]*:|\/|#)/i.test(url)) url = 'https://' + url;
|
||||
txt = txt || url;
|
||||
var esc = function (s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
||||
var cls = asButton ? ' class="action-btn action-btn-primary"' : '';
|
||||
var a = '<a href="' + esc(url) + '" target="_blank"' + cls + '>' + esc(txt) + '</a>';
|
||||
restoreSel(ed);
|
||||
document.execCommand('insertHTML', false, a + (asButton ? '<span> </span>' : ''));
|
||||
sync(ed);
|
||||
pop.hidden = true;
|
||||
};
|
||||
}
|
||||
|
||||
function emojiForm(pop, ed, anchorBtn) {
|
||||
saveSel(ed);
|
||||
var grid = '<div class="rte-emoji-grid">';
|
||||
for (var i = 0; i < EMOJI.length; i++) grid += '<button type="button" data-e="' + EMOJI[i] + '">' + EMOJI[i] + '</button>';
|
||||
grid += '</div>';
|
||||
openPop(pop, anchorBtn, grid);
|
||||
pop.querySelectorAll('.rte-emoji-grid button').forEach(function (b) {
|
||||
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
||||
b.addEventListener('click', function () {
|
||||
restoreSel(ed);
|
||||
document.execCommand('insertText', false, b.dataset.e);
|
||||
sync(ed);
|
||||
pop.hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.RTE = {
|
||||
init: function (wrap) {
|
||||
if (!wrap || wrap.dataset.rteReady) return;
|
||||
var src = wrap.querySelector('.rte-source');
|
||||
if (!src) return;
|
||||
// Skip un-instantiated templates whose placeholders aren't filled in yet
|
||||
// (e.g. edit-__TPL__-desc / __DESCNAME__). Closed modals are aria-hidden
|
||||
// but must still init, so we only key off the placeholder marker here.
|
||||
var marker = (src.id || '') + '|' + (src.getAttribute('name') || '');
|
||||
if (marker.indexOf('__') !== -1) return;
|
||||
wrap.dataset.rteReady = '1';
|
||||
|
||||
var toolbar = document.createElement('div');
|
||||
toolbar.className = 'rte-toolbar';
|
||||
|
||||
var ed = document.createElement('div');
|
||||
ed.className = 'rte-editable';
|
||||
ed.contentEditable = 'true';
|
||||
ed.setAttribute('data-placeholder', src.getAttribute('placeholder') || '');
|
||||
ed.style.minHeight = wrap.dataset.minHeight || '110px';
|
||||
ed.innerHTML = src.value || '';
|
||||
ed._source = src;
|
||||
src._editable = ed;
|
||||
|
||||
var pop = null;
|
||||
function P() { return pop || (pop = buildPopover(wrap, ed)); }
|
||||
|
||||
toolbar.appendChild(tbtn('type-bold', 'Bold', function () { exec(ed, 'bold'); }));
|
||||
toolbar.appendChild(tbtn('type-italic', 'Italic', function () { exec(ed, 'italic'); }));
|
||||
toolbar.appendChild(tbtn('type-underline', 'Underline', function () { exec(ed, 'underline'); }));
|
||||
toolbar.appendChild(tbtn('type-strikethrough', 'Strikethrough', function () { exec(ed, 'strikeThrough'); }));
|
||||
toolbar.appendChild(sep());
|
||||
toolbar.appendChild(tbtn('type-h2', 'Heading', function () {
|
||||
var inH = document.queryCommandValue('formatBlock').toLowerCase();
|
||||
exec(ed, 'formatBlock', (inH === 'h2' || inH === '<h2>') ? 'p' : 'h2');
|
||||
}));
|
||||
toolbar.appendChild(tbtn('list-ul', 'Bulleted list', function () { exec(ed, 'insertUnorderedList'); }));
|
||||
toolbar.appendChild(tbtn('list-ol', 'Numbered list', function () { exec(ed, 'insertOrderedList'); }));
|
||||
toolbar.appendChild(tbtn('quote', 'Quote', function () { exec(ed, 'formatBlock', 'blockquote'); }));
|
||||
toolbar.appendChild(sep());
|
||||
toolbar.appendChild(tbtn('text-left', 'Align left', function () { execAlign(ed, 'justifyLeft'); }));
|
||||
toolbar.appendChild(tbtn('text-center', 'Align center', function () { execAlign(ed, 'justifyCenter'); }));
|
||||
toolbar.appendChild(tbtn('text-right', 'Align right', function () { execAlign(ed, 'justifyRight'); }));
|
||||
toolbar.appendChild(sep());
|
||||
toolbar.appendChild(tbtn('link-45deg', 'Insert link', function (e, b) { linkForm(P(), ed, b, false); }));
|
||||
toolbar.appendChild(tbtn('hand-index-thumb', 'Insert button link', function (e, b) { linkForm(P(), ed, b, true); }));
|
||||
toolbar.appendChild(tbtn('emoji-smile', 'Insert emoji', function (e, b) { emojiForm(P(), ed, b); }));
|
||||
toolbar.appendChild(sep());
|
||||
toolbar.appendChild(tbtn('eraser', 'Clear formatting', function () { exec(ed, 'removeFormat'); }));
|
||||
|
||||
src.parentNode.insertBefore(toolbar, src);
|
||||
src.parentNode.insertBefore(ed, src);
|
||||
|
||||
ed.addEventListener('input', function () { sync(ed); });
|
||||
ed.addEventListener('keyup', function () { saveSel(ed); });
|
||||
ed.addEventListener('mouseup', function () { saveSel(ed); });
|
||||
ed.addEventListener('blur', function () { saveSel(ed); sync(ed); });
|
||||
// Paste as plain text to avoid junk markup.
|
||||
ed.addEventListener('paste', function (e) {
|
||||
e.preventDefault();
|
||||
var t = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, t);
|
||||
sync(ed);
|
||||
});
|
||||
// Keep editor in sync if external code rewrites the textarea value.
|
||||
src.addEventListener('rte:refresh', function () { ed.innerHTML = src.value || ''; });
|
||||
|
||||
var form = src.closest('form');
|
||||
if (form && !form.dataset.rteSubmitBound) {
|
||||
form.dataset.rteSubmitBound = '1';
|
||||
form.addEventListener('submit', function () {
|
||||
form.querySelectorAll('.rte-editable').forEach(function (e2) { if (e2._source) sync(e2); });
|
||||
}, true);
|
||||
}
|
||||
},
|
||||
initAll: function (root) {
|
||||
(root || document).querySelectorAll('.rte-wrap:not([data-rte-ready])').forEach(function (w) { RTE.init(w); });
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-init any editor markup added later (modals, JS-cloned track rows).
|
||||
var mo = new MutationObserver(function (muts) {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
for (var j = 0; j < muts[i].addedNodes.length; j++) {
|
||||
var n = muts[i].addedNodes[j];
|
||||
if (n.nodeType !== 1) continue;
|
||||
if (n.classList && n.classList.contains('rte-wrap')) RTE.init(n);
|
||||
if (n.querySelectorAll) n.querySelectorAll('.rte-wrap:not([data-rte-ready])').forEach(function (w) { RTE.init(w); });
|
||||
}
|
||||
}
|
||||
});
|
||||
function rteBoot() {
|
||||
RTE.initAll();
|
||||
if (document.body) mo.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', rteBoot);
|
||||
} else {
|
||||
rteBoot();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
@endonce
|
||||
|
||||
{{-- ── Instance ───────────────────────────────────────────────────────────── --}}
|
||||
<div class="rte-wrap {{ $class }}" data-min-height="{{ $minHeight }}" @if($style) style="{{ $style }}" @endif>
|
||||
<textarea class="rte-source"
|
||||
name="{{ $name }}"
|
||||
id="{{ $rid }}"
|
||||
placeholder="{{ $placeholder }}">{{ $value }}</textarea>
|
||||
</div>
|
||||
@@ -207,15 +207,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Description ────────────────────────────────────────────── --}}
|
||||
{{-- ── Description (rich text) ────────────────────────────────── --}}
|
||||
<div class="um-field">
|
||||
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description @if(!$isPrimary)<span class="um-lbl-hint">optional</span>@endif</label>
|
||||
<textarea name="{{ $descName }}"
|
||||
id="{{ $descId }}"
|
||||
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="$descName" :id="$descId" placeholder="Tell viewers about this content…" />
|
||||
</div>
|
||||
|
||||
{{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}}
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="action-btn desktop-action"
|
||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||
<i class="bi bi-share"></i><span>Share</span>
|
||||
</button>
|
||||
@endif
|
||||
@@ -271,7 +271,7 @@
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="dropdown-item"
|
||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
@@ -514,6 +514,14 @@ if (!window._slideshowDlInit) {
|
||||
document.getElementById('sl-dl-cancel').textContent = 'Close';
|
||||
}
|
||||
|
||||
// Share the version the viewer is on: append ?track={id} so the recipient's player
|
||||
// opens directly on that language (window._ytpTrackId; 0 = primary → plain link).
|
||||
window.shareCurrent = function (baseUrl, title, recordUrl) {
|
||||
var t = window._ytpTrackId || 0;
|
||||
var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl;
|
||||
if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl);
|
||||
};
|
||||
|
||||
window.startSlideshowDownload = function (routeKey) {
|
||||
if (_currentKey && _currentKey !== routeKey) {
|
||||
// Different video — reset
|
||||
@@ -521,6 +529,17 @@ if (!window._slideshowDlInit) {
|
||||
_pollTimer = null;
|
||||
}
|
||||
_currentKey = routeKey;
|
||||
|
||||
// Act on the version the viewer is currently playing: the visualizer toggle AND
|
||||
// the selected language track (window._ytpTrackId; 0 = primary).
|
||||
var vizOn = localStorage.getItem('audioBarsOn') === '1';
|
||||
var trackId = window._ytpTrackId || 0;
|
||||
var _p = [];
|
||||
if (vizOn) _p.push('visualizer=1');
|
||||
if (trackId) _p.push('track=' + trackId);
|
||||
var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download
|
||||
var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration=
|
||||
|
||||
_maxPct = 0;
|
||||
document.getElementById('sl-dl-bar').style.background = ''; // restore gradient
|
||||
document.getElementById('sl-dl-cancel').textContent = 'Cancel';
|
||||
@@ -532,7 +551,7 @@ if (!window._slideshowDlInit) {
|
||||
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
var token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
|
||||
|
||||
fetch('/videos/' + routeKey + '/slideshow/generate', {
|
||||
fetch('/videos/' + routeKey + '/slideshow/generate' + qs, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
|
||||
})
|
||||
@@ -546,7 +565,7 @@ if (!window._slideshowDlInit) {
|
||||
document.getElementById('sl-dl-cancel').style.display = 'none';
|
||||
setTimeout(function () {
|
||||
window._slideshowDlCancel();
|
||||
window.location.href = '/videos/' + routeKey + '/download';
|
||||
window.location.href = '/videos/' + routeKey + '/download' + qs;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
@@ -559,7 +578,7 @@ if (!window._slideshowDlInit) {
|
||||
_setProgress(2, 'Generating video...');
|
||||
|
||||
_pollTimer = setInterval(function () {
|
||||
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration, {
|
||||
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration + qsAmp, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
@@ -581,7 +600,7 @@ if (!window._slideshowDlInit) {
|
||||
document.getElementById('sl-dl-cancel').style.display = 'none';
|
||||
setTimeout(function () {
|
||||
window._slideshowDlCancel();
|
||||
window.location.href = '/videos/' + routeKey + '/download';
|
||||
window.location.href = '/videos/' + routeKey + '/download' + qs;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
@@ -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
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user