Add lyrics pipeline, playlist views, admin toggles, and player polish

Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-31 22:01:47 +03:00
1 parent 73527f3781
commit f98e5415a3
60 files changed
+7920 -2675

No files matched your search

+481 -35
View File
@@ -16,6 +16,118 @@
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
{{-- Image cropper assets — must be in the layout head (not inside the
x-image-cropper component) because page-level uses of the cropper
render those styles inside #main, which the SPA navigation later
wipes via innerHTML swap. The layout-level modals (upload,
sports-match) would then render their cropper overlays unstyled. --}}
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
<script src="{{ asset('js/cropme.min.js') }}"></script>
<style>
/* TakeOne Cropper Modal — must be in the head, not in the component,
because page-level uses render those styles inside #main and SPA
navigation later wipes that scope. */
.tc-overlay {
display: none; position: fixed; inset: 0; z-index: 10100;
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
.tc-modal {
background: #141414; border: 1px solid rgba(255,255,255,.12);
border-radius: 18px; width: min(540px, 95vw);
box-shadow: 0 24px 80px rgba(0,0,0,.75);
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
}
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.tc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.tc-modal-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 8px;
}
.tc-modal-title i { color: #ef4444; }
.tc-modal-close {
background: none; border: none; color: rgba(255,255,255,.45);
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
border-radius: 6px; transition: color .15s, background .15s;
}
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.tc-modal-body { padding: 16px 20px; }
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tc-file-label {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
height: 36px; padding: 0 14px; border-radius: 8px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.tc-file-label:hover { background: rgba(255,255,255,.13); }
.tc-file-name {
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tc-canvas {
width: 100%; height: 320px; background: #0d0d0d;
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
overflow: hidden; position: relative;
}
.tc-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
}
.tc-placeholder i { font-size: 42px; }
.tc-placeholder span { font-size: 13px; }
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
.tc-control { flex: 1; }
.tc-control-label {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
}
.tc-range {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
}
.tc-range::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #ef4444; cursor: pointer;
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
}
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
.tc-range::-moz-range-thumb {
width: 15px; height: 15px; border: none;
border-radius: 50%; background: #ef4444; cursor: pointer;
}
.tc-modal-footer {
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
}
.tc-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 38px; padding: 0 18px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
transition: background .15s, transform .1s, opacity .15s;
}
.tc-btn-ghost {
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
border: 1px solid rgba(255,255,255,.12);
}
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
.tc-btn-as-is {
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
}
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
.tc-btn-primary { background: #ef4444; color: #fff; }
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
</style>
<style>
:root {
--brand-red: #e61e1e;
@@ -868,7 +980,12 @@
aspect-ratio: 16/9;
background: #000;
overflow: hidden;
cursor: move; /* drag handle */
user-select: none;
-webkit-user-select: none;
}
#ytpMini.dragging { cursor: grabbing; opacity: .92; }
#ytpMini.dragging #ytpMiniVideo { cursor: grabbing; }
#ytpMiniVideo video { width:100%; height:100%; object-fit:contain; display:block; }
#ytpMiniVideo .ytp-chrome-bottom,
#ytpMiniVideo .ytp-gradient-bottom,
@@ -884,7 +1001,12 @@
gap: 6px;
background: #1a1a1a;
}
#ytpMiniInfo { flex:1; min-width:0; }
#ytpMiniInfo {
flex:1; min-width:0;
cursor: move; /* secondary drag handle on the title area */
user-select: none;
-webkit-user-select: none;
}
#ytpMiniTitle {
font-size: 12px;
color: #eee;
@@ -948,12 +1070,24 @@
.yt-main.video-view-page { padding: 0 !important; }
.yt-header-right .yt-icon-btn:not(:first-child) { display: none; }
}
/* Base grid — kept in the layout (not the per-page extra_styles block)
so SPA navigations from a video page back to a gallery still get it.
No !important: pages with their own .yt-video-grid rules (e.g. the
channel page) override these via normal cascade since their <style>
comes from @section('extra_styles') in <body>. */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
@media (max-height: 500px) and (orientation: landscape) {
.yt-sidebar { width: 200px; }
.yt-video-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
}
@media (min-width: 1440px) {
.yt-video-grid { grid-template-columns: repeat(4, 1fr) !important; }
.yt-video-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 768px) {
.video-container {
@@ -1771,7 +1905,14 @@
}
</script>
@yield('scripts')
{{-- Page-specific scripts come from each view's @section('scripts').
Wrapped in a marker element so the SPA navigator can swap & re-run
them — without this, SPA nav into a page whose JS defines functions
like switchTab() leaves those functions undefined, and any onclick
handler that calls them silently fails. --}}
<div id="page-scripts">
@yield('scripts')
</div>
{{-- ═══════════════════════════════════════════════════════════
ADMIN ERROR CATCHER — only rendered for super_admins
@@ -1907,6 +2048,21 @@
'scroll' — player scrolled out of viewport on the video page
'nav' — user navigated to a non-video page via SPA
───────────────────────────────────────────────────────────────────── */
/* Global on/off for the floating mini player. Persisted in localStorage so
the user's choice survives reloads and applies across video AND music
players. Default ON. The gear-menu toggles in each player flip this. */
window._ytpMiniEnabled = function () {
try { return localStorage.getItem('ytpMiniEnabled') !== '0'; }
catch (e) { return true; }
};
window._ytpMiniSetEnabled = function (on) {
try { localStorage.setItem('ytpMiniEnabled', on ? '1' : '0'); } catch (e) {}
/* Closing the mini cleanly if the user disabled it while it was active. */
if (!on && window._miniPlayer && window._miniPlayer.isActive()) {
window._miniPlayer.deactivate();
}
};
window._miniPlayer = (function () {
var wrap = document.getElementById('ytpMini');
var slot = document.getElementById('ytpMiniVideo');
@@ -1916,50 +2072,99 @@
var closeBtn = document.getElementById('ytpMiniClose');
var _mode = null; /* 'scroll' | 'nav' | null */
var _kind = null; /* 'video' | 'audio' | null */
var _origParent = null;
var _origNext = null;
function getVid() { return document.getElementById('videoPlayer'); }
function getVid() { return document.getElementById('videoPlayer'); }
function getAudio() { return document.getElementById('audioEl'); }
/* The element the mini player drives — video element if present, else the
page's <audio>. Returned as a generic HTMLMediaElement either way. */
function getMedia() { return getVid() || getAudio(); }
function syncBtn() {
var v = getVid();
var m = getMedia();
if (!playBtn) return;
playBtn.querySelector('i').className = (!v || v.paused)
playBtn.querySelector('i').className = (!m || m.paused)
? 'bi bi-play-fill' : 'bi bi-pause-fill';
}
function activate(title, url, mode) {
if (_mode !== null) return false; /* already active — prevent re-entry */
if (!slot) return false;
var v = getVid();
if (!v || !slot) return false;
if (v) {
/* VIDEO MODE — teleport the <video> element so HLS.js stays attached */
_kind = 'video';
_origParent = v.parentNode;
_origNext = v.nextSibling;
slot.appendChild(v);
} else {
/* AUDIO MODE — teleport the <audio> element OUT of #main so SPA
navigation (which replaces #main.innerHTML) cannot destroy it,
and playback continues uninterrupted. Show the current cover
art (or active slide) inside the visible slot. */
var a = getAudio();
if (!a) return false;
_kind = 'audio';
var coverSrc = '';
var slideA = document.getElementById('slideA');
if (slideA && slideA.offsetParent !== null && slideA.src) coverSrc = slideA.src;
if (!coverSrc) {
var cover = document.getElementById('audioCoverImg');
if (cover && cover.src) coverSrc = cover.src;
}
slot.innerHTML = coverSrc
? '<img id="ytpMiniCover" src="' + coverSrc + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block;">'
: '<div style="width:100%;height:100%;background:#1a1a1a;display:flex;align-items:center;justify-content:center;color:#666;"><i class="bi bi-music-note-beamed" style="font-size:32px;"></i></div>';
/* Remember original DOM position to restore later */
_origParent = v.parentNode;
_origNext = v.nextSibling;
/* Teleport the audio element to the mini wrap. <audio> is
invisible, so visual layout is unaffected. */
_origParent = a.parentNode;
_origNext = a.nextSibling;
wrap.appendChild(a);
}
/* Teleport — HLS.js stays attached, playback uninterrupted */
slot.appendChild(v);
titleEl.textContent = title || 'Video';
titleEl.textContent = title || 'Now playing';
expandBtn.href = url || '#';
_mode = mode;
wrap.style.display = 'block';
syncBtn();
v.addEventListener('play', syncBtn);
v.addEventListener('pause', syncBtn);
var m = getMedia();
if (m) {
m.addEventListener('play', syncBtn);
m.addEventListener('pause', syncBtn);
}
return true;
}
function restore() {
var v = getVid();
if (!v || !_origParent) return;
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(v, _origNext);
} else {
_origParent.appendChild(v);
if (_kind === 'video') {
var v = getVid();
if (!v || !_origParent) return;
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(v, _origNext);
} else {
_origParent.appendChild(v);
}
} else if (_kind === 'audio') {
/* Move <audio> back to its original parent if it still exists
(e.g. scroll-mode → user scrolled back to the player). If the
parent was wiped by an SPA nav, leave the audio in the mini. */
var a = getAudio();
if (a && _origParent && _origParent.isConnected) {
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
_origParent.insertBefore(a, _origNext);
} else {
_origParent.appendChild(a);
}
}
if (slot) slot.innerHTML = '';
}
_origParent = null;
_origNext = null;
_kind = null;
}
function deactivate() {
@@ -1970,10 +2175,10 @@
if (playBtn) {
playBtn.addEventListener('click', function () {
var v = getVid();
if (!v) return;
if (v.paused) v.play().catch(function(){});
else v.pause();
var m = getMedia();
if (!m) return;
if (m.paused) m.play().catch(function(){});
else m.pause();
});
}
@@ -1985,24 +2190,148 @@
var main = document.getElementById('main');
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
deactivate();
return;
}
/* nav mode: follow href — full page navigation to video */
/* nav mode — hand off the playhead so the destination page
resumes playback from where the mini left off. The expand
target is the original player URL; we append:
resume=1 — tells the player to auto-start
t=<sec> — current playhead position
The video/audio player reads these query params on init. */
var m = getMedia();
if (!m) return; /* fall through to default nav */
var href = expandBtn.getAttribute('href') || '';
if (!href || href === '#') return;
try {
var u = new URL(href, location.href);
u.searchParams.set('resume', '1');
if (!isNaN(m.currentTime) && m.currentTime > 0) {
u.searchParams.set('t', Math.floor(m.currentTime).toString());
}
expandBtn.setAttribute('href', u.toString());
} catch (err) { /* leave href as-is */ }
});
}
if (closeBtn) {
closeBtn.addEventListener('click', function () {
var v = getVid();
if (v) v.pause();
if (_mode === 'scroll') restore();
if (_mode === 'scroll') {
/* User is still on the player's own page. Close the mini
and put the media element back in its original box so it
keeps playing like a background tab — no pause, no scroll
back up. We dispatch a custom event so the per-page
IntersectionObservers can reset their local "is the mini
on?" flag — otherwise scrolling away again wouldn't
re-trigger the mini until the user scrolls back over the
player first. */
restore();
wrap.style.display = 'none';
_mode = null;
window.dispatchEvent(new CustomEvent('miniplayer:scroll-closed'));
return;
}
/* nav mode — user has navigated away from the player's page.
Pause and fully tear down; the original player no longer
exists in the DOM to receive the media element. */
var m = getMedia();
if (m) m.pause();
wrap.style.display = 'none';
_mode = null;
_kind = null;
});
}
/* ── Drag-to-reposition ────────────────────────────────────────────
Desktop-only (mobile mini is already disabled). Persists the chosen
position in localStorage so the next session keeps it. Buttons /
anchors inside the bar are NOT drag handles — pointerdown on those
goes through to their click handler. */
var _drag = null;
var POS_KEY = 'ytpMiniPos';
function clampToViewport(left, top) {
var r = wrap.getBoundingClientRect();
var maxL = window.innerWidth - r.width - 4;
var maxT = window.innerHeight - r.height - 4;
return {
left: Math.max(4, Math.min(left, maxL)),
top: Math.max(4, Math.min(top, maxT)),
};
}
function applyPos(left, top) {
var c = clampToViewport(left, top);
wrap.style.left = c.left + 'px';
wrap.style.top = c.top + 'px';
wrap.style.right = 'auto';
wrap.style.bottom = 'auto';
}
function loadSavedPos() {
try {
var raw = localStorage.getItem(POS_KEY);
if (!raw) return;
var p = JSON.parse(raw);
if (typeof p.left === 'number' && typeof p.top === 'number') {
applyPos(p.left, p.top);
}
} catch (e) {}
}
function startDrag(e) {
/* Ignore drag attempts on interactive children — buttons/anchors
keep their click semantics. */
if (e.target.closest('button, a')) return;
if (e.button !== undefined && e.button !== 0) return;
var r = wrap.getBoundingClientRect();
_drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
wrap.classList.add('dragging');
/* Lock in pixel coords for the first move so the wrap stops
relying on right/bottom anchoring. */
applyPos(r.left, r.top);
try { wrap.setPointerCapture(e.pointerId); } catch (er) {}
e.preventDefault();
}
function moveDrag(e) {
if (!_drag) return;
applyPos(e.clientX - _drag.dx, e.clientY - _drag.dy);
}
function endDrag(e) {
if (!_drag) return;
_drag = null;
wrap.classList.remove('dragging');
try { wrap.releasePointerCapture(e.pointerId); } catch (er) {}
try {
var r = wrap.getBoundingClientRect();
localStorage.setItem(POS_KEY, JSON.stringify({ left: r.left, top: r.top }));
} catch (er) {}
}
wrap.addEventListener('pointerdown', startDrag);
wrap.addEventListener('pointermove', moveDrag);
wrap.addEventListener('pointerup', endDrag);
wrap.addEventListener('pointercancel', endDrag);
/* Re-clamp on resize so the mini doesn't get stranded off-screen. */
window.addEventListener('resize', function () {
if (wrap.style.display === 'none') return;
var r = wrap.getBoundingClientRect();
applyPos(r.left, r.top);
});
/* Apply saved position when the mini activates (no point reading it
while the wrap is display:none — getBoundingClientRect would be 0). */
function _activateAndPosition(title, url, mode) {
var ok = activate(title, url, mode);
if (ok) loadSavedPos();
return ok;
}
return {
activate: function (t, u) { return activate(t, u, 'nav'); },
activateScroll: function (t, u) { return activate(t, u, 'scroll'); },
activate: function (t, u) { return _activateAndPosition(t, u, 'nav'); },
activateScroll: function (t, u) { return _activateAndPosition(t, u, 'scroll'); },
deactivate: deactivate,
deactivateScroll: function () { if (_mode === 'scroll') deactivate(); },
isActive: function () { return _mode !== null; },
@@ -2010,6 +2339,19 @@
isNavMode: function () { return _mode === 'nav'; },
setUrl: function (u) { if (expandBtn) expandBtn.href = u; },
setTitle: function (t) { if (titleEl) titleEl.textContent = t || 'Video'; },
/* Called when the user SPA-navigates away while the mini is in
scroll mode — converts it to nav mode so the expand button
returns to the player's original URL instead of scrolling. */
convertToNav: function (u) {
if (_mode !== 'scroll') return;
_mode = 'nav';
if (u && expandBtn) expandBtn.href = u;
/* Audio mode: the original parent is about to be wiped by the
SPA innerHTML swap, so forget it — restore() will then leave
the audio in the mini wrap on deactivate. */
_origParent = null;
_origNext = null;
},
syncBtn: syncBtn
};
})();
@@ -2054,7 +2396,67 @@
});
}
/* Import <style> blocks from the destination doc's <head> that
aren't already present in the current head. Idempotent: identical
textContent is only added once across the SPA session, so navigating
between pages doesn't keep growing the head. */
function importHeadStyles(doc) {
try {
var have = {};
document.head.querySelectorAll('style[data-spa-style]').forEach(function (s) {
have[s.dataset.spaStyle] = true;
});
var srcStyles = doc.head ? doc.head.querySelectorAll('style') : [];
Array.prototype.forEach.call(srcStyles, function (s) {
var txt = s.textContent || '';
if (!txt.trim()) return;
/* Hash via length + first/last bytes — cheap dedupe key */
var key = txt.length + ':' + txt.slice(0, 80) + ':' + txt.slice(-40);
if (have[key]) return;
have[key] = true;
var n = document.createElement('style');
n.dataset.spaStyle = key;
n.textContent = txt;
document.head.appendChild(n);
});
} catch (e) { /* non-fatal */ }
}
/* Top progress bar — gives the user immediate visual feedback that
the SPA navigation is in flight. The actual DOM swap can take
a noticeable beat on big pages; without this the click feels dead. */
var _spaBar = null;
function spaBarStart() {
if (!_spaBar) {
_spaBar = document.createElement('div');
_spaBar.style.cssText = 'position:fixed;top:0;left:0;height:2px;background:var(--brand-red,#e61e1e);z-index:99999;width:0%;transition:width .2s ease,opacity .25s ease;pointer-events:none;box-shadow:0 0 8px rgba(230,30,30,.6);';
document.body.appendChild(_spaBar);
}
_spaBar.style.opacity = '1';
_spaBar.style.width = '0%';
/* Two-stage trickle: jump to 25% immediately, creep to 70% while
waiting on network. spaBarDone() finishes the run. */
requestAnimationFrame(function () { _spaBar.style.width = '25%'; });
setTimeout(function () { if (_spaBar) _spaBar.style.width = '70%'; }, 300);
}
function spaBarDone() {
if (!_spaBar) return;
_spaBar.style.width = '100%';
setTimeout(function () {
if (!_spaBar) return;
_spaBar.style.opacity = '0';
setTimeout(function () { if (_spaBar) _spaBar.style.width = '0%'; }, 250);
}, 150);
}
function spaGo(url) {
spaBarStart();
/* Update the URL immediately so the address bar reflects the click.
If the load fails, popstate-like recovery isn't needed: the catch
below falls back to a hard nav which corrects the URL again. */
try { history.pushState({ spa: true, url: url, pending: true }, '', url); } catch (e) {}
updateNavStates(url);
fetch(url, { headers: { 'X-SPA-Nav': '1' }, credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) {
@@ -2073,16 +2475,36 @@
curM.className = newM.className;
curM.innerHTML = newM.innerHTML;
reExecScripts(curM);
/* Pages render @section('extra_styles') into <head>, so a
plain #main swap loses the destination's styles. Copy
any <style> blocks from the new doc's <head> that we
don't already have. Identified by data-spa-style (set
below on first import) or by content hash. */
importHeadStyles(doc);
/* Page-level scripts live in #page-scripts (the wrapper
around the per-page scripts section) — swap and re-
execute so per-page helpers like channel's switchTab()
get defined again on this navigation. */
var srcPS = doc.getElementById('page-scripts');
var curPS = document.getElementById('page-scripts');
if (srcPS && curPS) {
curPS.innerHTML = srcPS.innerHTML;
reExecScripts(curPS);
}
curM.scrollTop = 0;
history.pushState({ spa: true, url: url }, doc.title, url);
updateNavStates(url);
/* URL was already pushed at the top; replace state to drop
the `pending` flag now that the load succeeded. */
history.replaceState({ spa: true, url: url }, doc.title, url);
spaBarDone();
})
.catch(function () { location.href = url; });
}
function stopMiniAndNavigate(url) {
var v = document.getElementById('videoPlayer');
var a = document.getElementById('audioEl');
if (v) v.pause();
if (a) a.pause();
document.getElementById('ytpMini').style.display = 'none';
/* Allow browser to do a normal full-page load */
}
@@ -2100,23 +2522,47 @@
var destUrl = new URL(href, location.href).href;
var v = document.getElementById('videoPlayer');
var playing = v && (window._ytpWasPlaying || !v.paused);
var aEl = document.getElementById('audioEl');
var playing = (v && (window._ytpWasPlaying || !v.paused))
|| (aEl && !aEl.paused);
var miniOn = window._miniPlayer && window._miniPlayer.isActive();
if (!playing && !miniOn) return;
/* Mobile: no floating mini player. Let the browser do a normal
full-page navigation; playback stops with the page like any
other site. The desktop-only mini is the only place where a
persistent floating bar makes sense. */
if (window.innerWidth <= 768) {
if (miniOn) stopMiniAndNavigate(destUrl);
return;
}
/* Going to a video page: stop mini, let browser do a full load */
if (isVideoShowPage(destUrl)) {
if (miniOn) stopMiniAndNavigate(destUrl);
return;
}
/* Mini disabled in the user's gear preference: treat as a normal
full navigation — pause and let the page change cleanly. Must
come BEFORE preventDefault(), otherwise we cancel the browser
click without running spaGo() and the link goes nowhere. */
if (!window._ytpMiniEnabled()) {
if (miniOn) stopMiniAndNavigate(destUrl);
return;
}
e.preventDefault();
/* Activate mini if the video is live in the page (not already in mini) */
if (playing && !miniOn) {
var title = document.title.replace(/\s*\|.*$/, '').trim();
window._miniPlayer.activate(title, location.href);
} else if (miniOn && window._miniPlayer.isScrollMode()) {
/* Already in scroll mode — convert to nav mode so the expand
button returns to the original player URL after SPA nav. */
window._miniPlayer.convertToNav(location.href);
}
spaGo(destUrl);
@@ -246,7 +246,7 @@
<div class="playlist-item" style="display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border-radius: 8px; cursor: pointer; transition: background 0.2s; margin-bottom: 8px;"
onmouseover="this.style.background='#3f3f3f'"
onmouseout="this.style.background='transparent'"
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId})">
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId}, ${isInPlaylist ? 'true' : 'false'})">
<div style="display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0;">
<div style="width: 100px; height: 56px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;">
${playlist.thumbnail_url
@@ -312,33 +312,38 @@
@endauth
}
// Toggle video in playlist
function toggleVideoInPlaylist(playlistId, videoId) {
// Toggle video in/out of a playlist. `currentlyIn` reflects what the row
// showed when the user clicked, so a single click flips the state via the
// matching REST endpoint (POST to add, DELETE to remove).
function toggleVideoInPlaylist(playlistId, videoId, currentlyIn) {
if (!videoId) return;
// Check authentication before adding
if (!isAuthenticated()) {
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
return;
}
fetch(`/playlists/${playlistId}/videos`, {
method: 'POST',
// Both add and remove go through the same /playlists/{id}/videos URL with
// a JSON body carrying the numeric video id — keeps the front-end ignorant
// of the encoded route key used by GET /videos/{video}.
var url = `/playlists/${playlistId}/videos`;
var method = currentlyIn ? 'DELETE' : 'POST';
var body = JSON.stringify({ video_id: videoId });
fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({
video_id: videoId
})
body
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message || 'Video added to playlist');
// Reload playlists to update checkmarks
loadPlaylistsForModal(videoId);
showToast(data.message || (currentlyIn ? 'Removed from playlist' : 'Added to playlist'));
loadPlaylistsForModal(videoId); // refresh checkmarks
}
})
.catch(error => console.error('Error:', error));
@@ -1661,11 +1661,18 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
const formData = new FormData(this);
if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_slidesData)) {
if (tid === 't1') continue;
const n = parseInt(tid.replace('e', ''));
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
}
// Walk extra-track form sections in DOM order so the slide index lines up
// with the positional `extra_track_files[]` the form already submits.
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
extraForms.forEach(function (el, domIdx) {
var m = el.id.match(/^um-tf-(e\d+)$/);
if (!m) return;
var tid = m[1];
var files = _slidesData[tid] || [];
files.forEach(function (f) {
formData.append('extra_track_slides[' + domIdx + '][]', f);
});
});
}
const xhr = new XMLHttpRequest();