From a4384113c239d361bd44c6b0251915f6637b28f8 Mon Sep 17 00:00:00 2001 From: ghassan Date: Sat, 23 May 2026 14:03:43 +0300 Subject: [PATCH] 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) --- .claude/component-usage.md | 31 +- CLAUDE.md | 59 ++- app/Console/Commands/NasFreeLocalStorage.php | 21 +- .../Commands/ReorganizeAudioTracks.php | 325 +++++++++++++++++ app/Http/Controllers/SuperAdminController.php | 21 +- app/Http/Controllers/VideoController.php | 344 +++++++++++++++--- app/Jobs/CompressVideoJob.php | 4 +- app/Jobs/GenerateHlsJob.php | 11 +- app/Models/Setting.php | 100 ++++- app/Support/HtmlSanitizer.php | 223 ++++++++++++ .../views/components/image-cropper.blade.php | 26 +- .../components/rich-text-editor.blade.php | 304 ++++++++++++++++ .../components/track-editor-form.blade.php | 9 +- .../views/components/video-actions.blade.php | 31 +- .../partials/edit-video-modal.blade.php | 71 +++- .../layouts/partials/share-modal.blade.php | 10 + .../layouts/partials/upload-modal.blade.php | 64 +++- resources/views/videos/create.blade.php | 70 +++- resources/views/videos/edit.blade.php | 55 ++- .../videos/partials/audio-player.blade.php | 136 +++---- .../videos/partials/description-box.blade.php | 46 ++- .../views/videos/types/generic.blade.php | 6 +- resources/views/videos/types/match.blade.php | 6 +- resources/views/videos/types/music.blade.php | 23 +- 24 files changed, 1738 insertions(+), 258 deletions(-) create mode 100644 app/Console/Commands/ReorganizeAudioTracks.php create mode 100644 app/Support/HtmlSanitizer.php create mode 100644 resources/views/components/rich-text-editor.blade.php diff --git a/.claude/component-usage.md b/.claude/component-usage.md index 8ae6a14..70aaad5 100644 --- a/.claude/component-usage.md +++ b/.claude/component-usage.md @@ -90,8 +90,9 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS ## `` **File:** `resources/views/components/image-cropper.blade.php` -**Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading). -**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). After crop: POSTs base64 to `/image-upload`, optionally POSTs the path to `update-url`, then calls `callback(url)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required. +**Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading), `target-input` (form mode: ID of file input the cropped File is set on), `preview-img` (ID of `` updated with the cropped preview), `output-width` (final output px width), `result-callback` (callback mode: name of a global JS fn given the cropped `File`). +**Three operating modes (mutually exclusive, checked in this order):** (1) **callback mode** — when `result-callback` is set, both "Crop & Save" and "Upload as-is" hand the resulting `File` to `window[resultCallback](file)` and do **not** auto-close; the host fn decides when to close (`closeCropperModal(id)`) or load the next image. Used for multi-image queues (cover slides). (2) **form mode** — when `target-input` is set, the cropped File is placed on that file input (DataTransfer) and a `change` event is dispatched. (3) **server mode** — otherwise POSTs base64 to `/image-upload`, optionally POSTs path to `update-url`, then calls `callback(url)`. +**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). Exposes per-id globals: `openCropperModal_{id}()`, `tcPreload_{id}(file)`, `closeCropperModal(id)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required. **Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`. **Routes needed:** `image.upload` (POST `/image-upload`). @@ -105,6 +106,10 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS | `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px | | `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px | | `resources/views/playlists/show.blade.php` | `thumb_pl_edit` — square 448×252 | Form mode; `target-input=playlistThumbnailInput`; output 1280px | +| `resources/views/layouts/partials/edit-video-modal.blade.php` | `slides_edit` — square 448×252 | Callback mode; `result-callback=editSlidesCropDone`; crops each cover slide before it enters the strip (queues multiple) | +| `resources/views/layouts/partials/upload-modal.blade.php` | `slides_upload` — square 448×252 | Callback mode; `result-callback=uploadSlidesCropDone`; cover-slide crop queue | +| `resources/views/videos/create.blade.php` | `slides_create_mobile` — square 448×252 | Mobile; callback mode; `result-callback=cSlidesCropDone`; cover-slide crop queue | +| `resources/views/videos/edit.blade.php` | `slides_edit_mobile` — square 448×252 | Mobile; callback mode; `result-callback=epSlidesCropDone`; cover-slide crop queue | --- @@ -156,7 +161,7 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`). **File:** `resources/views/components/track-editor-form.blade.php` **Props:** `prefix` (default `'t1'`), `isPrimary` (bool, default `false`), `languageName`, `languageId`, `titleName`, `titleId`, `descName`, `descId`, `videoFileInputId`. -**Behaviour:** Renders the full track editor form panel shown inside the Track Editor popup. Contains: optional Primary Track banner (when `:is-primary="true"`), language dropdown (``), title input, description textarea, video+thumbnail zone (hidden, shown for video/match type via `_editApplyMode`), and audio+slides zone (hidden, shown for music type). All element IDs are prefixed with `edit-{prefix}-*`. JS functions `editHandleThumbnail(input, prefix)`, `editRemoveThumbnail(event, prefix)`, `editSlidesZoneClick(event, tid)`, `editHandleSlides(files, tid)`, `editClearSlides(event, tid)` all accept the prefix/tid param. +**Behaviour:** Renders the full track editor form panel shown inside the Track Editor popup. Contains: optional Primary Track banner (when `:is-primary="true"`), language dropdown (``), title input, description rich-text editor (``), video+thumbnail zone (hidden, shown for video/match type via `_editApplyMode`), and audio+slides zone (hidden, shown for music type). All element IDs are prefixed with `edit-{prefix}-*`. JS functions `editHandleThumbnail(input, prefix)`, `editRemoveThumbnail(event, prefix)`, `editSlidesZoneClick(event, tid)`, `editHandleSlides(files, tid)`, `editClearSlides(event, tid)` all accept the prefix/tid param. Adding cover slides routes through the `slides_edit` image-cropper (callback mode `editSlidesCropDone`) — each picked/dropped image is cropped to 16:9 before entering `_editSlidesData`; the live `` instances are defined in edit-video-modal.blade.php. | View file | Prefix used | Notes | |---|---|---| @@ -184,6 +189,26 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`). --- +## `` + +**File:** `resources/views/components/rich-text-editor.blade.php` +**Props:** `name`, `id`, `value` (initial HTML), `placeholder`, `class`, `style`, `minHeight` (default `110px`). +**Server sanitizer:** `app/Support/HtmlSanitizer.php` — `HtmlSanitizer::clean()` (allowlist, on save) and `HtmlSanitizer::render()` (display; upgrades legacy plain text). +**Stored value:** sanitized HTML. Allowed tags: `p, br, div, span, b/strong, i/em, u, s, h2, h3, ul, ol, li, blockquote, a`. `` may carry `href` (http/https/mailto only), `target="_blank"` (auto `rel=noopener`), and `class` limited to `.action-btn` variants (button links). `style` limited to `text-align`. +**Behaviour:** Renders a hidden ` + diff --git a/resources/views/components/track-editor-form.blade.php b/resources/views/components/track-editor-form.blade.php index 348dfff..73d128d 100644 --- a/resources/views/components/track-editor-form.blade.php +++ b/resources/views/components/track-editor-form.blade.php @@ -207,15 +207,10 @@ - {{-- ── Description ────────────────────────────────────────────── --}} + {{-- ── Description (rich text) ────────────────────────────────── --}}
- +
{{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}} diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php index b740c92..b57cbe2 100644 --- a/resources/views/components/video-actions.blade.php +++ b/resources/views/components/video-actions.blade.php @@ -168,7 +168,7 @@ @if ($video->isShareable()) @endif @@ -271,7 +271,7 @@ @if ($video->isShareable()) @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; } diff --git a/resources/views/layouts/partials/edit-video-modal.blade.php b/resources/views/layouts/partials/edit-video-modal.blade.php index 48ac078..d1e6756 100644 --- a/resources/views/layouts/partials/edit-video-modal.blade.php +++ b/resources/views/layouts/partials/edit-video-modal.blade.php @@ -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" /> + + diff --git a/resources/views/layouts/partials/share-modal.blade.php b/resources/views/layouts/partials/share-modal.blade.php index c8e2a72..6666649 100644 --- a/resources/views/layouts/partials/share-modal.blade.php +++ b/resources/views/layouts/partials/share-modal.blade.php @@ -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() {}); diff --git a/resources/views/layouts/partials/upload-modal.blade.php b/resources/views/layouts/partials/upload-modal.blade.php index 13b2c9f..9cf2444 100644 --- a/resources/views/layouts/partials/upload-modal.blade.php +++ b/resources/views/layouts/partials/upload-modal.blade.php @@ -188,9 +188,7 @@
- +
{{-- 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() { - +
+ +
- +
@@ -266,9 +266,7 @@
- +
@@ -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 @@ - +
+ +
@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, '
'); // 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 ''; } + 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') - + '' + t.label + '' + + '' + langName(t.language) + '' + '' + ''; }); diff --git a/resources/views/videos/partials/description-box.blade.php b/resources/views/videos/partials/description-box.blade.php index 5c655ff..8b7acc9 100644 --- a/resources/views/videos/partials/description-box.blade.php +++ b/resources/views/videos/partials/description-box.blade.php @@ -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) @@ -50,12 +56,9 @@ @if(isset($descriptionSlot)) {!! $descriptionSlot !!} - @elseif($hasDesc) -
{{ $needsExpand ? $shortDescription : $fullDescription }}
- @if($needsExpand) - - - @endif + @elseif($renderedDescription !== '') +
{!! $renderedDescription !!}
+ @else

No description added.

@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); @endif diff --git a/resources/views/videos/types/generic.blade.php b/resources/views/videos/types/generic.blade.php index 8028a52..fa42de3 100644 --- a/resources/views/videos/types/generic.blade.php +++ b/resources/views/videos/types/generic.blade.php @@ -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 @@