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:
1 parent
73527f3781
commit
f98e5415a3
60 files changed
+7920
-2675
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user