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
@@ -14,113 +14,8 @@
|
||||
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
|
||||
@endphp
|
||||
|
||||
@once
|
||||
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
|
||||
<script src="{{ asset('js/cropme.min.js') }}"></script>
|
||||
<style>
|
||||
/* ── TakeOne Cropper Modal ─────────────────────────── */
|
||||
.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>
|
||||
@endonce
|
||||
{{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
|
||||
so they survive SPA-nav innerHTML swaps on #main. --}}
|
||||
|
||||
{{-- Modal --}}
|
||||
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
$plUrl = $firstVid
|
||||
? route('videos.show', $firstVid) . '?playlist=' . $pl->share_token
|
||||
: route('playlists.show', $pl->id);
|
||||
$plIsOwner = auth()->check() && auth()->id() === $pl->user_id;
|
||||
$plShuffleUrl = $firstVid ? route('playlists.shuffle', $pl->id) : null;
|
||||
@endphp
|
||||
|
||||
@once
|
||||
@@ -115,5 +117,84 @@
|
||||
{{ number_format($pl->view_count) }} {{ Str::plural('view', $pl->view_count) }} · {{ $pl->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="position-relative">
|
||||
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More options">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if($firstVid)
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ $plUrl }}">
|
||||
<i class="bi bi-play-fill"></i> Play all
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ $plShuffleUrl }}">
|
||||
<i class="bi bi-shuffle"></i> Shuffle
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ route('playlists.show', $pl->id) }}">
|
||||
<i class="bi bi-collection-play"></i> View playlist
|
||||
</a>
|
||||
</li>
|
||||
@if($pl->visibility !== 'private' || $plIsOwner)
|
||||
<li>
|
||||
<button type="button" class="dropdown-item"
|
||||
onclick="openShareModal({{ json_encode($pl->share_url) }}, {{ json_encode($pl->name) }}, {{ json_encode(route('playlists.recordShare', $pl->id)) }})">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
</li>
|
||||
@endif
|
||||
@if($plIsOwner)
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ route('playlists.show', $pl->id) }}#edit">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item text-danger"
|
||||
onclick="plCardDelete({{ $pl->id }}, {{ json_encode($pl->name) }}, this)">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once
|
||||
<script>
|
||||
function plCardDelete(plId, plName, btnEl) {
|
||||
var done = function () {
|
||||
fetch('/playlists/' + plId, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d && d.success) {
|
||||
if (typeof showToast === 'function') showToast('Playlist deleted', 'success');
|
||||
var card = btnEl && btnEl.closest('.yt-video-card');
|
||||
if (card) card.remove();
|
||||
else window.location.reload();
|
||||
} else {
|
||||
if (typeof showToast === 'function') showToast((d && d.message) || 'Failed to delete', 'error');
|
||||
}
|
||||
}).catch(function () {
|
||||
if (typeof showToast === 'function') showToast('Failed to delete playlist', 'error');
|
||||
});
|
||||
};
|
||||
if (typeof showConfirm === 'function') {
|
||||
showConfirm('Delete "' + plName + '"?', done, 'Delete');
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endonce
|
||||
@@ -128,6 +128,8 @@
|
||||
<i class="bi bi-pencil"></i>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
{{-- Lyrics generate/regenerate + edit now live inside the player's gear menu
|
||||
so they're always reachable on both mobile and desktop. --}}
|
||||
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
|
||||
style="color:#ef4444;border-color:rgba(239,68,68,.35);">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -542,9 +544,14 @@ if (!window._slideshowDlInit) {
|
||||
// the selected language track (window._ytpTrackId; 0 = primary).
|
||||
var vizOn = localStorage.getItem('audioBarsOn') === '1';
|
||||
var trackId = window._ytpTrackId || 0;
|
||||
// Burn lyrics into the download only when the viewer has them enabled AND
|
||||
// they actually exist for the current track (the toggle button is visible).
|
||||
var lyrBtn = document.getElementById('ytpLyricsBtn');
|
||||
var lyrOn = localStorage.getItem('ytpLyricsOn') === '1' && lyrBtn && lyrBtn.style.display !== 'none';
|
||||
var _p = [];
|
||||
if (vizOn) _p.push('visualizer=1');
|
||||
if (trackId) _p.push('track=' + trackId);
|
||||
if (lyrOn) _p.push('lyrics=1');
|
||||
var qs = _p.length ? '?' + _p.join('&') : ''; // generate + download
|
||||
var qsAmp = _p.length ? '&' + _p.join('&') : ''; // appended to progress ?duration=
|
||||
|
||||
|
||||
@@ -87,10 +87,10 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
@if($video)
|
||||
<div class="yt-video-meta">
|
||||
@if($video->type && $video->type !== 'generic')
|
||||
@if($video->type)
|
||||
<span class="yt-type-label yt-type-{{ $video->type }}">
|
||||
<i class="bi {{ $typeIcon }}"></i>
|
||||
{{ ucfirst($video->type === 'match' ? 'Sports' : $video->type) }}
|
||||
{{ ucfirst($video->type === 'match' ? 'Sports' : ($video->type === 'generic' ? 'Video' : $video->type)) }}
|
||||
</span>
|
||||
·
|
||||
@endif
|
||||
@@ -269,6 +269,7 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once
|
||||
<style>
|
||||
/* Base styles for video card */
|
||||
.yt-video-card {
|
||||
@@ -531,8 +532,9 @@ $sizeClasses = match($size) {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.yt-type-music { color: #c084fc; }
|
||||
.yt-type-match { color: #60a5fa; }
|
||||
.yt-type-music { color: #c084fc; }
|
||||
.yt-type-match { color: #60a5fa; }
|
||||
.yt-type-generic { color: #f87171; }
|
||||
|
||||
/* More button — visible only on hover (touch devices always show it) */
|
||||
.yt-video-card .yt-more-btn {
|
||||
@@ -932,6 +934,7 @@ $sizeClasses = match($size) {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
|
||||
@once
|
||||
<script>
|
||||
@@ -1087,6 +1090,11 @@ function closeEditVideoModal(videoId) {
|
||||
}
|
||||
}
|
||||
|
||||
// SPA navigation re-executes this once-protected script on every page swap.
|
||||
// Guard the document-level listeners so they don't stack up across navs.
|
||||
if (!window._videoCardListenersBound) {
|
||||
window._videoCardListenersBound = true;
|
||||
|
||||
// Type option click handlers
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.cute-type-option')) {
|
||||
@@ -1160,5 +1168,7 @@ document.addEventListener('submit', function(e) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} // end _videoCardListenersBound guard
|
||||
</script>
|
||||
@endonce
|
||||
@@ -134,6 +134,12 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
</button>
|
||||
<div class="ytp-settings-panel" id="ytpSettingsPanel">
|
||||
{{-- Mini player toggle — desktop-only, persisted in localStorage --}}
|
||||
<div class="ytp-settings-item" id="ytpMiniToggleRow" onclick="(function(el){var on=!(window._ytpMiniEnabled&&window._ytpMiniEnabled());window._ytpMiniSetEnabled(on);el.querySelector('.ytp-settings-val').textContent=on?'On':'Off';})(this)">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/></svg>
|
||||
<span>Mini player</span>
|
||||
<span class="ytp-settings-val">On</span>
|
||||
</div>
|
||||
<div class="ytp-settings-item" id="ytpSpeedRow">
|
||||
<svg viewBox="0 0 24 24"><path d="M10 8v8l6-4-6-4zm6.5 4A6.5 6.5 0 1 1 9 6.04V4.02A8.5 8.5 0 1 0 18.5 12H16.5z"/></svg>
|
||||
<span>Playback speed</span>
|
||||
@@ -818,6 +824,19 @@ video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
|
||||
function initSource() {
|
||||
video.muted = true;
|
||||
video.autoplay = true;
|
||||
/* Resume handoff from the mini player: ?t=<sec> seeks the video to that
|
||||
position once metadata is ready. One-shot — only the initial load. */
|
||||
try {
|
||||
var _qs = new URLSearchParams(location.search);
|
||||
var _t = parseInt(_qs.get('t') || '0', 10);
|
||||
if (_t > 0) {
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
if (_t < (video.duration || Infinity)) {
|
||||
try { video.currentTime = _t; } catch (e) {}
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (HLS_URL && window.Hls && Hls.isSupported()) {
|
||||
window._ytpHls = new Hls({ startLevel: -1 });
|
||||
// Register MANIFEST_PARSED before loadSource to avoid cache race condition
|
||||
@@ -1077,6 +1096,17 @@ settingsBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const open = settingsPanel.classList.toggle('open');
|
||||
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
|
||||
/* Sync the mini-player toggle row's label to the current preference each
|
||||
time the gear opens, so reloading the page or toggling from the music
|
||||
player keeps the indicator honest. */
|
||||
if (open) {
|
||||
const miniRow = document.getElementById('ytpMiniToggleRow');
|
||||
if (miniRow) {
|
||||
const v = miniRow.querySelector('.ytp-settings-val');
|
||||
const on = !window._ytpMiniEnabled || window._ytpMiniEnabled();
|
||||
if (v) v.textContent = on ? 'On' : 'Off';
|
||||
}
|
||||
}
|
||||
showControls();
|
||||
clearTimeout(hideTimer); // keep controls visible while settings open
|
||||
});
|
||||
@@ -1370,10 +1400,11 @@ function init() {
|
||||
largePlay.classList.add('visible');
|
||||
showControls();
|
||||
|
||||
// Scroll-based mini player: watch when #ytpWrap leaves the viewport
|
||||
if (window.IntersectionObserver && window._miniPlayer) {
|
||||
/* On mobile, #main is the scroll container; on desktop the window scrolls */
|
||||
var _scrollRoot = window.innerWidth <= 768 ? document.getElementById('main') : null;
|
||||
// Scroll-based mini player: watch when #ytpWrap leaves the viewport.
|
||||
// Desktop-only — on mobile the fixed bottom-nav + locked scroll model
|
||||
// make a floating overlay disruptive.
|
||||
if (window.IntersectionObserver && window._miniPlayer && window.innerWidth > 768) {
|
||||
var _scrollRoot = null; /* desktop: window scrolls */
|
||||
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
|
||||
new IntersectionObserver(function (entries) {
|
||||
var e0 = entries[0];
|
||||
@@ -1381,7 +1412,8 @@ function init() {
|
||||
Using !video.paused was unreliable: autoplay fires asynchronously and the
|
||||
initial IntersectionObserver callback could run before HLS.js even attaches,
|
||||
teleporting the element before it ever played in the main player. */
|
||||
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && !window._miniPlayer.isNavMode()) {
|
||||
var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
|
||||
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && miniAllowed && !window._miniPlayer.isNavMode()) {
|
||||
_scrollMiniOn = true;
|
||||
window._miniPlayer.activateScroll(
|
||||
document.title.replace(/\s*\|.*$/, '').trim(),
|
||||
@@ -1392,6 +1424,12 @@ function init() {
|
||||
window._miniPlayer.deactivateScroll();
|
||||
}
|
||||
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrap);
|
||||
|
||||
/* User clicked the X on the mini while still on the video page —
|
||||
reset the flag so a subsequent scroll-away re-activates it. */
|
||||
window.addEventListener('miniplayer:scroll-closed', function () {
|
||||
_scrollMiniOn = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user